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..8fbda8ce684 --- /dev/null +++ b/plugins/tracker-resources/src/duplicate.ts @@ -0,0 +1,79 @@ +// +// 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 } +}