diff --git a/models/task/src/index.ts b/models/task/src/index.ts index 288c95d57ff..266f1e1f43c 100644 --- a/models/task/src/index.ts +++ b/models/task/src/index.ts @@ -210,6 +210,9 @@ export class TTaskType extends TDoc implements TaskType { @Prop(ArrOf(TypeRef(task.class.TaskType)), getEmbeddedLabel('Parent')) allowedAsChildOf!: Ref[] // In case of specified, task type is for sub-tasks + @Prop(TypeRef(core.class.Mixin), getEmbeddedLabel('Base Mixin')) + baseMixin?: Ref> // Existing mixin to inherit attributes from + @Prop(TypeRef(core.class.Class), getEmbeddedLabel('Task class')) ofClass!: Ref> // Base class for task diff --git a/plugins/task-assets/lang/en.json b/plugins/task-assets/lang/en.json index 3c9edeb61cb..8b345751cef 100644 --- a/plugins/task-assets/lang/en.json +++ b/plugins/task-assets/lang/en.json @@ -79,6 +79,14 @@ "StatusChange": "Status changed", "TaskCreated": "Task created", "TaskType": "Task type", + "BaseMixin": "Inherit from", + "BaseMixinDescription": "Inherit attributes from an existing task type", + "NoBaseMixin": "None (create new)", + "CreateNew": "Create new", + "CloneExisting": "Clone existing", + "CloneTaskTypes": "Clone", + "SelectTaskTypesToClone": "Select task types to clone", + "NoTaskTypesAvailable": "No task types available to clone", "ManageProjects": "Project types", "Export": "Export", "CreateProjectType": "Create project type", diff --git a/plugins/task-resources/src/components/taskTypes/ClassMixinSelector.svelte b/plugins/task-resources/src/components/taskTypes/ClassMixinSelector.svelte new file mode 100644 index 00000000000..209ec822f29 --- /dev/null +++ b/plugins/task-resources/src/components/taskTypes/ClassMixinSelector.svelte @@ -0,0 +1,145 @@ + + +
+ + + {#if readonly} + {#if selected} + {selected.label} + {/if} + {:else} + + {/if} +
+ + diff --git a/plugins/task-resources/src/components/taskTypes/CreateTaskType.svelte b/plugins/task-resources/src/components/taskTypes/CreateTaskType.svelte index 9d7dfa4844c..5289cc42bfe 100644 --- a/plugins/task-resources/src/components/taskTypes/CreateTaskType.svelte +++ b/plugins/task-resources/src/components/taskTypes/CreateTaskType.svelte @@ -25,9 +25,11 @@ createState, findStatusAttr } from '@hcengineering/task' - import { DropdownIntlItem, Modal, ModernEditbox, Label, ButtonMenu } from '@hcengineering/ui' + import { DropdownIntlItem, Modal, ModernEditbox, Label, ButtonMenu, Toggle } from '@hcengineering/ui' import task from '../../plugin' import TaskTypeKindEditor from './TaskTypeKindEditor.svelte' + import ClassMixinSelector from './ClassMixinSelector.svelte' + import LinkTaskTypeSelector from './LinkTaskTypeSelector.svelte' import { clearSettingsStore } from '@hcengineering/setting-resources' const client = getClient() @@ -68,6 +70,12 @@ let { kind, name, targetClass, statusCategories, statuses, allowedAsChildOf } = taskType !== undefined ? { ...taskType } : { ...defaultTaskType(type) } + let baseMixin = taskType?.baseMixin + + // Mode: 'create' for new TaskType, 'clone' for cloning existing + let mode: 'create' | 'clone' = 'create' + let selectedTaskTypesToClone: Ref[] = [] + function findStatusClass (_class: Ref>): Ref> | undefined { const h = getClient().getHierarchy() const attrs = h.getAllAttributes(_class) @@ -83,6 +91,12 @@ async function save (): Promise { if (type === undefined) return + // Clone mode: clone selected TaskTypes + if (mode === 'clone') { + await cloneSelectedTaskTypes() + return + } + const descr = taskTypeDescriptors.find((it) => it._id === taskTypeDescriptor._id) if (descr === undefined) return @@ -98,7 +112,8 @@ allowedAsChildOf, statusClass: findStatusClass(ofClass) ?? core.class.Status, parent: type._id, - icon: descr.icon + icon: descr.icon, + baseMixin } if (taskType === undefined && descr.statusCategoriesFunc !== undefined) { @@ -136,8 +151,10 @@ } else { const ofClassClass = client.getHierarchy().getClass(ofClass) // Create target class for custom field. + // Use baseMixin if provided, otherwise use ofClass + const extendsClass = baseMixin ?? ofClass _taskType.targetClass = await client.createDoc(core.class.Class, core.space.Model, { - extends: ofClass, + extends: extendsClass, kind: ClassifierKind.MIXIN, label: getEmbeddedLabel(name), icon: ofClassClass.icon @@ -158,49 +175,158 @@ icon: it.icon, label: it.name })) + + async function cloneSelectedTaskTypes (): Promise { + if (selectedTaskTypesToClone.length === 0) return + + // Fetch the selected TaskTypes + const sourceTaskTypes = await client.findAll(task.class.TaskType, { + _id: { $in: selectedTaskTypesToClone } + }) + + for (const source of sourceTaskTypes) { + const newTaskTypeId: Ref = generateId() + + // Create new targetClass (mixin) that extends the source's targetClass + const sourceClass = client.getHierarchy().getClass(source.ofClass) + const newTargetClass = await client.createDoc(core.class.Class, core.space.Model, { + extends: source.targetClass, // Inherit from source's mixin + kind: ClassifierKind.MIXIN, + label: getEmbeddedLabel(source.name), + icon: sourceClass.icon + }) + + // Clone statuses for this TaskType + const clonedStatuses: Ref[] = [] + const statusAttr = findStatusAttr(client.getHierarchy(), source.ofClass) ?? + client.getHierarchy().getAttribute(task.class.Task, 'status') + + for (const statusId of source.statuses) { + const originalStatus = await client.findOne(core.class.Status, { _id: statusId }) + if (originalStatus !== undefined) { + const newStatus = await createState(client, source.statusClass, { + name: originalStatus.name, + ofAttribute: statusAttr._id, + category: originalStatus.category + }) + clonedStatuses.push(newStatus) + + // Add to ProjectType statuses + if (type.statuses.find((it) => it._id === newStatus) === undefined) { + await client.update(type, { + $push: { statuses: { _id: newStatus, taskType: newTaskTypeId } } + }) + } + } + } + + // Create the cloned TaskType + const clonedTaskType: Data = { + name: source.name, + kind: source.kind, + parent: type._id, + descriptor: source.descriptor, + ofClass: source.ofClass, + targetClass: newTargetClass, + statusCategories: source.statusCategories, + statuses: clonedStatuses, + statusClass: source.statusClass, + allowedAsChildOf: source.allowedAsChildOf, + baseMixin: source.targetClass, // Point to source's targetClass for inheritance + icon: source.icon, + color: source.color + } + + await client.createDoc(task.class.TaskType, core.space.Model, clonedTaskType, newTaskTypeId) + + // Add to ProjectType tasks array + if (!type.tasks.includes(newTaskTypeId)) { + await client.update(type, { $push: { tasks: newTaskTypeId } }) + } + } + + clearSettingsStore() + } + + $: canSave = mode === 'create' ? name.trim().length > 0 : selectedTaskTypesToClone.length > 0 { clearSettingsStore() }} > -
- -
-
-
- - - + + {#if taskType === undefined} +
+
+ + + { mode = mode === 'create' ? 'clone' : 'create' }} + /> + + +
+
+ {/if} + + {#if mode === 'clone' && taskType === undefined} + +
+
- {#if taskTypeDescriptors.length > 1} + {:else} + +
+ +
+
- - { - if (evt.detail != null) { - const tt = taskTypeDescriptors.find((tt) => tt._id === evt.detail) - if (tt) taskTypeDescriptor = tt - } - }} + +
+
+
- {/if} -
+ {#if taskTypeDescriptors.length > 1} +
+ + + { + if (evt.detail != null) { + const tt = taskTypeDescriptors.find((tt) => tt._id === evt.detail) + if (tt) taskTypeDescriptor = tt + } + }} + /> +
+ {/if} +
+ {/if} diff --git a/plugins/task-resources/src/components/taskTypes/LinkTaskTypeSelector.svelte b/plugins/task-resources/src/components/taskTypes/LinkTaskTypeSelector.svelte new file mode 100644 index 00000000000..5e64184ada5 --- /dev/null +++ b/plugins/task-resources/src/components/taskTypes/LinkTaskTypeSelector.svelte @@ -0,0 +1,188 @@ + + + + + diff --git a/plugins/task-resources/src/components/taskTypes/MixinSelector.svelte b/plugins/task-resources/src/components/taskTypes/MixinSelector.svelte new file mode 100644 index 00000000000..9532bd9bb98 --- /dev/null +++ b/plugins/task-resources/src/components/taskTypes/MixinSelector.svelte @@ -0,0 +1,93 @@ + + +
+ + + {#if readonly} + {#if selected} + {selected.label} + {/if} + {:else} + + {/if} +
+ + diff --git a/plugins/task-resources/src/plugin.ts b/plugins/task-resources/src/plugin.ts index 88ef8abd842..8ecdb5a2cd3 100644 --- a/plugins/task-resources/src/plugin.ts +++ b/plugins/task-resources/src/plugin.ts @@ -89,7 +89,15 @@ export default mergeIds(taskId, task, { RenameStatus: '' as IntlString, UpdateTasksStatusRequest: '' as IntlString, TaskTypes: '' as IntlString, - Collections: '' as IntlString + Collections: '' as IntlString, + BaseMixin: '' as IntlString, + BaseMixinDescription: '' as IntlString, + NoBaseMixin: '' as IntlString, + CreateNew: '' as IntlString, + CloneExisting: '' as IntlString, + CloneTaskTypes: '' as IntlString, + SelectTaskTypesToClone: '' as IntlString, + NoTaskTypesAvailable: '' as IntlString }, status: { AssigneeRequired: '' as IntlString diff --git a/plugins/task/src/index.ts b/plugins/task/src/index.ts index 412161345ce..547bc2b4e73 100644 --- a/plugins/task/src/index.ts +++ b/plugins/task/src/index.ts @@ -120,6 +120,9 @@ export interface TaskType extends Doc, IconProps { // Specify if task is allowed to be used as subtask of following tasks. allowedAsChildOf?: Ref[] + // Existing mixin to inherit attributes from (optional) + baseMixin?: Ref> + ofClass: Ref> // Base class for task targetClass: Ref> // Class or Mixin mixin to hold all user defined attributes. diff --git a/plugins/task/src/utils.ts b/plugins/task/src/utils.ts index 69f43946385..f88cf3a83d3 100644 --- a/plugins/task/src/utils.ts +++ b/plugins/task/src/utils.ts @@ -21,6 +21,7 @@ import core, { DocumentQuery, Hierarchy, IdMap, + Mixin, Ref, Status, TxOperations, @@ -330,11 +331,14 @@ async function createTaskTypes ( const targetClassId = `${taskId}:type:mixin` as Ref> tdata.targetClass = targetClassId + // Use baseMixin if provided, otherwise extend ofClass directly + const extendsClass = data.baseMixin ?? data.ofClass + await client.createDoc( core.class.Mixin, core.space.Model, { - extends: data.ofClass, + extends: extendsClass, kind: ClassifierKind.MIXIN, label: ofClassClass.label, icon: ofClassClass.icon @@ -353,3 +357,17 @@ async function createTaskTypes ( } return hasUpdates } + +/** + * @public + * Validates that a baseMixin is compatible with the ofClass hierarchy. + * Returns true if baseMixin is undefined or if it derives from ofClass. + */ +export function validateMixinHierarchy ( + hierarchy: Hierarchy, + baseMixin: Ref> | undefined, + ofClass: Ref> +): boolean { + if (baseMixin === undefined) return true + return hierarchy.isDerived(baseMixin, ofClass) +}