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:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -40,6 +40,10 @@ fragment ProjectFields on Project {
|
||||
icon
|
||||
color
|
||||
archived
|
||||
template {
|
||||
description
|
||||
checklist
|
||||
}
|
||||
}
|
||||
areas {
|
||||
id
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user