From 0d9a3f1e39a2b1964524fedc7c8c3118f92bfc22 Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Sun, 18 Oct 2026 01:55:51 +0000 Subject: [PATCH] fix(task): create a default task type for new project types Creating a project type from Settings (for example a new tracker space type) called createProjectType with an empty task type list. The new type therefore had no task type and no statuses, so issues created in projects of that type had no type and misbehaved in the UI. CreateProjectType now passes a default task type to createProjectType, so the type is created together with it. The task type uses the first allowed (or, if unrestricted, any enabled) creatable task type descriptor. If a built-in (system) project type of the same descriptor has a task type of that descriptor (classic tracker Issue, default vacancy Applicant, default funnel), it is used as a template: its kind, sub-task setting and statuses are reused, limited to the status categories the descriptor reports for the new type (e.g. a non-classic tracker type gets Backlog, In Progress, Done, Canceled). Otherwise a generic task type with one default status per category is created. statusCategoriesFunc receives the fully built project type value that is about to be created. The pure parts live in makeTaskTypeFromTemplate and makeDefaultTaskType in @hcengineering/task, covered by unit tests. Fixes #8920 Signed-off-by: Michael Uray --- .../projectTypes/CreateProjectType.svelte | 117 +++++++++++++--- .../src/__tests__/defaultTaskType.test.ts | 128 ++++++++++++++++++ plugins/task/src/utils.ts | 73 +++++++++- 3 files changed, 299 insertions(+), 19 deletions(-) create mode 100644 plugins/task/src/__tests__/defaultTaskType.test.ts diff --git a/plugins/task-resources/src/components/projectTypes/CreateProjectType.svelte b/plugins/task-resources/src/components/projectTypes/CreateProjectType.svelte index 797c778336a..bcafced522e 100644 --- a/plugins/task-resources/src/components/projectTypes/CreateProjectType.svelte +++ b/plugins/task-resources/src/components/projectTypes/CreateProjectType.svelte @@ -14,10 +14,30 @@ --> diff --git a/plugins/task/src/__tests__/defaultTaskType.test.ts b/plugins/task/src/__tests__/defaultTaskType.test.ts new file mode 100644 index 00000000000..9b2a976d87b --- /dev/null +++ b/plugins/task/src/__tests__/defaultTaskType.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 core, { type AnyAttribute, type Class, type Ref, type Status, type StatusCategory } from '@hcengineering/core' +import { type ProjectType, type Task, type TaskType, type TaskTypeDescriptor } from '..' +import { makeDefaultTaskType, makeTaskTypeFromTemplate } from '../utils' + +describe('makeTaskTypeFromTemplate', () => { + const unstarted = 'cat-unstarted' as Ref + const todo = 'cat-todo' as Ref + const active = 'cat-active' as Ref + const won = 'cat-won' as Ref + const status = (_id: string, name: string, category: Ref, color: number): Status => + ({ _id, name, category, color, ofAttribute: 'issue-status' }) as unknown as Status + const statuses = [ + status('backlog', 'Backlog', unstarted, 1), + status('todo', 'Todo', todo, 2), + status('progress', 'In Progress', active, 3), + status('done', 'Done', won, 4) + ] + const template = { + _id: 'issue' as Ref, + parent: 'classic' as Ref, + descriptor: 'descr' as Ref, + name: 'Issue', + kind: 'both', + allowedAsChildOf: ['issue' as Ref], + ofClass: 'issue-class' as Ref>, + targetClass: 'issue-mixin' as Ref>, + statuses: ['backlog', 'todo', 'progress', 'done'] as Array>, + statusClass: 'issue-status-class' as Ref>, + statusCategories: [unstarted, todo, active, won] + } as unknown as TaskType + const newId = 'new' as Ref + + it('copies the template shape and statuses', () => { + const res = makeTaskTypeFromTemplate(newId, template, [...statuses].reverse()) + + expect(res._id).toBe(newId) + expect(res.name).toBe('Issue') + expect(res.descriptor).toBe('descr') + expect(res.ofClass).toBe('issue-class') + expect(res.kind).toBe('both') + expect(res.allowedAsChildOf).toEqual([newId]) + expect(res.statusClass).toBe('issue-status-class') + expect(res.targetClass).toBeUndefined() + expect(res.statusCategories).toEqual([unstarted, todo, active, won]) + expect(res.factory).toEqual([ + { name: 'Backlog', ofAttribute: 'issue-status', category: unstarted, color: 1 }, + { name: 'Todo', ofAttribute: 'issue-status', category: todo, color: 2 }, + { name: 'In Progress', ofAttribute: 'issue-status', category: active, color: 3 }, + { name: 'Done', ofAttribute: 'issue-status', category: won, color: 4 } + ]) + }) + + it('limits statuses to the given categories', () => { + const res = makeTaskTypeFromTemplate(newId, template, statuses, [unstarted, active, won]) + + expect(res.statusCategories).toEqual([unstarted, active, won]) + expect(res.factory.map((it) => it.name)).toEqual(['Backlog', 'In Progress', 'Done']) + }) + + it('keeps the template kind and does not allow itself as child if the template does not', () => { + const res = makeTaskTypeFromTemplate(newId, { ...template, kind: 'task', allowedAsChildOf: [] }, statuses) + + expect(res.kind).toBe('task') + expect(res.allowedAsChildOf).toEqual([]) + }) +}) + +describe('makeDefaultTaskType', () => { + const taskTypeId = 'tt1' as Ref + const descriptor = { + _id: 'descr' as Ref, + baseClass: 'issue' as Ref> + } + const categories = [ + { _id: 'cat-backlog' as Ref, defaultStatusName: 'Backlog' }, + { _id: 'cat-active' as Ref, defaultStatusName: 'New state' }, + { _id: 'cat-won' as Ref, defaultStatusName: 'Won' } + ] + + it('creates a usable task type with one status per category', () => { + const statusAttr = { + _id: 'issue-status', + type: { _class: core.class.RefTo, to: 'issue-status-class' as Ref> } + } as unknown as AnyAttribute + + const res = makeDefaultTaskType(taskTypeId, 'Issue', descriptor, statusAttr, categories) + + expect(res._id).toBe(taskTypeId) + expect(res.name).toBe('Issue') + expect(res.descriptor).toBe(descriptor._id) + expect(res.ofClass).toBe(descriptor.baseClass) + expect(res.kind).toBe('task') + expect(res.allowedAsChildOf).toEqual([]) + expect(res.statusClass).toBe('issue-status-class') + expect(res.targetClass).toBeUndefined() + expect(res.statusCategories).toEqual(['cat-backlog', 'cat-active', 'cat-won']) + expect(res.factory).toEqual([ + { name: 'Backlog', ofAttribute: 'issue-status', category: 'cat-backlog' }, + { name: 'New state', ofAttribute: 'issue-status', category: 'cat-active' }, + { name: 'Won', ofAttribute: 'issue-status', category: 'cat-won' } + ]) + }) + + it('falls back to the base status class', () => { + const statusAttr = { _id: 'status', type: { _class: 'other' } } as unknown as AnyAttribute + + const res = makeDefaultTaskType(taskTypeId, 'Task', descriptor, statusAttr, []) + + expect(res.statusClass).toBe(core.class.Status) + expect(res.statusCategories).toEqual([]) + expect(res.factory).toEqual([]) + }) +}) diff --git a/plugins/task/src/utils.ts b/plugins/task/src/utils.ts index 69f43946385..4ad8050ac20 100644 --- a/plugins/task/src/utils.ts +++ b/plugins/task/src/utils.ts @@ -26,10 +26,11 @@ import core, { TxOperations, type AnyAttribute, type Rank, - type RefTo + type RefTo, + type StatusCategory } from '@hcengineering/core' import { PlatformError, getEmbeddedLabel, unknownStatus } from '@hcengineering/platform' -import task, { Project, ProjectStatus, ProjectType, Task, TaskType } from '.' +import task, { Project, ProjectStatus, ProjectType, Task, TaskType, TaskTypeDescriptor } from '.' import { makeRank } from '@hcengineering/rank' export { genRanks, makeRank } from '@hcengineering/rank' @@ -169,6 +170,74 @@ export type TaskTypeWithFactory = Omit, 'statuses' | 'parent' | ' type ProjectData = Omit, 'statuses' | 'targetClass'> +/** + * @public + * + * Build a task type for a new project type from a built-in task type of the same descriptor + * (for example the Issue type of the classic tracker project type). + * The statuses of the template are reused, limited to the given status categories if specified. + */ +export function makeTaskTypeFromTemplate ( + _id: Ref, + template: TaskType, + templateStatuses: Status[], + categories?: Array> +): TaskTypeWithFactory { + const isAllowed = (category: Ref | undefined): boolean => + categories === undefined || (category !== undefined && categories.includes(category)) + const statuses = new Map(templateStatuses.map((it) => [it._id, it])) + const factory: Data[] = [] + for (const st of template.statuses) { + const status = statuses.get(st) + if (status === undefined || !isAllowed(status.category)) continue + factory.push({ + name: status.name, + ofAttribute: status.ofAttribute, + category: status.category, + ...(status.color !== undefined ? { color: status.color } : {}) + }) + } + return { + _id, + name: template.name, + descriptor: template.descriptor, + ofClass: template.ofClass, + kind: template.kind, + allowedAsChildOf: template.allowedAsChildOf?.includes(template._id) === true ? [_id] : [], + statusClass: template.statusClass, + statusCategories: template.statusCategories.filter((it) => isAllowed(it)), + factory + } +} + +/** + * @public + * + * Build a generic task type for a new project type, with one default status per status category. + * Used when there is no built-in task type to take as a template. + */ +export function makeDefaultTaskType ( + _id: Ref, + name: string, + descriptor: Pick, + statusAttr: AnyAttribute, + categories: Array> +): TaskTypeWithFactory { + const statusClass = + statusAttr.type._class === core.class.RefTo ? (statusAttr.type as RefTo).to : core.class.Status + return { + _id, + name, + descriptor: descriptor._id, + ofClass: descriptor.baseClass, + kind: 'task', + allowedAsChildOf: [], + statusClass, + statusCategories: categories.map((it) => it._id), + factory: categories.map((it) => ({ name: it.defaultStatusName, ofAttribute: statusAttr._id, category: it._id })) + } +} + async function createStates ( client: TxOperations, states: Data[],