From efd1b33000fd2e3731d4392da43687ed7e47ea5d Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Sat, 17 Oct 2026 12:48:39 +0000 Subject: [PATCH 1/2] fix(tracker): keep task type, milestone and custom fields when duplicating an issue The Duplicate action opens the create issue dialog with the original issue, but only a fixed set of fields was copied. The task type, milestone, start date and all user defined attributes (custom attributes of the Issue class and of applied mixins such as task type classes) were lost. Copy the task type, milestone and start date, and collect the values of custom attributes of the original issue. System attributes, collections, identifiers and collaborative documents are skipped. Class attributes are stored with the new issue, mixin values are applied with one createMixin per mixin. Mixins that are the target class of a task type are only applied when the duplicate keeps one of those task types; an invalid task type or status for the target project is still replaced by the existing selectors. When another project is selected, the milestone and component of the original project are cleared. The Duplicate action no longer stores the form as a new issue draft. Fixes #4968 Signed-off-by: Michael Uray --- models/tracker/src/actions.ts | 3 + .../src/__tests__/duplicate.test.ts | 128 ++++++++++++++++++ .../src/components/CreateIssue.svelte | 43 +++++- plugins/tracker-resources/src/duplicate.ts | 83 ++++++++++++ 4 files changed, 255 insertions(+), 2 deletions(-) create mode 100644 plugins/tracker-resources/src/__tests__/duplicate.test.ts create mode 100644 plugins/tracker-resources/src/duplicate.ts diff --git a/models/tracker/src/actions.ts b/models/tracker/src/actions.ts index 659c90358dd..09815ed7c65 100644 --- a/models/tracker/src/actions.ts +++ b/models/tracker/src/actions.ts @@ -684,6 +684,9 @@ export function createActions (builder: Builder, issuesId: string, componentsId: actionProps: { component: tracker.component.CreateIssue, element: 'top', + props: { + shouldSaveDraft: false + }, fillProps: { _object: 'originalIssue', space: 'space' diff --git a/plugins/tracker-resources/src/__tests__/duplicate.test.ts b/plugins/tracker-resources/src/__tests__/duplicate.test.ts new file mode 100644 index 00000000000..6be847256e3 --- /dev/null +++ b/plugins/tracker-resources/src/__tests__/duplicate.test.ts @@ -0,0 +1,128 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import type { AnyAttribute, Class, Classifier, Doc, Ref } from '@hcengineering/core' +import { getCustomAttributesToCopy, type DuplicateHierarchy } from '../duplicate' + +const issueClass = 'tracker:class:Issue' as Ref> +const typeMixin = 'task-type:type:mixin' as Ref> +const systemMixin = 'sync:mixin:External' as Ref> +const emptyMixin = 'custom:mixin:Empty' as Ref> +const stringType = 'core:class:TypeString' as any +const collectionType = 'core:class:Collection' as any +const identifierType = 'core:class:TypeIdentifier' as any +const collaborativeDocType = 'core:class:TypeCollaborativeDoc' as any +const excludedTypes = [collectionType, identifierType, collaborativeDocType] + +function attr (name: string, isCustom: boolean, type = stringType): AnyAttribute { + return { name, isCustom, type: { _class: type } } as unknown as AnyAttribute +} + +function attrs (...list: AnyAttribute[]): Map { + return new Map(list.map((a) => [a.name, a])) +} + +const attributesByClass = new Map, Map>([ + [ + issueClass, + attrs( + attr('title', false), + attr('status', false), + attr('subIssues', false, collectionType), + attr('customText', true), + attr('customEmpty', true), + attr('customCollection', true, collectionType), + attr('customIdentifier', true, identifierType), + attr('customDoc', true, collaborativeDocType) + ) + ], + [ + typeMixin, + attrs(attr('customTypeField', true), attr('customTypeOther', true), attr('customTypeId', true, identifierType)) + ], + [systemMixin, attrs(attr('externalId', false))], + [emptyMixin, attrs(attr('customUnset', true))] +]) + +const hierarchy: DuplicateHierarchy = { + getAllAttributes: (clazz) => attributesByClass.get(clazz) ?? new Map(), + findAllMixins: (doc) => [typeMixin, systemMixin, emptyMixin].filter((m) => typeof (doc as any)[m] === 'object') +} + +describe('getCustomAttributesToCopy', () => { + const doc = { + _id: 'issue-1', + _class: issueClass, + title: 'Original', + status: 'status-1', + subIssues: 3, + customText: 'hello', + customCollection: 2, + customIdentifier: 'ID-1', + customDoc: 'blob-1', + [typeMixin]: { customTypeField: 42, customTypeId: 'ID-2' }, + [systemMixin]: { externalId: 'ext-1' }, + [emptyMixin]: {} + } as unknown as Doc + + it('copies only custom attributes of the class', () => { + const result = getCustomAttributesToCopy(hierarchy, doc, issueClass, excludedTypes) + expect(result.attributes).toEqual({ customText: 'hello' }) + }) + + it('copies custom attributes of applied mixins and skips system or empty mixins', () => { + const result = getCustomAttributesToCopy(hierarchy, doc, issueClass, excludedTypes) + expect(Array.from(result.mixins.entries())).toEqual([[typeMixin, { customTypeField: 42 }]]) + }) + + it('does not copy identifier, collaborative document and collection attributes', () => { + const result = getCustomAttributesToCopy(hierarchy, doc, issueClass, excludedTypes) + expect(result.attributes).not.toHaveProperty('customIdentifier') + expect(result.attributes).not.toHaveProperty('customDoc') + expect(result.attributes).not.toHaveProperty('customCollection') + expect(result.mixins.get(typeMixin as any)).not.toHaveProperty('customTypeId') + }) + + it('copies every custom attribute type that is not excluded', () => { + const result = getCustomAttributesToCopy(hierarchy, doc, issueClass) + expect(result.attributes).toEqual({ + customText: 'hello', + customCollection: 2, + customIdentifier: 'ID-1', + customDoc: 'blob-1' + }) + }) + + it('keeps falsy but defined values', () => { + const result = getCustomAttributesToCopy( + hierarchy, + { ...doc, customText: '', [typeMixin]: { customTypeField: 0, customTypeOther: null } } as unknown as Doc, + issueClass, + excludedTypes + ) + expect(result.attributes).toEqual({ customText: '' }) + expect(result.mixins.get(typeMixin as any)).toEqual({ customTypeField: 0, customTypeOther: null }) + }) + + it('returns nothing for a document without custom values', () => { + const result = getCustomAttributesToCopy( + hierarchy, + { _id: 'issue-2', _class: issueClass, title: 'Plain' } as unknown as Doc, + issueClass + ) + expect(result.attributes).toEqual({}) + expect(result.mixins.size).toBe(0) + }) +}) diff --git a/plugins/tracker-resources/src/components/CreateIssue.svelte b/plugins/tracker-resources/src/components/CreateIssue.svelte index e724a2a38c2..3e73d0b6245 100644 --- a/plugins/tracker-resources/src/components/CreateIssue.svelte +++ b/plugins/tracker-resources/src/components/CreateIssue.svelte @@ -26,6 +26,7 @@ fillDefaults, generateId, getCurrentAccount, + Hierarchy, makeCollabId, makeDocCollabId, type PersonId, @@ -50,7 +51,7 @@ SpaceSelector } from '@hcengineering/presentation' import tags, { type TagElement, TagReference } from '@hcengineering/tags' - import { TaskType } from '@hcengineering/task' + import task, { type Task, TaskType } from '@hcengineering/task' import { TaskKindSelector } from '@hcengineering/task-resources' import { EmptyMarkup, isEmptyMarkup } from '@hcengineering/text' import { @@ -83,6 +84,7 @@ import { ObjectBox } from '@hcengineering/view-resources' import { createEventDispatcher, onDestroy } from 'svelte' + import { type CopiedCustomAttributes, getCustomAttributesToCopy } from '../duplicate' import { activeComponent, activeMilestone, generateIssueShortLink, updateIssueRelation } from '../issues' import tracker from '../plugin' import SetParentIssueActionPopup from './SetParentIssueActionPopup.svelte' @@ -133,10 +135,13 @@ const parentQuery = createQuery() let _space = draft?.space ?? space + // Custom attribute values copied from the original issue on duplication. + let copiedAttributes: CopiedCustomAttributes | undefined // let project: Project | undefined let object = getDefaultObjectFromDraft() ?? getDefaultObject(id) let isAssigneeTouched = false - let kind: Ref | undefined = undefined + // TaskKindSelector replaces a kind that is not part of the selected project type. + let kind: Ref | undefined = draft == null ? originalIssue?.kind : undefined let templateId: Ref | undefined = draft?.template?.template let appliedTemplateId: Ref | undefined = draft?.template?.template @@ -205,15 +210,23 @@ if (originalIssue !== undefined && !ignoreOriginal) { const res: IssueDraft = { ...base, + kind: originalIssue.kind, status: originalIssue.status, priority: originalIssue.priority, component: originalIssue.component, + milestone: originalIssue.milestone ?? null, + startDate: originalIssue.startDate ?? null, dueDate: originalIssue.dueDate, assignee: originalIssue.assignee, estimation: originalIssue.estimation, parentIssue: originalIssue.parents[0]?.parentId, title: `${originalIssue.title} (copy)` } + copiedAttributes = getCustomAttributesToCopy(hierarchy, Hierarchy.toDoc(originalIssue), tracker.class.Issue, [ + core.class.Collection, + core.class.TypeIdentifier, + core.class.TypeCollaborativeDoc + ]) void getMarkup(makeDocCollabId(originalIssue, 'description'), originalIssue.description).then((res) => { object.description = res }) @@ -258,6 +271,20 @@ $: objectChange(object, empty) + // Milestone and component of the original issue belong to its project. + $: if (originalIssue !== undefined && _space !== undefined && _space !== originalIssue.space) { + clearOriginalProjectRefs(originalIssue) + } + + function clearOriginalProjectRefs (original: Issue): void { + if (object.milestone != null && object.milestone === original.milestone) { + object.milestone = null + } + if (object.component != null && object.component === original.component) { + object.component = null + } + } + $: if (_space !== undefined && object.space !== _space) { object.space = _space } @@ -265,6 +292,7 @@ function resetObject (): void { templateId = undefined template = undefined + copiedAttributes = undefined object = getDefaultObject(undefined, true) fillDefaults(hierarchy, object, tracker.class.Issue) } @@ -484,6 +512,7 @@ const identifier = `${currentProject?.identifier}-${number}` const value: DocData = { + ...copiedAttributes?.attributes, title: getTitle(object.title), description: null, assignee: object.assignee, @@ -535,6 +564,16 @@ value, _id ) + for (const [mixin, data] of copiedAttributes?.mixins ?? []) { + // Custom fields of a task type are only relevant for issues of that task type. + const taskTypes = client.getModel().findAllSync(task.class.TaskType, { + targetClass: mixin as Ref> + }) + if (taskTypes.length > 0 && !taskTypes.some((it) => it._id === kind)) { + continue + } + await operations.createMixin(_id, tracker.class.Issue, _space, mixin, data) + } await docCreateManager.commit(operations, _id, currentProject, value, 'post') for (const label of object.labels) { await operations.addCollection(label._class, label.space, _id, tracker.class.Issue, 'labels', { diff --git a/plugins/tracker-resources/src/duplicate.ts b/plugins/tracker-resources/src/duplicate.ts new file mode 100644 index 00000000000..b9da7081238 --- /dev/null +++ b/plugins/tracker-resources/src/duplicate.ts @@ -0,0 +1,83 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import type { AnyAttribute, Class, Classifier, Doc, Mixin, Ref, Type } from '@hcengineering/core' + +/** + * Subset of Hierarchy used to pick the attributes copied on duplication. + * @public + */ +export interface DuplicateHierarchy { + getAllAttributes: (clazz: Ref, to?: Ref) => Map + findAllMixins: (doc: Doc) => Array>> +} + +/** + * Values of user defined attributes of a document, split into plain class + * attributes and attributes stored in mixins. + * @public + */ +export interface CopiedCustomAttributes { + attributes: Record + mixins: Map>, Record> +} + +function pickCustomValues ( + source: Record | undefined, + attributes: Map, + excludedTypes: Set>>> +): Record { + const result: Record = {} + if (source == null) return result + for (const [name, attr] of attributes) { + if (attr.isCustom !== true || excludedTypes.has(attr.type._class)) continue + const value = source[name] + if (value === undefined) continue + result[name] = value + } + return result +} + +/** + * Collect user defined (custom) attribute values of a document, so they could be + * copied to a duplicate. System attributes are never returned, as well as + * attributes of excluded types (for example collections). + * + * @param doc - plain document (not a mixin proxy) + * @param _class - document class + * @public + */ +export function getCustomAttributesToCopy ( + hierarchy: DuplicateHierarchy, + doc: Doc, + _class: Ref>, + excludedTypes: Array>>> = [] +): CopiedCustomAttributes { + const excluded = new Set(excludedTypes) + const attributes = pickCustomValues(doc as any, hierarchy.getAllAttributes(_class), excluded) + + const mixins = new Map>, Record>() + for (const mixin of hierarchy.findAllMixins(doc)) { + const values = pickCustomValues( + (doc as any)[mixin], + hierarchy.getAllAttributes(mixin, _class), + excluded + ) + if (Object.keys(values).length > 0) { + mixins.set(mixin as Ref>, values) + } + } + return { attributes, mixins } +} From 1dc9aedd78f479963755f40e098d74a9414e4874 Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Sat, 17 Oct 2026 16:31:06 +0000 Subject: [PATCH 2/2] style: apply formatter Signed-off-by: Michael Uray --- plugins/tracker-resources/src/duplicate.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/plugins/tracker-resources/src/duplicate.ts b/plugins/tracker-resources/src/duplicate.ts index b9da7081238..8fbda8ce684 100644 --- a/plugins/tracker-resources/src/duplicate.ts +++ b/plugins/tracker-resources/src/duplicate.ts @@ -70,11 +70,7 @@ export function getCustomAttributesToCopy ( const mixins = new Map>, Record>() for (const mixin of hierarchy.findAllMixins(doc)) { - const values = pickCustomValues( - (doc as any)[mixin], - hierarchy.getAllAttributes(mixin, _class), - excluded - ) + const values = pickCustomValues((doc as any)[mixin], hierarchy.getAllAttributes(mixin, _class), excluded) if (Object.keys(values).length > 0) { mixins.set(mixin as Ref>, values) }