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>
451 lines
14 KiB
TypeScript
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;
|
|
}
|