[UBER-196] Fix duplicated project ids when creating a project (#3247)

Signed-off-by: Sergei Ogorelkov <sergei.ogorelkov@icloud.com>
This commit is contained in:
Sergei Ogorelkov 2023-05-30 10:28:36 +04:00 committed by GitHub
parent 60dcf3ad02
commit c0cb5a87e7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 118 additions and 33 deletions

View File

@ -96,5 +96,7 @@ export class TTxApplyIf extends TTx implements TxApplyIf {
// All matches should be true with at least one document. // All matches should be true with at least one document.
match!: DocumentClassQuery<Doc>[] match!: DocumentClassQuery<Doc>[]
// All matches should be false for all documents.
notMatch!: DocumentClassQuery<Doc>[]
txes!: TxCUD<Doc>[] txes!: TxCUD<Doc>[]
} }

View File

@ -281,6 +281,7 @@ export class TxOperations implements Omit<Client, 'notify'> {
export class ApplyOperations extends TxOperations { export class ApplyOperations extends TxOperations {
txes: TxCUD<Doc>[] = [] txes: TxCUD<Doc>[] = []
matches: DocumentClassQuery<Doc>[] = [] matches: DocumentClassQuery<Doc>[] = []
notMatches: DocumentClassQuery<Doc>[] = []
constructor (readonly ops: TxOperations, readonly scope: string) { constructor (readonly ops: TxOperations, readonly scope: string) {
const txClient: Client = { const txClient: Client = {
getHierarchy: () => ops.client.getHierarchy(), getHierarchy: () => ops.client.getHierarchy(),
@ -303,10 +304,15 @@ export class ApplyOperations extends TxOperations {
return this return this
} }
notMatch<T extends Doc>(_class: Ref<Class<T>>, query: DocumentQuery<T>): ApplyOperations {
this.notMatches.push({ _class, query })
return this
}
async commit (): Promise<boolean> { async commit (): Promise<boolean> {
if (this.txes.length > 0) { if (this.txes.length > 0) {
return await ((await this.ops.tx( return await ((await this.ops.tx(
this.ops.txFactory.createTxApplyIf(core.space.Tx, this.scope, this.matches, this.txes) this.ops.txFactory.createTxApplyIf(core.space.Tx, this.scope, this.matches, this.notMatches, this.txes)
)) as Promise<boolean>) )) as Promise<boolean>)
} }
return true return true

View File

@ -120,6 +120,9 @@ export interface TxApplyIf extends Tx {
// All matches should be true with at least one document. // All matches should be true with at least one document.
match: DocumentClassQuery<Doc>[] match: DocumentClassQuery<Doc>[]
// All matches should be false for all documents.
notMatch: DocumentClassQuery<Doc>[]
// If all matched execute following transactions. // If all matched execute following transactions.
txes: TxCUD<Doc>[] txes: TxCUD<Doc>[]
} }
@ -586,6 +589,7 @@ export class TxFactory {
space: Ref<Space>, space: Ref<Space>,
scope: string, scope: string,
match: DocumentClassQuery<Doc>[], match: DocumentClassQuery<Doc>[],
notMatch: DocumentClassQuery<Doc>[],
txes: TxCUD<Doc>[], txes: TxCUD<Doc>[],
modifiedOn?: Timestamp, modifiedOn?: Timestamp,
modifiedBy?: Ref<Account> modifiedBy?: Ref<Account>
@ -599,6 +603,7 @@ export class TxFactory {
objectSpace: space, objectSpace: space,
scope, scope,
match, match,
notMatch,
txes txes
} }
} }

View File

@ -1,34 +1,26 @@
<script lang="ts"> <script lang="ts">
import presentation, { Card, getClient } from '@hcengineering/presentation' import presentation, { Card } from '@hcengineering/presentation'
import { Project } from '@hcengineering/tracker'
import EditBox from '@hcengineering/ui/src/components/EditBox.svelte' import EditBox from '@hcengineering/ui/src/components/EditBox.svelte'
import { createEventDispatcher } from 'svelte' import { createEventDispatcher } from 'svelte'
import tracker from '../../plugin' import tracker from '../../plugin'
export let project: Project export let identifier: string
export let projectsIdentifiers: Set<string>
let identifier = project.identifier let newIdentifier = identifier
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
function save () { function save () {
dispatch('close', identifier) dispatch('close', newIdentifier)
} }
let projects: Set<string> = new Set()
$: getClient()
.findAll(tracker.class.Project, {})
.then((pr) => {
projects = new Set(pr.map((p) => p.identifier))
})
</script> </script>
<Card <Card
label={projects.has(identifier) ? tracker.string.IdentifierExists : tracker.string.ProjectIdentifier} label={projectsIdentifiers.has(newIdentifier) ? tracker.string.IdentifierExists : tracker.string.ProjectIdentifier}
okLabel={presentation.string.Save} okLabel={presentation.string.Save}
okAction={save} okAction={save}
canSave={identifier !== project.identifier && !projects.has(identifier)} canSave={!!newIdentifier && newIdentifier !== identifier && !projectsIdentifiers.has(newIdentifier)}
on:close={() => { on:close={() => {
dispatch('close') dispatch('close')
}} }}
@ -36,7 +28,7 @@
> >
<div class="float-left-box"> <div class="float-left-box">
<div class="float-left p-2"> <div class="float-left p-2">
<EditBox bind:value={identifier} /> <EditBox bind:value={newIdentifier} />
</div> </div>
</div> </div>
</Card> </Card>

View File

@ -15,9 +15,17 @@
<script lang="ts"> <script lang="ts">
import { Employee } from '@hcengineering/contact' import { Employee } from '@hcengineering/contact'
import { AccountArrayEditor, AssigneeBox } from '@hcengineering/contact-resources' import { AccountArrayEditor, AssigneeBox } from '@hcengineering/contact-resources'
import core, { Account, DocumentUpdate, Ref, SortingOrder, generateId, getCurrentAccount } from '@hcengineering/core' import core, {
Account,
ApplyOperations,
DocumentUpdate,
Ref,
SortingOrder,
generateId,
getCurrentAccount
} from '@hcengineering/core'
import { Asset } from '@hcengineering/platform' import { Asset } from '@hcengineering/platform'
import presentation, { Card, getClient } from '@hcengineering/presentation' import presentation, { Card, createQuery, getClient } from '@hcengineering/presentation'
import { StyledTextBox } from '@hcengineering/text-editor' import { StyledTextBox } from '@hcengineering/text-editor'
import { IssueStatus, Project, TimeReportDayType, genRanks } from '@hcengineering/tracker' import { IssueStatus, Project, TimeReportDayType, genRanks } from '@hcengineering/tracker'
import { import {
@ -43,6 +51,7 @@
const client = getClient() const client = getClient()
const hierarchy = client.getHierarchy() const hierarchy = client.getHierarchy()
const projectsQuery = createQuery()
let name: string = project?.name ?? '' let name: string = project?.name ?? ''
let description: string = project?.description ?? '' let description: string = project?.description ?? ''
@ -52,13 +61,21 @@
let defaultAssignee: Ref<Employee> | null | undefined = project?.defaultAssignee ?? null let defaultAssignee: Ref<Employee> | null | undefined = project?.defaultAssignee ?? null
let members: Ref<Account>[] = let members: Ref<Account>[] =
project?.members !== undefined ? hierarchy.clone(project.members) : [getCurrentAccount()._id] project?.members !== undefined ? hierarchy.clone(project.members) : [getCurrentAccount()._id]
let projectsIdentifiers: Set<string> = new Set()
let isSaving = false
let changeIdentityRef: HTMLElement
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
$: isNew = !project $: isNew = !project
async function handleSave () { async function handleSave () {
isNew ? createProject() : updateProject() if (isNew) {
await createProject()
} else {
await updateProject()
}
} }
let identifier: string = project?.identifier ?? 'TSK' let identifier: string = project?.identifier ?? 'TSK'
@ -84,6 +101,10 @@
} }
async function updateProject () { async function updateProject () {
if (!project) {
return
}
const { sequence, issueStatuses, defaultIssueStatus, ...projectData } = getProjectData() const { sequence, issueStatuses, defaultIssueStatus, ...projectData } = getProjectData()
const update: DocumentUpdate<Project> = {} const update: DocumentUpdate<Project> = {}
if (projectData.name !== project?.name) { if (projectData.name !== project?.name) {
@ -121,21 +142,46 @@
} }
} }
if (Object.keys(update).length > 0) { if (Object.keys(update).length > 0) {
await client.update(project!, update) const ops = client.apply(project._id).notMatch(tracker.class.Project, { identifier: projectData.identifier })
isSaving = true
await ops.update(project, update)
const succeeded = await ops.commit()
isSaving = false
if (succeeded) {
close()
} else {
changeIdentity(changeIdentityRef)
}
} }
} }
async function createProject () { async function createProject () {
const id = await client.createDoc(tracker.class.Project, core.space.Space, getProjectData()) const projectId = generateId<Project>()
await createProjectIssueStatuses(id, defaultStatusId) const projectData = getProjectData()
const ops = client.apply(projectId).notMatch(tracker.class.Project, { identifier: projectData.identifier })
isSaving = true
await ops.createDoc(tracker.class.Project, core.space.Space, projectData, projectId)
await createProjectIssueStatuses(ops, projectId, defaultStatusId)
const succeeded = await ops.commit()
isSaving = false
if (succeeded) {
close()
} else {
changeIdentity(changeIdentityRef)
}
} }
async function createProjectIssueStatuses ( async function createProjectIssueStatuses (
ops: ApplyOperations,
projectId: Ref<Project>, projectId: Ref<Project>,
defaultStatusId: Ref<IssueStatus>, defaultStatusId: Ref<IssueStatus>,
defaultCategoryId = tracker.issueStatusCategory.Backlog defaultCategoryId = tracker.issueStatusCategory.Backlog
): Promise<void> { ): Promise<void> {
const categories = await client.findAll( const categories = await ops.findAll(
core.class.StatusCategory, core.class.StatusCategory,
{ ofAttribute: tracker.attribute.IssueStatus }, { ofAttribute: tracker.attribute.IssueStatus },
{ sort: { order: SortingOrder.Ascending } } { sort: { order: SortingOrder.Ascending } }
@ -147,7 +193,7 @@
const rank = issueStatusRanks[i] const rank = issueStatusRanks[i]
if (defaultStatusName !== undefined) { if (defaultStatusName !== undefined) {
await client.createDoc( await ops.createDoc(
tracker.class.IssueStatus, tracker.class.IssueStatus,
projectId, projectId,
{ {
@ -170,26 +216,37 @@
} }
}) })
} }
function changeIdentity (ev: MouseEvent) { function changeIdentity (element: HTMLElement) {
showPopup(ChangeIdentity, { project }, eventToHTMLElement(ev), (result) => { showPopup(ChangeIdentity, { identifier, projectsIdentifiers }, element, (result) => {
if (result != null) { if (result != null) {
identifier = result identifier = result
} }
}) })
} }
function close () {
dispatch('close')
}
$: projectsQuery.query(
tracker.class.Project,
{ _id: { $nin: project ? [project._id] : [] } },
(res) => (projectsIdentifiers = new Set(res.map(({ identifier }) => identifier)))
)
</script> </script>
<Card <Card
label={isNew ? tracker.string.NewProject : tracker.string.EditProject} label={isNew ? tracker.string.NewProject : tracker.string.EditProject}
okLabel={isNew ? presentation.string.Create : presentation.string.Save} okLabel={isNew ? presentation.string.Create : presentation.string.Save}
okAction={handleSave} okAction={handleSave}
canSave={name.length > 0 && !(members.length === 0 && isPrivate)} canSave={name.length > 0 &&
identifier.length > 0 &&
!projectsIdentifiers.has(identifier) &&
!(members.length === 0 && isPrivate)}
accentHeader accentHeader
width={'medium'} width={'medium'}
gap={'gapV-6'} gap={'gapV-6'}
on:close={() => { onCancel={close}
dispatch('close')
}}
on:changeContent on:changeContent
> >
<div class="antiGrid"> <div class="antiGrid">
@ -217,7 +274,7 @@
<Label label={tracker.string.Identifier} /> <Label label={tracker.string.Identifier} />
<span><Label label={tracker.string.UsedInIssueIDs} /></span> <span><Label label={tracker.string.UsedInIssueIDs} /></span>
</div> </div>
<div class="padding flex-row-center"> <div bind:this={changeIdentityRef} class="padding flex-row-center relative">
<EditBox <EditBox
bind:value={identifier} bind:value={identifier}
disabled={!isNew} disabled={!isNew}
@ -226,7 +283,13 @@
uppercase uppercase
/> />
{#if !isNew} {#if !isNew}
<Button size={'small'} icon={IconEdit} on:click={changeIdentity} /> <div class="ml-1">
<Button size={'small'} icon={IconEdit} on:click={(ev) => changeIdentity(eventToHTMLElement(ev))} />
</div>
{:else if !isSaving && projectsIdentifiers.has(identifier)}
<div class="absolute overflow-label duplicated-identifier">
<Label label={tracker.string.IdentifierExists} />
</div>
{/if} {/if}
</div> </div>
</div> </div>
@ -305,3 +368,11 @@
</div> </div>
</div> </div>
</Card> </Card>
<style lang="scss">
.duplicated-identifier {
left: 0;
bottom: -0.25rem;
color: var(--theme-warning-color);
}
</style>

View File

@ -594,6 +594,15 @@ class TServerStorage implements ServerStorage {
break break
} }
} }
if (passed) {
for (const { _class, query } of applyIf.notMatch) {
const res = await findAll(ctx, _class, query, { limit: 1 })
if (res.length > 0) {
passed = false
break
}
}
}
return { passed, onEnd } return { passed, onEnd }
} }