From c37502077c4ddebba8ec7ac372ba199086659953 Mon Sep 17 00:00:00 2001 From: Denis Bykhov Date: Fri, 24 Mar 2023 16:23:44 +0600 Subject: [PATCH] TSK-462 Allow sub issue drafts (#2823) Signed-off-by: Denis Bykhov --- packages/presentation/src/attributes.ts | 2 +- packages/presentation/src/drafts.ts | 170 +++--- .../src/components/Activity.svelte | 4 +- .../src/components/AttachmentRefInput.svelte | 15 +- .../src/components/AttachmentStyledBox.svelte | 19 +- .../src/components/CommentInput.svelte | 108 ++-- .../src/components/CreateEmployee.svelte | 2 +- .../src/components/CreateOrganization.svelte | 2 +- .../src/components/CreatePerson.svelte | 2 +- plugins/contact/src/utils.ts | 22 +- .../src/components/CreateCustomer.svelte | 15 +- plugins/recruit-resources/package.json | 2 - .../src/components/CreateCandidate.svelte | 321 +++++------- .../src/components/NewCandidateHeader.svelte | 10 +- plugins/recruit/src/index.ts | 6 +- plugins/request-resources/package.json | 1 - .../src/components/CreateIssue.svelte | 489 +++++++----------- .../src/components/NewIssueHeader.svelte | 12 +- .../components/SetDueDateActionPopup.svelte | 6 +- .../SetParentIssueActionPopup.svelte | 6 +- .../src/components/SubIssues.svelte | 29 +- .../components/issues/AssigneeEditor.svelte | 17 +- .../src/components/issues/IssuePreview.svelte | 17 +- .../src/components/issues/KanbanView.svelte | 2 +- .../components/issues/PriorityEditor.svelte | 6 +- .../src/components/issues/StatusEditor.svelte | 9 +- .../issues/edit/CreateSubIssue.svelte | 162 +++--- .../components/issues/edit/EditIssue.svelte | 22 +- .../components/issues/edit/SubIssues.svelte | 12 +- .../issues/timereport/EstimationEditor.svelte | 11 +- .../issues/timereport/EstimationPopup.svelte | 2 +- .../EstimationStatsPresenter.svelte | 3 +- .../timereport/EstimationSubIssueList.svelte | 2 +- .../components/sprints/SprintEditor.svelte | 8 +- .../sprints/SprintRefPresenter.svelte | 9 +- .../templates/DraftIssueChildEditor.svelte | 190 ++++--- .../templates/DraftIssueChildList.svelte | 24 +- .../templates/EstimationEditor.svelte | 6 +- plugins/tracker-resources/src/utils.ts | 8 +- plugins/tracker/src/index.ts | 26 +- tests/sanity/tests/tracker.spec.ts | 138 +++++ 41 files changed, 933 insertions(+), 984 deletions(-) diff --git a/packages/presentation/src/attributes.ts b/packages/presentation/src/attributes.ts index 9e2f662ab2..cbf4e22223 100644 --- a/packages/presentation/src/attributes.ts +++ b/packages/presentation/src/attributes.ts @@ -43,7 +43,7 @@ export async function updateAttribute ( export function getAttribute (client: Client, object: any, key: KeyedAttribute): any { // Check if attr is mixin and return it's value if (client.getHierarchy().isMixin(key.attr.attributeOf)) { - return object[key.attr.attributeOf]?.[key.key] + return (client.getHierarchy().as(object, key.attr.attributeOf) as any)[key.key] } else { return object[key.key] } diff --git a/packages/presentation/src/drafts.ts b/packages/presentation/src/drafts.ts index 9ac21eaf2c..7ec8b1e73b 100644 --- a/packages/presentation/src/drafts.ts +++ b/packages/presentation/src/drafts.ts @@ -1,72 +1,116 @@ -import { getCurrentAccount } from '@hcengineering/core' import { fetchMetadataLocalStorage, setMetadataLocalStorage } from '@hcengineering/ui' +import { deepEqual } from 'fast-equals' import { get, writable } from 'svelte/store' import presentation from './plugin' -/** - * @public - */ -// eslint-disable-next-line -export const draftStore = writable>(fetchMetadataLocalStorage(presentation.metadata.Draft) || {}) +export const draftsStore = writable>(fetchMetadataLocalStorage(presentation.metadata.Draft) ?? {}) +window.addEventListener('storage', storageHandler) +const saveInterval = 200 -/** - * @public - */ -export function updateDraftStore (id: string, draft: any): void { - draftStore.update((drafts) => { - if (draft !== undefined) { - drafts[id] = draft - } else { - // eslint-disable-next-line @typescript-eslint/no-dynamic-delete - delete drafts[id] - } - setMetadataLocalStorage(presentation.metadata.Draft, drafts) - return drafts - }) -} - -/** - * @public - */ -export function updateUserDraft (id: string, draft: any): void { - const me = getCurrentAccount()._id - draftStore.update((drafts) => { - // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions - const userDrafts: Record = drafts[me] || {} - userDrafts[id] = draft - drafts[me] = userDrafts - setMetadataLocalStorage(presentation.metadata.Draft, drafts) - return drafts - }) -} - -/** - * @public - */ -export function getUserDraft (id: string): any { - const me = getCurrentAccount()._id - const drafts: Record = get(draftStore) - // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions - const userDrafts: Record = drafts[me] || {} - const draft: Record = userDrafts[id] - return draft -} - -/** - * @public - */ -export function isUserDraftExists (id: string): boolean { - const me = getCurrentAccount()._id - const drafts: Record = get(draftStore) - const userDrafts: Record = drafts[me] - // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions - if (!userDrafts) { - return false +function storageHandler (evt: StorageEvent): void { + if (evt.storageArea !== localStorage) return + if (evt.key !== presentation.metadata.Draft) return + if (evt.newValue !== null) { + draftsStore.set(JSON.parse(evt.newValue)) } - const draftRecord: Record = userDrafts[id] - // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions - if (!draftRecord) { - return false +} + +function isEmptyDraft (object: T, emptyObj: Partial | undefined): boolean { + for (const key in object) { + if (key === '_id') continue + const value = object[key] + let res: boolean = false + if (Array.isArray(value)) { + res = value.length > 0 + if (res && emptyObj != null) { + res = !deepEqual(value, emptyObj[key]) + } + } else { + res = value != null + if (res && typeof value === 'string') { + res = value.trim() !== '' + } + if (res && typeof value === 'number') { + res = value !== 0 + } + if (res && emptyObj != null) { + res = !deepEqual(value, emptyObj[key]) + } + } + if (res) { + return false + } } return true } + +export class DraftController { + private timer: number | undefined = undefined + constructor (private readonly id: string) {} + + static remove (id: string): void { + const drafts = get(draftsStore) + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete drafts[id] + draftsStore.set(drafts) + setMetadataLocalStorage(presentation.metadata.Draft, drafts) + } + + static save(id: string, object: T, emptyObj: Partial | undefined = undefined): void { + const drafts = get(draftsStore) + if (emptyObj !== undefined && isEmptyDraft(object, emptyObj)) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete drafts[id] + } else { + drafts[id] = object + } + draftsStore.set(drafts) + setMetadataLocalStorage(presentation.metadata.Draft, drafts) + } + + get (): T | undefined { + const drafts = get(draftsStore) + const res = drafts[this.id] + return res + } + + save (object: T, emptyObj: Partial | undefined = undefined): void { + const drafts = get(draftsStore) + if (emptyObj !== undefined && isEmptyDraft(object, emptyObj)) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete drafts[this.id] + } else { + drafts[this.id] = object + } + draftsStore.set(drafts) + setMetadataLocalStorage(presentation.metadata.Draft, drafts) + } + + private update (object: T, emptyObj: Partial | undefined): void { + this.timer = window.setTimeout(() => { + this.save(object, emptyObj) + this.update(object, emptyObj) + }, saveInterval) + } + + unsubscribe (): void { + if (this?.timer !== undefined) { + clearTimeout(this.timer) + } + } + + watch (object: T, emptyObj: Partial | undefined = undefined): void { + this.unsubscribe() + this.save(object, emptyObj) + this.update(object, emptyObj) + } + + remove (): void { + this.unsubscribe() + const drafts = get(draftsStore) + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete drafts[this.id] + setMetadataLocalStorage(presentation.metadata.Draft, drafts) + draftsStore.set(drafts) + } +} diff --git a/plugins/activity-resources/src/components/Activity.svelte b/plugins/activity-resources/src/components/Activity.svelte index 424c2b913f..04f8e9bff8 100644 --- a/plugins/activity-resources/src/components/Activity.svelte +++ b/plugins/activity-resources/src/components/Activity.svelte @@ -178,7 +178,7 @@ {#if showCommenInput}
- +
{/if} @@ -213,7 +213,7 @@ {#if showCommenInput}
- +
{/if}
diff --git a/plugins/attachment-resources/src/components/AttachmentRefInput.svelte b/plugins/attachment-resources/src/components/AttachmentRefInput.svelte index 8b4a9cbb7a..ffff3351e9 100644 --- a/plugins/attachment-resources/src/components/AttachmentRefInput.svelte +++ b/plugins/attachment-resources/src/components/AttachmentRefInput.svelte @@ -13,7 +13,7 @@ // limitations under the License. --> diff --git a/plugins/recruit-resources/package.json b/plugins/recruit-resources/package.json index 6a0846b13f..713c981277 100644 --- a/plugins/recruit-resources/package.json +++ b/plugins/recruit-resources/package.json @@ -28,7 +28,6 @@ "eslint": "^8.26.0", "prettier": "^2.7.1", "svelte-check": "^2.8.0", - "@types/deep-equal": "^1.0.1", "typescript": "^4.3.5" }, "dependencies": { @@ -43,7 +42,6 @@ "@hcengineering/contact": "^0.6.11", "@hcengineering/login": "^0.6.1", "@hcengineering/workbench": "^0.6.2", - "deep-equal": "^2.0.5", "@hcengineering/panel": "^0.6.1", "@hcengineering/activity": "^0.6.0", "@hcengineering/attachment": "^0.6.1", diff --git a/plugins/recruit-resources/src/components/CreateCandidate.svelte b/plugins/recruit-resources/src/components/CreateCandidate.svelte index ca253fd0a4..256ab4937b 100644 --- a/plugins/recruit-resources/src/components/CreateCandidate.svelte +++ b/plugins/recruit-resources/src/components/CreateCandidate.svelte @@ -34,13 +34,13 @@ import presentation, { Card, createQuery, + DraftController, + draftsStore, getClient, - getUserDraft, InlineAttributeBar, KeyedAttribute, MessageBox, - PDFViewer, - updateUserDraft + PDFViewer } from '@hcengineering/presentation' import type { Candidate, CandidateDraft } from '@hcengineering/recruit' import { recognizeDocument } from '@hcengineering/rekoni' @@ -55,27 +55,36 @@ IconFile as FileIcon, IconInfo, Label, - Link, showPopup, Spinner } from '@hcengineering/ui' - import deepEqual from 'deep-equal' - import { createEventDispatcher } from 'svelte' + import { createEventDispatcher, onDestroy } from 'svelte' import recruit from '../plugin' import FileUpload from './icons/FileUpload.svelte' import YesNo from './YesNo.svelte' export let shouldSaveDraft: boolean = false - export let onDraftChanged: () => void - const draft: CandidateDraft | undefined = shouldSaveDraft ? getUserDraft(recruit.mixin.Candidate) : undefined - const emptyObject = { - title: undefined, - city: '', - avatar: undefined, - onsite: undefined, - remote: undefined + const draftController = new DraftController(recruit.mixin.Candidate) + + function getEmptyCandidate (): CandidateDraft { + return { + _id: generateId(), + firstName: '', + lastName: '', + title: '', + channels: [], + skills: [], + city: '' + } } + const empty = {} + const client = getClient() + const hierarchy = client.getHierarchy() + const ignoreKeys = ['onsite', 'remote'] + + const draft = shouldSaveDraft ? draftController.get() : undefined + let object = draft ?? getEmptyCandidate() type resumeFile = { name: string uuid: string @@ -83,12 +92,7 @@ type: string lastModified: number } - - let candidateId = draft ? draft.candidateId : generateId() - let firstName = draft?.firstName || '' - let lastName = draft?.lastName || '' let createMore: boolean = false - let saveTimer: number | undefined export function canClose (): boolean { return true @@ -96,34 +100,24 @@ let avatarEditor: EditableAvatar - function toCandidate (draft: CandidateDraft | undefined): Candidate { - if (!draft) { - return emptyObject as Candidate - } - return { - title: draft?.title || '', - city: draft?.city || '', - onsite: draft?.onsite, - remote: draft?.remote - } as Candidate - } - const client = getClient() - const hierarchy = client.getHierarchy() - - const object: Candidate = toCandidate(draft) + fillDefaults(hierarchy, empty, recruit.mixin.Candidate) fillDefaults(hierarchy, object, recruit.mixin.Candidate) function resumeDraft () { return { - uuid: draft?.resumeUuid, - name: draft?.resumeName, - size: draft?.resumeSize, - type: draft?.resumeType, - lastModified: draft?.resumeLastModified + uuid: object?.resumeUuid, + name: object?.resumeName, + size: object?.resumeSize, + type: object?.resumeType, + lastModified: object?.resumeLastModified } } - let resume = resumeDraft() as resumeFile + if (shouldSaveDraft) { + draftController.watch(object, empty) + } + + onDestroy(() => draftController.unsubscribe()) const dispatch = createEventDispatcher() @@ -132,12 +126,10 @@ let dragover = false let avatar: File | undefined = draft?.avatar - let channels: AttachedData[] = draft?.channels || [] let matches: WithLookup[] = [] let matchedChannels: AttachedData[] = [] - let skills: TagReference[] = draft?.skills || [] const key: KeyedAttribute = { key: 'skills', attr: client.getHierarchy().getAttribute(recruit.mixin.Candidate, 'skills') @@ -164,89 +156,11 @@ }) }) - $: updateDraft(object, firstName, lastName, avatar, channels, skills, resume) - - async function updateDraft (...param: any) { - if (saveTimer) { - clearTimeout(saveTimer) - } - saveTimer = setTimeout(() => { - saveDraft() - }, 200) - } - - async function saveDraft () { - if (!shouldSaveDraft) { - return - } - - let newDraft: Data | undefined = createDraftFromObject() - const isEmpty = await isDraftEmpty(newDraft) - - if (isEmpty) { - newDraft = undefined - } - - updateUserDraft(recruit.mixin.Candidate, newDraft) - - if (onDraftChanged) { - return onDraftChanged() - } - } - - function createDraftFromObject () { - const newDraft: Data = { - candidateId: candidateId as Ref, - firstName, - lastName, - title: object.title, - city: object.city, - resumeUuid: resume?.uuid, - resumeName: resume?.name, - resumeType: resume?.type, - resumeSize: resume?.size, - resumeLastModified: resume?.lastModified, - avatar, - channels, - onsite: object.onsite, - remote: object.remote, - skills - } - - return newDraft - } - - async function isDraftEmpty (draft: Data): Promise { - const emptyDraft: Partial = { - firstName: '', - lastName: '', - title: undefined, - city: '', - resumeUuid: undefined, - resumeName: undefined, - resumeType: undefined, - resumeSize: undefined, - resumeLastModified: undefined, - avatar: undefined, - channels: [], - onsite: undefined, - remote: undefined, - skills: [] - } - - for (const key of Object.keys(emptyDraft)) { - if (!deepEqual((emptyDraft as any)[key], (draft as any)[key])) { - return false - } - } - - return true - } - async function createCandidate () { const candidate: Data = { - name: combineName(firstName, lastName), + name: combineName(object.firstName ?? '', object.lastName ?? ''), city: object.city, + channels: 0, createOn: Date.now() } if (avatar !== undefined) { @@ -255,36 +169,44 @@ const candidateData: MixinData = { title: object.title, onsite: object.onsite, - remote: object.remote + remote: object.remote, + skills: 0 } // Store all extra values. for (const [k, v] of Object.entries(object)) { if (v != null && k !== 'createOn' && k !== 'avatar') { - if (client.getHierarchy().getAttribute(recruit.mixin.Candidate, k).attributeOf === recruit.mixin.Candidate) { - ;(candidateData as any)[k] = v + const attr = hierarchy.findAttribute(recruit.mixin.Candidate, k) + if (attr === undefined) continue + if (attr.attributeOf === recruit.mixin.Candidate) { + if ((candidateData as any)[k] === undefined) { + ;(candidateData as any)[k] = v + } } else { - ;(candidate as any)[k] = v + if ((candidate as any)[k] === undefined) { + ;(candidate as any)[k] = v + } } } } - const applyOps = client.apply(candidateId) + const applyOps = client.apply(object._id) - await applyOps.createDoc(contact.class.Person, contact.space.Contacts, candidate, candidateId) + await applyOps.createDoc(contact.class.Person, contact.space.Contacts, candidate, object._id) await applyOps.createMixin( - candidateId as Ref, + object._id, contact.class.Person, contact.space.Contacts, recruit.mixin.Candidate, candidateData ) - if (resume.uuid !== undefined) { + if (object.resumeUuid !== undefined) { + const resume = resumeDraft() as resumeFile applyOps.addCollection( attachment.class.Attachment, contact.space.Contacts, - candidateId, + object._id, contact.class.Person, 'attachments', { @@ -296,11 +218,11 @@ } ) } - for (const channel of channels) { + for (const channel of object.channels) { await applyOps.addCollection( contact.class.Channel, contact.space.Contacts, - candidateId, + object._id, contact.class.Person, 'channels', { @@ -313,9 +235,9 @@ const categories = await client.findAll(tags.class.TagCategory, { targetClass: recruit.mixin.Candidate }) // Tag elements const skillTagElements = toIdMap( - await client.findAll(tags.class.TagElement, { _id: { $in: skills.map((it) => it.tag) } }) + await client.findAll(tags.class.TagElement, { _id: { $in: object.skills.map((it) => it.tag) } }) ) - for (const skill of skills) { + for (const skill of object.skills) { // Create update tag if missing if (!skillTagElements.has(skill.tag)) { skill.tag = await client.createDoc(tags.class.TagElement, skill.space, { @@ -326,7 +248,7 @@ category: findTagCategory(skill.title, categories) }) } - await applyOps.addCollection(skill._class, skill.space, candidateId, recruit.mixin.Candidate, 'skills', { + await applyOps.addCollection(skill._class, skill.space, object._id, recruit.mixin.Candidate, 'skills', { title: skill.title, color: skill.color, tag: skill.tag, @@ -335,12 +257,11 @@ } await applyOps.commit() - + draftController.remove() if (!createMore) { - dispatch('close', candidateId) + dispatch('close', object._id) } resetObject() - saveDraft() } function isUndef (value?: string): boolean { @@ -373,12 +294,12 @@ object.title = doc.title } - if (isUndef(firstName) && doc.firstName !== undefined) { - firstName = doc.firstName + if (isUndef(object.firstName) && doc.firstName !== undefined) { + object.firstName = doc.firstName } - if (isUndef(lastName) && doc.lastName !== undefined) { - lastName = doc.lastName + if (isUndef(object.lastName) && doc.lastName !== undefined) { + object.lastName = doc.lastName } if (isUndef(object.city) && doc.city !== undefined) { @@ -396,7 +317,7 @@ avatar = new File([u8arr], doc.avatarName ?? 'avatar.png', { type: doc.avatarFormat ?? 'image/png' }) } - const newChannels = [...channels] + const newChannels = [...object.channels] addChannel(newChannels, contact.channelProvider.Email, doc.email) addChannel(newChannels, contact.channelProvider.GitHub, doc.github) addChannel(newChannels, contact.channelProvider.LinkedIn, doc.linkedin) @@ -404,7 +325,7 @@ addChannel(newChannels, contact.channelProvider.Telegram, doc.telegram) addChannel(newChannels, contact.channelProvider.Twitter, doc.twitter) addChannel(newChannels, contact.channelProvider.Facebook, doc.facebook) - channels = newChannels + object.channels = newChannels // Create skills await elementsPromise @@ -448,16 +369,16 @@ ) ) } - skills = [...skills, ...newSkills] + object.skills = [...object.skills, ...newSkills] } catch (err: any) { console.error(err) } } async function deleteResume (): Promise { - if (resume.uuid) { + if (object.resumeUuid) { try { - await deleteFile(resume.uuid) + await deleteFile(object.resumeUuid) } catch (err) { console.error(err) } @@ -469,11 +390,11 @@ try { const uploadFile = await getResource(attachment.helper.UploadFile) - resume.uuid = await uploadFile(file) - resume.name = file.name - resume.size = file.size - resume.type = file.type - resume.lastModified = file.lastModified + object.resumeUuid = await uploadFile(file) + object.resumeName = file.name + object.resumeSize = file.size + object.resumeType = file.type + object.resumeLastModified = file.lastModified await recognize(file) } catch (err: any) { @@ -501,8 +422,8 @@ } function addTagRef (tag: TagElement): void { - skills = [ - ...skills, + object.skills = [ + ...object.skills, { _class: tags.class.TagReference, _id: generateId() as Ref, @@ -519,45 +440,34 @@ ] } - $: findContacts( - client, - contact.class.Person, - { ...object, name: combineName(firstName.trim(), lastName.trim()) }, - channels - ).then((p) => { - matches = p.contacts - matchedChannels = p.channels - }) + $: object.firstName && + object.lastName && + findContacts( + client, + contact.class.Person, + combineName(object.firstName.trim(), object.lastName.trim()), + object.channels + ).then((p) => { + matches = p.contacts + matchedChannels = p.channels + }) const manager = createFocusManager() function resetObject (): void { - candidateId = generateId() - avatar = undefined - firstName = '' - lastName = '' - channels = [] - skills = [] - resume = {} as resumeFile - object.title = undefined - object.city = '' - object.avatar = undefined - object.onsite = undefined - object.remote = undefined + object = getEmptyCandidate() fillDefaults(hierarchy, object, recruit.mixin.Candidate) } export async function onOutsideClick () { - saveDraft() - - if (onDraftChanged) { - return onDraftChanged() + if (shouldSaveDraft) { + draftController.save(object, empty) } } async function showConfirmationDialog () { - const newDraft = createDraftFromObject() - const isFormEmpty = await isDraftEmpty(newDraft) + draftController.save(object, empty) + const isFormEmpty = $draftsStore[recruit.mixin.Candidate] === undefined if (isFormEmpty) { dispatch('close') @@ -574,7 +484,7 @@ dispatch('close') deleteResume() resetObject() - saveDraft() + draftController.remove() } } ) @@ -587,7 +497,8 @@ 0 || lastName.length > 0 || channels.length > 0)} + canSave={!loading && + ((object.firstName?.length ?? 0) > 0 || (object.lastName?.length ?? 0) > 0 || object.channels.length > 0)} on:close={() => { dispatch('close') }} @@ -609,7 +520,7 @@
@@ -657,7 +568,7 @@ it.provider)} /> { - skills = skills.filter((it) => it._id !== evt.detail) + object.skills = object.skills.filter((it) => it._id !== evt.detail) }} /> - {#if skills.length > 0} + {#if object.skills.length > 0}
{ - skills = skills.filter((it) => it._id !== evt.detail) + object.skills = object.skills.filter((it) => it._id !== evt.detail) }} on:change={(evt) => { evt.detail.tag.weight = evt.detail.weight - skills = skills + object.skills = object.skills }} />
@@ -728,7 +639,7 @@ _class={recruit.mixin.Candidate} {object} toClass={contact.class.Contact} - ignoreKeys={['onsite', 'remote']} + {ignoreKeys} extraProps={{ showNavigate: false }} />
@@ -737,7 +648,7 @@
{ dragover = true }} @@ -746,12 +657,12 @@ }} on:drop|preventDefault|stopPropagation={drop} > - {#if loading && resume.uuid} - + {#if loading && object.resumeUuid} + {:else} diff --git a/plugins/recruit-resources/src/components/NewCandidateHeader.svelte b/plugins/recruit-resources/src/components/NewCandidateHeader.svelte index 46c5bac166..e8b4d33d4d 100644 --- a/plugins/recruit-resources/src/components/NewCandidateHeader.svelte +++ b/plugins/recruit-resources/src/components/NewCandidateHeader.svelte @@ -13,19 +13,15 @@ // limitations under the License. --> diff --git a/plugins/recruit/src/index.ts b/plugins/recruit/src/index.ts index 578555ca79..52fc1cd1f2 100644 --- a/plugins/recruit/src/index.ts +++ b/plugins/recruit/src/index.ts @@ -18,9 +18,9 @@ import type { Channel, Organization, Person } from '@hcengineering/contact' import type { AttachedData, AttachedDoc, Class, Doc, Mixin, Ref, Space, Timestamp } from '@hcengineering/core' import type { Asset, Plugin, Resource } from '@hcengineering/platform' import { plugin } from '@hcengineering/platform' +import { TagReference } from '@hcengineering/tags' import type { KanbanTemplateSpace, SpaceWithStates, Task } from '@hcengineering/task' import { AnyComponent, ResolvedLocation } from '@hcengineering/ui' -import { TagReference } from '@hcengineering/tags' /** * @public @@ -63,8 +63,8 @@ export interface Candidate extends Person { /** * @public */ -export interface CandidateDraft extends Doc { - candidateId: Ref +export interface CandidateDraft { + _id: Ref firstName?: string lastName?: string title?: string diff --git a/plugins/request-resources/package.json b/plugins/request-resources/package.json index c3e4e9f655..d942e3cbf5 100644 --- a/plugins/request-resources/package.json +++ b/plugins/request-resources/package.json @@ -28,7 +28,6 @@ "eslint": "^8.26.0", "prettier": "^2.7.1", "svelte-check": "^2.8.0", - "@types/deep-equal": "^1.0.1", "typescript": "^4.3.5" }, "dependencies": { diff --git a/plugins/tracker-resources/src/components/CreateIssue.svelte b/plugins/tracker-resources/src/components/CreateIssue.svelte index 062187e189..fb824959fd 100644 --- a/plugins/tracker-resources/src/components/CreateIssue.svelte +++ b/plugins/tracker-resources/src/components/CreateIssue.svelte @@ -16,32 +16,22 @@ import { AttachmentStyledBox } from '@hcengineering/attachment-resources' import chunter from '@hcengineering/chunter' import { Employee } from '@hcengineering/contact' - import core, { - Account, - AttachedData, - Data, - Doc, - fillDefaults, - generateId, - Ref, - SortingOrder - } from '@hcengineering/core' + import core, { Account, AttachedData, Doc, fillDefaults, generateId, Ref, SortingOrder } from '@hcengineering/core' import { getResource, translate } from '@hcengineering/platform' import { Card, createQuery, + DraftController, + draftsStore, getClient, - getUserDraft, KeyedAttribute, MessageBox, - SpaceSelector, - updateUserDraft + SpaceSelector } from '@hcengineering/presentation' import tags, { TagElement, TagReference } from '@hcengineering/tags' import { calcRank, Component as ComponentType, - DraftIssueChild, Issue, IssueDraft, IssuePriority, @@ -65,7 +55,7 @@ } from '@hcengineering/ui' import view from '@hcengineering/view' import { ObjectBox } from '@hcengineering/view-resources' - import { deepEqual } from 'fast-equals' + import { onDestroy } from 'svelte' import { createEventDispatcher } from 'svelte' import { activeComponent, activeSprint, generateIssueShortLink, getIssueId, updateIssueRelation } from '../issues' import tracker from '../plugin' @@ -91,82 +81,105 @@ export let shouldSaveDraft: boolean = false export let parentIssue: Issue | undefined export let originalIssue: Issue | undefined - export let onDraftChanged: () => void - const draft: IssueDraft | undefined = shouldSaveDraft ? getUserDraft(tracker.class.IssueDraft) : undefined + const draftController = new DraftController(tracker.ids.IssueDraft) + + const draft: IssueDraft | undefined = shouldSaveDraft ? draftController.get() : undefined const client = getClient() const hierarchy = client.getHierarchy() + const parentQuery = createQuery() + let _space = space + + let object = draft ?? getDefaultObject() + + $: if (object.parentIssue) { + parentQuery.query( + tracker.class.Issue, + { + _id: object.parentIssue + }, + (res) => { + ;[parentIssue] = res + } + ) + } else { + parentQuery.unsubscribe() + parentIssue = undefined + } + + function getDefaultObject (ignoreOriginal = false): IssueDraft { + const base: IssueDraft = { + _id: generateId(), + title: '', + description: '', + priority, + space: _space, + component, + dueDate: null, + attachments: 0, + estimation: 0, + sprint, + status, + assignee, + labels: [], + parentIssue: parentIssue?._id, + subIssues: [] + } + if (originalIssue && !ignoreOriginal) { + const res: IssueDraft = { + ...base, + description: originalIssue.description, + status: originalIssue.status, + priority: originalIssue.priority, + component: originalIssue.component, + dueDate: originalIssue.dueDate, + assignee: originalIssue.assignee, + estimation: originalIssue.estimation, + parentIssue: originalIssue.parents[0]?.parentId, + title: `${originalIssue.title} (copy)` + } + client.findAll(tags.class.TagReference, { attachedTo: originalIssue._id }).then((p) => { + object.labels = p + }) + if (originalIssue.relations?.[0]) { + client.findOne(tracker.class.Issue, { _id: originalIssue.relations[0]._id as Ref }).then((p) => { + relatedTo = p + }) + } + + return res + } + return base + } + fillDefaults(hierarchy, object, tracker.class.Issue) let subIssuesComponent: SubIssues - let labels: TagReference[] = draft?.labels || [] - let objectId: Ref = draft?.issueId || generateId() - let saveTimer: number | undefined let currentProject: Project | undefined - function toIssue (initials: AttachedData, draft: IssueDraft | undefined): AttachedData { - if (draft === undefined) { - return { ...initials } - } - const { labels, subIssues, ...issue } = draft - return { ...initials, ...issue } - } + $: updateIssueStatusId(object, currentProject) + $: updateAssigneeId(object, currentProject) + $: canSave = getTitle(object.title ?? '').length > 0 && object.status !== undefined - const defaultIssue = { - title: '', - description: '', - assignee, + $: empty = { + assignee: assignee ?? currentProject?.defaultAssignee, + status: status ?? currentProject?.defaultIssueStatus, + parentIssue: parentIssue?._id, + description: '

', component, sprint, - number: 0, - rank: '', - status: '' as Ref, priority, - dueDate: null, - comments: 0, - attachments: 0, - subIssues: 0, - parents: [], - reportedTime: 0, - estimation: 0, - reports: 0, - childInfo: [], - createOn: Date.now() + space } - $: _space = draft?.project || space - $: !originalIssue && !draft && updateIssueStatusId(currentProject, status) - $: !originalIssue && !draft && updateAssigneeId(currentProject) - $: canSave = getTitle(object.title ?? '').length > 0 - $: if (object.space !== _space) { object.space = _space } - let object = originalIssue - ? { - ...originalIssue, - title: `${originalIssue.title} (copy)`, - subIssues: 0, - attachments: 0, - reportedTime: 0, - reports: 0, - childInfo: [], - space: _space - } - : { ...toIssue(defaultIssue, draft), space: _space } - fillDefaults(hierarchy, object, tracker.class.Issue) - function resetObject (): void { templateId = undefined template = undefined - object = { ...defaultIssue, space: _space } - subIssues = [] - labels = [] - if (!originalIssue && !draft) { - updateIssueStatusId(currentProject, status) - updateAssigneeId(currentProject) - } + object = getDefaultObject(true) fillDefaults(hierarchy, object, tracker.class.Issue) } @@ -176,8 +189,6 @@ let template: IssueTemplate | undefined = undefined const templateQuery = createQuery() - let subIssues: DraftIssueChild[] = draft?.subIssues || [] - $: if (templateId !== undefined) { templateQuery.query(tracker.class.IssueTemplate, { _id: templateId }, (res) => { template = res[0] @@ -203,14 +214,22 @@ } } - async function updateObject (template: IssueTemplate): Promise { + async function updateTemplate (template: IssueTemplate): Promise { if (object.template?.template === template._id) { return } - const { _class, _id, space, children, comments, attachments, labels: labels_, description, ...templBase } = template + const { _class, _id, space, children, comments, attachments, labels, description, ...templBase } = template - subIssues = template.children.map((p) => { - return { ...p, status: currentProject?.defaultIssueStatus ?? ('' as Ref) } + object.subIssues = template.children.map((p) => { + return { + ...p, + _id: p.id, + space: _space, + subIssues: [], + dueDate: null, + labels: [], + status: currentProject?.defaultIssueStatus + } }) object = { @@ -222,17 +241,11 @@ } } appliedTemplateId = templateId - const tagElements = await client.findAll(tags.class.TagElement, { _id: { $in: labels_ } }) - labels = tagElements.map(tagAsRef) + const tagElements = await client.findAll(tags.class.TagElement, { _id: { $in: labels } }) + object.labels = tagElements.map(tagAsRef) } - function updateTemplate (template?: IssueTemplate): void { - if (template !== undefined) { - updateObject(template) - } - } - - $: updateTemplate(template) + $: template && updateTemplate(template) const dispatch = createEventDispatcher() const spaceQuery = createQuery() @@ -248,81 +261,24 @@ currentProject = res.shift() }) - async function setPropsFromOriginalIssue () { - if (!originalIssue) { - return - } - const { _id, relations, parents } = originalIssue - - if (relations?.[0]) { - relatedTo = await client.findOne(tracker.class.Issue, { _id: relations[0]._id as Ref }) - } - if (parents?.[0]) { - parentIssue = await client.findOne(tracker.class.Issue, { _id: parents[0].parentId }) - } - if (originalIssue.labels) { - labels = await client.findAll(tags.class.TagReference, { attachedTo: _id }) - } - } - - async function setPropsFromDraft () { - if (!draft?.parentIssue) { - return - } - - parentIssue = await client.findOne(tracker.class.Issue, { _id: draft.parentIssue as Ref }) - } - - $: originalIssue && setPropsFromOriginalIssue() - $: draft && setPropsFromDraft() - $: object && updateDraft() - - async function updateDraft () { - if (saveTimer) { - clearTimeout(saveTimer) - } - saveTimer = setTimeout(() => { - saveDraft() - }, 200) - } - - async function saveDraft () { - if (!shouldSaveDraft) { - return - } - - let newDraft: Data | undefined = createDraftFromObject() - const isEmpty = await isDraftEmpty(newDraft) - - if (isEmpty) { - newDraft = undefined - } - updateUserDraft(tracker.class.IssueDraft, newDraft) - - if (onDraftChanged) { - return onDraftChanged() - } - } - - async function updateIssueStatusId (currentProject: Project | undefined, issueStatusId?: Ref) { - if (issueStatusId !== undefined) { - object.status = issueStatusId - return - } - - if (currentProject?.defaultIssueStatus) { + async function updateIssueStatusId (object: IssueDraft, currentProject: Project | undefined) { + if (currentProject?.defaultIssueStatus && object.status === undefined) { object.status = currentProject.defaultIssueStatus } } - function updateAssigneeId (currentProject: Project | undefined) { - if (currentProject?.defaultAssignee !== undefined) { - object.assignee = currentProject.defaultAssignee - } else { - object.assignee = null + function updateAssigneeId (object: IssueDraft, currentProject: Project | undefined) { + if (object.assignee === undefined && currentProject !== undefined) { + if (currentProject.defaultAssignee !== undefined) { + object.assignee = currentProject.defaultAssignee + } else { + object.assignee = null + } } } function clearParentIssue () { + object.parentIssue = undefined + parentQuery.unsubscribe() parentIssue = undefined } @@ -330,94 +286,24 @@ return value.trim() } - async function isDraftEmpty (draft: Data): Promise { - const emptyDraft: Partial = { - description: '', - dueDate: null, - estimation: 0, - attachments: 0, - labels: [], - parentIssue: undefined, - priority: 0, - subIssues: [], - template: undefined, - title: '' - } - - for (const key of Object.keys(emptyDraft)) { - if (!deepEqual((emptyDraft as any)[key], (draft as any)[key])) { - return false - } - } - - if (object.attachments && object.attachments > 0) { - return false - } - - if (draft.component && draft.component !== defaultIssue.component) { - return false - } - - if (draft.sprint && draft.sprint !== defaultIssue.sprint) { - return false - } - - if (draft.status === '') { - return true - } - - if (currentProject?.defaultIssueStatus) { - return draft.status === currentProject.defaultIssueStatus - } - - if (draft.assignee === null) { - return true - } - - if (currentProject?.defaultAssignee) { - return draft.assignee === currentProject.defaultAssignee - } - - return false - } - export function canClose (): boolean { return true } - function createDraftFromObject () { - const newDraft: Data = { - issueId: objectId, - title: getTitle(object.title), - description: (object.description as string).replaceAll('

', ''), - assignee: object.assignee, - component: object.component, - sprint: object.sprint, - status: object.status, - priority: object.priority, - dueDate: object.dueDate, - estimation: object.estimation, - template: object.template, - attachments: object.attachments, - labels, - parentIssue: parentIssue?._id, - project: _space, - subIssues + export async function onOutsideClick () { + if (shouldSaveDraft) { + draftController.save(object, empty) } - - return newDraft } - export async function onOutsideClick () { - saveDraft() - - if (onDraftChanged) { - return onDraftChanged() - } + $: watch(empty) + function watch (empty: Record): void { + if (!shouldSaveDraft) return + draftController.watch(object, empty) } async function createIssue () { - if (!canSave) { + if (!canSave || object.status === undefined) { return } @@ -463,10 +349,10 @@ parentIssue?._class ?? tracker.class.Issue, 'subIssues', value, - objectId + object._id ) - for (const label of labels) { - await client.addCollection(label._class, label.space, objectId, tracker.class.Issue, 'labels', { + for (const label of object.labels) { + await client.addCollection(label._class, label.space, object._id, tracker.class.Issue, 'labels', { title: label.title, color: label.color, tag: label.tag @@ -475,7 +361,7 @@ await descriptionBox.createAttachments() if (relatedTo !== undefined) { - const doc = await client.findOne(tracker.class.Issue, { _id: objectId }) + const doc = await client.findOne(tracker.class.Issue, { _id: object._id }) if (doc !== undefined) { if (client.getHierarchy().isDerived(relatedTo._class, tracker.class.Issue)) { await updateIssueRelation(client, relatedTo as Issue, doc, 'relations', '$push') @@ -487,21 +373,20 @@ } const parents = parentIssue ? [ - { parentId: objectId, parentTitle: value.title }, + { parentId: object._id, parentTitle: value.title }, { parentId: parentIssue._id, parentTitle: parentIssue.title }, ...parentIssue.parents ] - : [{ parentId: objectId, parentTitle: value.title }] + : [{ parentId: object._id, parentTitle: value.title }] await subIssuesComponent.save(parents) addNotification(await translate(tracker.string.IssueCreated, {}), getTitle(object.title), IssueNotification, { - issueId: objectId, + issueId: object._id, subTitlePostfix: (await translate(tracker.string.Created, { value: 1 })).toLowerCase(), issueUrl: currentProject && generateIssueShortLink(getIssueId(currentProject, value as Issue)) }) - objectId = generateId() + draftController.remove() resetObject() - saveDraft() descriptionBox?.removeDraft(false) } @@ -529,7 +414,12 @@ SetParentIssueActionPopup, { value: { ...object, space: _space, attachedTo: parentIssue?._id } }, 'top', - (selectedIssue) => selectedIssue !== undefined && (parentIssue = selectedIssue) + (selectedIssue) => { + if (selectedIssue !== undefined) { + parentIssue = selectedIssue + object.parentIssue = parentIssue?._id + } + } ) } @@ -553,7 +443,7 @@ return } - object = { ...object, component: componentId } + object.component = componentId } const handleSprintIdChanged = async (sprintId: Ref | null | undefined) => { @@ -566,11 +456,12 @@ componentSprintId = sprint && sprint.component ? sprint.component : null } else componentSprintId = null - object = { ...object, sprint: sprintId, component: componentSprintId } + object.sprint = sprintId + object.component = componentSprintId } function addTagRef (tag: TagElement): void { - labels = [...labels, tagAsRef(tag)] + object.labels = [...object.labels, tagAsRef(tag)] } function handleTemplateChange (evt: CustomEvent>): void { if (templateId == null) { @@ -590,7 +481,7 @@ templateId = evt.detail ?? undefined if (templateId === undefined) { - subIssues = [] + object.subIssues = [] resetObject() } } @@ -599,11 +490,10 @@ } async function showConfirmationDialog () { - const newDraft = createDraftFromObject() - const isFormEmpty = await isDraftEmpty(newDraft) + draftController.save(object, empty) + const isFormEmpty = $draftsStore[tracker.ids.IssueDraft] === undefined if (isFormEmpty) { - console.log('isFormEmpty') dispatch('close') } else { showPopup( @@ -617,13 +507,17 @@ if (result === true) { dispatch('close') resetObject() - saveDraft() + draftController.remove() descriptionBox?.removeDraft(true) } } ) } } + + onDestroy(() => draftController.unsubscribe()) + + $: objectId = object._id {/if} - - {#key [objectId, appliedTemplateId]} - dispatch('changeContent')} - on:attach={(ev) => { - if (ev.detail.action === 'saved') { - object.attachments = ev.detail.value - } - }} - /> - {/key} +
+ +
+
+ {#key [objectId, appliedTemplateId]} + dispatch('changeContent')} + on:attach={(ev) => { + if (ev.detail.action === 'saved') { + object.attachments = ev.detail.value + } + }} + /> + {/key} +
@@ -715,26 +614,30 @@ on:change={({ detail }) => (object.status = detail)} />
- (object.priority = detail)} - /> - (object.assignee = detail)} - /> +
+ (object.priority = detail)} + /> +
+
+ (object.assignee = detail)} + /> +
{ - labels = labels.filter((it) => it._id !== evt.detail) + object.labels = object.labels.filter((it) => it._id !== evt.detail) }} /> - +
+ +
{/if} - +