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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user