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

@@ -3076,6 +3076,107 @@
] ]
} }
}, },
"/api/issues/bulk": {
"post": {
"description": "Each ticket goes through the normal rules (permissions, workflow, open sprints and releases) and may refuse on its own; the answer says which.",
"operationId": "IssuesController_bulkUpdate",
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BulkUpdateIssuesDto"
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BulkResultDto"
}
}
}
},
"400": {
"description": "Invalid input",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"401": {
"description": "Missing or rejected Pireon token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"403": {
"description": "Not a member of the project (or not allowed)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"409": {
"description": "Someone changed it at the same time: retry",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"422": {
"description": "Business rule (e.g. a transition the workflow forbids)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
}
},
"security": [
{
"pireon-oauth": []
},
{
"pireon": []
}
],
"summary": "The same change on up to 100 tickets",
"tags": [
"Issues"
]
}
},
"/api/issues/{issue}/parent": { "/api/issues/{issue}/parent": {
"put": { "put": {
"description": "Same project, three levels at most (epic → ticket → subtask), no loops. null takes it back to the top.", "description": "Same project, three levels at most (epic → ticket → subtask), no loops. null takes it back to the top.",
@@ -10641,6 +10742,102 @@
"releaseId" "releaseId"
] ]
}, },
"BulkUpdateIssuesDto": {
"type": "object",
"properties": {
"issueIds": {
"maxItems": 100,
"type": "array",
"items": {
"type": "string",
"format": "uuid"
}
},
"status": {
"type": "string",
"example": "IN_PROGRESS"
},
"unassign": {
"type": "boolean",
"description": "Unassign everything"
},
"assigneeId": {
"type": "string",
"description": "Pireon user id of a member"
},
"noSprint": {
"type": "boolean",
"description": "Back to the backlog"
},
"sprintId": {
"type": "string",
"format": "uuid"
},
"noRelease": {
"type": "boolean",
"description": "Out of their release"
},
"releaseId": {
"type": "string",
"format": "uuid"
},
"priority": {
"allOf": [
{
"$ref": "#/components/schemas/IssuePriority"
}
]
},
"addLabels": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"issueIds"
]
},
"BulkIssueResultDto": {
"type": "object",
"properties": {
"issueId": {
"type": "string",
"format": "uuid"
},
"ok": {
"type": "boolean"
},
"error": {
"type": "string",
"nullable": true
}
},
"required": [
"issueId",
"ok",
"error"
]
},
"BulkResultDto": {
"type": "object",
"properties": {
"updated": {
"type": "number"
},
"results": {
"type": "array",
"items": {
"$ref": "#/components/schemas/BulkIssueResultDto"
}
}
},
"required": [
"updated",
"results"
]
},
"SetIssueParentDto": { "SetIssueParentDto": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@@ -128,6 +128,36 @@ type BoardColumn {
total: Int! total: Int!
} }
type BulkIssueResult {
error: String
issueId: ID!
ok: Boolean!
}
type BulkResult {
results: [BulkIssueResult!]!
updated: Int!
}
input BulkUpdateIssuesInput {
addLabels: [String!]
assigneeId: ID
issueIds: [ID!]!
"""Out of their release"""
noRelease: Boolean
"""Back to the backlog"""
noSprint: Boolean
priority: IssuePriority
releaseId: ID
sprintId: ID
status: String
"""Unassign everything"""
unassign: Boolean
}
input ChangeIssueStatusInput { input ChangeIssueStatusInput {
issueId: ID! issueId: ID!
@@ -558,6 +588,11 @@ type Mutation {
"""Assign (or unassign with null) a project member""" """Assign (or unassign with null) a project member"""
assignIssue(assigneeId: ID, issueId: ID!): CommandResult! assignIssue(assigneeId: ID, issueId: ID!): CommandResult!
"""
The same change on up to 100 tickets; each goes through the normal rules and may refuse on its own
"""
bulkUpdateIssues(input: BulkUpdateIssuesInput!): BulkResult!
"""Move a ticket through the workflow (incl. QA)""" """Move a ticket through the workflow (incl. QA)"""
changeIssueStatus(input: ChangeIssueStatusInput!): CommandResult! changeIssueStatus(input: ChangeIssueStatusInput!): CommandResult!
changeProjectMemberRole(input: ProjectMemberInput!): CommandResult! changeProjectMemberRole(input: ProjectMemberInput!): CommandResult!

View File

@@ -455,6 +455,26 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/issues/bulk": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* The same change on up to 100 tickets
* @description Each ticket goes through the normal rules (permissions, workflow, open sprints and releases) and may refuse on its own; the answer says which.
*/
post: operations["IssuesController_bulkUpdate"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/issues/{issue}/parent": { "/api/issues/{issue}/parent": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1877,6 +1897,35 @@ export interface components {
*/ */
releaseId: string | null; releaseId: string | null;
}; };
BulkUpdateIssuesDto: {
issueIds: string[];
/** @example IN_PROGRESS */
status?: string;
/** @description Unassign everything */
unassign?: boolean;
/** @description Pireon user id of a member */
assigneeId?: string;
/** @description Back to the backlog */
noSprint?: boolean;
/** Format: uuid */
sprintId?: string;
/** @description Out of their release */
noRelease?: boolean;
/** Format: uuid */
releaseId?: string;
priority?: components["schemas"]["IssuePriority"];
addLabels?: string[];
};
BulkIssueResultDto: {
/** Format: uuid */
issueId: string;
ok: boolean;
error: string | null;
};
BulkResultDto: {
updated: number;
results: components["schemas"]["BulkIssueResultDto"][];
};
SetIssueParentDto: { SetIssueParentDto: {
/** /**
* @description Epic or ticket of the same project (id or key), or null to take it back to the top * @description Epic or ticket of the same project (id or key), or null to take it back to the top
@@ -4329,6 +4378,83 @@ export interface operations {
}; };
}; };
}; };
IssuesController_bulkUpdate: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["BulkUpdateIssuesDto"];
};
};
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["BulkResultDto"];
};
};
/** @description Invalid input */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Missing or rejected Pireon token */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not a member of the project (or not allowed) */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Someone changed it at the same time: retry */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Business rule (e.g. a transition the workflow forbids) */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
};
};
IssuesController_setParent: { IssuesController_setParent: {
parameters: { parameters: {
query?: never; query?: never;

View File

@@ -20,6 +20,7 @@
| 🎲 Estimación | Puntos por ticket (escala 0-1-2-3-5-8-13-21) en la ficha, las tarjetas, las columnas y el backlog; velocidad por sprint y aviso si un sprint planificado supera la media | | 🎲 Estimación | Puntos por ticket (escala 0-1-2-3-5-8-13-21) en la ficha, las tarjetas, las columnas y el backlog; velocidad por sprint y aviso si un sprint planificado supera la media |
| 🏃 Sprints | Iteraciones con fechas y objetivo, una en curso por proyecto, con burndown (tickets o puntos) y traspaso de lo pendiente al cerrar | | 🏃 Sprints | Iteraciones con fechas y objetivo, una en curso por proyecto, con burndown (tickets o puntos) y traspaso de lo pendiente al cerrar |
| 📋 Backlog | Lo pendiente de empezar, en el orden en que se va a coger; se reordena arrastrando y el tablero sigue ese orden | | 📋 Backlog | Lo pendiente de empezar, en el orden en que se va a coger; se reordena arrastrando y el tablero sigue ese orden |
| ☑️ En bloque | Selecciona varios tickets del backlog y cámbiales a la vez estado, persona, sprint, prioridad o etiqueta |
| 🗺️ Hoja de ruta | Las épicas en el tiempo (fecha de inicio y límite), con los sprints y las releases como referencias | | 🗺️ Hoja de ruta | Las épicas en el tiempo (fecha de inicio y límite), con los sprints y las releases como referencias |
| 🧩 Desglose | Épicas → tickets → subtareas (tres niveles), con su progreso; y una checklist dentro de cada ticket para los pasos pequeños | | 🧩 Desglose | Épicas → tickets → subtareas (tres niveles), con su progreso; y una checklist dentro de cada ticket para los pasos pequeños |
| 👀 Seguir | Cualquier miembro sigue los tickets que le interesan; quien comenta, los sigue solo | | 👀 Seguir | Cualquier miembro sigue los tickets que le interesan; quien comenta, los sigue solo |

View File

@@ -167,6 +167,10 @@ stateDiagram-v2
actor `system`); el mismo enlace solo vuelve a contar si cambia (una PR fusionada). Proyecto desconocido, actor `system`); el mismo enlace solo vuelve a contar si cambia (una PR fusionada). Proyecto desconocido,
integración apagada o firma mala responden igual: 403. El secreto vive en MongoDB (`git_integrations`), no en el integración apagada o firma mala responden igual: 403. El secreto vive en MongoDB (`git_integrations`), no en el
event store. event store.
- **Edición en bloque**: `bulkUpdateIssues` / `POST /api/issues/bulk` aplica el mismo cambio (estado, persona o
`unassign`, sprint o `noSprint`, release o `noRelease`, prioridad, etiquetas que añadir) a hasta 100 tickets. Cada
uno pasa por los comandos normales (permisos, flujo, sprints y releases abiertos) y puede negarse sin parar al resto;
la respuesta dice cuáles.
- **Plantillas por tipo**: en la configuración, cada tipo de ticket puede llevar `template` con una descripción - **Plantillas por tipo**: en la configuración, cada tipo de ticket puede llevar `template` con una descripción
(hasta 5000 caracteres) y una checklist (hasta 20 pasos). Al crear un ticket de ese tipo, la descripción se usa si (hasta 5000 caracteres) y una checklist (hasta 20 pasos). Al crear un ticket de ese tipo, la descripción se usa si
llega vacía y los pasos se añaden siempre (las importaciones no aplican plantillas). llega vacía y los pasos se añaden siempre (las importaciones no aplican plantillas).

View File

@@ -0,0 +1,115 @@
import {
Command,
CommandBus,
CommandHandler,
ICommandHandler,
} from '@nestjs/cqrs';
import { assertThat } from '@task-stream/api/shared/domain';
import { IssuePriority } from '@task-stream/api/tracker/domain';
import { AssignIssueCommand } from './assign-issue.command';
import { ChangeIssueStatusCommand } from './change-issue-status.command';
import { SetIssueReleaseCommand } from './set-issue-release.command';
import { SetIssueSprintCommand } from './sprint.commands';
import { UpdateIssueCommand } from './update-issue.command';
import { WriteAccess } from './write-access';
/** At most this many tickets per bulk change. */
export const MAX_BULK = 100;
/** What to change on every ticket; absent fields stay as they are. */
export interface BulkChange {
status?: string;
/** null unassigns. */
assigneeId?: string | null;
/** null: back to the backlog. */
sprintId?: string | null;
/** null: out of its release. */
releaseId?: string | null;
priority?: IssuePriority;
/** Added to the ticket's labels. */
addLabels?: string[];
}
export interface BulkResult {
updated: number;
results: { issueId: string; ok: boolean; error: string | null }[];
}
/**
* The same change on several tickets. Each ticket goes through the normal
* commands (permissions, workflow, open sprints and releases), one after
* another; one refusing does not stop the rest.
*/
export class BulkUpdateIssuesCommand extends Command<BulkResult> {
constructor(
public readonly actorId: string,
public readonly issueIds: string[],
public readonly change: BulkChange,
) {
super();
}
}
@CommandHandler(BulkUpdateIssuesCommand)
export class BulkUpdateIssuesCommandHandler
implements ICommandHandler<BulkUpdateIssuesCommand>
{
constructor(
private readonly commandBus: CommandBus,
private readonly access: WriteAccess,
) {}
async execute(command: BulkUpdateIssuesCommand): Promise<BulkResult> {
const ids = [...new Set(command.issueIds)];
assertThat(ids.length > 0, 'Pick at least one ticket');
assertThat(ids.length <= MAX_BULK, `At most ${MAX_BULK} tickets at once`);
const change = command.change;
assertThat(
Object.values(change).some((v) => v !== undefined),
'Nothing to change',
);
const actor = command.actorId;
const result: BulkResult = { updated: 0, results: [] };
for (const issueId of ids) {
try {
if (change.status !== undefined)
await this.run(
new ChangeIssueStatusCommand(actor, issueId, change.status),
);
if (change.assigneeId !== undefined)
await this.run(
new AssignIssueCommand(actor, issueId, change.assigneeId),
);
if (change.sprintId !== undefined)
await this.run(
new SetIssueSprintCommand(actor, issueId, change.sprintId),
);
if (change.releaseId !== undefined)
await this.run(
new SetIssueReleaseCommand(actor, issueId, change.releaseId),
);
const details: { priority?: IssuePriority; labels?: string[] } = {};
if (change.priority) details.priority = change.priority;
if (change.addLabels?.length) {
const issue = await this.access.issue(issueId);
details.labels = [...issue.labels, ...change.addLabels];
}
if (Object.keys(details).length)
await this.run(new UpdateIssueCommand(actor, issueId, details));
result.updated++;
result.results.push({ issueId, ok: true, error: null });
} catch (error) {
result.results.push({
issueId,
ok: false,
error: error instanceof Error ? error.message : String(error),
});
}
}
return result;
}
private run(command: object): Promise<unknown> {
return this.commandBus.execute(command as never);
}
}

View File

@@ -32,6 +32,7 @@ import { SetIssueParentCommandHandler } from './set-issue-parent.command-handler
import { RankIssueCommandHandler } from './rank-issue.command-handler'; import { RankIssueCommandHandler } from './rank-issue.command-handler';
import { WriteReleaseNotesCommandHandler } from './write-release-notes.command-handler'; import { WriteReleaseNotesCommandHandler } from './write-release-notes.command-handler';
import { SPRINT_COMMAND_HANDLERS } from './sprint.command-handlers'; import { SPRINT_COMMAND_HANDLERS } from './sprint.command-handlers';
import { BulkUpdateIssuesCommandHandler } from './bulk-update-issues';
import { DetachFromParentCommandHandler } from './detach-from-parent.command-handler'; import { DetachFromParentCommandHandler } from './detach-from-parent.command-handler';
import { import {
AddChecklistItemCommandHandler, AddChecklistItemCommandHandler,
@@ -75,6 +76,7 @@ export const COMMAND_HANDLERS = [
RankIssueCommandHandler, RankIssueCommandHandler,
WriteReleaseNotesCommandHandler, WriteReleaseNotesCommandHandler,
...SPRINT_COMMAND_HANDLERS, ...SPRINT_COMMAND_HANDLERS,
BulkUpdateIssuesCommandHandler,
AddChecklistItemCommandHandler, AddChecklistItemCommandHandler,
UpdateChecklistItemCommandHandler, UpdateChecklistItemCommandHandler,
RemoveChecklistItemCommandHandler, RemoveChecklistItemCommandHandler,

View File

@@ -78,6 +78,7 @@ import { DeleteCommentCommand } from './delete-comment.command';
import { PublishReleaseCommand } from './publish-release.command'; import { PublishReleaseCommand } from './publish-release.command';
import { SetIssueReleaseCommand } from './set-issue-release.command'; import { SetIssueReleaseCommand } from './set-issue-release.command';
import { WriteAccess } from './write-access'; import { WriteAccess } from './write-access';
import { BulkResult, BulkUpdateIssuesCommand } from './bulk-update-issues';
import { UpdateProjectSettingsCommand } from './update-project-settings.command'; import { UpdateProjectSettingsCommand } from './update-project-settings.command';
import { ReadAccess } from '../queries/read-access'; import { ReadAccess } from '../queries/read-access';
import { FindProjectsRepository } from '../ports/find-projects.repository'; import { FindProjectsRepository } from '../ports/find-projects.repository';
@@ -626,6 +627,45 @@ describe('tracker commands (event sourced)', () => {
); );
}); });
it('changes several tickets at once and says which refused', async () => {
const { run, rehydrator } = await setup();
const projectId = await projectWithTeam(run);
const ids: string[] = [];
for (const title of ['A', 'B'])
ids.push(
(
await run(
new CreateIssueCommand('dev', projectId, IssueType.TASK, title, {}),
)
).id,
);
for (const status of ['TODO', 'IN_PROGRESS'])
await run(new ChangeIssueStatusCommand('dev', ids[1], status));
// A is in BACKLOG: the workflow does not let it jump to review.
const result = (await run(
new BulkUpdateIssuesCommand('dev', [...ids, ids[1]], {
status: 'IN_REVIEW',
assigneeId: 'qa',
addLabels: ['sprint-14'],
}),
)) as unknown as BulkResult;
expect(result.updated).toBe(1);
expect(result.results.map((r) => [r.issueId, r.ok])).toEqual([
[ids[0], false],
[ids[1], true],
]);
const b = await rehydrator.rehydrate(ids[1], Issue);
expect([b.status, b.assigneeId, b.labels]).toEqual([
'IN_REVIEW',
'qa',
['sprint-14'],
]);
await expect(
run(new BulkUpdateIssuesCommand('dev', ids, {})),
).rejects.toThrow(/Nothing to change/);
});
it('does not let viewers modify tickets', async () => { it('does not let viewers modify tickets', async () => {
const { run } = await setup(); const { run } = await setup();
const projectId = await projectWithTeam(run); const projectId = await projectWithTeam(run);

View File

@@ -35,3 +35,4 @@ export * from './change-checklist.command';
export * from './rank-issue.command'; export * from './rank-issue.command';
export * from './sprint.commands'; export * from './sprint.commands';
export * from './write-release-notes.command'; export * from './write-release-notes.command';
export * from './bulk-update-issues';

View File

@@ -16,6 +16,11 @@ import { DetachFileCommand } from './commands/detach-file.command';
import { WatchIssueCommand } from './commands/watch-issue.command'; import { WatchIssueCommand } from './commands/watch-issue.command';
import { SetIssueParentCommand } from './commands/set-issue-parent.command'; import { SetIssueParentCommand } from './commands/set-issue-parent.command';
import { RankIssueCommand } from './commands/rank-issue.command'; import { RankIssueCommand } from './commands/rank-issue.command';
import {
BulkChange,
BulkResult,
BulkUpdateIssuesCommand,
} from './commands/bulk-update-issues';
import { import {
AddChecklistItemCommand, AddChecklistItemCommand,
RemoveChecklistItemCommand, RemoveChecklistItemCommand,
@@ -231,6 +236,17 @@ export class IssuesService {
); );
} }
/** The same change on several tickets; each one may succeed or not. */
bulkUpdate(
actorId: string,
issueIds: string[],
change: BulkChange,
): Promise<BulkResult> {
return this.commandBus.execute(
new BulkUpdateIssuesCommand(actorId, issueIds, change),
);
}
addChecklistItem( addChecklistItem(
actorId: string, actorId: string,
issueId: string, issueId: string,

View File

@@ -0,0 +1,27 @@
import { BulkChange } from '@task-stream/api/tracker/application';
import { IssuePriority } from '@task-stream/api/tracker/domain';
/** The API's flat shape (`unassign`, `noSprint`…) as the change to apply. */
export function toBulkChange(input: {
status?: string | null;
unassign?: boolean | null;
assigneeId?: string | null;
noSprint?: boolean | null;
sprintId?: string | null;
noRelease?: boolean | null;
releaseId?: string | null;
priority?: IssuePriority | null;
addLabels?: string[] | null;
}): BulkChange {
const change: BulkChange = {};
if (input.status) change.status = input.status;
if (input.unassign) change.assigneeId = null;
else if (input.assigneeId) change.assigneeId = input.assigneeId;
if (input.noSprint) change.sprintId = null;
else if (input.sprintId) change.sprintId = input.sprintId;
if (input.noRelease) change.releaseId = null;
else if (input.releaseId) change.releaseId = input.releaseId;
if (input.priority) change.priority = input.priority;
if (input.addLabels?.length) change.addLabels = input.addLabels;
return change;
}

View File

@@ -279,6 +279,49 @@ export class SaveFilterInput {
criteria!: SavedFilterCriteriaInput; criteria!: SavedFilterCriteriaInput;
} }
@InputType()
export class BulkUpdateIssuesInput {
@Field(() => [ID])
@IsArray()
@ArrayMaxSize(100)
@IsUUID('all', { each: true })
issueIds!: string[];
@Field({ nullable: true }) @IsOptional() @Matches(SETTING_ID) status?: string;
@Field({ nullable: true, description: 'Unassign everything' })
@IsOptional()
@IsBoolean()
unassign?: boolean;
@Field(() => ID, { nullable: true })
@IsOptional()
@IsString()
assigneeId?: string;
@Field({ nullable: true, description: 'Back to the backlog' })
@IsOptional()
@IsBoolean()
noSprint?: boolean;
@Field(() => ID, { nullable: true })
@IsOptional()
@IsUUID()
sprintId?: string;
@Field({ nullable: true, description: 'Out of their release' })
@IsOptional()
@IsBoolean()
noRelease?: boolean;
@Field(() => ID, { nullable: true })
@IsOptional()
@IsUUID()
releaseId?: string;
@Field(() => IssuePriority, { nullable: true })
@IsOptional()
@IsEnum(IssuePriority)
priority?: IssuePriority;
@Field(() => [String], { nullable: true })
@IsOptional()
@ArrayMaxSize(10)
@IsString({ each: true })
addLabels?: string[];
}
@InputType() @InputType()
export class AutomationTriggerInput { export class AutomationTriggerInput {
@Field(() => AutomationTriggerKind) @Field(() => AutomationTriggerKind)

View File

@@ -24,6 +24,7 @@ import {
} from '@task-stream/api/tracker/domain'; } from '@task-stream/api/tracker/domain';
import { import {
AddChecklistItemInput, AddChecklistItemInput,
BulkUpdateIssuesInput,
ChangeIssueStatusInput, ChangeIssueStatusInput,
CreateIssueInput, CreateIssueInput,
IssueFilterInput, IssueFilterInput,
@@ -34,8 +35,10 @@ import {
} from './inputs'; } from './inputs';
import { withoutNulls } from '../common/issue-input'; import { withoutNulls } from '../common/issue-input';
import { viewerOf } from '../common/viewer'; import { viewerOf } from '../common/viewer';
import { toBulkChange } from '../common/bulk';
import { import {
ActivityGql, ActivityGql,
BulkResultGql,
CommandResultGql, CommandResultGql,
CommentPageGql, CommentPageGql,
EpicGql, EpicGql,
@@ -280,6 +283,21 @@ export class IssueResolver {
); );
} }
@Mutation(() => BulkResultGql, {
description:
'The same change on up to 100 tickets; each goes through the normal rules and may refuse on its own',
})
bulkUpdateIssues(
@CurrentUser() me: User,
@Args('input') input: BulkUpdateIssuesInput,
) {
return this.issuesService.bulkUpdate(
me.id,
input.issueIds,
toBulkChange(input),
);
}
@Mutation(() => CommandResultGql, { @Mutation(() => CommandResultGql, {
description: "Add a step to a ticket's checklist", description: "Add a step to a ticket's checklist",
}) })

View File

@@ -240,6 +240,19 @@ export class AutomationGql {
lastError!: string | null; lastError!: string | null;
} }
@ObjectType('BulkIssueResult')
export class BulkIssueResultGql {
@Field(() => ID) issueId!: string;
@Field() ok!: boolean;
@Field(() => String, { nullable: true }) error!: string | null;
}
@ObjectType('BulkResult')
export class BulkResultGql {
@Field(() => Int) updated!: number;
@Field(() => [BulkIssueResultGql]) results!: BulkIssueResultGql[];
}
@ObjectType('WebhookDelivery') @ObjectType('WebhookDelivery')
export class WebhookDeliveryGql { export class WebhookDeliveryGql {
@Field() at!: string; @Field() at!: string;

View File

@@ -789,6 +789,51 @@ export class ImportQueryDto {
dryRun = false; dryRun = false;
} }
export class BulkUpdateIssuesDto {
@ApiProperty({ type: [String], format: 'uuid', maxItems: 100 })
@IsArray()
@ArrayMaxSize(100)
@IsUUID('all', { each: true })
issueIds!: string[];
@ApiPropertyOptional({ example: 'IN_PROGRESS' })
@IsOptional()
@Matches(SETTING_ID)
status?: string;
@ApiPropertyOptional({ description: 'Unassign everything' })
@IsOptional()
@IsBoolean()
unassign?: boolean;
@ApiPropertyOptional({ description: 'Pireon user id of a member' })
@IsOptional()
@IsString()
assigneeId?: string;
@ApiPropertyOptional({ description: 'Back to the backlog' })
@IsOptional()
@IsBoolean()
noSprint?: boolean;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
sprintId?: string;
@ApiPropertyOptional({ description: 'Out of their release' })
@IsOptional()
@IsBoolean()
noRelease?: boolean;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
releaseId?: string;
@ApiPropertyOptional({ enum: IssuePriority, enumName: 'IssuePriority' })
@IsOptional()
@IsEnum(IssuePriority)
priority?: IssuePriority;
@ApiPropertyOptional({ type: [String] })
@IsOptional()
@ArrayMaxSize(10)
@IsString({ each: true })
addLabels?: string[];
}
export class RankIssueDto { export class RankIssueDto {
@ApiPropertyOptional({ @ApiPropertyOptional({
type: String, type: String,

View File

@@ -223,6 +223,17 @@ export class ImportReportDto {
@ApiProperty({ type: [ImportLineDto] }) warnings!: ImportLineDto[]; @ApiProperty({ type: [ImportLineDto] }) warnings!: ImportLineDto[];
} }
export class BulkIssueResultDto {
@ApiProperty({ format: 'uuid' }) issueId!: string;
@ApiProperty() ok!: boolean;
@ApiProperty({ type: String, nullable: true }) error!: string | null;
}
export class BulkResultDto {
@ApiProperty() updated!: number;
@ApiProperty({ type: [BulkIssueResultDto] }) results!: BulkIssueResultDto[];
}
export class GitDeliveryResultDto { export class GitDeliveryResultDto {
@ApiProperty({ description: 'Tickets that got something new' }) @ApiProperty({ description: 'Tickets that got something new' })
recorded!: number; recorded!: number;

View File

@@ -3,6 +3,7 @@ import {
Controller, Controller,
Delete, Delete,
Get, Get,
HttpCode,
Param, Param,
ParseUUIDPipe, ParseUUIDPipe,
Patch, Patch,
@@ -35,6 +36,7 @@ import { IssueLinkType } from '@task-stream/api/tracker/domain';
import { withoutNulls } from '../common/issue-input'; import { withoutNulls } from '../common/issue-input';
import { import {
AssignIssueDto, AssignIssueDto,
BulkUpdateIssuesDto,
ChangeStatusDto, ChangeStatusDto,
ChecklistItemBodyDto, ChecklistItemBodyDto,
CommentBodyDto, CommentBodyDto,
@@ -52,6 +54,7 @@ import {
} from './dto/requests.dto'; } from './dto/requests.dto';
import { import {
ActivityDto, ActivityDto,
BulkResultDto,
CommandResultDto, CommandResultDto,
CommentPageDto, CommentPageDto,
EpicDto, EpicDto,
@@ -59,6 +62,7 @@ import {
IssuePageDto, IssuePageDto,
} from './dto/responses.dto'; } from './dto/responses.dto';
import { viewerOf } from '../common/viewer'; import { viewerOf } from '../common/viewer';
import { toBulkChange } from '../common/bulk';
import { Refs } from './refs'; import { Refs } from './refs';
const PROJECT = ApiParam({ const PROJECT = ApiParam({
@@ -306,6 +310,19 @@ export class IssuesController {
); );
} }
@Post('issues/bulk')
@HttpCode(200)
@ApiOperation({
summary: 'The same change on up to 100 tickets',
description:
'Each ticket goes through the normal rules (permissions, workflow, open sprints and releases) and may refuse on its own; the answer says which.',
})
@ApiOkResponse({ type: BulkResultDto })
@ApiCommandErrors()
bulkUpdate(@CurrentUser() me: User, @Body() body: BulkUpdateIssuesDto) {
return this.issues.bulkUpdate(me.id, body.issueIds, toBulkChange(body));
}
@Put('issues/:issue/parent') @Put('issues/:issue/parent')
@ISSUE @ISSUE
@ApiOperation({ @ApiOperation({

View File

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

View File

@@ -1,5 +1,6 @@
import { inject, Injectable, Provider } from '@angular/core'; import { inject, Injectable, Provider } from '@angular/core';
import { injectDispatch } from '@ngrx/signals/events'; import { injectDispatch } from '@ngrx/signals/events';
import { BulkChange } from '@task-stream/web/tracker/domain';
import { backlogPageEvents } from '../events/backlog.events'; import { backlogPageEvents } from '../events/backlog.events';
import { BacklogStore } from '../stores/backlog.store'; import { BacklogStore } from '../stores/backlog.store';
@@ -20,6 +21,11 @@ export class BacklogFacade {
this.dispatch.opened({ projectId }); 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. */ /** Puts the ticket at `index` of the list as shown now. */
move(issueId: string, index: number): void { move(issueId: string, index: number): void {
this.dispatch.moveRequested({ issueId, index }); this.dispatch.moveRequested({ issueId, index });

View File

@@ -79,7 +79,7 @@ export const BacklogStore = signalStore(
switchMap(({ payload }) => switchMap(({ payload }) =>
merge( merge(
realtime.issueChanges(payload.projectId).pipe(debounceTime(400)), realtime.issueChanges(payload.projectId).pipe(debounceTime(400)),
events.on(backlogApiEvents.moveFailed), events.on(backlogApiEvents.moveFailed, backlogApiEvents.bulkDone),
).pipe( ).pipe(
startWith(null), startWith(null),
switchMap(() => fetch(payload.projectId)), 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 feedback$: events
.on(backlogApiEvents.moveFailed) .on(backlogApiEvents.moveFailed)
.pipe(tap(({ payload }) => notifications.error(payload))), .pipe(tap(({ payload }) => notifications.error(payload))),

View File

@@ -92,6 +92,22 @@ export interface BoardFilter {
sprintId?: string; 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 { export interface CreateSprintInput {
projectId: string; projectId: string;
name: string; name: string;

View File

@@ -41,6 +41,8 @@ import {
UpdateIssueInput, UpdateIssueInput,
UpdateProjectInput, UpdateProjectInput,
CreateSprintInput, CreateSprintInput,
BulkChange,
BulkResult,
UpdateSprintInput, UpdateSprintInput,
} from '../models/inputs'; } from '../models/inputs';
@@ -113,6 +115,11 @@ export abstract class IssuesGateway {
parentId: string | null, parentId: string | null,
): Observable<CommandResult>; ): Observable<CommandResult>;
abstract epics(projectId: string): Observable<Epic[]>; 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. */ /** Tickets still to start (TODO category), in backlog order. */
abstract backlog(projectId: string): Observable<IssuePage>; abstract backlog(projectId: string): Observable<IssuePage>;
/** Moves a ticket between two others of the backlog (null: an end). */ /** 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 { import {
BoardColumn, BoardColumn,
BoardFilter, BoardFilter,
BulkChange,
BulkResult,
ChangeStatusInput, ChangeStatusInput,
CommandResult, CommandResult,
CreateIssueInput, CreateIssueInput,
@@ -19,6 +21,7 @@ import {
AddChecklistItemDocument, AddChecklistItemDocument,
AssignIssueDocument, AssignIssueDocument,
BacklogDocument, BacklogDocument,
BulkUpdateIssuesDocument,
BoardDocument, BoardDocument,
ChangeIssueStatusDocument, ChangeIssueStatusDocument,
CreateIssueDocument, 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> { backlog(projectId: string): Observable<IssuePage> {
return this.gql return this.gql
.query(BacklogDocument, { .query(BacklogDocument, {

View File

@@ -53,6 +53,22 @@ export type AutomationTriggerInput = {
status?: string | null | undefined; 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 = { export type ChangeIssueStatusInput = {
issueId: string; issueId: string;
/** Required by statuses with requiresReason */ /** Required by statuses with requiresReason */
@@ -1040,6 +1056,17 @@ export type RankIssueMutation = {
rankIssue: { id: string; key: string | null; version: number }; 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 = { export type NotificationFieldsFragment = {
id: string; id: string;
kind: NotificationKind; kind: NotificationKind;
@@ -6533,6 +6560,75 @@ export const RankIssueDocument = {
}, },
], ],
} as unknown as DocumentNode<RankIssueMutation, RankIssueMutationVariables>; } 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 = { export const MyNotificationsDocument = {
kind: 'Document', kind: 'Document',
definitions: [ definitions: [

View File

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

View File

@@ -4,24 +4,37 @@ import {
Component, Component,
computed, computed,
effect, effect,
signal,
inject, inject,
untracked, untracked,
} from '@angular/core'; } from '@angular/core';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { import {
PiActionButtonComponent,
PiEmptyStateComponent, PiEmptyStateComponent,
PiIconButtonComponent, PiIconButtonComponent,
PiIconComponent, PiIconComponent,
PiNoteComponent, PiNoteComponent,
PiSelectFieldComponent,
PiSkeletonComponent, PiSkeletonComponent,
PiTextFieldComponent,
type PiSelectFieldOption,
} from '@pi/ui'; } from '@pi/ui';
import { import {
BacklogFacade, BacklogFacade,
ProjectFacade, ProjectFacade,
provideBacklogFacade, provideBacklogFacade,
} from '@task-stream/web/tracker/application'; } from '@task-stream/web/tracker/application';
import { IssueSummary, sumPoints } from '@task-stream/web/tracker/domain'; import {
import { initials, PRIORITY, statusLook, typeLook } from '../ui/copy'; 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. */ /** /p/:key/backlog — what is still to start, in the order the team takes it. */
@Component({ @Component({
@@ -31,11 +44,14 @@ import { initials, PRIORITY, statusLook, typeLook } from '../ui/copy';
RouterLink, RouterLink,
CdkDrag, CdkDrag,
CdkDropList, CdkDropList,
PiActionButtonComponent,
PiEmptyStateComponent, PiEmptyStateComponent,
PiIconButtonComponent, PiIconButtonComponent,
PiIconComponent, PiIconComponent,
PiNoteComponent, PiNoteComponent,
PiSelectFieldComponent,
PiSkeletonComponent, PiSkeletonComponent,
PiTextFieldComponent,
], ],
providers: [provideBacklogFacade()], providers: [provideBacklogFacade()],
template: ` template: `
@@ -68,6 +84,62 @@ import { initials, PRIORITY, statusLook, typeLook } from '../ui/copy';
} @else if (!facade.loaded()) { } @else if (!facade.loaded()) {
<pi-skeleton shape="text" [lines]="8" /> <pi-skeleton shape="text" [lines]="8" />
} @else if (facade.issues().length) { } @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 <ol
class="ts-backlog ts-panel" class="ts-backlog ts-panel"
cdkDropList cdkDropList
@@ -75,7 +147,20 @@ import { initials, PRIORITY, statusLook, typeLook } from '../ui/copy';
(cdkDropListDropped)="drop($event)" (cdkDropListDropped)="drop($event)"
> >
@for (issue of facade.issues(); track issue.id; let i = $index) { @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__pos ts-mono">{{ i + 1 }}</span>
<span <span
class="ts-backlog__type" class="ts-backlog__type"
@@ -148,7 +233,7 @@ import { initials, PRIORITY, statusLook, typeLook } from '../ui/copy';
.ts-backlog__row { .ts-backlog__row {
display: grid; display: grid;
grid-template-columns: grid-template-columns:
2rem auto auto minmax(0, 1fr) 1rem 2rem auto auto minmax(0, 1fr)
auto auto 2rem auto auto; auto auto 2rem auto auto;
gap: 0.625rem; gap: 0.625rem;
align-items: center; align-items: center;
@@ -160,7 +245,7 @@ import { initials, PRIORITY, statusLook, typeLook } from '../ui/copy';
border-top: 0; border-top: 0;
} }
@media (max-width: 720px) { @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__type,
.ts-backlog__points, .ts-backlog__points,
.ts-pill, .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 { .ts-backlog__pos {
color: var(--ts-muted); color: var(--ts-muted);
font-size: 0.75rem; font-size: 0.75rem;
@@ -221,6 +326,91 @@ export class BacklogPage {
protected readonly facade = inject(BacklogFacade); protected readonly facade = inject(BacklogFacade);
protected readonly project = inject(ProjectFacade); protected readonly project = inject(ProjectFacade);
protected readonly initialsOf = initials; 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 points = computed(() => sumPoints(this.facade.issues()));
protected readonly unestimated = computed( protected readonly unestimated = computed(
() => this.facade.issues().filter((i) => i.storyPoints === null).length, () => this.facade.issues().filter((i) => i.storyPoints === null).length,