Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions models/tracker/src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
128 changes: 128 additions & 0 deletions plugins/tracker-resources/src/__tests__/duplicate.test.ts
Original file line number Diff line number Diff line change
@@ -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<Class<Doc>>
const typeMixin = 'task-type:type:mixin' as Ref<Class<Doc>>
const systemMixin = 'sync:mixin:External' as Ref<Class<Doc>>
const emptyMixin = 'custom:mixin:Empty' as Ref<Class<Doc>>
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<string, AnyAttribute> {
return new Map(list.map((a) => [a.name, a]))
}

const attributesByClass = new Map<Ref<Classifier>, Map<string, AnyAttribute>>([
[
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)
})
})
43 changes: 41 additions & 2 deletions plugins/tracker-resources/src/components/CreateIssue.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
fillDefaults,
generateId,
getCurrentAccount,
Hierarchy,
makeCollabId,
makeDocCollabId,
type PersonId,
Expand All @@ -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 {
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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<TaskType> | undefined = undefined
// TaskKindSelector replaces a kind that is not part of the selected project type.
let kind: Ref<TaskType> | undefined = draft == null ? originalIssue?.kind : undefined

let templateId: Ref<IssueTemplate> | undefined = draft?.template?.template
let appliedTemplateId: Ref<IssueTemplate> | undefined = draft?.template?.template
Expand Down Expand Up @@ -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
})
Expand Down Expand Up @@ -258,13 +271,28 @@

$: 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
}

function resetObject (): void {
templateId = undefined
template = undefined
copiedAttributes = undefined
object = getDefaultObject(undefined, true)
fillDefaults(hierarchy, object, tracker.class.Issue)
}
Expand Down Expand Up @@ -484,6 +512,7 @@
const identifier = `${currentProject?.identifier}-${number}`

const value: DocData<Issue> = {
...copiedAttributes?.attributes,
title: getTitle(object.title),
description: null,
assignee: object.assignee,
Expand Down Expand Up @@ -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<Class<Task>>
})
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', {
Expand Down
79 changes: 79 additions & 0 deletions plugins/tracker-resources/src/duplicate.ts
Original file line number Diff line number Diff line change
@@ -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<Classifier>, to?: Ref<Classifier>) => Map<string, AnyAttribute>
findAllMixins: (doc: Doc) => Array<Ref<Class<Doc>>>
}

/**
* Values of user defined attributes of a document, split into plain class
* attributes and attributes stored in mixins.
* @public
*/
export interface CopiedCustomAttributes {
attributes: Record<string, any>
mixins: Map<Ref<Mixin<Doc>>, Record<string, any>>
}

function pickCustomValues (
source: Record<string, any> | undefined,
attributes: Map<string, AnyAttribute>,
excludedTypes: Set<Ref<Class<Type<any>>>>
): Record<string, any> {
const result: Record<string, any> = {}
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<Class<Doc>>,
excludedTypes: Array<Ref<Class<Type<any>>>> = []
): CopiedCustomAttributes {
const excluded = new Set(excludedTypes)
const attributes = pickCustomValues(doc as any, hierarchy.getAllAttributes(_class), excluded)

const mixins = new Map<Ref<Mixin<Doc>>, Record<string, any>>()
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<Mixin<Doc>>, values)
}
}
return { attributes, mixins }
}
Loading