feat: bulk edit tickets

Select several tickets in the backlog and change their status, assignee,
sprint, priority or labels at once. Each ticket goes through the normal
rules and may refuse on its own; the result says which did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 18:26:53 +02:00
parent 5863d773be
commit 4148335457
26 changed files with 1092 additions and 7 deletions

View File

@@ -4,24 +4,37 @@ import {
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 { IssueSummary, sumPoints } from '@task-stream/web/tracker/domain';
import { initials, PRIORITY, statusLook, typeLook } from '../ui/copy';
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({
@@ -31,11 +44,14 @@ import { initials, PRIORITY, statusLook, typeLook } from '../ui/copy';
RouterLink,
CdkDrag,
CdkDropList,
PiActionButtonComponent,
PiEmptyStateComponent,
PiIconButtonComponent,
PiIconComponent,
PiNoteComponent,
PiSelectFieldComponent,
PiSkeletonComponent,
PiTextFieldComponent,
],
providers: [provideBacklogFacade()],
template: `
@@ -68,6 +84,62 @@ import { initials, PRIORITY, statusLook, typeLook } from '../ui/copy';
} @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
@@ -75,7 +147,20 @@ import { initials, PRIORITY, statusLook, typeLook } from '../ui/copy';
(cdkDropListDropped)="drop($event)"
>
@for (issue of facade.issues(); track issue.id; let i = $index) {
<li class="ts-backlog__row" cdkDrag [cdkDragData]="issue">
<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"
@@ -148,7 +233,7 @@ import { initials, PRIORITY, statusLook, typeLook } from '../ui/copy';
.ts-backlog__row {
display: grid;
grid-template-columns:
2rem auto auto minmax(0, 1fr)
1rem 2rem auto auto minmax(0, 1fr)
auto auto 2rem auto auto;
gap: 0.625rem;
align-items: center;
@@ -160,7 +245,7 @@ import { initials, PRIORITY, statusLook, typeLook } from '../ui/copy';
border-top: 0;
}
@media (max-width: 720px) {
grid-template-columns: 2rem auto minmax(0, 1fr) auto;
grid-template-columns: 1rem 2rem auto minmax(0, 1fr) auto;
.ts-backlog__type,
.ts-backlog__points,
.ts-pill,
@@ -170,6 +255,26 @@ import { initials, PRIORITY, statusLook, typeLook } from '../ui/copy';
}
}
}
.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__pos {
color: var(--ts-muted);
font-size: 0.75rem;
@@ -221,6 +326,91 @@ 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,