feat: templates per ticket type
Each ticket type can carry a starting description and a checklist, edited in the project settings. New tickets of that type get the description when they come without one and always get the checklist; the new ticket form says what the template will add. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,7 @@ import {
|
||||
IssueType,
|
||||
ProjectRole,
|
||||
TRACKER_FACTORIES,
|
||||
defaultProjectSettings,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import {
|
||||
FakeUserDirectory,
|
||||
@@ -77,6 +78,7 @@ import { DeleteCommentCommand } from './delete-comment.command';
|
||||
import { PublishReleaseCommand } from './publish-release.command';
|
||||
import { SetIssueReleaseCommand } from './set-issue-release.command';
|
||||
import { WriteAccess } from './write-access';
|
||||
import { UpdateProjectSettingsCommand } from './update-project-settings.command';
|
||||
import { ReadAccess } from '../queries/read-access';
|
||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||
|
||||
@@ -595,6 +597,35 @@ describe('tracker commands (event sourced)', () => {
|
||||
).toBe('No sprint is under way');
|
||||
});
|
||||
|
||||
it("fills a new ticket with its type's template", async () => {
|
||||
const { run, rehydrator } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
const settings = defaultProjectSettings();
|
||||
settings.issueTypes[0].template = {
|
||||
description: '## Pasos para reproducir',
|
||||
checklist: ['Probar en Safari', 'Adjuntar captura'],
|
||||
};
|
||||
await run(new UpdateProjectSettingsCommand('owner', projectId, settings));
|
||||
const plain = await run(
|
||||
new CreateIssueCommand('qa', projectId, IssueType.BUG, 'Crash', {}),
|
||||
);
|
||||
const written = await run(
|
||||
new CreateIssueCommand('qa', projectId, IssueType.BUG, 'Crash 2', {
|
||||
description: 'Ya lo explico yo',
|
||||
}),
|
||||
);
|
||||
|
||||
const first = await rehydrator.rehydrate(plain.id, Issue);
|
||||
expect(first.description).toBe('## Pasos para reproducir');
|
||||
expect(first.checklist.map((i) => i.text)).toEqual([
|
||||
'Probar en Safari',
|
||||
'Adjuntar captura',
|
||||
]);
|
||||
expect((await rehydrator.rehydrate(written.id, Issue)).description).toBe(
|
||||
'Ya lo explico yo',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not let viewers modify tickets', async () => {
|
||||
const { run } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { IssueFactory, assertCanNest } from '@task-stream/api/tracker/domain';
|
||||
import { IssueNumbersRepository } from '../ports/issue-numbers.repository';
|
||||
@@ -39,9 +40,14 @@ export class CreateIssueCommandHandler
|
||||
0,
|
||||
);
|
||||
|
||||
const template = command.useTemplate
|
||||
? workflow.templateOf(command.type)
|
||||
: null;
|
||||
const issue = this.issueFactory.create(
|
||||
{
|
||||
...command.details,
|
||||
description:
|
||||
command.details.description?.trim() || template?.description || null,
|
||||
projectId: project.id,
|
||||
projectKey: project.key,
|
||||
number: await this.issueNumbers.next(project.id),
|
||||
@@ -55,6 +61,8 @@ export class CreateIssueCommandHandler
|
||||
);
|
||||
if (command.releaseId) issue.setRelease(command.actorId, command.releaseId);
|
||||
if (parent) issue.setParent(command.actorId, parent);
|
||||
for (const step of template?.checklist ?? [])
|
||||
issue.addChecklistItem(command.actorId, randomUUID(), step);
|
||||
|
||||
this.eventPublisher.mergeObjectContext(issue);
|
||||
await issue.commitAndWait();
|
||||
|
||||
@@ -16,6 +16,8 @@ export class CreateIssueCommand extends Command<CommandResult> {
|
||||
public readonly parentId?: string | null,
|
||||
/** Imports only: start in this (active) status instead of the first one. */
|
||||
public readonly initialStatus?: string | null,
|
||||
/** Fill in the type's template (imports bring their own content). */
|
||||
public readonly useTemplate = true,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
@@ -115,6 +115,7 @@ export class ImportIssuesCommandHandler
|
||||
null,
|
||||
null,
|
||||
row.status,
|
||||
false,
|
||||
),
|
||||
);
|
||||
report.created++;
|
||||
|
||||
@@ -99,6 +99,29 @@ describe('Project settings', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps a template per type, only when it says something', () => {
|
||||
const project = newProject();
|
||||
const settings = defaultProjectSettings();
|
||||
settings.issueTypes[0].template = {
|
||||
description: ' ## Pasos para reproducir ',
|
||||
checklist: [' Probar en Safari ', ''],
|
||||
};
|
||||
settings.issueTypes[1].template = { description: ' ', checklist: [] };
|
||||
project.updateSettings('owner', settings);
|
||||
|
||||
expect(project.workflow.templateOf('BUG')).toEqual({
|
||||
description: '## Pasos para reproducir',
|
||||
checklist: ['Probar en Safari'],
|
||||
});
|
||||
expect(project.workflow.templateOf('FEATURE')).toBeNull();
|
||||
const tooLong = defaultProjectSettings();
|
||||
tooLong.issueTypes[0].template = {
|
||||
description: null,
|
||||
checklist: Array.from({ length: 21 }, (_, i) => `Paso ${i}`),
|
||||
};
|
||||
expect(() => project.updateSettings('owner', tooLong)).toThrow(/plantilla/);
|
||||
});
|
||||
|
||||
it('keeps work-in-progress limits, from 1 to 999', () => {
|
||||
const project = newProject();
|
||||
const limited = defaultProjectSettings();
|
||||
|
||||
@@ -68,14 +68,26 @@ export interface WorkflowStatus {
|
||||
wipLimit?: number | null;
|
||||
}
|
||||
|
||||
/** What a new ticket of a type starts with. */
|
||||
export interface IssueTemplate {
|
||||
/** Used when the ticket comes without a description. */
|
||||
description: string | null;
|
||||
/** Steps added to every new ticket of the type. */
|
||||
checklist: string[];
|
||||
}
|
||||
|
||||
export interface IssueTypeOption {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: TypeIcon;
|
||||
color: SettingColor;
|
||||
archived: boolean;
|
||||
template?: IssueTemplate;
|
||||
}
|
||||
|
||||
export const MAX_TEMPLATE_DESCRIPTION = 5000;
|
||||
export const MAX_TEMPLATE_STEPS = 20;
|
||||
|
||||
export interface AreaOption {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -198,6 +210,13 @@ export class ProjectWorkflow {
|
||||
return this.settings.statuses.find((s) => s.id === id);
|
||||
}
|
||||
|
||||
/** What a new ticket of the type starts with, if anything. */
|
||||
templateOf(typeId: string): IssueTemplate | null {
|
||||
return (
|
||||
this.settings.issueTypes.find((t) => t.id === typeId)?.template ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/** Most tickets the status should hold (null: no limit). */
|
||||
wipLimit(id: string): number | null {
|
||||
return this.status(id)?.wipLimit ?? null;
|
||||
@@ -300,13 +319,18 @@ export function validateProjectSettings(
|
||||
// Only when set: settings saved unchanged must compare equal.
|
||||
...(s.wipLimit != null ? { wipLimit: s.wipLimit } : {}),
|
||||
}));
|
||||
const issueTypes = next.issueTypes.map((t) => ({
|
||||
id: normId(t.id),
|
||||
name: normName(t.name),
|
||||
icon: t.icon,
|
||||
color: t.color,
|
||||
archived: !!t.archived,
|
||||
}));
|
||||
const issueTypes = next.issueTypes.map((t) => {
|
||||
const template = normTemplate(t.template);
|
||||
return {
|
||||
id: normId(t.id),
|
||||
name: normName(t.name),
|
||||
icon: t.icon,
|
||||
color: t.color,
|
||||
archived: !!t.archived,
|
||||
// Only when there is one: settings saved unchanged must compare equal.
|
||||
...(template ? { template } : {}),
|
||||
};
|
||||
});
|
||||
const areas = next.areas.map((a) => ({
|
||||
id: normId(a.id),
|
||||
name: normName(a.name),
|
||||
@@ -393,6 +417,26 @@ export function validateProjectSettings(
|
||||
return { statuses, transitions, issueTypes, areas };
|
||||
}
|
||||
|
||||
function normTemplate(
|
||||
template: IssueTemplate | undefined,
|
||||
): IssueTemplate | null {
|
||||
const description = template?.description?.trim() || null;
|
||||
const checklist = (template?.checklist ?? [])
|
||||
.map((step) => step.trim())
|
||||
.filter(Boolean);
|
||||
if (!description && !checklist.length) return null;
|
||||
assertThat(
|
||||
(description?.length ?? 0) <= MAX_TEMPLATE_DESCRIPTION,
|
||||
`Una plantilla tiene como mucho ${MAX_TEMPLATE_DESCRIPTION} caracteres`,
|
||||
);
|
||||
assertThat(
|
||||
checklist.length <= MAX_TEMPLATE_STEPS &&
|
||||
checklist.every((step) => step.length <= 300),
|
||||
`Una plantilla tiene como mucho ${MAX_TEMPLATE_STEPS} pasos de 300 caracteres`,
|
||||
);
|
||||
return { description, checklist };
|
||||
}
|
||||
|
||||
function normId(id: string): string {
|
||||
const value = (id ?? '').trim().toUpperCase();
|
||||
assertThat(
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface SettingsPayload {
|
||||
icon: string;
|
||||
color: string;
|
||||
archived: boolean;
|
||||
template?: { description?: string | null; checklist?: string[] } | null;
|
||||
}[];
|
||||
areas: { id: string; name: string; archived: boolean }[];
|
||||
}
|
||||
@@ -47,10 +48,18 @@ export function fromSettingsPayload(payload: SettingsPayload): ProjectSettings {
|
||||
transitions: Object.fromEntries(
|
||||
payload.transitions.map((t) => [t.from, t.to]),
|
||||
),
|
||||
issueTypes: payload.issueTypes.map((t) => ({
|
||||
issueTypes: payload.issueTypes.map(({ template, ...t }) => ({
|
||||
...t,
|
||||
icon: t.icon as TypeIcon,
|
||||
color: t.color as SettingColor,
|
||||
...(template
|
||||
? {
|
||||
template: {
|
||||
description: template.description ?? null,
|
||||
checklist: template.checklist ?? [],
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
})),
|
||||
areas: payload.areas,
|
||||
};
|
||||
|
||||
@@ -548,6 +548,21 @@ export class StatusTransitionsInput {
|
||||
to!: string[];
|
||||
}
|
||||
|
||||
@InputType('IssueTemplateInput')
|
||||
export class IssueTemplateInput {
|
||||
@Field(() => String, { nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(5000)
|
||||
description?: string | null;
|
||||
@Field(() => [String], { defaultValue: [] })
|
||||
@IsArray()
|
||||
@ArrayMaxSize(20)
|
||||
@IsString({ each: true })
|
||||
@MaxLength(300, { each: true })
|
||||
checklist!: string[];
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class IssueTypeOptionInput {
|
||||
@Field() @Matches(SETTING_ID) id!: string;
|
||||
@@ -555,6 +570,11 @@ export class IssueTypeOptionInput {
|
||||
@Field() @IsString() icon!: string;
|
||||
@Field() @IsString() color!: string;
|
||||
@Field({ defaultValue: false }) @IsBoolean() archived!: boolean;
|
||||
@Field(() => IssueTemplateInput, { nullable: true })
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => IssueTemplateInput)
|
||||
template?: IssueTemplateInput | null;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
|
||||
@@ -53,6 +53,19 @@ export class StatusTransitionsGql {
|
||||
@Field(() => [String]) to!: string[];
|
||||
}
|
||||
|
||||
@ObjectType('IssueTemplate', {
|
||||
description: 'What a new ticket of the type starts with',
|
||||
})
|
||||
export class IssueTemplateGql {
|
||||
@Field(() => String, {
|
||||
nullable: true,
|
||||
description: 'Used when the ticket comes without a description',
|
||||
})
|
||||
description!: string | null;
|
||||
@Field(() => [String], { description: 'Steps added to every new ticket' })
|
||||
checklist!: string[];
|
||||
}
|
||||
|
||||
@ObjectType('IssueTypeOption')
|
||||
export class IssueTypeOptionGql {
|
||||
@Field() id!: string;
|
||||
@@ -60,6 +73,8 @@ export class IssueTypeOptionGql {
|
||||
@Field() icon!: string;
|
||||
@Field() color!: string;
|
||||
@Field() archived!: boolean;
|
||||
@Field(() => IssueTemplateGql, { nullable: true })
|
||||
template?: IssueTemplateGql | null;
|
||||
}
|
||||
|
||||
@ObjectType('AreaOption')
|
||||
|
||||
@@ -165,6 +165,21 @@ export class StatusTransitionsBodyDto {
|
||||
to!: string[];
|
||||
}
|
||||
|
||||
export class IssueTemplateBodyDto {
|
||||
@ApiPropertyOptional({ type: String, nullable: true, maxLength: 5000 })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(5000)
|
||||
description?: string | null;
|
||||
@ApiPropertyOptional({ type: [String], maxItems: 20 })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(20)
|
||||
@IsString({ each: true })
|
||||
@MaxLength(300, { each: true })
|
||||
checklist?: string[];
|
||||
}
|
||||
|
||||
export class IssueTypeOptionBodyDto {
|
||||
@ApiProperty({ example: 'SPIKE' }) @Matches(SETTING_ID) id!: string;
|
||||
@ApiProperty({ example: 'Spike', maxLength: 40 })
|
||||
@@ -182,6 +197,15 @@ export class IssueTypeOptionBodyDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
archived = false;
|
||||
@ApiPropertyOptional({
|
||||
type: IssueTemplateBodyDto,
|
||||
nullable: true,
|
||||
description: 'What a new ticket of the type starts with',
|
||||
})
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => IssueTemplateBodyDto)
|
||||
template?: IssueTemplateBodyDto | null;
|
||||
}
|
||||
|
||||
export class AreaOptionBodyDto {
|
||||
|
||||
@@ -53,6 +53,11 @@ export class StatusTransitionsDto {
|
||||
to!: string[];
|
||||
}
|
||||
|
||||
export class IssueTemplateDto {
|
||||
@ApiProperty({ type: String, nullable: true }) description!: string | null;
|
||||
@ApiProperty({ type: [String] }) checklist!: string[];
|
||||
}
|
||||
|
||||
export class IssueTypeOptionDto {
|
||||
@ApiProperty({ example: 'BUG' }) id!: string;
|
||||
@ApiProperty({ example: 'Bug' }) name!: string;
|
||||
@@ -60,6 +65,11 @@ export class IssueTypeOptionDto {
|
||||
@ApiProperty({ enum: SETTING_COLORS, enumName: 'SettingColor' })
|
||||
color!: string;
|
||||
@ApiProperty() archived!: boolean;
|
||||
@ApiPropertyOptional({
|
||||
type: IssueTemplateDto,
|
||||
description: 'What a new ticket of the type starts with',
|
||||
})
|
||||
template?: IssueTemplateDto;
|
||||
}
|
||||
|
||||
export class AreaOptionDto {
|
||||
|
||||
Reference in New Issue
Block a user