Files
task-stream/libs/web/tracker/presenters/src/lib/pages/backlog.page.ts
Josep Piguillem bec39da46d feat: a trash for tickets
Deleting a ticket now sends it to the trash, for whoever opened it or an
admin: it disappears from boards, searches and statistics, cannot change,
and comes back from the ticket page or the trash page for 30 days. After
that it is deleted for good with the usual cascade.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 18:47:30 +02:00

451 lines
14 KiB
TypeScript

import { CdkDrag, CdkDragDrop, CdkDropList } from '@angular/cdk/drag-drop';
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
signal,
inject,
untracked,
} from '@angular/core';
import { RouterLink } from '@angular/router';
import {
PiActionButtonComponent,
PiEmptyStateComponent,
PiIconButtonComponent,
PiIconComponent,
PiNoteComponent,
PiSelectFieldComponent,
PiSkeletonComponent,
PiTextFieldComponent,
type PiSelectFieldOption,
} from '@pi/ui';
import {
BacklogFacade,
ProjectFacade,
provideBacklogFacade,
} from '@task-stream/web/tracker/application';
import {
BulkChange,
IssuePriority,
IssueSummary,
sumPoints,
} from '@task-stream/web/tracker/domain';
import { initials, options, PRIORITY, statusLook, typeLook } from '../ui/copy';
/** "Nobody" / "no sprint" in the bulk selects. */
const NOBODY = '__none__';
/** /p/:key/backlog — what is still to start, in the order the team takes it. */
@Component({
selector: 'ts-backlog-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
RouterLink,
CdkDrag,
CdkDropList,
PiActionButtonComponent,
PiEmptyStateComponent,
PiIconButtonComponent,
PiIconComponent,
PiNoteComponent,
PiSelectFieldComponent,
PiSkeletonComponent,
PiTextFieldComponent,
],
providers: [provideBacklogFacade()],
template: `
<div class="ts-section-header">
<p class="ts-muted">
Lo que aún no ha empezado, en el orden en que se va a coger. Arrastra
para reordenar; las columnas del tablero siguen este mismo orden.
</p>
@if (facade.loaded() && facade.issues().length) {
<span class="ts-muted ts-mono"
>{{ points() }} pts estimados · {{ unestimated() }} sin estimar</span
>
}
@if (facade.loaded() && facade.total() > facade.issues().length) {
<span class="ts-muted ts-mono"
>{{ facade.issues().length }} de {{ facade.total() }}</span
>
}
</div>
@if (facade.error(); as error) {
<pi-note
gap="xs"
status="error"
appearance="fill"
type="status"
title="No se pudo cargar el backlog"
[text]="error"
/>
} @else if (!facade.loaded()) {
<pi-skeleton shape="text" [lines]="8" />
} @else if (facade.issues().length) {
@if (project.canContribute()) {
<div class="ts-bulk" role="toolbar" aria-label="Edición en bloque">
<label class="ts-bulk__all">
<input
type="checkbox"
[checked]="allSelected()"
[indeterminate]="selected().size > 0 && !allSelected()"
(change)="selectAll($any($event.target).checked)"
/>
{{
selected().size
? selected().size + ' seleccionados'
: 'Seleccionar'
}}
</label>
@if (selected().size) {
<pi-select-field [options]="bulkFields" [(value)]="bulkField" />
@switch (bulkField()) {
@case ('status') {
<pi-select-field
[options]="statusOptions()"
[(value)]="bulkValue"
/>
}
@case ('assignee') {
<pi-select-field
[options]="assigneeOptions()"
[(value)]="bulkValue"
/>
}
@case ('sprint') {
<pi-select-field
[options]="sprintOptions()"
[(value)]="bulkValue"
/>
}
@case ('priority') {
<pi-select-field
[options]="priorityOptions"
[(value)]="bulkValue"
/>
}
@case ('label') {
<pi-text-field placeholder="etiqueta" [(value)]="bulkLabel" />
}
}
<pi-action-button
size="small"
color="primary"
text="Aplicar"
[disabled]="!canApply()"
(execute)="applyBulk()"
/>
}
</div>
}
<ol
class="ts-backlog ts-panel"
cdkDropList
[cdkDropListDisabled]="!project.canContribute()"
(cdkDropListDropped)="drop($event)"
>
@for (issue of facade.issues(); track issue.id; let i = $index) {
<li
class="ts-backlog__row"
cdkDrag
[cdkDragData]="issue"
[class.is-selected]="selected().has(issue.id)"
>
<input
type="checkbox"
class="ts-backlog__pick"
[attr.aria-label]="'Seleccionar ' + issue.key"
[disabled]="!project.canContribute()"
[checked]="selected().has(issue.id)"
(change)="toggle(issue.id, $any($event.target).checked)"
/>
<span class="ts-backlog__pos ts-mono">{{ i + 1 }}</span>
<span
class="ts-backlog__type"
[style.--tone]="typeTone(issue)"
[title]="typeLabel(issue)"
>
<pi-icon [icon]="typeIcon(issue)" size="x-small" />
</span>
<a class="ts-key" [routerLink]="['..', 'issues', issue.key]">{{
issue.key
}}</a>
<a
class="ts-backlog__title"
[routerLink]="['..', 'issues', issue.key]"
>{{ issue.title }}</a
>
<span class="ts-pill" [style.--tone]="statusTone(issue)">{{
statusLabel(issue)
}}</span>
<span class="ts-backlog__priority ts-muted">{{
priorityLabel(issue)
}}</span>
<span class="ts-backlog__points ts-mono" title="Estimación">{{
issue.storyPoints ?? '—'
}}</span>
<span
class="ts-backlog__who"
[title]="issue.assignee?.name ?? 'Sin asignar'"
>{{
issue.assignee ? initialsOf(issue.assignee.name) : '—'
}}</span
>
@if (project.canContribute()) {
<span class="ts-backlog__moves">
<pi-icon-button
icon="arrow-up"
color="grey"
appearance="ghost"
[attr.aria-label]="'Subir ' + issue.key"
[disabled]="i === 0"
(execute)="facade.move(issue.id, i - 1)"
/>
<pi-icon-button
icon="arrow-down"
color="grey"
appearance="ghost"
[attr.aria-label]="'Bajar ' + issue.key"
[disabled]="i === facade.issues().length - 1"
(execute)="facade.move(issue.id, i + 1)"
/>
</span>
}
</li>
}
</ol>
} @else {
<pi-empty-state
icon="task"
title="Nada pendiente de empezar"
description="Los tickets nuevos aparecen aquí, al final, hasta que alguien los coge."
/>
}
<p class="ts-backlog__trash">
<a class="ts-plain-link" routerLink="../trash">Papelera</a>
</p>
`,
styles: `
.ts-backlog {
margin: 0;
padding: 0.25rem 0;
list-style: none;
}
.ts-backlog__row {
display: grid;
grid-template-columns:
1rem 2rem auto auto minmax(0, 1fr)
auto auto 2rem auto auto;
gap: 0.625rem;
align-items: center;
padding: 0.375rem 0.875rem;
border-top: 1px solid var(--ts-line);
background: var(--ts-surface);
cursor: grab;
&:first-child {
border-top: 0;
}
@media (max-width: 720px) {
grid-template-columns: 1rem 2rem auto minmax(0, 1fr) auto;
.ts-backlog__type,
.ts-backlog__points,
.ts-pill,
.ts-backlog__priority,
.ts-backlog__who {
display: none;
}
}
}
.ts-backlog__row.is-selected {
background: color-mix(in srgb, var(--ts-brand) 8%, var(--ts-surface));
}
.ts-bulk {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
margin-bottom: 0.75rem;
pi-select-field,
pi-text-field {
width: 13rem;
}
}
.ts-bulk__all {
display: inline-flex;
gap: 0.375rem;
align-items: center;
font-size: 0.875rem;
}
.ts-backlog__trash {
margin: 1rem 0 0;
font-size: 0.875rem;
text-align: right;
}
.ts-backlog__pos {
color: var(--ts-muted);
font-size: 0.75rem;
text-align: right;
}
.ts-backlog__type {
display: inline-flex;
color: var(--tone);
}
.ts-backlog__title {
overflow: hidden;
color: inherit;
text-decoration: none;
text-overflow: ellipsis;
white-space: nowrap;
&:hover {
color: var(--ts-brand);
}
}
.ts-backlog__priority {
font-size: 0.8125rem;
}
.ts-backlog__points {
font-size: 0.8125rem;
text-align: right;
}
.ts-backlog__who {
display: inline-grid;
place-items: center;
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
background: var(--ts-line);
font-size: 0.625rem;
font-weight: 600;
}
.ts-backlog__moves {
display: inline-flex;
}
.cdk-drag-preview {
box-shadow: 0 8px 24px rgb(0 0 0 / 18%);
}
.cdk-drag-placeholder {
opacity: 0.35;
}
`,
})
export class BacklogPage {
protected readonly facade = inject(BacklogFacade);
protected readonly project = inject(ProjectFacade);
protected readonly initialsOf = initials;
/* Bulk edit */
protected readonly selected = signal(new Set<string>());
protected readonly allSelected = computed(
() =>
this.facade.issues().length > 0 &&
this.facade.issues().every((i) => this.selected().has(i.id)),
);
protected readonly bulkFields: PiSelectFieldOption<string>[] = [
{ value: 'status', label: 'Mover a…' },
{ value: 'assignee', label: 'Asignar a…' },
{ value: 'sprint', label: 'Sprint…' },
{ value: 'priority', label: 'Prioridad…' },
{ value: 'label', label: 'Añadir etiqueta…' },
];
protected readonly bulkField = signal<string | null>('status');
protected readonly bulkValue = signal<string | null>(null);
protected readonly bulkLabel = signal('');
protected readonly statusOptions = computed<PiSelectFieldOption<string>[]>(
() =>
this.project
.workflow()
.settings.statuses.filter((s) => !s.archived)
.map((s) => ({ value: s.id, label: s.name })),
);
protected readonly assigneeOptions = computed<PiSelectFieldOption<string>[]>(
() => [
{ value: NOBODY, label: 'Nadie (sin asignar)' },
...this.project
.members()
.map((m) => ({ value: m.user.id, label: m.user.name })),
],
);
protected readonly sprintOptions = computed<PiSelectFieldOption<string>[]>(
() => [
{ value: NOBODY, label: 'Backlog (sin sprint)' },
...this.project
.openSprints()
.map((s) => ({ value: s.id, label: s.name })),
],
);
protected readonly priorityOptions = options(PRIORITY);
protected readonly canApply = computed(() =>
this.bulkField() === 'label'
? !!this.bulkLabel().trim()
: !!this.bulkValue(),
);
protected toggle(issueId: string, on: boolean): void {
this.selected.update((set) => {
const next = new Set(set);
if (on) next.add(issueId);
else next.delete(issueId);
return next;
});
}
protected selectAll(on: boolean): void {
this.selected.set(new Set(on ? this.facade.issues().map((i) => i.id) : []));
}
protected applyBulk(): void {
const value = this.bulkValue();
const change: BulkChange = {};
switch (this.bulkField()) {
case 'status':
if (value) change.status = value;
break;
case 'assignee':
change.assigneeId = value === NOBODY ? null : value;
break;
case 'sprint':
change.sprintId = value === NOBODY ? null : value;
break;
case 'priority':
if (value) change.priority = value as IssuePriority;
break;
case 'label':
change.addLabels = [this.bulkLabel().trim().toLowerCase()];
break;
}
this.facade.bulkUpdate([...this.selected()], change);
this.selected.set(new Set());
this.bulkLabel.set('');
}
protected readonly points = computed(() => sumPoints(this.facade.issues()));
protected readonly unestimated = computed(
() => this.facade.issues().filter((i) => i.storyPoints === null).length,
);
constructor() {
effect(() => {
const projectId = this.project.project()?.id;
if (projectId) untracked(() => this.facade.open(projectId));
});
}
protected drop(event: CdkDragDrop<unknown, unknown, IssueSummary>): void {
this.facade.move(event.item.data.id, event.currentIndex);
}
protected typeIcon = (issue: IssueSummary) =>
typeLook(this.project.workflow(), issue.type).icon;
protected typeTone = (issue: IssueSummary) =>
typeLook(this.project.workflow(), issue.type).tone;
protected typeLabel = (issue: IssueSummary) =>
typeLook(this.project.workflow(), issue.type).label;
protected statusTone = (issue: IssueSummary) =>
statusLook(this.project.workflow(), issue.status).tone;
protected statusLabel = (issue: IssueSummary) =>
statusLook(this.project.workflow(), issue.status).label;
protected priorityLabel = (issue: IssueSummary) =>
PRIORITY[issue.priority].label;
}