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:
@@ -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>(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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))),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -208,3 +208,14 @@ mutation RankIssue($input: RankIssueInput!) {
|
||||
...CommandResultFields
|
||||
}
|
||||
}
|
||||
|
||||
mutation BulkUpdateIssues($input: BulkUpdateIssuesInput!) {
|
||||
bulkUpdateIssues(input: $input) {
|
||||
updated
|
||||
results {
|
||||
issueId
|
||||
ok
|
||||
error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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