feat: an ordered backlog
A Backlog tab lists the tickets still to start in the order the team will take them; drag them, or use the arrows, to reorder. Moving a ticket only rewrites that ticket's rank, read from the event store, and board columns follow the same order. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
232
libs/web/tracker/presenters/src/lib/pages/backlog.page.ts
Normal file
232
libs/web/tracker/presenters/src/lib/pages/backlog.page.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { CdkDrag, CdkDragDrop, CdkDropList } from '@angular/cdk/drag-drop';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
effect,
|
||||
inject,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import {
|
||||
PiEmptyStateComponent,
|
||||
PiIconButtonComponent,
|
||||
PiIconComponent,
|
||||
PiNoteComponent,
|
||||
PiSkeletonComponent,
|
||||
} from '@pi/ui';
|
||||
import {
|
||||
BacklogFacade,
|
||||
ProjectFacade,
|
||||
provideBacklogFacade,
|
||||
} from '@task-stream/web/tracker/application';
|
||||
import { IssueSummary } from '@task-stream/web/tracker/domain';
|
||||
import { initials, PRIORITY, statusLook, typeLook } from '../ui/copy';
|
||||
|
||||
/** /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,
|
||||
PiEmptyStateComponent,
|
||||
PiIconButtonComponent,
|
||||
PiIconComponent,
|
||||
PiNoteComponent,
|
||||
PiSkeletonComponent,
|
||||
],
|
||||
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.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) {
|
||||
<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">
|
||||
<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__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."
|
||||
/>
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
.ts-backlog {
|
||||
margin: 0;
|
||||
padding: 0.25rem 0;
|
||||
list-style: none;
|
||||
}
|
||||
.ts-backlog__row {
|
||||
display: grid;
|
||||
grid-template-columns: 2rem auto auto minmax(0, 1fr) auto auto 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: 2rem auto minmax(0, 1fr) auto;
|
||||
.ts-backlog__type,
|
||||
.ts-pill,
|
||||
.ts-backlog__priority,
|
||||
.ts-backlog__who {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
.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__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;
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -85,6 +85,7 @@ import { projectTone, PUBLIC_TONE, ROLE } from '../ui/copy';
|
||||
}
|
||||
<nav class="ts-tabs" aria-label="Secciones del proyecto">
|
||||
<a routerLink="board" routerLinkActive="is-active">Tablero</a>
|
||||
<a routerLink="backlog" routerLinkActive="is-active">Backlog</a>
|
||||
<a routerLink="epics" routerLinkActive="is-active">Épicas</a>
|
||||
<a routerLink="releases" routerLinkActive="is-active">Releases</a>
|
||||
<a routerLink="dashboard" routerLinkActive="is-active">Seguimiento</a>
|
||||
|
||||
@@ -38,6 +38,12 @@ export const trackerRoutes: Routes = [
|
||||
loadComponent: () =>
|
||||
import('./pages/issue.page').then((m) => m.IssuePage),
|
||||
},
|
||||
{
|
||||
path: 'backlog',
|
||||
title: 'Backlog · TaskStream',
|
||||
loadComponent: () =>
|
||||
import('./pages/backlog.page').then((m) => m.BacklogPage),
|
||||
},
|
||||
{
|
||||
path: 'epics',
|
||||
title: 'Épicas · TaskStream',
|
||||
|
||||
Reference in New Issue
Block a user