diff --git a/models/tracker/src/actions.ts b/models/tracker/src/actions.ts index 659c90358dd..fab27d2f064 100644 --- a/models/tracker/src/actions.ts +++ b/models/tracker/src/actions.ts @@ -375,6 +375,24 @@ export function createActions (builder: Builder, issuesId: string, componentsId: override: [view.action.Open] }) + createAction( + builder, + { + action: tracker.actionImpl.CopyIssueTemplateToProject, + label: tracker.string.CopyToProject, + icon: view.icon.Copy, + input: 'focus', + category: tracker.category.Tracker, + target: tracker.class.IssueTemplate, + context: { + mode: ['context', 'browser'], + application: tracker.app.Tracker, + group: 'associate' + } + }, + tracker.action.CopyIssueTemplateToProject + ) + createAction(builder, { action: view.actionImpl.ShowPopup, actionProps: { diff --git a/models/tracker/src/plugin.ts b/models/tracker/src/plugin.ts index 89d904ac03f..5a69a6fdcc7 100644 --- a/models/tracker/src/plugin.ts +++ b/models/tracker/src/plugin.ts @@ -99,6 +99,7 @@ export default mergeIds(trackerId, tracker, { }, actionImpl: { Move: '' as ViewAction, + CopyIssueTemplateToProject: '' as ViewAction, CopyToClipboard: '' as ViewAction, EditWorkflowStatuses: '' as ViewAction, EditProject: '' as ViewAction, @@ -111,7 +112,8 @@ export default mergeIds(trackerId, tracker, { DeleteMilestone: '' as Ref>>, DeleteProject: '' as Ref>>, DeleteProjectClean: '' as Ref>>, - DeleteIssue: '' as Ref>> + DeleteIssue: '' as Ref>>, + CopyIssueTemplateToProject: '' as Ref>> }, // For migration only diff --git a/plugins/tracker-assets/lang/cs.json b/plugins/tracker-assets/lang/cs.json index a86d17ab3ca..ad74b70d928 100644 --- a/plugins/tracker-assets/lang/cs.json +++ b/plugins/tracker-assets/lang/cs.json @@ -135,6 +135,7 @@ "ComponentLeadSearchPlaceholder": "Nastavit vedoucího komponenty…", "ComponentMembersSearchPlaceholder": "Změnit členy komponenty…", "MoveToProject": "Přesunout do projektu", + "CopyToProject": "Kopírovat do projektu…", "Duplicate": "Duplikovat", "GotoIssues": "Přejít na úkoly", diff --git a/plugins/tracker-assets/lang/de.json b/plugins/tracker-assets/lang/de.json index 4ef15e6c56f..907f9d44e4f 100644 --- a/plugins/tracker-assets/lang/de.json +++ b/plugins/tracker-assets/lang/de.json @@ -176,6 +176,7 @@ "ComponentLeadSearchPlaceholder": "Komponentenleitung festlegen...", "ComponentMembersSearchPlaceholder": "Komponentenmitglieder ändern...", "MoveToProject": "Zu Projekt verschieben", + "CopyToProject": "In Projekt kopieren…", "Duplicate": "Duplizieren", "GotoIssues": "Zu Aufgaben", diff --git a/plugins/tracker-assets/lang/en.json b/plugins/tracker-assets/lang/en.json index 01a260ee457..d4e25a79dd8 100644 --- a/plugins/tracker-assets/lang/en.json +++ b/plugins/tracker-assets/lang/en.json @@ -162,6 +162,7 @@ "ComponentLeadSearchPlaceholder": "Set component lead\u2026", "ComponentMembersSearchPlaceholder": "Change component members\u2026", "MoveToProject": "Move to project", + "CopyToProject": "Copy to project…", "Duplicate": "Duplicate", "GotoIssues": "Go to issues", diff --git a/plugins/tracker-assets/lang/es.json b/plugins/tracker-assets/lang/es.json index e64739a8677..67575d7ea2c 100644 --- a/plugins/tracker-assets/lang/es.json +++ b/plugins/tracker-assets/lang/es.json @@ -143,6 +143,7 @@ "ComponentLeadSearchPlaceholder": "Establecer responsable del componente...", "ComponentMembersSearchPlaceholder": "Cambiar miembros del componente...", "MoveToProject": "Mover al proyecto", + "CopyToProject": "Copiar al proyecto…", "Duplicate": "Duplicar", "GotoIssues": "Ir a tareas", "GotoActive": "Ir a tareas activas", diff --git a/plugins/tracker-assets/lang/fr.json b/plugins/tracker-assets/lang/fr.json index f50efabb503..40cd0d2c4e4 100644 --- a/plugins/tracker-assets/lang/fr.json +++ b/plugins/tracker-assets/lang/fr.json @@ -143,6 +143,7 @@ "ComponentLeadSearchPlaceholder": "Définir le responsable du composant...", "ComponentMembersSearchPlaceholder": "Modifier les membres du composant...", "MoveToProject": "Déplacer vers le projet", + "CopyToProject": "Copier vers le projet…", "Duplicate": "Dupliquer", "GotoIssues": "Aller aux issues", "GotoActive": "Aller aux problèmes actifs", diff --git a/plugins/tracker-assets/lang/it.json b/plugins/tracker-assets/lang/it.json index 6987099ebb9..26ec9b5338e 100644 --- a/plugins/tracker-assets/lang/it.json +++ b/plugins/tracker-assets/lang/it.json @@ -143,6 +143,7 @@ "ComponentLeadSearchPlaceholder": "Imposta responsabile del componente…", "ComponentMembersSearchPlaceholder": "Cambia membri del componente…", "MoveToProject": "Sposta nel progetto", + "CopyToProject": "Copia nel progetto…", "Duplicate": "Duplicato", "GotoIssues": "Vai alle issue", "GotoActive": "Vai alle issue attive", diff --git a/plugins/tracker-assets/lang/ja.json b/plugins/tracker-assets/lang/ja.json index 72af11ae93f..5485b39ff06 100644 --- a/plugins/tracker-assets/lang/ja.json +++ b/plugins/tracker-assets/lang/ja.json @@ -143,6 +143,7 @@ "ComponentLeadSearchPlaceholder": "コンポーネントリーダーを設定\u2026", "ComponentMembersSearchPlaceholder": "コンポーネントメンバーを変更\u2026", "MoveToProject": "プロジェクトに移動", + "CopyToProject": "プロジェクトにコピー…", "Duplicate": "複製", "GotoIssues": "イシューへ移動", "GotoActive": "アクティブなイシューへ移動", diff --git a/plugins/tracker-assets/lang/ko.json b/plugins/tracker-assets/lang/ko.json index 628aad44b3b..a245b84a8aa 100644 --- a/plugins/tracker-assets/lang/ko.json +++ b/plugins/tracker-assets/lang/ko.json @@ -143,6 +143,7 @@ "ComponentLeadSearchPlaceholder": "컴포넌트 리더 설정…", "ComponentMembersSearchPlaceholder": "컴포넌트 멤버 변경…", "MoveToProject": "프로젝트로 이동", + "CopyToProject": "프로젝트로 복사…", "Duplicate": "복제", "GotoIssues": "이슈로 이동", "GotoActive": "활성 이슈로 이동", diff --git a/plugins/tracker-assets/lang/pl.json b/plugins/tracker-assets/lang/pl.json index 9f2fe1c26da..dee64a0be8c 100644 --- a/plugins/tracker-assets/lang/pl.json +++ b/plugins/tracker-assets/lang/pl.json @@ -142,6 +142,7 @@ "ComponentLeadSearchPlaceholder": "Ustaw skrót komponentu\u2026", "ComponentMembersSearchPlaceholder": "Zmień elementy komponentu\u2026", "MoveToProject": "Przenieś do projektu", + "CopyToProject": "Kopiuj do projektu…", "Duplicate": "Duplikuj", "GotoIssues": "Przejdź do zadań", diff --git a/plugins/tracker-assets/lang/pt-br.json b/plugins/tracker-assets/lang/pt-br.json index af8ed33bea5..a3bf12bc155 100644 --- a/plugins/tracker-assets/lang/pt-br.json +++ b/plugins/tracker-assets/lang/pt-br.json @@ -143,6 +143,7 @@ "ComponentLeadSearchPlaceholder": "Definir responsável pelo componente...", "ComponentMembersSearchPlaceholder": "Alterar membros do componente...", "MoveToProject": "Mover para o projeto", + "CopyToProject": "Copiar para o projeto…", "Duplicate": "Duplicar", "GotoIssues": "Ir para as questões", "GotoActive": "Ir para as questões ativas", diff --git a/plugins/tracker-assets/lang/pt.json b/plugins/tracker-assets/lang/pt.json index 7742053a95e..b0083541796 100644 --- a/plugins/tracker-assets/lang/pt.json +++ b/plugins/tracker-assets/lang/pt.json @@ -143,6 +143,7 @@ "ComponentLeadSearchPlaceholder": "Definir responsável pelo componente...", "ComponentMembersSearchPlaceholder": "Alterar membros do componente...", "MoveToProject": "Mover para o projeto", + "CopyToProject": "Copiar para o projeto…", "Duplicate": "Duplicar", "GotoIssues": "Ir para as questões", "GotoActive": "Ir para as questões ativas", diff --git a/plugins/tracker-assets/lang/ru.json b/plugins/tracker-assets/lang/ru.json index b67cf1828c5..b85f3882fe9 100644 --- a/plugins/tracker-assets/lang/ru.json +++ b/plugins/tracker-assets/lang/ru.json @@ -162,6 +162,7 @@ "ComponentLeadSearchPlaceholder": "Назначьте руководителя компонента\u2026", "ComponentMembersSearchPlaceholder": "Изменить участников компонента\u2026", "MoveToProject": "Изменить проект", + "CopyToProject": "Копировать в проект…", "Duplicate": "Дублировать", "GotoIssues": "Перейти к задачам", diff --git a/plugins/tracker-assets/lang/tr.json b/plugins/tracker-assets/lang/tr.json index f465a56e61a..adb86ccca39 100644 --- a/plugins/tracker-assets/lang/tr.json +++ b/plugins/tracker-assets/lang/tr.json @@ -160,6 +160,7 @@ "ComponentLeadSearchPlaceholder": "Bileşen lideri belirle…", "ComponentMembersSearchPlaceholder": "Bileşen üyelerini değiştir…", "MoveToProject": "Projeye taşı", + "CopyToProject": "Projeye kopyala…", "Duplicate": "Çoğalt", "GotoIssues": "Sorunlara git", "GotoActive": "Aktif sorunlara git", diff --git a/plugins/tracker-assets/lang/zh.json b/plugins/tracker-assets/lang/zh.json index 68634b0207a..80fb8aab641 100644 --- a/plugins/tracker-assets/lang/zh.json +++ b/plugins/tracker-assets/lang/zh.json @@ -145,6 +145,7 @@ "ComponentLeadSearchPlaceholder": "设置组件负责人…", "ComponentMembersSearchPlaceholder": "更改组件成员…", "MoveToProject": "移动到项目", + "CopyToProject": "复制到项目…", "Duplicate": "复制", "GotoIssues": "前往问题", diff --git a/plugins/tracker-resources/src/components/templates/CopyIssueTemplate.svelte b/plugins/tracker-resources/src/components/templates/CopyIssueTemplate.svelte new file mode 100644 index 00000000000..060d2981e7e --- /dev/null +++ b/plugins/tracker-resources/src/components/templates/CopyIssueTemplate.svelte @@ -0,0 +1,71 @@ + + + + dispatch('close')} + on:changeContent +> + + + + + diff --git a/plugins/tracker-resources/src/components/templates/__tests__/copy.test.ts b/plugins/tracker-resources/src/components/templates/__tests__/copy.test.ts new file mode 100644 index 00000000000..f413d2c80a0 --- /dev/null +++ b/plugins/tracker-resources/src/components/templates/__tests__/copy.test.ts @@ -0,0 +1,115 @@ +// +// 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 { IssueTemplate } from '@hcengineering/tracker' +import { makeIssueTemplateCopy } from '../copy' + +function template (): IssueTemplate { + return { + _id: 'tmpl-1' as any, + _class: 'tracker:class:IssueTemplate' as any, + space: 'project-1' as any, + modifiedOn: 0, + modifiedBy: 'user' as any, + title: 'Release', + description: '

Steps

', + priority: 2, + assignee: 'person-1' as any, + component: 'component-1' as any, + milestone: 'milestone-1' as any, + estimation: 3, + labels: ['label-1' as any], + kind: 'kind-shared' as any, + comments: 5, + attachments: 2, + relations: [{ _id: 'doc-1' as any, _class: 'cls' as any }], + children: [ + { + id: 'child-1' as any, + title: 'Child', + description: '', + priority: 1, + assignee: null, + component: 'component-2' as any, + milestone: 'milestone-2' as any, + estimation: 1, + labels: ['label-2' as any], + kind: 'kind-other' as any + } + ] + } +} + +describe('makeIssueTemplateCopy', () => { + let counter = 0 + const newId = (): any => `new-${++counter}` + + beforeEach(() => { + counter = 0 + }) + + it('clears project specific references when copying to another project', () => { + const source = template() + const data = makeIssueTemplateCopy(source, 'project-2' as any, ['kind-shared' as any], newId) + + expect(data).toMatchObject({ + title: 'Release', + description: '

Steps

', + priority: 2, + assignee: 'person-1', + component: null, + milestone: null, + estimation: 3, + labels: ['label-1'], + kind: 'kind-shared', + comments: 0, + attachments: 0, + relations: [{ _id: 'doc-1', _class: 'cls' }] + }) + expect(data.children).toEqual([ + { + id: 'new-1', + title: 'Child', + description: '', + priority: 1, + assignee: null, + component: null, + milestone: null, + estimation: 1, + labels: ['label-2'], + kind: undefined + } + ]) + expect((data as any)._id).toBeUndefined() + expect((data as any).space).toBeUndefined() + }) + + it('keeps component and milestone within the same project', () => { + const data = makeIssueTemplateCopy(template(), 'project-1' as any, [], newId) + expect(data.component).toBe('component-1') + expect(data.milestone).toBe('milestone-1') + expect(data.children[0].component).toBe('component-2') + expect(data.kind).toBeUndefined() + expect(data.children[0].id).toBe('new-1') + }) + + it('does not share arrays with the source template', () => { + const source = template() + const data = makeIssueTemplateCopy(source, 'project-2' as any, [], newId) + expect(data.labels).not.toBe(source.labels) + expect(data.children[0].labels).not.toBe(source.children[0].labels) + expect(data.relations).not.toBe(source.relations) + }) +}) diff --git a/plugins/tracker-resources/src/components/templates/copy.ts b/plugins/tracker-resources/src/components/templates/copy.ts new file mode 100644 index 00000000000..4b9c8d8ad96 --- /dev/null +++ b/plugins/tracker-resources/src/components/templates/copy.ts @@ -0,0 +1,62 @@ +// +// 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 { Data, Ref } from '@hcengineering/core' +import type { TaskType } from '@hcengineering/task' +import type { Issue, IssueTemplate, IssueTemplateData, Project } from '@hcengineering/tracker' + +/** + * Build the data of a copy of an issue template for another project. + * + * Components and milestones belong to the source project, so they are cleared when the + * target project differs. A task type is kept only if the target project type provides it. + * Child templates get fresh ids, comments and attachments are not copied. + */ +export function makeIssueTemplateCopy ( + template: IssueTemplate, + targetProject: Ref, + targetTaskTypes: Array>, + newChildId: () => Ref +): Data { + const sameProject = template.space === targetProject + + function copyData (data: T): T { + return { + ...data, + labels: data.labels !== undefined ? [...data.labels] : undefined, + component: sameProject ? data.component : null, + milestone: sameProject ? data.milestone : null, + kind: data.kind !== undefined && targetTaskTypes.includes(data.kind) ? data.kind : undefined + } + } + + return { + ...copyData({ + title: template.title, + description: template.description, + priority: template.priority, + assignee: template.assignee, + component: template.component, + milestone: template.milestone, + estimation: template.estimation, + labels: template.labels, + kind: template.kind + }), + children: template.children.map((child) => ({ ...copyData(child), id: newChildId() })), + comments: 0, + attachments: 0, + relations: [...(template.relations ?? [])] + } +} diff --git a/plugins/tracker-resources/src/index.ts b/plugins/tracker-resources/src/index.ts index 0dc6d9d6822..68594df2cf7 100644 --- a/plugins/tracker-resources/src/index.ts +++ b/plugins/tracker-resources/src/index.ts @@ -30,7 +30,7 @@ import { } from '@hcengineering/core' import { type Resources, type Status, translate } from '@hcengineering/platform' import { getClient, MessageBox, type ObjectSearchResult } from '@hcengineering/presentation' -import { type Component, type Issue, type Milestone, type Project } from '@hcengineering/tracker' +import { type Component, type Issue, type IssueTemplate, type Milestone, type Project } from '@hcengineering/tracker' import { closePanel, getCurrentLocation, @@ -92,6 +92,7 @@ import SetParentIssueActionPopup from './components/SetParentIssueActionPopup.sv import LinkSubIssueActionPopup from './components/LinkSubIssueActionPopup.svelte' import SettingsRelatedTargets from './components/SettingsRelatedTargets.svelte' import CreateIssueTemplate from './components/templates/CreateIssueTemplate.svelte' +import CopyIssueTemplate from './components/templates/CopyIssueTemplate.svelte' import { getIssueIdByIdentifier, getIssueTitle, @@ -241,6 +242,13 @@ async function move (issues: Issue | Issue[]): Promise { showPopup(MoveIssues, { selected: issues }, 'top') } +async function copyIssueTemplateToProject (template: IssueTemplate | IssueTemplate[]): Promise { + const value = Array.isArray(template) ? template[0] : template + if (value !== undefined) { + showPopup(CopyIssueTemplate, { value }, 'top') + } +} + async function editWorkflowStatuses (project: Project): Promise { const loc = getCurrentLocation() loc.path[2] = settingId @@ -538,6 +546,7 @@ export default async (): Promise => ({ }, actionImpl: { Move: move, + CopyIssueTemplateToProject: copyIssueTemplateToProject, EditWorkflowStatuses: editWorkflowStatuses, EditProject: editProject, DeleteMilestone: deleteMilestone, diff --git a/plugins/tracker-resources/src/plugin.ts b/plugins/tracker-resources/src/plugin.ts index b4e326877ae..7baac2635d0 100644 --- a/plugins/tracker-resources/src/plugin.ts +++ b/plugins/tracker-resources/src/plugin.ts @@ -187,6 +187,7 @@ export default mergeIds(trackerId, tracker, { List: '' as IntlString, NumberLabels: '' as IntlString, MoveToProject: '' as IntlString, + CopyToProject: '' as IntlString, Duplicate: '' as IntlString, MoveIssues: '' as IntlString, MoveIssuesDescription: '' as IntlString,