feat: epics, subtasks and checklists
Tickets can hang from another one of the same project, three levels deep at most (epic → ticket → subtask). The ticket page lists its subtasks with their progress, creates new ones and moves the ticket under an epic; a new Épicas tab shows every epic and how far it has got. Each ticket also gets a checklist for the small steps that don't deserve a ticket. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,11 +14,13 @@ import { form, FormField, required } from '@angular/forms/signals';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import {
|
||||
PiActionButtonComponent,
|
||||
PiCheckboxComponent,
|
||||
PiFileUploadComponent,
|
||||
PiIconButtonComponent,
|
||||
PiIconComponent,
|
||||
PiLabelDirective,
|
||||
PiNoteComponent,
|
||||
PiProgressBarComponent,
|
||||
PiSelectFieldComponent,
|
||||
PiSkeletonComponent,
|
||||
PiTextareaComponent,
|
||||
@@ -68,11 +70,13 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
FormField,
|
||||
AttachmentItem,
|
||||
PiActionButtonComponent,
|
||||
PiCheckboxComponent,
|
||||
PiFileUploadComponent,
|
||||
PiIconComponent,
|
||||
PiIconButtonComponent,
|
||||
PiLabelDirective,
|
||||
PiNoteComponent,
|
||||
PiProgressBarComponent,
|
||||
PiSelectFieldComponent,
|
||||
PiSkeletonComponent,
|
||||
PiTextFieldComponent,
|
||||
@@ -96,6 +100,17 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
<span class="ts-pill" [style.--tone]="status().tone">{{
|
||||
status().label
|
||||
}}</span>
|
||||
@if (issue.parent; as parent) {
|
||||
<a
|
||||
class="ts-parent"
|
||||
[routerLink]="['..', parent.key]"
|
||||
[title]="'Cuelga de ' + parent.key"
|
||||
>
|
||||
<span aria-hidden="true">↑</span>
|
||||
<span class="ts-key">{{ parent.key }}</span>
|
||||
<span class="ts-parent__title">{{ parent.title }}</span>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
<div class="ts-issue__title-row">
|
||||
<h1>{{ issue.title }}</h1>
|
||||
@@ -161,6 +176,168 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
}
|
||||
</article>
|
||||
|
||||
<article class="ts-block">
|
||||
<h2 class="ts-section">
|
||||
Checklist
|
||||
@if (issue.checklist.length) {
|
||||
<span class="ts-mono"
|
||||
>{{ facade.checklistProgress().done }}/{{
|
||||
facade.checklistProgress().total
|
||||
}}</span
|
||||
>
|
||||
}
|
||||
</h2>
|
||||
@if (issue.checklist.length) {
|
||||
<ul class="ts-checklist">
|
||||
@for (item of issue.checklist; track item.id) {
|
||||
<li class="ts-checklist__item" [class.is-done]="item.done">
|
||||
<pi-checkbox
|
||||
[label]="item.text"
|
||||
[checked]="item.done"
|
||||
[disabled]="!project.canContribute()"
|
||||
(checkedChange)="
|
||||
facade.updateChecklistItem(item.id, { done: $event })
|
||||
"
|
||||
/>
|
||||
@if (project.canContribute()) {
|
||||
<pi-icon-button
|
||||
icon="close"
|
||||
color="grey"
|
||||
appearance="ghost"
|
||||
[attr.aria-label]="'Quitar «' + item.text + '»'"
|
||||
(execute)="facade.removeChecklistItem(item.id)"
|
||||
/>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @else if (!project.canContribute()) {
|
||||
<p class="ts-muted">Sin pasos.</p>
|
||||
}
|
||||
@if (project.canContribute()) {
|
||||
<form
|
||||
class="ts-inline-form ts-inline-form--two"
|
||||
(submit)="$event.preventDefault(); addChecklistItem()"
|
||||
novalidate
|
||||
>
|
||||
<pi-text-field
|
||||
placeholder="Nuevo paso, p. ej. Actualizar el changelog"
|
||||
[(value)]="checklistText"
|
||||
/>
|
||||
<pi-action-button
|
||||
size="small"
|
||||
color="primary"
|
||||
text="Añadir"
|
||||
[disabled]="!checklistText().trim()"
|
||||
(execute)="addChecklistItem()"
|
||||
/>
|
||||
</form>
|
||||
}
|
||||
</article>
|
||||
|
||||
<article class="ts-block">
|
||||
<h2 class="ts-section">
|
||||
Subtareas
|
||||
@if (facade.children().length) {
|
||||
<span class="ts-mono"
|
||||
>{{ facade.childProgress().finished }}/{{
|
||||
facade.childProgress().total
|
||||
}}</span
|
||||
>
|
||||
}
|
||||
</h2>
|
||||
@if (facade.children().length) {
|
||||
<pi-progress-bar
|
||||
[label]="facade.childProgress().percent + '% terminado'"
|
||||
[value]="facade.childProgress().percent"
|
||||
/>
|
||||
<ul class="ts-children">
|
||||
@for (child of facade.children(); track child.id) {
|
||||
<li class="ts-child">
|
||||
<a class="ts-key" [routerLink]="['..', child.key]">{{
|
||||
child.key
|
||||
}}</a>
|
||||
<span class="ts-child__title">{{ child.title }}</span>
|
||||
<span
|
||||
class="ts-pill"
|
||||
[style.--tone]="statusTone(child.status)"
|
||||
>{{ statusLabel(child.status) }}</span
|
||||
>
|
||||
@if (child.assignee) {
|
||||
<span
|
||||
class="ts-comment__avatar"
|
||||
[title]="child.assignee.name"
|
||||
>{{ initialsOf(child.assignee.name) }}</span
|
||||
>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @else if (!project.canContribute()) {
|
||||
<p class="ts-muted">Sin subtareas.</p>
|
||||
}
|
||||
@if (project.canContribute()) {
|
||||
<form
|
||||
class="ts-inline-form"
|
||||
(submit)="$event.preventDefault(); addSubtask()"
|
||||
novalidate
|
||||
>
|
||||
<pi-select-field
|
||||
[options]="subtaskTypeOptions()"
|
||||
[value]="subtaskType() ?? defaultSubtaskType()"
|
||||
(valueChange)="subtaskType.set($event)"
|
||||
/>
|
||||
<pi-text-field
|
||||
placeholder="Título de la subtarea"
|
||||
[(value)]="subtaskTitle"
|
||||
/>
|
||||
<pi-action-button
|
||||
size="small"
|
||||
color="primary"
|
||||
text="Crear"
|
||||
[disabled]="!subtaskTitle().trim()"
|
||||
(execute)="addSubtask()"
|
||||
/>
|
||||
</form>
|
||||
<div class="ts-parent-form">
|
||||
@if (issue.parent; as parent) {
|
||||
<span class="ts-muted"
|
||||
>Cuelga de
|
||||
<a class="ts-key" [routerLink]="['..', parent.key]">{{
|
||||
parent.key
|
||||
}}</a></span
|
||||
>
|
||||
<pi-action-button
|
||||
size="small"
|
||||
color="grey"
|
||||
appearance="outline"
|
||||
text="Sacar"
|
||||
(execute)="facade.setParent(null)"
|
||||
/>
|
||||
} @else {
|
||||
<form
|
||||
class="ts-inline-form ts-inline-form--two"
|
||||
(submit)="$event.preventDefault(); setParent()"
|
||||
novalidate
|
||||
>
|
||||
<pi-text-field
|
||||
placeholder="Colgar de… (clave de la épica, p. ej. WEB-3)"
|
||||
[(value)]="parentKey"
|
||||
/>
|
||||
<pi-action-button
|
||||
size="small"
|
||||
color="grey"
|
||||
appearance="outline"
|
||||
text="Colgar"
|
||||
[disabled]="!parentKey().trim()"
|
||||
(execute)="setParent()"
|
||||
/>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</article>
|
||||
|
||||
<article class="ts-block">
|
||||
<h2 class="ts-section">
|
||||
Adjuntos
|
||||
@@ -639,6 +816,23 @@ export class IssuePage {
|
||||
)
|
||||
.slice(0, 6);
|
||||
});
|
||||
protected readonly checklistText = signal('');
|
||||
protected readonly subtaskTitle = signal('');
|
||||
protected readonly subtaskType = signal<string | null>(null);
|
||||
protected readonly parentKey = signal('');
|
||||
/** Active types; subtasks start as tasks when the project has them. */
|
||||
protected readonly subtaskTypeOptions = computed<
|
||||
PiSelectFieldOption<string>[]
|
||||
>(() =>
|
||||
this.project
|
||||
.workflow()
|
||||
.settings.issueTypes.filter((t) => !t.archived)
|
||||
.map((t) => ({ value: t.id, label: t.name })),
|
||||
);
|
||||
protected readonly defaultSubtaskType = computed(() => {
|
||||
const ids = this.subtaskTypeOptions().map((o) => o.value);
|
||||
return ids.includes('TASK') ? 'TASK' : (ids[0] ?? '');
|
||||
});
|
||||
protected readonly pendingFiles = signal<File[]>([]);
|
||||
protected readonly maxFileBytes = MAX_FILE_BYTES;
|
||||
|
||||
@@ -687,6 +881,28 @@ export class IssuePage {
|
||||
? LINK[link.type].outward
|
||||
: LINK[link.type].inward;
|
||||
|
||||
protected addChecklistItem(): void {
|
||||
const text = this.checklistText().trim();
|
||||
if (!text) return;
|
||||
this.facade.addChecklistItem(text);
|
||||
this.checklistText.set('');
|
||||
}
|
||||
|
||||
protected addSubtask(): void {
|
||||
const title = this.subtaskTitle().trim();
|
||||
const type = this.subtaskType() ?? this.defaultSubtaskType();
|
||||
if (!title || !type) return;
|
||||
this.facade.addSubtask(title, type);
|
||||
this.subtaskTitle.set('');
|
||||
}
|
||||
|
||||
protected setParent(): void {
|
||||
const key = this.parentKey().trim();
|
||||
if (!key) return;
|
||||
this.facade.setParent(key);
|
||||
this.parentKey.set('');
|
||||
}
|
||||
|
||||
protected move(to: string): void {
|
||||
if (this.project.workflow().requiresReason(to)) {
|
||||
this.pendingStatus = to;
|
||||
|
||||
Reference in New Issue
Block a user