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:
2026-10-08 15:58:37 +02:00
parent 8994590b54
commit 5863d773be
23 changed files with 565 additions and 9 deletions

View File

@@ -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);

View File

@@ -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();

View File

@@ -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();
}

View File

@@ -115,6 +115,7 @@ export class ImportIssuesCommandHandler
null,
null,
row.status,
false,
),
);
report.created++;

View File

@@ -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();

View File

@@ -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(

View File

@@ -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,
};

View File

@@ -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()

View File

@@ -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')

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -51,12 +51,21 @@ export interface StatusTransitions {
to: string[];
}
/** 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: string;
color: string;
archived: boolean;
template?: IssueTemplate | null;
}
export interface AreaOption {

View File

@@ -86,6 +86,12 @@ export function toSettingsInput(
icon: t.icon,
color: t.color,
archived: t.archived,
template: t.template
? {
description: t.template.description ?? null,
checklist: t.template.checklist,
}
: null,
})),
areas: settings.areas.map((a) => ({
id: a.id,

View File

@@ -40,6 +40,10 @@ fragment ProjectFields on Project {
icon
color
archived
template {
description
checklist
}
}
areas {
id

View File

@@ -162,12 +162,18 @@ export type IssuePriority = 'CRITICAL' | 'HIGH' | 'LOW' | 'LOWEST' | 'MEDIUM';
/** RECENT: last updated first. RANK: backlog order. */
export type IssueSort = 'RANK' | 'RECENT';
export type IssueTemplateInput = {
checklist?: Array<string>;
description?: string | null | undefined;
};
export type IssueTypeOptionInput = {
archived?: boolean;
color: string;
icon: string;
id: string;
name: string;
template?: IssueTemplateInput | null | undefined;
};
export type LinkDirection = 'INWARD' | 'OUTWARD';
@@ -435,6 +441,7 @@ export type ProjectFieldsFragment = {
icon: string;
color: string;
archived: boolean;
template: { description: string | null; checklist: Array<string> } | null;
}>;
areas: Array<{ id: string; name: string; archived: boolean }>;
};
@@ -1140,6 +1147,10 @@ export type MyProjectsQuery = {
icon: string;
color: string;
archived: boolean;
template: {
description: string | null;
checklist: Array<string>;
} | null;
}>;
areas: Array<{ id: string; name: string; archived: boolean }>;
};
@@ -1178,6 +1189,10 @@ export type ProjectQuery = {
icon: string;
color: string;
archived: boolean;
template: {
description: string | null;
checklist: Array<string>;
} | null;
}>;
areas: Array<{ id: string; name: string; archived: boolean }>;
};
@@ -1326,6 +1341,10 @@ export type PublicProjectsQuery = {
icon: string;
color: string;
archived: boolean;
template: {
description: string | null;
checklist: Array<string>;
} | null;
}>;
areas: Array<{ id: string; name: string; archived: boolean }>;
};
@@ -1763,6 +1782,10 @@ export type ProjectChangedSubscription = {
icon: string;
color: string;
archived: boolean;
template: {
description: string | null;
checklist: Array<string>;
} | null;
}>;
areas: Array<{ id: string; name: string; archived: boolean }>;
};
@@ -1804,6 +1827,10 @@ export type MyProjectsChangedSubscription = {
icon: string;
color: string;
archived: boolean;
template: {
description: string | null;
checklist: Array<string>;
} | null;
}>;
areas: Array<{ id: string; name: string; archived: boolean }>;
};
@@ -2133,6 +2160,23 @@ export const ProjectFieldsFragmentDoc = {
kind: 'Field',
name: { kind: 'Name', value: 'archived' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'template' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'description' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'checklist' },
},
],
},
},
],
},
},
@@ -6994,6 +7038,23 @@ export const MyProjectsDocument = {
kind: 'Field',
name: { kind: 'Name', value: 'archived' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'template' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'description' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'checklist' },
},
],
},
},
],
},
},
@@ -7199,6 +7260,23 @@ export const ProjectDocument = {
kind: 'Field',
name: { kind: 'Name', value: 'archived' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'template' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'description' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'checklist' },
},
],
},
},
],
},
},
@@ -8255,6 +8333,23 @@ export const PublicProjectsDocument = {
kind: 'Field',
name: { kind: 'Name', value: 'archived' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'template' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'description' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'checklist' },
},
],
},
},
],
},
},
@@ -10703,6 +10798,23 @@ export const ProjectChangedDocument = {
kind: 'Field',
name: { kind: 'Name', value: 'archived' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'template' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'description' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'checklist' },
},
],
},
},
],
},
},
@@ -10895,6 +11007,23 @@ export const MyProjectsChangedDocument = {
kind: 'Field',
name: { kind: 'Name', value: 'archived' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'template' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'description' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'checklist' },
},
],
},
},
],
},
},

View File

@@ -182,3 +182,8 @@
.ts-column--lane .ts-column__list {
min-height: 3.5rem;
}
.ts-template-hint {
margin: -0.25rem 0 0;
font-size: 0.8125rem;
}

View File

@@ -342,10 +342,27 @@ const NOBODY = '__nobody__';
</pi-text-field>
<pi-textarea
[formField]="f.description"
placeholder="Pasos para reproducir, criterios de aceptación…"
[placeholder]="
template()?.description ??
'Pasos para reproducir, criterios de aceptación…'
"
>
<pi-label>Descripción</pi-label>
</pi-textarea>
@if (template(); as t) {
<p class="ts-muted ts-template-hint">
Plantilla de {{ typeLabel() }}:
@if (t.description) {
si dejas la descripción vacía se usará la de la plantilla
}
@if (t.description && t.checklist.length) {
·
}
@if (t.checklist.length) {
se añadirá su checklist ({{ t.checklist.length }} pasos)
}
</p>
}
@if (project.workflow().isDesignType(newType())) {
<pi-text-field
[formField]="f.figmaUrl"
@@ -641,6 +658,17 @@ export class BoardPage {
/* New ticket */
protected readonly newType = signal<string>('');
/** The template the new ticket will get (the API applies it). */
protected readonly template = computed(
() =>
this.project
.workflow()
.settings.issueTypes.find((t) => t.id === this.newType())?.template ??
null,
);
protected readonly typeLabel = computed(
() => typeLook(this.project.workflow(), this.newType()).label,
);
private readonly model = signal({
title: '',
description: '',

View File

@@ -22,6 +22,7 @@ import { TransferComponent } from '../ui/transfer';
import { AutomationsComponent } from '../ui/automations';
import { ProjectFacade } from '@task-stream/web/tracker/application';
import {
IssueTypeOption,
ProjectSettings,
SETTING_COLORS,
StatusCategory,
@@ -346,6 +347,16 @@ function idFrom(name: string): string {
"
/>
<code class="ts-id">{{ type.id }}</code>
<button
type="button"
class="ts-plain-link ts-template-toggle"
[attr.aria-expanded]="openTemplate() === i"
(click)="
openTemplate.set(openTemplate() === i ? null : i)
"
>
Plantilla{{ hasTemplate(type) ? ' ●' : '' }}
</button>
</span>
<select
class="ts-input"
@@ -385,6 +396,48 @@ function idFrom(name: string): string {
item: type,
}"
/>
@if (openTemplate() === i) {
<div class="ts-template">
<label class="ts-template__field">
<span
>Descripción inicial (si el ticket llega sin
ella)</span
>
<textarea
class="ts-input"
rows="5"
maxlength="5000"
[value]="type.template?.description ?? ''"
(input)="
setTemplate(i, {
description: $any($event.target).value,
})
"
></textarea>
</label>
<label class="ts-template__field">
<span>Checklist (un paso por línea, hasta 20)</span>
<textarea
class="ts-input"
rows="4"
[value]="
(type.template?.checklist ?? []).join(
'
'
)
"
(input)="
setTemplate(i, {
checklist: $any($event.target).value.split(
'
'
),
})
"
></textarea>
</label>
</div>
}
</li>
}
</ol>
@@ -733,6 +786,27 @@ function idFrom(name: string): string {
align-items: center;
gap: 0.5rem;
}
.ts-template {
display: grid;
grid-column: 1 / -1;
gap: 0.5rem;
padding: 0.5rem 0 0.25rem;
}
.ts-template__field {
display: grid;
gap: 0.25rem;
font-size: 0.8125rem;
color: var(--ts-muted);
textarea {
font: inherit;
color: var(--ts-ink);
resize: vertical;
}
}
.ts-template-toggle {
font-size: 0.75rem;
white-space: nowrap;
}
.ts-type-grid {
display: grid;
grid-template-columns: 2rem minmax(8rem, 1fr) 7rem 7rem 6.5rem;
@@ -1038,6 +1112,34 @@ export class SettingsPage {
return Number.isFinite(limit) && limit >= 1 ? Math.min(limit, 999) : null;
}
/** Index of the type whose template is open. */
protected readonly openTemplate = signal<number | null>(null);
protected hasTemplate(type: IssueTypeOption): boolean {
return !!(
type.template?.description?.trim() ||
type.template?.checklist.some((s) => s.trim())
);
}
/** Edits the template in the draft; empty lines go when saved. */
protected setTemplate(
index: number,
changes: { description?: string; checklist?: string[] },
): void {
this.patch((d) => {
const type = d.issueTypes[index];
const current = type.template ?? { description: null, checklist: [] };
type.template = {
description:
changes.description !== undefined
? changes.description || null
: current.description,
checklist: (changes.checklist ?? current.checklist).slice(0, 20),
};
});
}
protected edit(section: Section, index: number, changes: object): void {
this.patch((d) => Object.assign(d[section][index], changes));
}