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
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,30 @@
-->

<script lang="ts">
import { SpaceTypeDescriptor, generateId } from '@hcengineering/core'
import { getClient } from '@hcengineering/presentation'
import { ProjectTypeDescriptor, createProjectType } from '@hcengineering/task'
import { ToggleWithLabel } from '@hcengineering/ui'
import core, {
Class,
Ref,
SpaceTypeDescriptor,
StatusCategory,
generateId,
getCurrentAccount,
toIdMap
} from '@hcengineering/core'
import { Resource, getResource, translate } from '@hcengineering/platform'
import { getClient, hasResource } from '@hcengineering/presentation'
import {
Project,
ProjectType,
ProjectTypeDescriptor,
TaskType,
TaskTypeDescriptor,
TaskTypeWithFactory,
createProjectType,
findStatusAttr,
makeDefaultTaskType,
makeTaskTypeFromTemplate
} from '@hcengineering/task'
import { ToggleWithLabel, themeStore } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import task from '../../plugin'

Expand All @@ -32,23 +52,86 @@

const dispatch = createEventDispatcher()

// A task type of a built-in (system) project type with the same descriptor, used as a template.
function findTemplateTaskType (descriptor: Ref<TaskTypeDescriptor>, isClassic: boolean): TaskType | undefined {
const model = client.getModel()
const candidates = model.findAllSync(task.class.TaskType, { descriptor }).filter((it) => {
const parent = model.findObject(it.parent)
return parent?.descriptor === projDescriptor._id && parent.createdBy === core.account.System
})
return candidates.find((it) => model.findObject(it.parent)?.classic === isClassic) ?? candidates[0]
}

async function getDefaultTaskTypes (type: ProjectType): Promise<TaskTypeWithFactory[]> {
const allowed = projDescriptor.allowedTaskTypeDescriptors
const descr = client
.getModel()
.findAllSync(
task.class.TaskTypeDescriptor,
allowed !== undefined ? { allowCreate: true, _id: { $in: allowed } } : { allowCreate: true }
)
.find((p) => hasResource(p._id as any as Resource<any>))
if (descr === undefined) {
return []
}

let categoryIds: Array<Ref<StatusCategory>> | undefined
if (descr.statusCategoriesFunc !== undefined) {
const f = await getResource(descr.statusCategoriesFunc)
if (f !== undefined) {
categoryIds = f(type)
}
}

const template = findTemplateTaskType(descr._id, type.classic)
if (template !== undefined) {
const statuses = await client.findAll(template.statusClass, { _id: { $in: template.statuses } })
return [makeTaskTypeFromTemplate(generateId(), template, statuses, categoryIds)]
}

categoryIds = categoryIds ?? [
task.statusCategory.UnStarted,
task.statusCategory.Active,
task.statusCategory.Won,
task.statusCategory.Lost
]
const categories = toIdMap(await client.findAll(core.class.StatusCategory, { _id: { $in: categoryIds } }))
return [
makeDefaultTaskType(
generateId(),
await translate(descr.name, {}, $themeStore.language),
descr,
findStatusAttr(client.getHierarchy(), descr.baseClass),
categoryIds.map((it) => categories.get(it)).filter((it) => it !== undefined) as StatusCategory[]
)
]
}

async function createType (): Promise<void> {
if (projDescriptor === undefined) {
return
}
await createProjectType(
client,
{
name,
descriptor: projDescriptor._id,
description: '',
tasks: [],
roles: 0,
classic: projDescriptor.allowedClassic === true ? classic : false
},
[],
generateId()
)
const typeId: Ref<ProjectType> = generateId()
const data = {
name,
descriptor: projDescriptor._id,
description: '',
tasks: [],
roles: 0,
classic: projDescriptor.allowedClassic === true ? classic : false
}
// The type as it will be created, to resolve the default task type before creating it.
const type: ProjectType = {
...data,
_id: typeId,
_class: task.class.ProjectType,
space: core.space.Model,
statuses: [],
targetClass: `${typeId}:type:mixin` as Ref<Class<Project>>,
modifiedOn: Date.now(),
modifiedBy: getCurrentAccount().primarySocialId
}
await createProjectType(client, data, await getDefaultTaskTypes(type), typeId)
dispatch('close')
}
</script>
Expand Down
128 changes: 128 additions & 0 deletions plugins/task/src/__tests__/defaultTaskType.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 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<StatusCategory>
const todo = 'cat-todo' as Ref<StatusCategory>
const active = 'cat-active' as Ref<StatusCategory>
const won = 'cat-won' as Ref<StatusCategory>
const status = (_id: string, name: string, category: Ref<StatusCategory>, 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<TaskType>,
parent: 'classic' as Ref<ProjectType>,
descriptor: 'descr' as Ref<TaskTypeDescriptor>,
name: 'Issue',
kind: 'both',
allowedAsChildOf: ['issue' as Ref<TaskType>],
ofClass: 'issue-class' as Ref<Class<Task>>,
targetClass: 'issue-mixin' as Ref<Class<Task>>,
statuses: ['backlog', 'todo', 'progress', 'done'] as Array<Ref<Status>>,
statusClass: 'issue-status-class' as Ref<Class<Status>>,
statusCategories: [unstarted, todo, active, won]
} as unknown as TaskType
const newId = 'new' as Ref<TaskType>

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<TaskType>
const descriptor = {
_id: 'descr' as Ref<TaskTypeDescriptor>,
baseClass: 'issue' as Ref<Class<Task>>
}
const categories = [
{ _id: 'cat-backlog' as Ref<StatusCategory>, defaultStatusName: 'Backlog' },
{ _id: 'cat-active' as Ref<StatusCategory>, defaultStatusName: 'New state' },
{ _id: 'cat-won' as Ref<StatusCategory>, 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<Class<Status>> }
} 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([])
})
})
73 changes: 71 additions & 2 deletions plugins/task/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -169,6 +170,74 @@ export type TaskTypeWithFactory = Omit<Data<TaskType>, 'statuses' | 'parent' | '

type ProjectData = Omit<Data<ProjectType>, '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<TaskType>,
template: TaskType,
templateStatuses: Status[],
categories?: Array<Ref<StatusCategory>>
): TaskTypeWithFactory {
const isAllowed = (category: Ref<StatusCategory> | undefined): boolean =>
categories === undefined || (category !== undefined && categories.includes(category))
const statuses = new Map(templateStatuses.map((it) => [it._id, it]))
const factory: Data<Status>[] = []
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<TaskType>,
name: string,
descriptor: Pick<TaskTypeDescriptor, '_id' | 'baseClass'>,
statusAttr: AnyAttribute,
categories: Array<Pick<StatusCategory, '_id' | 'defaultStatusName'>>
): TaskTypeWithFactory {
const statusClass =
statusAttr.type._class === core.class.RefTo ? (statusAttr.type as RefTo<Status>).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<Status>[],
Expand Down
Loading