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))),