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

@@ -1,6 +1,10 @@
import { type } from '@ngrx/signals';
import { eventGroup } from '@ngrx/signals/events';
import { IssuePage } from '@task-stream/web/tracker/domain';
import {
BulkChange,
BulkResult,
IssuePage,
} from '@task-stream/web/tracker/domain';
export const backlogPageEvents = eventGroup({
source: 'Backlog Page',
@@ -8,6 +12,7 @@ export const backlogPageEvents = eventGroup({
opened: type<{ projectId: string }>(),
/** Put the ticket at `index` of the backlog as shown now. */
moveRequested: type<{ issueId: string; index: number }>(),
bulkRequested: type<{ issueIds: string[]; change: BulkChange }>(),
},
});
@@ -18,5 +23,6 @@ export const backlogApiEvents = eventGroup({
loadFailed: type<string>(),
moved: type<void>(),
moveFailed: type<string>(),
bulkDone: type<BulkResult>(),
},
});

View File

@@ -1,5 +1,6 @@
import { inject, Injectable, Provider } from '@angular/core';
import { injectDispatch } from '@ngrx/signals/events';
import { BulkChange } from '@task-stream/web/tracker/domain';
import { backlogPageEvents } from '../events/backlog.events';
import { BacklogStore } from '../stores/backlog.store';
@@ -20,6 +21,11 @@ export class BacklogFacade {
this.dispatch.opened({ projectId });
}
/** The same change on several tickets (each may refuse on its own). */
bulkUpdate(issueIds: string[], change: BulkChange): void {
if (issueIds.length) this.dispatch.bulkRequested({ issueIds, change });
}
/** Puts the ticket at `index` of the list as shown now. */
move(issueId: string, index: number): void {
this.dispatch.moveRequested({ issueId, index });

View File

@@ -79,7 +79,7 @@ export const BacklogStore = signalStore(
switchMap(({ payload }) =>
merge(
realtime.issueChanges(payload.projectId).pipe(debounceTime(400)),
events.on(backlogApiEvents.moveFailed),
events.on(backlogApiEvents.moveFailed, backlogApiEvents.bulkDone),
).pipe(
startWith(null),
switchMap(() => fetch(payload.projectId)),
@@ -111,6 +111,28 @@ export const BacklogStore = signalStore(
);
}),
),
bulk$: events.on(backlogPageEvents.bulkRequested).pipe(
concatMap(({ payload }) =>
issues.bulkUpdate(payload.issueIds, payload.change).pipe(
mapResponse({
next: (result) => backlogApiEvents.bulkDone(result),
error: (error) =>
backlogApiEvents.moveFailed(describeError(error)),
}),
),
),
),
bulkFeedback$: events.on(backlogApiEvents.bulkDone).pipe(
tap(({ payload }) => {
const refused = payload.results.filter((r) => !r.ok);
if (!refused.length)
notifications.success(`${payload.updated} tickets actualizados`);
else
notifications.error(
`${payload.updated} actualizados; ${refused.length} no: ${refused[0].error}`,
);
}),
),
feedback$: events
.on(backlogApiEvents.moveFailed)
.pipe(tap(({ payload }) => notifications.error(payload))),

View File

@@ -92,6 +92,22 @@ export interface BoardFilter {
sprintId?: string;
}
/** The same change on several tickets; absent fields stay as they are. */
export interface BulkChange {
status?: string;
/** null unassigns. */
assigneeId?: string | null;
/** null: back to the backlog. */
sprintId?: string | null;
priority?: IssuePriority;
addLabels?: string[];
}
export interface BulkResult {
updated: number;
results: { issueId: string; ok: boolean; error: string | null }[];
}
export interface CreateSprintInput {
projectId: string;
name: string;

View File

@@ -41,6 +41,8 @@ import {
UpdateIssueInput,
UpdateProjectInput,
CreateSprintInput,
BulkChange,
BulkResult,
UpdateSprintInput,
} from '../models/inputs';
@@ -113,6 +115,11 @@ export abstract class IssuesGateway {
parentId: string | null,
): Observable<CommandResult>;
abstract epics(projectId: string): Observable<Epic[]>;
/** The same change on several tickets; each may refuse on its own. */
abstract bulkUpdate(
issueIds: string[],
change: BulkChange,
): Observable<BulkResult>;
/** Tickets still to start (TODO category), in backlog order. */
abstract backlog(projectId: string): Observable<IssuePage>;
/** Moves a ticket between two others of the backlog (null: an end). */

View File

@@ -2,6 +2,8 @@ import { inject, Injectable } from '@angular/core';
import {
BoardColumn,
BoardFilter,
BulkChange,
BulkResult,
ChangeStatusInput,
CommandResult,
CreateIssueInput,
@@ -19,6 +21,7 @@ import {
AddChecklistItemDocument,
AssignIssueDocument,
BacklogDocument,
BulkUpdateIssuesDocument,
BoardDocument,
ChangeIssueStatusDocument,
CreateIssueDocument,
@@ -176,6 +179,23 @@ export class GraphqlIssuesGateway extends IssuesGateway {
);
}
bulkUpdate(issueIds: string[], change: BulkChange): Observable<BulkResult> {
return this.gql
.mutate(BulkUpdateIssuesDocument, {
input: {
issueIds,
status: change.status ?? null,
unassign: change.assigneeId === null ? true : null,
assigneeId: change.assigneeId ?? null,
noSprint: change.sprintId === null ? true : null,
sprintId: change.sprintId ?? null,
priority: (change.priority ?? null) as never,
addLabels: change.addLabels ?? null,
},
})
.pipe(map((d) => d.bulkUpdateIssues));
}
backlog(projectId: string): Observable<IssuePage> {
return this.gql
.query(BacklogDocument, {

View File

@@ -53,6 +53,22 @@ export type AutomationTriggerInput = {
status?: string | null | undefined;
};
export type BulkUpdateIssuesInput = {
addLabels?: Array<string> | null | undefined;
assigneeId?: string | null | undefined;
issueIds: Array<string>;
/** Out of their release */
noRelease?: boolean | null | undefined;
/** Back to the backlog */
noSprint?: boolean | null | undefined;
priority?: IssuePriority | null | undefined;
releaseId?: string | null | undefined;
sprintId?: string | null | undefined;
status?: string | null | undefined;
/** Unassign everything */
unassign?: boolean | null | undefined;
};
export type ChangeIssueStatusInput = {
issueId: string;
/** Required by statuses with requiresReason */
@@ -1040,6 +1056,17 @@ export type RankIssueMutation = {
rankIssue: { id: string; key: string | null; version: number };
};
export type BulkUpdateIssuesMutationVariables = Exact<{
input: BulkUpdateIssuesInput;
}>;
export type BulkUpdateIssuesMutation = {
bulkUpdateIssues: {
updated: number;
results: Array<{ issueId: string; ok: boolean; error: string | null }>;
};
};
export type NotificationFieldsFragment = {
id: string;
kind: NotificationKind;
@@ -6533,6 +6560,75 @@ export const RankIssueDocument = {
},
],
} as unknown as DocumentNode<RankIssueMutation, RankIssueMutationVariables>;
export const BulkUpdateIssuesDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'BulkUpdateIssues' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'input' },
},
type: {
kind: 'NonNullType',
type: {
kind: 'NamedType',
name: { kind: 'Name', value: 'BulkUpdateIssuesInput' },
},
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'bulkUpdateIssues' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'input' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'input' },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'updated' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'results' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'issueId' },
},
{ kind: 'Field', name: { kind: 'Name', value: 'ok' } },
{ kind: 'Field', name: { kind: 'Name', value: 'error' } },
],
},
},
],
},
},
],
},
},
],
} as unknown as DocumentNode<
BulkUpdateIssuesMutation,
BulkUpdateIssuesMutationVariables
>;
export const MyNotificationsDocument = {
kind: 'Document',
definitions: [

View File

@@ -208,3 +208,14 @@ mutation RankIssue($input: RankIssueInput!) {
...CommandResultFields
}
}
mutation BulkUpdateIssues($input: BulkUpdateIssuesInput!) {
bulkUpdateIssues(input: $input) {
updated
results {
issueId
ok
error
}
}
}

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,