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

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