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

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