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:
@@ -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": {
|
||||
"put": {
|
||||
"description": "Same project, three levels at most (epic → ticket → subtask), no loops. null takes it back to the top.",
|
||||
@@ -10641,6 +10742,102 @@
|
||||
"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": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -128,6 +128,36 @@ type BoardColumn {
|
||||
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 {
|
||||
issueId: ID!
|
||||
|
||||
@@ -558,6 +588,11 @@ type Mutation {
|
||||
"""Assign (or unassign with null) a project member"""
|
||||
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)"""
|
||||
changeIssueStatus(input: ChangeIssueStatusInput!): CommandResult!
|
||||
changeProjectMemberRole(input: ProjectMemberInput!): CommandResult!
|
||||
|
||||
@@ -455,6 +455,26 @@ export interface paths {
|
||||
patch?: 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": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1877,6 +1897,35 @@ export interface components {
|
||||
*/
|
||||
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: {
|
||||
/**
|
||||
* @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: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -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 |
|
||||
| 🏃 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 |
|
||||
| ☑️ 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 |
|
||||
| 🧩 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 |
|
||||
|
||||
@@ -167,6 +167,10 @@ stateDiagram-v2
|
||||
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
|
||||
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
|
||||
(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).
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import { SetIssueParentCommandHandler } from './set-issue-parent.command-handler
|
||||
import { RankIssueCommandHandler } from './rank-issue.command-handler';
|
||||
import { WriteReleaseNotesCommandHandler } from './write-release-notes.command-handler';
|
||||
import { SPRINT_COMMAND_HANDLERS } from './sprint.command-handlers';
|
||||
import { BulkUpdateIssuesCommandHandler } from './bulk-update-issues';
|
||||
import { DetachFromParentCommandHandler } from './detach-from-parent.command-handler';
|
||||
import {
|
||||
AddChecklistItemCommandHandler,
|
||||
@@ -75,6 +76,7 @@ export const COMMAND_HANDLERS = [
|
||||
RankIssueCommandHandler,
|
||||
WriteReleaseNotesCommandHandler,
|
||||
...SPRINT_COMMAND_HANDLERS,
|
||||
BulkUpdateIssuesCommandHandler,
|
||||
AddChecklistItemCommandHandler,
|
||||
UpdateChecklistItemCommandHandler,
|
||||
RemoveChecklistItemCommandHandler,
|
||||
|
||||
@@ -78,6 +78,7 @@ import { DeleteCommentCommand } from './delete-comment.command';
|
||||
import { PublishReleaseCommand } from './publish-release.command';
|
||||
import { SetIssueReleaseCommand } from './set-issue-release.command';
|
||||
import { WriteAccess } from './write-access';
|
||||
import { BulkResult, BulkUpdateIssuesCommand } from './bulk-update-issues';
|
||||
import { UpdateProjectSettingsCommand } from './update-project-settings.command';
|
||||
import { ReadAccess } from '../queries/read-access';
|
||||
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 () => {
|
||||
const { run } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
|
||||
@@ -35,3 +35,4 @@ export * from './change-checklist.command';
|
||||
export * from './rank-issue.command';
|
||||
export * from './sprint.commands';
|
||||
export * from './write-release-notes.command';
|
||||
export * from './bulk-update-issues';
|
||||
|
||||
@@ -16,6 +16,11 @@ import { DetachFileCommand } from './commands/detach-file.command';
|
||||
import { WatchIssueCommand } from './commands/watch-issue.command';
|
||||
import { SetIssueParentCommand } from './commands/set-issue-parent.command';
|
||||
import { RankIssueCommand } from './commands/rank-issue.command';
|
||||
import {
|
||||
BulkChange,
|
||||
BulkResult,
|
||||
BulkUpdateIssuesCommand,
|
||||
} from './commands/bulk-update-issues';
|
||||
import {
|
||||
AddChecklistItemCommand,
|
||||
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(
|
||||
actorId: string,
|
||||
issueId: string,
|
||||
|
||||
27
libs/api/tracker/presenters/src/lib/common/bulk.ts
Normal file
27
libs/api/tracker/presenters/src/lib/common/bulk.ts
Normal 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;
|
||||
}
|
||||
@@ -279,6 +279,49 @@ export class SaveFilterInput {
|
||||
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()
|
||||
export class AutomationTriggerInput {
|
||||
@Field(() => AutomationTriggerKind)
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import {
|
||||
AddChecklistItemInput,
|
||||
BulkUpdateIssuesInput,
|
||||
ChangeIssueStatusInput,
|
||||
CreateIssueInput,
|
||||
IssueFilterInput,
|
||||
@@ -34,8 +35,10 @@ import {
|
||||
} from './inputs';
|
||||
import { withoutNulls } from '../common/issue-input';
|
||||
import { viewerOf } from '../common/viewer';
|
||||
import { toBulkChange } from '../common/bulk';
|
||||
import {
|
||||
ActivityGql,
|
||||
BulkResultGql,
|
||||
CommandResultGql,
|
||||
CommentPageGql,
|
||||
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, {
|
||||
description: "Add a step to a ticket's checklist",
|
||||
})
|
||||
|
||||
@@ -240,6 +240,19 @@ export class AutomationGql {
|
||||
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')
|
||||
export class WebhookDeliveryGql {
|
||||
@Field() at!: string;
|
||||
|
||||
@@ -789,6 +789,51 @@ export class ImportQueryDto {
|
||||
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 {
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
|
||||
@@ -223,6 +223,17 @@ export class ImportReportDto {
|
||||
@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 {
|
||||
@ApiProperty({ description: 'Tickets that got something new' })
|
||||
recorded!: number;
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
@@ -35,6 +36,7 @@ import { IssueLinkType } from '@task-stream/api/tracker/domain';
|
||||
import { withoutNulls } from '../common/issue-input';
|
||||
import {
|
||||
AssignIssueDto,
|
||||
BulkUpdateIssuesDto,
|
||||
ChangeStatusDto,
|
||||
ChecklistItemBodyDto,
|
||||
CommentBodyDto,
|
||||
@@ -52,6 +54,7 @@ import {
|
||||
} from './dto/requests.dto';
|
||||
import {
|
||||
ActivityDto,
|
||||
BulkResultDto,
|
||||
CommandResultDto,
|
||||
CommentPageDto,
|
||||
EpicDto,
|
||||
@@ -59,6 +62,7 @@ import {
|
||||
IssuePageDto,
|
||||
} from './dto/responses.dto';
|
||||
import { viewerOf } from '../common/viewer';
|
||||
import { toBulkChange } from '../common/bulk';
|
||||
import { Refs } from './refs';
|
||||
|
||||
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')
|
||||
@ISSUE
|
||||
@ApiOperation({
|
||||
|
||||
@@ -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