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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user