diff --git a/apps/api/openapi.json b/apps/api/openapi.json index cc49ed1..5e8e190 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -2188,6 +2188,15 @@ "type": "string" } }, + { + "name": "sort", + "required": false, + "in": "query", + "description": "RECENT (default): last updated first. RANK: backlog order.", + "schema": { + "$ref": "#/components/schemas/IssueSort" + } + }, { "name": "search", "required": false, @@ -3098,6 +3107,118 @@ ] } }, + "/api/issues/{issue}/rank": { + "put": { + "description": "Between the ticket that ends up above it and the one below (either may be left out for the top or the bottom). Board columns follow the same order.", + "operationId": "IssuesController_rank", + "parameters": [ + { + "name": "issue", + "required": true, + "in": "path", + "description": "Ticket id or key", + "schema": { + "example": "WEB-12", + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RankIssueDto" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommandResultDto" + } + } + } + }, + "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": "Move the ticket in the backlog", + "tags": [ + "Issues" + ] + } + }, "/api/issues/{issue}/checklist": { "post": { "operationId": "IssuesController_addChecklistItem", @@ -6940,6 +7061,10 @@ } ] }, + "rank": { + "type": "string", + "description": "Place in the backlog and its board column (ascending, as text)" + }, "checklist": { "type": "array", "items": { @@ -6986,6 +7111,7 @@ "attachments", "watchers", "parent", + "rank", "checklist", "commentCount", "createdAt", @@ -7421,6 +7547,13 @@ "total" ] }, + "IssueSort": { + "type": "string", + "enum": [ + "RECENT", + "RANK" + ] + }, "CreateIssueDto": { "type": "object", "properties": { @@ -7656,6 +7789,23 @@ "parent" ] }, + "RankIssueDto": { + "type": "object", + "properties": { + "above": { + "type": "string", + "nullable": true, + "example": "WEB-4", + "description": "The ticket that ends up right above (id or key; null: the top)" + }, + "below": { + "type": "string", + "nullable": true, + "example": "WEB-9", + "description": "The ticket that ends up right below (id or key; null: the bottom)" + } + } + }, "ChecklistItemBodyDto": { "type": "object", "properties": { diff --git a/apps/api/schema.gql b/apps/api/schema.gql index eea4104..6f4c979 100644 --- a/apps/api/schema.gql +++ b/apps/api/schema.gql @@ -242,6 +242,11 @@ type Issue { priority: IssuePriority! projectId: ID! projectKey: String! + + """ + Place in the backlog and in its board column (ascending, compare as text) + """ + rank: String! release: ReleaseRef reporter: UserRef! @@ -281,6 +286,7 @@ input IssueFilterInput { """Matches key, title or description""" search: String + sort: IssueSort = RECENT statuses: [String!] types: [String!] unassigned: Boolean @@ -322,6 +328,12 @@ enum IssuePriority { MEDIUM } +"""RECENT: last updated first. RANK: backlog order.""" +enum IssueSort { + RANK + RECENT +} + type IssueTypeOption { archived: Boolean! color: String! @@ -400,6 +412,9 @@ type Mutation { """ publishRelease(force: Boolean! = false, releaseId: ID!): CommandResult! + """Move a ticket in the backlog, between two others of the same project""" + rankIssue(input: RankIssueInput!): CommandResult! + """ Remove a file from a ticket (whoever uploaded it, or an owner/admin). Upload with the REST API. """ @@ -641,6 +656,15 @@ type Query { users(limit: Int! = 20, search: String! = ""): [User!]! } +input RankIssueInput { + """The ticket that ends up right above (null: the top)""" + aboveIssueId: ID + + """The ticket that ends up right below (null: the bottom)""" + belowIssueId: ID + issueId: ID! +} + type Release { """Scope vs finished tickets, day by day""" burnup: ReleaseBurnup! diff --git a/apps/mcp/src/app/api/taskstream.schema.ts b/apps/mcp/src/app/api/taskstream.schema.ts index 5ce13f6..f582aad 100644 --- a/apps/mcp/src/app/api/taskstream.schema.ts +++ b/apps/mcp/src/app/api/taskstream.schema.ts @@ -475,6 +475,26 @@ export interface paths { patch?: never; trace?: never; }; + "/api/issues/{issue}/rank": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Move the ticket in the backlog + * @description Between the ticket that ends up above it and the one below (either may be left out for the top or the bottom). Board columns follow the same order. + */ + put: operations["IssuesController_rank"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/issues/{issue}/checklist": { parameters: { query?: never; @@ -1208,6 +1228,8 @@ export interface components { watchers: components["schemas"]["UserRefDto"][]; /** @description The epic or ticket this one breaks down */ parent: components["schemas"]["IssueParentDto"] | null; + /** @description Place in the backlog and its board column (ascending, as text) */ + rank: string; checklist: components["schemas"]["ChecklistItemDto"][]; commentCount: number; /** Format: date-time */ @@ -1334,6 +1356,8 @@ export interface components { /** @description Total matches (for pagination) */ total: number; }; + /** @enum {string} */ + IssueSort: "RECENT" | "RANK"; CreateIssueDto: { description?: string | null; /** @@ -1425,6 +1449,18 @@ export interface components { */ parent: string | null; }; + RankIssueDto: { + /** + * @description The ticket that ends up right above (id or key; null: the top) + * @example WEB-4 + */ + above?: string | null; + /** + * @description The ticket that ends up right below (id or key; null: the bottom) + * @example WEB-9 + */ + below?: string | null; + }; ChecklistItemBodyDto: { /** @example Actualizar el changelog */ text: string; @@ -3017,6 +3053,8 @@ export interface operations { label?: string[]; /** @description Only the tickets hanging from this one (id or key) */ parent?: string; + /** @description RECENT (default): last updated first. RANK: backlog order. */ + sort?: components["schemas"]["IssueSort"]; /** @description Matches key, title or description */ search?: string; }; @@ -3671,6 +3709,86 @@ export interface operations { }; }; }; + IssuesController_rank: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Ticket id or key */ + issue: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RankIssueDto"]; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CommandResultDto"]; + }; + }; + /** @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_addChecklistItem: { parameters: { query?: never; diff --git a/apps/web/src/app/app.routes.spec.ts b/apps/web/src/app/app.routes.spec.ts index 452d7af..47b1960 100644 --- a/apps/web/src/app/app.routes.spec.ts +++ b/apps/web/src/app/app.routes.spec.ts @@ -28,6 +28,7 @@ describe('appRoutes', () => { '', 'board', 'issues/:issueKey', + 'backlog', 'epics', 'releases', 'dashboard', diff --git a/docs/01-vision-general.md b/docs/01-vision-general.md index 71b45b1..39fda7a 100644 --- a/docs/01-vision-general.md +++ b/docs/01-vision-general.md @@ -14,6 +14,7 @@ | 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) | | 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona | | 💬 Comentarios | Decisiones y explicaciones dentro del ticket, con `@menciones`; el autor edita, el admin modera | +| 📋 Backlog | Lo pendiente de empezar, en el orden en que se va a coger; se reordena arrastrando y el tablero sigue ese orden | | 🧩 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 | | 🔔 Avisos | Campana con lo que te concierne (asignaciones, cambios de estado, QA fallido, comentarios), en vivo y opcionalmente por correo | diff --git a/docs/03-api.md b/docs/03-api.md index 875b0ca..907f28e 100644 --- a/docs/03-api.md +++ b/docs/03-api.md @@ -152,6 +152,11 @@ stateDiagram-v2 (épica → ticket → subtarea) y sin bucles; se comprueba sobre el event store, no sobre el read model. Una «épica» es cualquier ticket de primer nivel del que cuelgan otros (`epics(projectId)` da su progreso: terminados = hechos o cancelados, y puntos). Si se borra el padre, sus hijos vuelven al primer nivel. +- **Orden del backlog**: cada ticket tiene un `rank` (texto que se compara carácter a carácter). Mover uno + (`rankIssue`, `PUT /api/issues/{issue}/rank`) solo reescribe ese ticket: recibe un rango entre el de quien queda + encima y el de quien queda debajo, leídos del event store; si entretanto el orden cambió, la API lo rechaza y la web + recarga. Un ticket que nunca se movió ocupa su orden de creación (`IssueRankedEvent` no existe para él). Las + columnas del tablero y `issues(filter: { sort: RANK })` siguen este orden. - **Checklist**: hasta 100 pasos de hasta 300 caracteres por ticket; los cambia quien puede editar el ticket. ### Notificaciones @@ -183,12 +188,12 @@ por defecto); un correo que falla se registra y no bloquea el stream. Todo cambio es un evento guardado en el event store (stream = id del agregado). Llevan `actorId`, `occurredAt`, `aggregateId`, `projectId` y `aggregateVersion` (la posición en el stream). -| Agregado | Eventos | -| -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Project | `ProjectCreatedEvent`, `ProjectUpdatedEvent`, `ProjectSettingsChangedEvent`, `ProjectMemberAddedEvent`, `ProjectMemberRoleChangedEvent`, `ProjectMemberRemovedEvent`, `ProjectDeletedEvent` | -| Issue | `IssueCreatedEvent`, `IssueUpdatedEvent`, `IssueStatusChangedEvent`, `IssueAssignedEvent`, `IssueLinkedEvent`, `IssueUnlinkedEvent`, `IssueReleaseChangedEvent`, `IssueAttachmentAddedEvent`, `IssueAttachmentRemovedEvent`, `IssueWatchedEvent`, `IssueUnwatchedEvent`, `IssueParentChangedEvent`, `IssueChecklistItemAddedEvent`, `IssueChecklistItemUpdatedEvent`, `IssueChecklistItemRemovedEvent`, `IssueDeletedEvent` | -| Comment | `CommentAddedEvent`, `CommentEditedEvent`, `CommentDeletedEvent` | -| Release | `ReleaseCreatedEvent`, `ReleaseUpdatedEvent`, `ReleaseShippedEvent`, `ReleaseArchivedEvent`, `ReleaseDeletedEvent` | +| Agregado | Eventos | +| -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Project | `ProjectCreatedEvent`, `ProjectUpdatedEvent`, `ProjectSettingsChangedEvent`, `ProjectMemberAddedEvent`, `ProjectMemberRoleChangedEvent`, `ProjectMemberRemovedEvent`, `ProjectDeletedEvent` | +| Issue | `IssueCreatedEvent`, `IssueUpdatedEvent`, `IssueStatusChangedEvent`, `IssueAssignedEvent`, `IssueLinkedEvent`, `IssueUnlinkedEvent`, `IssueReleaseChangedEvent`, `IssueAttachmentAddedEvent`, `IssueAttachmentRemovedEvent`, `IssueWatchedEvent`, `IssueUnwatchedEvent`, `IssueParentChangedEvent`, `IssueRankedEvent`, `IssueChecklistItemAddedEvent`, `IssueChecklistItemUpdatedEvent`, `IssueChecklistItemRemovedEvent`, `IssueDeletedEvent` | +| Comment | `CommentAddedEvent`, `CommentEditedEvent`, `CommentDeletedEvent` | +| Release | `ReleaseCreatedEvent`, `ReleaseUpdatedEvent`, `ReleaseShippedEvent`, `ReleaseArchivedEvent`, `ReleaseDeletedEvent` | `IssueStatusChangedEvent` lleva además `meta` con el nombre y la categoría de los dos estados en el momento del cambio, para que el historial y las estadísticas no cambien si luego se renombra o recategoriza un estado. @@ -230,7 +235,7 @@ que funcionan también sin token. | `upcomingReleases(limit)` | Releases planificadas de mis proyectos, por fecha objetivo | | `publicProjects` | Proyectos públicos (sin token) | | `project(idOrKey)` | Un proyecto (mío, o público) con su `settings`. Campos calculados: `board(releaseId, assigneeId, area, perColumn)`, `stats`, `analytics(days)`, `releases` | -| `issues(filter, limit, offset)` | Búsqueda de tickets (estado, tipo, área, prioridad, persona, release, etiquetas, ticket padre, texto) | +| `issues(filter, limit, offset)` | Búsqueda de tickets (estado, tipo, área, prioridad, persona, release, etiquetas, ticket padre, texto); `sort: RANK` para el orden del backlog | | `epics(projectId)` | Épicas del proyecto (tickets de primer nivel de los que cuelgan otros) con el progreso de sus hijos | | `issue(idOrKey)` | Un ticket. Campos calculados: `allowedTransitions`, `linkedIssues`, `comments`, `history` | | `release(id)` | Una release. Campos calculados: `progress`, `burnup`, `issues` | @@ -240,13 +245,13 @@ que funcionan también sin token. Todas devuelven `CommandResult { id, key, version }`. -| Ámbito | Mutations | -| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` | -| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `setIssueParent`, `addChecklistItem`, `updateChecklistItem`, `removeChecklistItem`, `watchIssue`, `unwatchIssue`, `removeAttachment` (subir: por REST) | -| Comentarios | `addComment`, `editComment`, `deleteComment` | -| Avisos | `markNotificationsRead(ids)`, `updateNotificationSettings(email)` | -| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `archiveRelease` | +| Ámbito | Mutations | +| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` | +| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `setIssueParent`, `rankIssue`, `addChecklistItem`, `updateChecklistItem`, `removeChecklistItem`, `watchIssue`, `unwatchIssue`, `removeAttachment` (subir: por REST) | +| Comentarios | `addComment`, `editComment`, `deleteComment` | +| Avisos | `markNotificationsRead(ids)`, `updateNotificationSettings(email)` | +| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `archiveRelease` | ### Subscriptions @@ -442,17 +447,17 @@ Lo que cada usuario puede ver o cambiar depende de su rol en cada proyecto, como Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`). -| Recurso | Rutas | -| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` | -| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `PUT …/settings`, `…/board`, `…/epics`, `…/stats`, `…/analytics?days=`, `…/activity`, `…/members` (POST, PATCH, DELETE) | -| Tickets | `GET /api/me/issues` (mi trabajo), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `PUT/DELETE …/watch`, `PUT …/parent`, `POST …/checklist`, `PATCH/DELETE …/checklist/{item}`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history` | -| Comentarios | `PATCH/DELETE /api/comments/{commentId}` | -| Avisos | `GET /api/me/notifications`, `POST /api/me/notifications/read`, `GET/PUT /api/me/notification-settings` | -| Adjuntos | `POST /api/issues/{issue}/attachments` (multipart: `file` y opcional `commentId`), `GET/DELETE /api/issues/{issue}/attachments/{id}` | -| Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` | -| Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) | -| Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` | +| Recurso | Rutas | +| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` | +| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `PUT …/settings`, `…/board`, `…/epics`, `…/stats`, `…/analytics?days=`, `…/activity`, `…/members` (POST, PATCH, DELETE) | +| Tickets | `GET /api/me/issues` (mi trabajo), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `PUT/DELETE …/watch`, `PUT …/parent`, `PUT …/rank`, `POST …/checklist`, `PATCH/DELETE …/checklist/{item}`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history` | +| Comentarios | `PATCH/DELETE /api/comments/{commentId}` | +| Avisos | `GET /api/me/notifications`, `POST /api/me/notifications/read`, `GET/PUT /api/me/notification-settings` | +| Adjuntos | `POST /api/issues/{issue}/attachments` (multipart: `file` y opcional `commentId`), `GET/DELETE /api/issues/{issue}/attachments/{id}` | +| Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` | +| Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) | +| Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` | Los archivos van por REST porque GraphQL no está hecho para bytes; la lista de adjuntos de un ticket llega con el ticket (`attachments`, en GraphQL y en REST). La descarga sirve en línea solo imágenes, vídeo, audio, PDF y texto diff --git a/libs/api/tracker/application/src/lib/commands/command-handlers.ts b/libs/api/tracker/application/src/lib/commands/command-handlers.ts index 1195a17..c9c6f9e 100644 --- a/libs/api/tracker/application/src/lib/commands/command-handlers.ts +++ b/libs/api/tracker/application/src/lib/commands/command-handlers.ts @@ -29,6 +29,7 @@ import { AttachFileCommandHandler } from './attach-file.command-handler'; import { DetachFileCommandHandler } from './detach-file.command-handler'; import { WatchIssueCommandHandler } from './watch-issue.command-handler'; import { SetIssueParentCommandHandler } from './set-issue-parent.command-handler'; +import { RankIssueCommandHandler } from './rank-issue.command-handler'; import { DetachFromParentCommandHandler } from './detach-from-parent.command-handler'; import { AddChecklistItemCommandHandler, @@ -69,6 +70,7 @@ export const COMMAND_HANDLERS = [ DetachFileCommandHandler, WatchIssueCommandHandler, SetIssueParentCommandHandler, + RankIssueCommandHandler, AddChecklistItemCommandHandler, UpdateChecklistItemCommandHandler, RemoveChecklistItemCommandHandler, diff --git a/libs/api/tracker/application/src/lib/commands/commands.spec.ts b/libs/api/tracker/application/src/lib/commands/commands.spec.ts index 09adc95..2e68bb3 100644 --- a/libs/api/tracker/application/src/lib/commands/commands.spec.ts +++ b/libs/api/tracker/application/src/lib/commands/commands.spec.ts @@ -33,6 +33,7 @@ import { import { TrackerStreamsRepository } from '../ports/tracker-streams.repository'; import { IssueTree } from './issue-tree'; import { SetIssueParentCommand } from './set-issue-parent.command'; +import { RankIssueCommand } from './rank-issue.command'; import { AddChecklistItemCommand, UpdateChecklistItemCommand, @@ -372,6 +373,38 @@ describe('tracker commands (event sourced)', () => { }); }); + it('orders the backlog from the ranks in the event store', async () => { + const { run, rehydrator } = await setup(); + const projectId = await projectWithTeam(run); + const ids = []; + for (const title of ['A', 'B', 'C']) + ids.push( + ( + await run( + new CreateIssueCommand('dev', projectId, IssueType.TASK, title, {}), + ) + ).id, + ); + const [a, b, c] = ids; + + // C to the top, then B between C and A: C, B, A. + await run(new RankIssueCommand('dev', c, null, a)); + await run(new RankIssueCommand('dev', b, c, a)); + const ranks = await Promise.all( + [c, b, a].map( + async (id) => (await rehydrator.rehydrate(id, Issue)).backlogRank, + ), + ); + expect([...ranks].sort()).toEqual(ranks); + // Neighbours given the wrong way round: the order changed meanwhile. + await expect(run(new RankIssueCommand('dev', a, b, c))).rejects.toThrow( + BusinessRuleError, + ); + await expect(run(new RankIssueCommand('pm', a, null, c))).rejects.toThrow( + ForbiddenError, + ); + }); + it('does not let viewers modify tickets', async () => { const { run } = await setup(); const projectId = await projectWithTeam(run); diff --git a/libs/api/tracker/application/src/lib/commands/index.ts b/libs/api/tracker/application/src/lib/commands/index.ts index ad977ee..eeddea1 100644 --- a/libs/api/tracker/application/src/lib/commands/index.ts +++ b/libs/api/tracker/application/src/lib/commands/index.ts @@ -32,3 +32,4 @@ export * from './watch-issue.command'; export * from './set-issue-parent.command'; export * from './detach-from-parent.command'; export * from './change-checklist.command'; +export * from './rank-issue.command'; diff --git a/libs/api/tracker/application/src/lib/commands/rank-issue.command-handler.ts b/libs/api/tracker/application/src/lib/commands/rank-issue.command-handler.ts new file mode 100644 index 0000000..70fe6d6 --- /dev/null +++ b/libs/api/tracker/application/src/lib/commands/rank-issue.command-handler.ts @@ -0,0 +1,27 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; +import { CommandResult, ack } from './command-result'; +import { RankIssueCommand } from './rank-issue.command'; +import { WriteAccess } from './write-access'; + +@CommandHandler(RankIssueCommand) +export class RankIssueCommandHandler + implements ICommandHandler +{ + constructor(private readonly access: WriteAccess) {} + + async execute(command: RankIssueCommand): Promise { + const { issue } = await this.access.issueForContributor( + command.issueId, + command.actorId, + ); + // The neighbours' ranks come from their streams, not from the screen. + const [above, below] = await Promise.all( + [command.aboveIssueId, command.belowIssueId].map((id) => + id ? this.access.issue(id) : null, + ), + ); + issue.moveBetween(command.actorId, above, below); + await issue.commitAndWait(); + return ack(issue); + } +} diff --git a/libs/api/tracker/application/src/lib/commands/rank-issue.command.ts b/libs/api/tracker/application/src/lib/commands/rank-issue.command.ts new file mode 100644 index 0000000..f55329a --- /dev/null +++ b/libs/api/tracker/application/src/lib/commands/rank-issue.command.ts @@ -0,0 +1,17 @@ +import { Command } from '@nestjs/cqrs'; +import { CommandResult } from './command-result'; + +/** + * Moves a ticket in the backlog, between the one that ends up above it and the + * one that ends up below (null: the top or the bottom). + */ +export class RankIssueCommand extends Command { + constructor( + public readonly actorId: string, + public readonly issueId: string, + public readonly aboveIssueId: string | null, + public readonly belowIssueId: string | null, + ) { + super(); + } +} diff --git a/libs/api/tracker/application/src/lib/event-handlers/event-handlers.ts b/libs/api/tracker/application/src/lib/event-handlers/event-handlers.ts index 9da65b2..d86ea89 100644 --- a/libs/api/tracker/application/src/lib/event-handlers/event-handlers.ts +++ b/libs/api/tracker/application/src/lib/event-handlers/event-handlers.ts @@ -30,6 +30,7 @@ import { AttachmentCleanupEventHandler } from './attachment-cleanup.event-handle import { IssueWatchersEventHandler } from './issue-watchers.event-handler'; import { IssueParentChangedEventHandler } from './issue-parent-changed.event-handler'; import { IssueChecklistEventHandler } from './issue-checklist.event-handler'; +import { IssueRankedEventHandler } from './issue-ranked.event-handler'; /** Read-model projections (MongoDB) + realtime, fed by the EventsBridge. */ export const EVENT_HANDLERS = [ @@ -64,6 +65,7 @@ export const EVENT_HANDLERS = [ IssueWatchersEventHandler, IssueParentChangedEventHandler, IssueChecklistEventHandler, + IssueRankedEventHandler, ]; export { ReadModelNotifier }; diff --git a/libs/api/tracker/application/src/lib/event-handlers/issue-created.event-handler.ts b/libs/api/tracker/application/src/lib/event-handlers/issue-created.event-handler.ts index af91b39..f41d4fb 100644 --- a/libs/api/tracker/application/src/lib/event-handlers/issue-created.event-handler.ts +++ b/libs/api/tracker/application/src/lib/event-handlers/issue-created.event-handler.ts @@ -2,6 +2,7 @@ import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; import { DEFAULT_PROJECT_SETTINGS, IssueCreatedEvent, + implicitRank, ProjectWorkflow, } from '@task-stream/api/tracker/domain'; import { FindProjectsRepository } from '../ports/find-projects.repository'; @@ -49,6 +50,7 @@ export class IssueCreatedEventHandler assignee: users.ofNullable(issue.assigneeId), release: release ? { id: release.id, name: release.name } : null, parent: null, + rank: implicitRank(issue.number), labels: issue.labels, figmaUrl: issue.figmaUrl, storyPoints: issue.storyPoints, diff --git a/libs/api/tracker/application/src/lib/event-handlers/issue-ranked.event-handler.ts b/libs/api/tracker/application/src/lib/event-handlers/issue-ranked.event-handler.ts new file mode 100644 index 0000000..f980b75 --- /dev/null +++ b/libs/api/tracker/application/src/lib/event-handlers/issue-ranked.event-handler.ts @@ -0,0 +1,23 @@ +import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; +import { IssueRankedEvent } from '@task-stream/api/tracker/domain'; +import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository'; +import { ReadModelNotifier, versionOf } from './read-model-notifier'; + +@EventsHandler(IssueRankedEvent) +export class IssueRankedEventHandler + implements IEventHandler +{ + constructor( + private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository, + private readonly notifier: ReadModelNotifier, + ) {} + + async handle(event: IssueRankedEvent) { + await this.upsertMaterializedIssueRepository.upsert({ + id: event.aggregateId, + rank: event.rank, + version: versionOf(event), + }); + await this.notifier.issue(event.aggregateId, event); + } +} diff --git a/libs/api/tracker/application/src/lib/issues.service.ts b/libs/api/tracker/application/src/lib/issues.service.ts index 9ab4676..10a5d54 100644 --- a/libs/api/tracker/application/src/lib/issues.service.ts +++ b/libs/api/tracker/application/src/lib/issues.service.ts @@ -15,6 +15,7 @@ import { 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 { AddChecklistItemCommand, RemoveChecklistItemCommand, @@ -218,6 +219,18 @@ export class IssuesService { ); } + /** Moves a ticket in the backlog between `above` and `below` (null: an end). */ + rank( + actorId: string, + issueId: string, + above: string | null, + below: string | null, + ): Promise { + return this.commandBus.execute( + new RankIssueCommand(actorId, issueId, above, below), + ); + } + addChecklistItem( actorId: string, issueId: string, diff --git a/libs/api/tracker/application/src/lib/ports/find-issues.repository.ts b/libs/api/tracker/application/src/lib/ports/find-issues.repository.ts index c8edb6b..e2f4ad3 100644 --- a/libs/api/tracker/application/src/lib/ports/find-issues.repository.ts +++ b/libs/api/tracker/application/src/lib/ports/find-issues.repository.ts @@ -7,6 +7,11 @@ import { StatusCategory, } from '@task-stream/api/tracker/domain'; +export enum IssueSort { + RECENT = 'RECENT', + RANK = 'RANK', +} + export interface IssueFilter { projectId: string; /** Status, type and area ids of the project's settings. */ @@ -21,6 +26,8 @@ export interface IssueFilter { labels?: string[]; /** Only the tickets hanging from this one. */ parentId?: string; + /** RECENT (default): last updated first. RANK: backlog order. */ + sort?: IssueSort; search?: string; limit: number; offset: number; diff --git a/libs/api/tracker/application/src/lib/queries/get-board.query-handler.ts b/libs/api/tracker/application/src/lib/queries/get-board.query-handler.ts index 18aab66..97630f4 100644 --- a/libs/api/tracker/application/src/lib/queries/get-board.query-handler.ts +++ b/libs/api/tracker/application/src/lib/queries/get-board.query-handler.ts @@ -3,7 +3,10 @@ import { DEFAULT_PROJECT_SETTINGS, StatusCategory, } from '@task-stream/api/tracker/domain'; -import { FindIssuesRepository } from '../ports/find-issues.repository'; +import { + FindIssuesRepository, + IssueSort, +} from '../ports/find-issues.repository'; import { FindProjectsRepository } from '../ports/find-projects.repository'; import { BoardColumn, GetBoardQuery } from './get-board.query'; import { ReadAccess } from './read-access'; @@ -33,6 +36,8 @@ export class GetBoardQueryHandler const page = await this.findIssues.search({ ...query.filter, statuses: [status.id], + // Each column follows the backlog order. + sort: IssueSort.RANK, limit: Math.min(query.perColumn, 200), offset: 0, }); diff --git a/libs/api/tracker/domain/src/index.ts b/libs/api/tracker/domain/src/index.ts index 90a6fa9..94b1c8f 100644 --- a/libs/api/tracker/domain/src/index.ts +++ b/libs/api/tracker/domain/src/index.ts @@ -6,6 +6,7 @@ export * from './lib/issue/issue-attachment'; export * from './lib/issue/mentions'; export * from './lib/issue/issue-hierarchy'; export * from './lib/issue/issue-checklist'; +export * from './lib/issue/issue-rank'; export * from './lib/project/project-settings'; export * from './lib/comment/comment'; export * from './lib/release/release'; diff --git a/libs/api/tracker/domain/src/lib/events/index.ts b/libs/api/tracker/domain/src/lib/events/index.ts index c77b216..293196e 100644 --- a/libs/api/tracker/domain/src/lib/events/index.ts +++ b/libs/api/tracker/domain/src/lib/events/index.ts @@ -30,3 +30,4 @@ export * from './issue-parent-changed.event'; export * from './issue-checklist-item-added.event'; export * from './issue-checklist-item-updated.event'; export * from './issue-checklist-item-removed.event'; +export * from './issue-ranked.event'; diff --git a/libs/api/tracker/domain/src/lib/events/issue-ranked.event.ts b/libs/api/tracker/domain/src/lib/events/issue-ranked.event.ts new file mode 100644 index 0000000..85a004c --- /dev/null +++ b/libs/api/tracker/domain/src/lib/events/issue-ranked.event.ts @@ -0,0 +1,15 @@ +import { AutowiredEvent } from '@task-stream/api/shared/domain'; +import { TrackerEvent } from './tracker.event'; + +/** A ticket was moved to another place in the backlog. */ +@AutowiredEvent +export class IssueRankedEvent extends TrackerEvent { + constructor( + actorId: string, + issueId: string, + projectId: string, + readonly rank: string, + ) { + super(actorId, issueId, projectId); + } +} diff --git a/libs/api/tracker/domain/src/lib/issue/issue-rank.spec.ts b/libs/api/tracker/domain/src/lib/issue/issue-rank.spec.ts new file mode 100644 index 0000000..5c0d550 --- /dev/null +++ b/libs/api/tracker/domain/src/lib/issue/issue-rank.spec.ts @@ -0,0 +1,42 @@ +import { BusinessRuleError } from '@task-stream/api/shared/domain'; +import { implicitRank, rankBetween } from './issue-rank'; + +describe('backlog ranks', () => { + it('orders tickets never moved by creation', () => { + const ranks = [1, 2, 10, 36, 999].map(implicitRank); + expect([...ranks].sort()).toEqual(ranks); + }); + + it('always finds a rank between two, at the ends too', () => { + const pairs: [string | null, string | null][] = [ + [null, null], + [null, implicitRank(1)], + [implicitRank(1), implicitRank(2)], + [implicitRank(9), null], + ['a', 'b'], + ['az', 'b'], + ['a', 'a1'], + ['zz', null], + ]; + for (const [above, below] of pairs) { + const rank = rankBetween(above, below); + if (above !== null) expect(rank > above).toBe(true); + if (below !== null) expect(rank < below).toBe(true); + expect(rank.endsWith('0')).toBe(false); + } + }); + + it('keeps finding room when the same gap is split again and again', () => { + let above = implicitRank(1); + const below = implicitRank(2); + for (let i = 0; i < 200; i++) { + const rank = rankBetween(above, below); + expect(rank > above && rank < below).toBe(true); + above = rank; + } + }); + + it('refuses neighbours that are out of order', () => { + expect(() => rankBetween('b', 'a')).toThrow(BusinessRuleError); + }); +}); diff --git a/libs/api/tracker/domain/src/lib/issue/issue-rank.ts b/libs/api/tracker/domain/src/lib/issue/issue-rank.ts new file mode 100644 index 0000000..a42c28a --- /dev/null +++ b/libs/api/tracker/domain/src/lib/issue/issue-rank.ts @@ -0,0 +1,45 @@ +import { BusinessRuleError } from '@task-stream/api/shared/domain'; + +/* + * Backlog order as fractional ranks: strings compared character by character, + * so moving a ticket rewrites only that ticket. A rank is a base-36 fraction + * (`0.xyz…`) that never ends in `0`, which keeps a free key between any two. + */ + +const DIGITS = '0123456789abcdefghijklmnopqrstuvwxyz'; +const BASE = DIGITS.length; + +const digit = (rank: string, at: number) => + at < rank.length ? DIGITS.indexOf(rank[at]) : 0; + +/** + * Where a ticket that was never moved sits: after anything placed above it, + * in creation order. Starts at `n`, the middle of the key space. + */ +export function implicitRank(issueNumber: number): string { + return `n${issueNumber.toString(BASE).padStart(7, '0')}i`; +} + +/** A rank strictly between `above` and `below` (null: open end). */ +export function rankBetween( + above: string | null, + below: string | null, +): string { + const low = above ?? ''; + if (below !== null && low >= below) + throw new BusinessRuleError('The order changed meanwhile; reload it'); + let upper = below; + let rank = ''; + for (let at = 0; ; at++) { + const from = digit(low, at); + const to = upper === null ? BASE : digit(upper, at); + if (from === to) { + rank += DIGITS[from]; + continue; + } + if (to - from > 1) return rank + DIGITS[(from + to) >> 1]; + // Neighbours here: keep the lower digit, and anything after it fits below. + rank += DIGITS[from]; + upper = null; + } +} diff --git a/libs/api/tracker/domain/src/lib/issue/issue.spec.ts b/libs/api/tracker/domain/src/lib/issue/issue.spec.ts index 49b173a..0b36e84 100644 --- a/libs/api/tracker/domain/src/lib/issue/issue.spec.ts +++ b/libs/api/tracker/domain/src/lib/issue/issue.spec.ts @@ -279,6 +279,32 @@ describe('Issue', () => { }); }); + describe('backlog order', () => { + it('moves between two tickets, to the top, and survives a replay', () => { + const issue = newIssue(); + const other = (number: number) => + factory.create({ + projectId: 'p-1', + projectKey: 'TS', + number, + title: `#${number}`, + type: IssueType.TASK, + reporterId: 'u', + }); + const [one, two] = [other(1), other(2)]; + + issue.moveBetween('u-pm', one, two); + expect(issue.backlogRank > one.backlogRank).toBe(true); + expect(issue.backlogRank < two.backlogRank).toBe(true); + issue.moveBetween('u-pm', null, one); + expect(issue.backlogRank < one.backlogRank).toBe(true); + expect(replay(issue, Issue).rebuilt.rank).toBe(issue.rank); + expect(() => issue.moveBetween('u-pm', issue, null)).toThrow( + BusinessRuleError, + ); + }); + }); + describe('checklist', () => { it('adds, ticks, rewords and removes steps, and survives a replay', () => { const issue = newIssue(); diff --git a/libs/api/tracker/domain/src/lib/issue/issue.ts b/libs/api/tracker/domain/src/lib/issue/issue.ts index 859e6a0..c26b955 100644 --- a/libs/api/tracker/domain/src/lib/issue/issue.ts +++ b/libs/api/tracker/domain/src/lib/issue/issue.ts @@ -14,6 +14,7 @@ import { IssueCreatedEvent } from '../events/issue-created.event'; import { IssueDeletedEvent } from '../events/issue-deleted.event'; import { IssueLinkedEvent } from '../events/issue-linked.event'; import { IssueParentChangedEvent } from '../events/issue-parent-changed.event'; +import { IssueRankedEvent } from '../events/issue-ranked.event'; import { IssueReleaseChangedEvent } from '../events/issue-release-changed.event'; import { IssueStatusChangedEvent } from '../events/issue-status-changed.event'; import { IssueUnlinkedEvent } from '../events/issue-unlinked.event'; @@ -32,6 +33,7 @@ import { normalizeChecklistText, } from './issue-checklist'; import { ParentCandidate, assertCanNest } from './issue-hierarchy'; +import { implicitRank, rankBetween } from './issue-rank'; import { IssueLinkType, IssuePriority } from './issue.enums'; export interface IssueLink { @@ -108,6 +110,8 @@ export class Issue extends VersionedAggregateRoot implements IssueProps { /** The epic or ticket this one breaks down. */ public parentId: string | null = null; public checklist: ChecklistItem[] = []; + /** Place in the backlog once someone moved it (see `backlogRank`). */ + public rank: string | null = null; public createdAt!: string; public updatedAt!: string; public deleted = false; @@ -316,6 +320,28 @@ export class Issue extends VersionedAggregateRoot implements IssueProps { ); } + /** Where it sits in the backlog: where it was put, or its creation order. */ + get backlogRank(): string { + return this.rank ?? implicitRank(this.number); + } + + /** Puts the ticket between two others of the backlog (null: an end). */ + moveBetween(actorId: string, above: Issue | null, below: Issue | null): void { + for (const neighbour of [above, below]) { + if (neighbour?.id === this.id) + throw new BusinessRuleError(`${this.key} cannot move next to itself`); + if (neighbour && neighbour.projectId !== this.projectId) + throw new BusinessRuleError( + `${neighbour.key} belongs to another project`, + ); + } + const rank = rankBetween( + above?.backlogRank ?? null, + below?.backlogRank ?? null, + ); + this.apply(new IssueRankedEvent(actorId, this.id, this.projectId, rank)); + } + /** Adds a step to the checklist; `id` is generated by the caller. */ addChecklistItem(actorId: string, id: string, text: string): void { if (this.checklist.some((i) => i.id === id)) return; @@ -463,6 +489,11 @@ export class Issue extends VersionedAggregateRoot implements IssueProps { this.updatedAt = event.occurredAt; } + onIssueRankedEvent(event: IssueRankedEvent): void { + // Reordering is not a change to the ticket: `updatedAt` stays. + this.rank = event.rank; + } + onIssueChecklistItemAddedEvent(event: IssueChecklistItemAddedEvent): void { this.checklist = [...this.checklist, { ...event.item }]; this.updatedAt = event.occurredAt; diff --git a/libs/api/tracker/domain/src/lib/read-models/issue.read-model.ts b/libs/api/tracker/domain/src/lib/read-models/issue.read-model.ts index 8ff415b..178037b 100644 --- a/libs/api/tracker/domain/src/lib/read-models/issue.read-model.ts +++ b/libs/api/tracker/domain/src/lib/read-models/issue.read-model.ts @@ -79,6 +79,8 @@ export class IssueReadModel { /** People following the ticket. */ watchers!: UserRefReadModel[]; parent!: IssueParentReadModel | null; + /** Place in the backlog (and in each board column): ascending. */ + rank!: string; checklist!: ChecklistItemReadModel[]; commentCount!: number; createdAt!: string; diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts b/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts index 6c083b1..d91eef8 100644 --- a/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts +++ b/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts @@ -9,6 +9,7 @@ import { FindReleasesRepository, FindTimelineRepository, IssueFilter, + IssueSort, Page, } from '@task-stream/api/tracker/application'; import { @@ -135,7 +136,11 @@ export class MongoFindIssuesRepository extends FindIssuesRepository { const [docs, total] = await Promise.all([ this.issues .find(query) - .sort({ updatedAt: -1 }) + .sort( + filter.sort === IssueSort.RANK + ? { rank: 1, number: 1 } + : { updatedAt: -1 }, + ) .skip(filter.offset) .limit(filter.limit) .lean(), diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/rank.backfill.ts b/libs/api/tracker/infrastructure/src/lib/mongo/rank.backfill.ts new file mode 100644 index 0000000..3daf5c8 --- /dev/null +++ b/libs/api/tracker/infrastructure/src/lib/mongo/rank.backfill.ts @@ -0,0 +1,36 @@ +import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { implicitRank } from '@task-stream/api/tracker/domain'; +import { Model } from 'mongoose'; +import { IssueDoc, ReadModels } from './read-model.schemas'; + +/** + * Tickets projected before the backlog could be ordered lack `rank`; give + * them their creation order once at startup, the rank they have anyway. + */ +@Injectable() +export class RankBackfill implements OnApplicationBootstrap { + private readonly logger = new Logger(RankBackfill.name); + + constructor( + @InjectModel(ReadModels.Issue) private readonly issues: Model, + ) {} + + async onApplicationBootstrap(): Promise { + const missing = await this.issues + .find({ rank: { $exists: false } }, { number: 1 }) + .lean[]>(); + if (missing.length === 0) return; + const result = await this.issues.bulkWrite( + missing.map((doc) => ({ + updateOne: { + filter: { _id: doc._id, rank: { $exists: false } }, + update: { $set: { rank: implicitRank(doc.number) } }, + }, + })), + ); + this.logger.log( + `Backlog rank filled in on ${result.modifiedCount} tickets`, + ); + } +} diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts b/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts index e5989df..4a24069 100644 --- a/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts +++ b/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts @@ -7,6 +7,7 @@ import { IssueTimelineEntry, ProjectReadModel, ReleaseReadModel, + implicitRank, } from '@task-stream/api/tracker/domain'; import { Schema } from 'mongoose'; @@ -102,6 +103,7 @@ IssueSchema.index({ projectId: 1, statusCategory: 1 }); IssueSchema.index({ 'assignee.id': 1 }); IssueSchema.index({ 'watchers.id': 1 }); IssueSchema.index({ 'parent.id': 1 }); +IssueSchema.index({ projectId: 1, rank: 1 }); IssueSchema.index({ 'release.id': 1 }); IssueSchema.index({ title: 'text', description: 'text', key: 'text' }); @@ -186,6 +188,7 @@ export function issueFromDoc(doc: IssueDoc): IssueReadModel { attachments: rest.attachments ?? [], watchers: rest.watchers ?? [], parent: rest.parent?.id ? rest.parent : null, + rank: rest.rank ?? implicitRank(rest.number), checklist: rest.checklist ?? [], }; } diff --git a/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts b/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts index 133a4b8..00e4467 100644 --- a/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts +++ b/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts @@ -45,6 +45,7 @@ import { MongoUpsertMaterializedTimelineRepository, } from './mongo/upsert-materialized.repositories'; import { StatusCategoryBackfill } from './mongo/status-category.backfill'; +import { RankBackfill } from './mongo/rank.backfill'; import { LOOKUP_ENTITIES } from './postgres/lookup.entities'; import { PostgresIssueNumbersRepository, @@ -120,7 +121,7 @@ const PORTS = [ TypeOrmModule.forFeature(LOOKUP_ENTITIES), MongooseModule.forFeature(READ_MODEL_SCHEMAS), ], - providers: [...PORTS, StatusCategoryBackfill], + providers: [...PORTS, StatusCategoryBackfill, RankBackfill], exports: PORTS.map((port) => port.provide), }) export class TrackerInfrastructureModule {} diff --git a/libs/api/tracker/presenters/src/lib/graphql/enums.ts b/libs/api/tracker/presenters/src/lib/graphql/enums.ts index 32e09a6..cae9e6d 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/enums.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/enums.ts @@ -1,4 +1,5 @@ import { registerEnumType } from '@nestjs/graphql'; +import { IssueSort } from '@task-stream/api/tracker/application'; import { IssueLinkType, IssuePriority, @@ -23,6 +24,10 @@ registerEnumType(IssueLinkType, { name: 'IssueLinkType' }); registerEnumType(ProjectRole, { name: 'ProjectRole' }); registerEnumType(ReleaseStatus, { name: 'ReleaseStatus' }); registerEnumType(LinkDirection, { name: 'LinkDirection' }); +registerEnumType(IssueSort, { + name: 'IssueSort', + description: 'RECENT: last updated first. RANK: backlog order.', +}); registerEnumType(NotificationKind, { name: 'NotificationKind', description: 'Why someone is told', diff --git a/libs/api/tracker/presenters/src/lib/graphql/inputs.ts b/libs/api/tracker/presenters/src/lib/graphql/inputs.ts index fbfcdb0..52b5cef 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/inputs.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/inputs.ts @@ -5,6 +5,7 @@ import { ProjectRole, StatusCategory, } from '@task-stream/api/tracker/domain'; +import { IssueSort } from '@task-stream/api/tracker/application'; import { Type } from 'class-transformer'; import { ArrayMaxSize, @@ -180,6 +181,25 @@ export class ChangeIssueStatusInput { reason?: string | null; } +@InputType() +export class RankIssueInput { + @Field(() => ID) @IsUUID() issueId!: string; + @Field(() => ID, { + nullable: true, + description: 'The ticket that ends up right above (null: the top)', + }) + @IsOptional() + @IsUUID() + aboveIssueId?: string | null; + @Field(() => ID, { + nullable: true, + description: 'The ticket that ends up right below (null: the bottom)', + }) + @IsOptional() + @IsUUID() + belowIssueId?: string | null; +} + @InputType() export class AddChecklistItemInput { @Field(() => ID) @IsUUID() issueId!: string; @@ -249,6 +269,10 @@ export class IssueFilterInput { @IsOptional() @IsUUID() parentId?: string; + @Field(() => IssueSort, { nullable: true, defaultValue: IssueSort.RECENT }) + @IsOptional() + @IsEnum(IssueSort) + sort?: IssueSort; @Field({ nullable: true, description: 'Matches key, title or description' }) @IsOptional() @IsString() diff --git a/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts b/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts index 4552f71..5634457 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts @@ -28,6 +28,7 @@ import { CreateIssueInput, IssueFilterInput, LinkIssuesInput, + RankIssueInput, UpdateChecklistItemInput, UpdateIssueInput, } from './inputs'; @@ -266,6 +267,19 @@ export class IssueResolver { return this.issuesService.setParent(me.id, issueId, parentId ?? null); } + @Mutation(() => CommandResultGql, { + description: + 'Move a ticket in the backlog, between two others of the same project', + }) + rankIssue(@CurrentUser() me: User, @Args('input') input: RankIssueInput) { + return this.issuesService.rank( + me.id, + input.issueId, + input.aboveIssueId ?? null, + input.belowIssueId ?? null, + ); + } + @Mutation(() => CommandResultGql, { description: "Add a step to a ticket's checklist", }) diff --git a/libs/api/tracker/presenters/src/lib/graphql/types.ts b/libs/api/tracker/presenters/src/lib/graphql/types.ts index ed49cd0..87d6133 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/types.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/types.ts @@ -167,6 +167,11 @@ export class IssueGql { description: 'The epic or ticket this one breaks down', }) parent!: IssueParentGql | null; + @Field({ + description: + 'Place in the backlog and in its board column (ascending, compare as text)', + }) + rank!: string; @Field(() => [ChecklistItemGql]) checklist!: ChecklistItemGql[]; @Field(() => Int) commentCount!: number; @Field() createdAt!: string; diff --git a/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts b/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts index 12e933f..5bbc962 100644 --- a/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts +++ b/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts @@ -12,6 +12,7 @@ import { StatusCategory, TYPE_ICONS, } from '@task-stream/api/tracker/domain'; +import { IssueSort } from '@task-stream/api/tracker/application'; import { Transform, Type } from 'class-transformer'; import { ArrayMaxSize, @@ -416,6 +417,15 @@ export class IssueSearchQueryDto extends PageQueryDto { @IsString() parent?: string; + @ApiPropertyOptional({ + enum: IssueSort, + enumName: 'IssueSort', + description: 'RECENT (default): last updated first. RANK: backlog order.', + }) + @IsOptional() + @IsEnum(IssueSort) + sort?: IssueSort; + @ApiPropertyOptional({ description: 'Matches key, title or description' }) @IsOptional() @IsString() @@ -579,6 +589,30 @@ export class SetIssueReleaseDto { releaseId!: string | null; } +export class RankIssueDto { + @ApiPropertyOptional({ + type: String, + nullable: true, + example: 'WEB-4', + description: + 'The ticket that ends up right above (id or key; null: the top)', + }) + @IsOptional() + @IsString() + above?: string | null; + + @ApiPropertyOptional({ + type: String, + nullable: true, + example: 'WEB-9', + description: + 'The ticket that ends up right below (id or key; null: the bottom)', + }) + @IsOptional() + @IsString() + below?: string | null; +} + export class SetIssueParentDto { @ApiProperty({ type: String, diff --git a/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts b/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts index ec7b904..f3ee9b4 100644 --- a/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts +++ b/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts @@ -179,6 +179,11 @@ export class IssueDto { description: 'The epic or ticket this one breaks down', }) parent!: IssueParentDto | null; + @ApiProperty({ + description: + 'Place in the backlog and its board column (ascending, as text)', + }) + rank!: string; @ApiProperty({ type: [ChecklistItemDto] }) checklist!: ChecklistItemDto[]; @ApiProperty() commentCount!: number; @ApiProperty({ format: 'date-time' }) createdAt!: string; diff --git a/libs/api/tracker/presenters/src/lib/http/issues.controller.ts b/libs/api/tracker/presenters/src/lib/http/issues.controller.ts index f068aeb..2842b96 100644 --- a/libs/api/tracker/presenters/src/lib/http/issues.controller.ts +++ b/libs/api/tracker/presenters/src/lib/http/issues.controller.ts @@ -44,6 +44,7 @@ import { LinkTypeParamDto, MyIssuesQueryDto, PageQueryDto, + RankIssueDto, SetIssueParentDto, SetIssueReleaseDto, UpdateChecklistItemDto, @@ -156,6 +157,7 @@ export class IssuesController { unassigned: query.unassigned, releaseId: query.releaseId, labels: query.label, + sort: query.sort, search: query.search, limit: query.limit, offset: query.offset, @@ -323,6 +325,30 @@ export class IssuesController { ); } + @Put('issues/:issue/rank') + @ISSUE + @ApiOperation({ + summary: 'Move the ticket in the backlog', + description: + 'Between the ticket that ends up above it and the one below (either may be left out for the top or the bottom). Board columns follow the same order.', + }) + @ApiOkResponse({ type: CommandResultDto }) + @ApiCommandErrors() + async rank( + @CurrentUser() me: User, + @Param('issue') issue: string, + @Body() body: RankIssueDto, + ) { + const ref = (value?: string | null) => + value ? this.refs.issueId(me.id, value) : null; + return this.issues.rank( + me.id, + await this.refs.issueId(me.id, issue), + await ref(body.above), + await ref(body.below), + ); + } + @Post('issues/:issue/checklist') @ISSUE @ApiOperation({ summary: "Add a step to the ticket's checklist" }) diff --git a/libs/web/tracker/application/src/index.ts b/libs/web/tracker/application/src/index.ts index 2026c27..bacf5c3 100644 --- a/libs/web/tracker/application/src/index.ts +++ b/libs/web/tracker/application/src/index.ts @@ -7,3 +7,4 @@ export * from './lib/facades/home.facade'; export * from './lib/facades/explore.facade'; export * from './lib/facades/inbox.facade'; export * from './lib/facades/epics.facade'; +export * from './lib/facades/backlog.facade'; diff --git a/libs/web/tracker/application/src/lib/events/backlog.events.ts b/libs/web/tracker/application/src/lib/events/backlog.events.ts new file mode 100644 index 0000000..f35e8cc --- /dev/null +++ b/libs/web/tracker/application/src/lib/events/backlog.events.ts @@ -0,0 +1,22 @@ +import { type } from '@ngrx/signals'; +import { eventGroup } from '@ngrx/signals/events'; +import { IssuePage } from '@task-stream/web/tracker/domain'; + +export const backlogPageEvents = eventGroup({ + source: 'Backlog Page', + events: { + opened: type<{ projectId: string }>(), + /** Put the ticket at `index` of the backlog as shown now. */ + moveRequested: type<{ issueId: string; index: number }>(), + }, +}); + +export const backlogApiEvents = eventGroup({ + source: 'Backlog API', + events: { + loaded: type(), + loadFailed: type(), + moved: type(), + moveFailed: type(), + }, +}); diff --git a/libs/web/tracker/application/src/lib/facades/backlog.facade.ts b/libs/web/tracker/application/src/lib/facades/backlog.facade.ts new file mode 100644 index 0000000..64d0c9a --- /dev/null +++ b/libs/web/tracker/application/src/lib/facades/backlog.facade.ts @@ -0,0 +1,32 @@ +import { inject, Injectable, Provider } from '@angular/core'; +import { injectDispatch } from '@ngrx/signals/events'; +import { backlogPageEvents } from '../events/backlog.events'; +import { BacklogStore } from '../stores/backlog.store'; + +/** "Backlog": tickets still to start, in the order the team will take them. */ +@Injectable() +export class BacklogFacade { + private readonly store = inject(BacklogStore); + private readonly dispatch = injectDispatch(backlogPageEvents); + + /** In backlog order (the first 200). */ + readonly issues = this.store.issues; + readonly total = this.store.total; + /** False until the first load: show skeletons, not empty states. */ + readonly loaded = this.store.loaded; + readonly error = this.store.error; + + open(projectId: string): void { + this.dispatch.opened({ projectId }); + } + + /** Puts the ticket at `index` of the list as shown now. */ + move(issueId: string, index: number): void { + this.dispatch.moveRequested({ issueId, index }); + } +} + +/** Store + facade of the backlog view, for its route component. */ +export function provideBacklogFacade(): Provider[] { + return [BacklogStore, BacklogFacade]; +} diff --git a/libs/web/tracker/application/src/lib/stores/backlog.reducers.spec.ts b/libs/web/tracker/application/src/lib/stores/backlog.reducers.spec.ts new file mode 100644 index 0000000..e07d6f2 --- /dev/null +++ b/libs/web/tracker/application/src/lib/stores/backlog.reducers.spec.ts @@ -0,0 +1,25 @@ +import { IssueSummary } from '@task-stream/web/tracker/domain'; +import { reorder } from './backlog.reducers'; + +const list = ['a', 'b', 'c', 'd'].map((id) => ({ id }) as IssueSummary); +const ids = (issues: IssueSummary[] | undefined) => issues?.map((i) => i.id); + +describe('backlog reorder', () => { + it('moves a ticket and tells who ends up around it', () => { + const down = reorder(list, 'a', 2); + expect(ids(down?.issues)).toEqual(['b', 'c', 'a', 'd']); + expect([down?.above?.id, down?.below?.id]).toEqual(['c', 'd']); + + const top = reorder(list, 'd', 0); + expect(ids(top?.issues)).toEqual(['d', 'a', 'b', 'c']); + expect([top?.above, top?.below?.id]).toEqual([null, 'a']); + + const bottom = reorder(list, 'b', 99); + expect([bottom?.above?.id, bottom?.below]).toEqual(['d', null]); + }); + + it('does nothing when it stays where it was', () => { + expect(reorder(list, 'b', 1)).toBeNull(); + expect(reorder(list, 'zz', 0)).toBeNull(); + }); +}); diff --git a/libs/web/tracker/application/src/lib/stores/backlog.reducers.ts b/libs/web/tracker/application/src/lib/stores/backlog.reducers.ts new file mode 100644 index 0000000..c0a5d05 --- /dev/null +++ b/libs/web/tracker/application/src/lib/stores/backlog.reducers.ts @@ -0,0 +1,23 @@ +import { IssueSummary } from '@task-stream/web/tracker/domain'; + +/** Where a ticket lands when moved to `index`, and its new neighbours. */ +export function reorder( + issues: IssueSummary[], + issueId: string, + index: number, +): { + issues: IssueSummary[]; + above: IssueSummary | null; + below: IssueSummary | null; +} | null { + const from = issues.findIndex((i) => i.id === issueId); + if (from === -1) return null; + const rest = issues.filter((i) => i.id !== issueId); + const at = Math.max(0, Math.min(index, rest.length)); + if (at === from) return null; + return { + issues: [...rest.slice(0, at), issues[from], ...rest.slice(at)], + above: rest[at - 1] ?? null, + below: rest[at] ?? null, + }; +} diff --git a/libs/web/tracker/application/src/lib/stores/backlog.store.ts b/libs/web/tracker/application/src/lib/stores/backlog.store.ts new file mode 100644 index 0000000..7ff731a --- /dev/null +++ b/libs/web/tracker/application/src/lib/stores/backlog.store.ts @@ -0,0 +1,120 @@ +import { inject } from '@angular/core'; +import { mapResponse } from '@ngrx/operators'; +import { patchState, signalStore, withState } from '@ngrx/signals'; +import { + Events, + on, + withEventHandlers, + withReducer, +} from '@ngrx/signals/events'; +import { + IssuesGateway, + IssueSummary, + TrackerRealtimeGateway, +} from '@task-stream/web/tracker/domain'; +import { + concatMap, + debounceTime, + EMPTY, + merge, + startWith, + switchMap, + tap, +} from 'rxjs'; +import { backlogApiEvents, backlogPageEvents } from '../events/backlog.events'; +import { describeError, Notifications } from '../notifications'; +import { reorder } from './backlog.reducers'; + +interface BacklogState { + projectId: string | null; + issues: IssueSummary[]; + total: number; + loaded: boolean; + error: string | null; +} + +/** Tickets still to start, in the order the team will take them. Live. */ +export const BacklogStore = signalStore( + withState({ + projectId: null, + issues: [], + total: 0, + loaded: false, + error: null, + }), + withReducer( + on(backlogPageEvents.opened, ({ payload }) => ({ + projectId: payload.projectId, + loaded: false, + error: null, + })), + on(backlogApiEvents.loaded, ({ payload }) => ({ + issues: payload.items, + total: payload.total, + loaded: true, + })), + on(backlogApiEvents.loadFailed, ({ payload }) => ({ + loaded: true, + error: payload, + })), + ), + withEventHandlers( + ( + store, + events = inject(Events), + issues = inject(IssuesGateway), + realtime = inject(TrackerRealtimeGateway), + notifications = inject(Notifications), + ) => { + const fetch = (projectId: string) => + issues.backlog(projectId).pipe( + mapResponse({ + next: (page) => backlogApiEvents.loaded(page), + error: (error) => backlogApiEvents.loadFailed(describeError(error)), + }), + ); + return { + // Anyone's change may add, remove or move a ticket: reload, calmly. + load$: events.on(backlogPageEvents.opened).pipe( + switchMap(({ payload }) => + merge( + realtime.issueChanges(payload.projectId).pipe(debounceTime(400)), + events.on(backlogApiEvents.moveFailed), + ).pipe( + startWith(null), + switchMap(() => fetch(payload.projectId)), + ), + ), + ), + // One move after another: each needs the ranks the previous left. + move$: events.on(backlogPageEvents.moveRequested).pipe( + concatMap(({ payload }) => { + const next = reorder( + store.issues(), + payload.issueId, + payload.index, + ); + if (!next) return EMPTY; + patchState(store, { issues: next.issues }); + return issues + .rank( + payload.issueId, + next.above?.id ?? null, + next.below?.id ?? null, + ) + .pipe( + mapResponse({ + next: () => backlogApiEvents.moved(), + error: (error) => + backlogApiEvents.moveFailed(describeError(error)), + }), + ); + }), + ), + feedback$: events + .on(backlogApiEvents.moveFailed) + .pipe(tap(({ payload }) => notifications.error(payload))), + }; + }, + ), +); diff --git a/libs/web/tracker/application/src/lib/stores/board.reducers.spec.ts b/libs/web/tracker/application/src/lib/stores/board.reducers.spec.ts index 19f015e..8f1aba5 100644 --- a/libs/web/tracker/application/src/lib/stores/board.reducers.spec.ts +++ b/libs/web/tracker/application/src/lib/stores/board.reducers.spec.ts @@ -19,6 +19,7 @@ const issue = ( area: null, labels: [], commentCount: 0, + rank: `n-${id}`, }) as unknown as IssueSummary; const board = (): BoardColumn[] => [ @@ -57,8 +58,12 @@ describe('board reducers', () => { expect(next[0].issues).toEqual([]); }); - it('adds new issues from other users', () => { + it('adds new issues from other users at their place in the backlog', () => { const next = applyIssueChange(board(), issue('b', 'TODO', 1), null); - expect(next[0].issues.map((i) => i.id)).toEqual(['b', 'a']); + expect(next[0].issues.map((i) => i.id)).toEqual(['a', 'b']); + + const ranked = { ...issue('c', 'TODO', 1), rank: 'a' }; + const top = applyIssueChange(next, ranked, null); + expect(top[0].issues.map((i) => i.id)).toEqual(['c', 'a', 'b']); }); }); diff --git a/libs/web/tracker/application/src/lib/stores/board.reducers.ts b/libs/web/tracker/application/src/lib/stores/board.reducers.ts index 6b87fbf..8321b81 100644 --- a/libs/web/tracker/application/src/lib/stores/board.reducers.ts +++ b/libs/web/tracker/application/src/lib/stores/board.reducers.ts @@ -29,7 +29,18 @@ export function matchesFilter( return true; } -/** Removes the issue wherever it is and puts `next` (if any) on top of its status column. */ +/** `issues` (in backlog order) with `issue` at its place by rank. */ +export function insertByRank( + issues: IssueSummary[], + issue: IssueSummary, +): IssueSummary[] { + const at = issues.findIndex((i) => i.rank > issue.rank); + return at === -1 + ? [...issues, issue] + : [...issues.slice(0, at), issue, ...issues.slice(at)]; +} + +/** Removes the issue wherever it is and puts `next` (if any) at its place by rank in its status column. */ export function placeIssue( columns: BoardColumn[], issueId: string, @@ -42,7 +53,7 @@ export function placeIssue( : column.issues; let total = column.total - (had ? 1 : 0); if (next && next.status === column.status) { - issues = [next, ...issues]; + issues = insertByRank(issues, next); total += 1; } return had || issues !== column.issues diff --git a/libs/web/tracker/domain/src/lib/models/models.ts b/libs/web/tracker/domain/src/lib/models/models.ts index 1c3f46b..c303485 100644 --- a/libs/web/tracker/domain/src/lib/models/models.ts +++ b/libs/web/tracker/domain/src/lib/models/models.ts @@ -87,6 +87,8 @@ export interface IssueSummary { labels: string[]; /** The epic or ticket this one breaks down. */ parent: IssueParent | null; + /** Place in the backlog and in its board column (ascending, as text). */ + rank: string; commentCount: number; updatedAt: string; version: number; diff --git a/libs/web/tracker/domain/src/lib/ports/gateways.ts b/libs/web/tracker/domain/src/lib/ports/gateways.ts index 8c57fd0..6c0fcd5 100644 --- a/libs/web/tracker/domain/src/lib/ports/gateways.ts +++ b/libs/web/tracker/domain/src/lib/ports/gateways.ts @@ -101,6 +101,14 @@ export abstract class IssuesGateway { parentId: string | null, ): Observable; abstract epics(projectId: string): Observable; + /** Tickets still to start (TODO category), in backlog order. */ + abstract backlog(projectId: string): Observable; + /** Moves a ticket between two others of the backlog (null: an end). */ + abstract rank( + issueId: string, + aboveIssueId: string | null, + belowIssueId: string | null, + ): Observable; abstract addChecklistItem( issueId: string, text: string, diff --git a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-issues.gateway.ts b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-issues.gateway.ts index 2835a0a..d207466 100644 --- a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-issues.gateway.ts +++ b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-issues.gateway.ts @@ -18,6 +18,7 @@ import { map, Observable } from 'rxjs'; import { AddChecklistItemDocument, AssignIssueDocument, + BacklogDocument, BoardDocument, ChangeIssueStatusDocument, CreateIssueDocument, @@ -26,6 +27,7 @@ import { IssueDocument, LinkIssuesDocument, MyIssuesDocument, + RankIssueDocument, RemoveChecklistItemDocument, SearchIssuesDocument, SetIssueParentDocument, @@ -166,6 +168,31 @@ export class GraphqlIssuesGateway extends IssuesGateway { ); } + backlog(projectId: string): Observable { + return this.gql + .query(BacklogDocument, { + filter: { projectId, categories: ['TODO'], sort: 'RANK' }, + }) + .pipe( + map((d) => ({ + items: d.issues.items.map(toIssueSummary), + total: d.issues.total, + })), + ); + } + + rank( + issueId: string, + aboveIssueId: string | null, + belowIssueId: string | null, + ): Observable { + return this.gql + .mutate(RankIssueDocument, { + input: { issueId, aboveIssueId, belowIssueId }, + }) + .pipe(map((d) => toCommandResult(d.rankIssue))); + } + addChecklistItem(issueId: string, text: string): Observable { return this.gql .mutate(AddChecklistItemDocument, { input: { issueId, text } }) diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql index 90c907d..37da01e 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql +++ b/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql @@ -74,6 +74,7 @@ fragment IssueSummaryFields on Issue { key title } + rank } fragment IssueFields on Issue { diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts index b128741..ee3c327 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts +++ b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts @@ -75,6 +75,7 @@ export type IssueFilterInput = { releaseId?: string | null | undefined; /** Matches key, title or description */ search?: string | null | undefined; + sort?: IssueSort | null | undefined; statuses?: Array | null | undefined; types?: Array | null | undefined; unassigned?: boolean | null | undefined; @@ -89,6 +90,9 @@ export type IssueLinkType = export type IssuePriority = 'CRITICAL' | 'HIGH' | 'LOW' | 'LOWEST' | 'MEDIUM'; +/** RECENT: last updated first. RANK: backlog order. */ +export type IssueSort = 'RANK' | 'RECENT'; + export type IssueTypeOptionInput = { archived?: boolean; color: string; @@ -133,6 +137,14 @@ export type ProjectSettingsInput = { transitions: Array; }; +export type RankIssueInput = { + /** The ticket that ends up right above (null: the top) */ + aboveIssueId?: string | null | undefined; + /** The ticket that ends up right below (null: the bottom) */ + belowIssueId?: string | null | undefined; + issueId: string; +}; + export type ReleaseStatus = 'ARCHIVED' | 'PLANNED' | 'RELEASED'; /** What a status means, whatever each project calls it. DONE and CANCELLED are finished. */ @@ -233,6 +245,7 @@ export type IssueSummaryFieldsFragment = { commentCount: number; updatedAt: string; version: number; + rank: string; assignee: { id: string; name: string } | null; release: { id: string; name: string } | null; parent: { id: string; key: string; title: string } | null; @@ -258,6 +271,7 @@ export type IssueFieldsFragment = { commentCount: number; updatedAt: string; version: number; + rank: string; reporter: { id: string; name: string }; links: Array<{ type: IssueLinkType; @@ -343,6 +357,7 @@ export type BoardQuery = { commentCount: number; updatedAt: string; version: number; + rank: string; assignee: { id: string; name: string } | null; release: { id: string; name: string } | null; parent: { id: string; key: string; title: string } | null; @@ -376,6 +391,7 @@ export type IssueQuery = { commentCount: number; updatedAt: string; version: number; + rank: string; reporter: { id: string; name: string }; links: Array<{ type: IssueLinkType; @@ -420,6 +436,7 @@ export type SearchIssuesQuery = { commentCount: number; updatedAt: string; version: number; + rank: string; assignee: { id: string; name: string } | null; release: { id: string; name: string } | null; parent: { id: string; key: string; title: string } | null; @@ -570,6 +587,7 @@ export type MyIssuesQuery = { commentCount: number; updatedAt: string; version: number; + rank: string; assignee: { id: string; name: string } | null; release: { id: string; name: string } | null; parent: { id: string; key: string; title: string } | null; @@ -597,6 +615,7 @@ export type IssueChildrenQuery = { commentCount: number; updatedAt: string; version: number; + rank: string; assignee: { id: string; name: string } | null; release: { id: string; name: string } | null; parent: { id: string; key: string; title: string } | null; @@ -624,6 +643,7 @@ export type EpicsQuery = { commentCount: number; updatedAt: string; version: number; + rank: string; assignee: { id: string; name: string } | null; release: { id: string; name: string } | null; parent: { id: string; key: string; title: string } | null; @@ -672,6 +692,43 @@ export type RemoveChecklistItemMutation = { removeChecklistItem: { id: string; key: string | null; version: number }; }; +export type BacklogQueryVariables = Exact<{ + filter: IssueFilterInput; +}>; + +export type BacklogQuery = { + issues: { + total: number; + items: Array<{ + id: string; + projectId: string; + key: string; + type: string; + area: string | null; + title: string; + status: string; + statusCategory: StatusCategory; + priority: IssuePriority; + labels: Array; + commentCount: number; + updatedAt: string; + version: number; + rank: string; + assignee: { id: string; name: string } | null; + release: { id: string; name: string } | null; + parent: { id: string; key: string; title: string } | null; + }>; + }; +}; + +export type RankIssueMutationVariables = Exact<{ + input: RankIssueInput; +}>; + +export type RankIssueMutation = { + rankIssue: { id: string; key: string | null; version: number }; +}; + export type NotificationFieldsFragment = { id: string; kind: NotificationKind; @@ -1104,6 +1161,7 @@ export type IssueChangedSubscription = { commentCount: number; updatedAt: string; version: number; + rank: string; assignee: { id: string; name: string } | null; release: { id: string; name: string } | null; parent: { id: string; key: string; title: string } | null; @@ -1478,6 +1536,7 @@ export const IssueSummaryFieldsFragmentDoc = { ], }, }, + { kind: 'Field', name: { kind: 'Name', value: 'rank' } }, ], }, }, @@ -1680,6 +1739,7 @@ export const IssueFieldsFragmentDoc = { ], }, }, + { kind: 'Field', name: { kind: 'Name', value: 'rank' } }, ], }, }, @@ -2094,6 +2154,7 @@ export const BoardDocument = { ], }, }, + { kind: 'Field', name: { kind: 'Name', value: 'rank' } }, ], }, }, @@ -2225,6 +2286,7 @@ export const IssueDocument = { ], }, }, + { kind: 'Field', name: { kind: 'Name', value: 'rank' } }, ], }, }, @@ -2475,6 +2537,7 @@ export const SearchIssuesDocument = { ], }, }, + { kind: 'Field', name: { kind: 'Name', value: 'rank' } }, ], }, }, @@ -3690,6 +3753,7 @@ export const MyIssuesDocument = { ], }, }, + { kind: 'Field', name: { kind: 'Name', value: 'rank' } }, ], }, }, @@ -3835,6 +3899,7 @@ export const IssueChildrenDocument = { ], }, }, + { kind: 'Field', name: { kind: 'Name', value: 'rank' } }, ], }, }, @@ -3998,6 +4063,7 @@ export const EpicsDocument = { ], }, }, + { kind: 'Field', name: { kind: 'Name', value: 'rank' } }, ], }, }, @@ -4324,6 +4390,223 @@ export const RemoveChecklistItemDocument = { RemoveChecklistItemMutation, RemoveChecklistItemMutationVariables >; +export const BacklogDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: { kind: 'Name', value: 'Backlog' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'filter' }, + }, + type: { + kind: 'NonNullType', + type: { + kind: 'NamedType', + name: { kind: 'Name', value: 'IssueFilterInput' }, + }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'issues' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'filter' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'filter' }, + }, + }, + { + kind: 'Argument', + name: { kind: 'Name', value: 'limit' }, + value: { kind: 'IntValue', value: '200' }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'items' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'IssueSummaryFields' }, + }, + ], + }, + }, + { kind: 'Field', name: { kind: 'Name', value: 'total' } }, + ], + }, + }, + ], + }, + }, + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'UserRefFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'UserRef' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + ], + }, + }, + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'IssueSummaryFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'Issue' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'projectId' } }, + { kind: 'Field', name: { kind: 'Name', value: 'key' } }, + { kind: 'Field', name: { kind: 'Name', value: 'type' } }, + { kind: 'Field', name: { kind: 'Name', value: 'area' } }, + { kind: 'Field', name: { kind: 'Name', value: 'title' } }, + { kind: 'Field', name: { kind: 'Name', value: 'status' } }, + { kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } }, + { kind: 'Field', name: { kind: 'Name', value: 'priority' } }, + { kind: 'Field', name: { kind: 'Name', value: 'labels' } }, + { kind: 'Field', name: { kind: 'Name', value: 'commentCount' } }, + { kind: 'Field', name: { kind: 'Name', value: 'updatedAt' } }, + { kind: 'Field', name: { kind: 'Name', value: 'version' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'assignee' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'UserRefFields' }, + }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'release' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'parent' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'key' } }, + { kind: 'Field', name: { kind: 'Name', value: 'title' } }, + ], + }, + }, + { kind: 'Field', name: { kind: 'Name', value: 'rank' } }, + ], + }, + }, + ], +} as unknown as DocumentNode; +export const RankIssueDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: { kind: 'Name', value: 'RankIssue' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'input' }, + }, + type: { + kind: 'NonNullType', + type: { + kind: 'NamedType', + name: { kind: 'Name', value: 'RankIssueInput' }, + }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'rankIssue' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'input' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'input' }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'CommandResultFields' }, + }, + ], + }, + }, + ], + }, + }, + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'CommandResultFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'CommandResult' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'key' } }, + { kind: 'Field', name: { kind: 'Name', value: 'version' } }, + ], + }, + }, + ], +} as unknown as DocumentNode; export const MyNotificationsDocument = { kind: 'Document', definitions: [ @@ -7020,6 +7303,7 @@ export const IssueChangedDocument = { ], }, }, + { kind: 'Field', name: { kind: 'Name', value: 'rank' } }, ], }, }, diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql index e5c11ab..92f63fb 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql +++ b/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql @@ -176,3 +176,18 @@ mutation RemoveChecklistItem($issueId: ID!, $itemId: ID!) { ...CommandResultFields } } + +query Backlog($filter: IssueFilterInput!) { + issues(filter: $filter, limit: 200) { + items { + ...IssueSummaryFields + } + total + } +} + +mutation RankIssue($input: RankIssueInput!) { + rankIssue(input: $input) { + ...CommandResultFields + } +} diff --git a/libs/web/tracker/presenters/src/lib/pages/backlog.page.ts b/libs/web/tracker/presenters/src/lib/pages/backlog.page.ts new file mode 100644 index 0000000..ecf8cbb --- /dev/null +++ b/libs/web/tracker/presenters/src/lib/pages/backlog.page.ts @@ -0,0 +1,232 @@ +import { CdkDrag, CdkDragDrop, CdkDropList } from '@angular/cdk/drag-drop'; +import { + ChangeDetectionStrategy, + Component, + effect, + inject, + untracked, +} from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { + PiEmptyStateComponent, + PiIconButtonComponent, + PiIconComponent, + PiNoteComponent, + PiSkeletonComponent, +} from '@pi/ui'; +import { + BacklogFacade, + ProjectFacade, + provideBacklogFacade, +} from '@task-stream/web/tracker/application'; +import { IssueSummary } from '@task-stream/web/tracker/domain'; +import { initials, PRIORITY, statusLook, typeLook } from '../ui/copy'; + +/** /p/:key/backlog — what is still to start, in the order the team takes it. */ +@Component({ + selector: 'ts-backlog-page', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + RouterLink, + CdkDrag, + CdkDropList, + PiEmptyStateComponent, + PiIconButtonComponent, + PiIconComponent, + PiNoteComponent, + PiSkeletonComponent, + ], + providers: [provideBacklogFacade()], + template: ` +
+

+ Lo que aún no ha empezado, en el orden en que se va a coger. Arrastra + para reordenar; las columnas del tablero siguen este mismo orden. +

+ @if (facade.loaded() && facade.total() > facade.issues().length) { + {{ facade.issues().length }} de {{ facade.total() }} + } +
+ + @if (facade.error(); as error) { + + } @else if (!facade.loaded()) { + + } @else if (facade.issues().length) { +
    + @for (issue of facade.issues(); track issue.id; let i = $index) { +
  1. + {{ i + 1 }} + + + + {{ + issue.key + }} + {{ issue.title }} + {{ + statusLabel(issue) + }} + {{ + priorityLabel(issue) + }} + {{ + issue.assignee ? initialsOf(issue.assignee.name) : '—' + }} + @if (project.canContribute()) { + + + + + } +
  2. + } +
+ } @else { + + } + `, + styles: ` + .ts-backlog { + margin: 0; + padding: 0.25rem 0; + list-style: none; + } + .ts-backlog__row { + display: grid; + grid-template-columns: 2rem auto auto minmax(0, 1fr) auto auto auto auto; + gap: 0.625rem; + align-items: center; + padding: 0.375rem 0.875rem; + border-top: 1px solid var(--ts-line); + background: var(--ts-surface); + cursor: grab; + &:first-child { + border-top: 0; + } + @media (max-width: 720px) { + grid-template-columns: 2rem auto minmax(0, 1fr) auto; + .ts-backlog__type, + .ts-pill, + .ts-backlog__priority, + .ts-backlog__who { + display: none; + } + } + } + .ts-backlog__pos { + color: var(--ts-muted); + font-size: 0.75rem; + text-align: right; + } + .ts-backlog__type { + display: inline-flex; + color: var(--tone); + } + .ts-backlog__title { + overflow: hidden; + color: inherit; + text-decoration: none; + text-overflow: ellipsis; + white-space: nowrap; + &:hover { + color: var(--ts-brand); + } + } + .ts-backlog__priority { + font-size: 0.8125rem; + } + .ts-backlog__who { + display: inline-grid; + place-items: center; + width: 1.5rem; + height: 1.5rem; + border-radius: 50%; + background: var(--ts-line); + font-size: 0.625rem; + font-weight: 600; + } + .ts-backlog__moves { + display: inline-flex; + } + .cdk-drag-preview { + box-shadow: 0 8px 24px rgb(0 0 0 / 18%); + } + .cdk-drag-placeholder { + opacity: 0.35; + } + `, +}) +export class BacklogPage { + protected readonly facade = inject(BacklogFacade); + protected readonly project = inject(ProjectFacade); + protected readonly initialsOf = initials; + + constructor() { + effect(() => { + const projectId = this.project.project()?.id; + if (projectId) untracked(() => this.facade.open(projectId)); + }); + } + + protected drop(event: CdkDragDrop): void { + this.facade.move(event.item.data.id, event.currentIndex); + } + + protected typeIcon = (issue: IssueSummary) => + typeLook(this.project.workflow(), issue.type).icon; + protected typeTone = (issue: IssueSummary) => + typeLook(this.project.workflow(), issue.type).tone; + protected typeLabel = (issue: IssueSummary) => + typeLook(this.project.workflow(), issue.type).label; + protected statusTone = (issue: IssueSummary) => + statusLook(this.project.workflow(), issue.status).tone; + protected statusLabel = (issue: IssueSummary) => + statusLook(this.project.workflow(), issue.status).label; + protected priorityLabel = (issue: IssueSummary) => + PRIORITY[issue.priority].label; +} diff --git a/libs/web/tracker/presenters/src/lib/pages/project-shell.page.ts b/libs/web/tracker/presenters/src/lib/pages/project-shell.page.ts index 3d57c27..58258ef 100644 --- a/libs/web/tracker/presenters/src/lib/pages/project-shell.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/project-shell.page.ts @@ -85,6 +85,7 @@ import { projectTone, PUBLIC_TONE, ROLE } from '../ui/copy'; }