diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 5e8e190..d78d7a7 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -1007,6 +1007,58 @@ "type": "string" } }, + { + "name": "type", + "required": false, + "in": "query", + "description": "Ticket type ids", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "priority", + "required": false, + "in": "query", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IssuePriority" + } + } + }, + { + "name": "label", + "required": false, + "in": "query", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "search", + "required": false, + "in": "query", + "description": "Matches key, title or description", + "schema": { + "type": "string" + } + }, + { + "name": "unassigned", + "required": false, + "in": "query", + "description": "Only tickets without assignee", + "schema": { + "type": "boolean" + } + }, { "name": "perColumn", "required": false, @@ -5933,6 +5985,293 @@ "Users" ] } + }, + "/api/projects/{project}/filters": { + "get": { + "operationId": "SavedFiltersController_list", + "parameters": [ + { + "name": "project", + "required": true, + "in": "path", + "description": "Project id or key", + "schema": { + "example": "WEB", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SavedFilterDto" + } + } + } + } + }, + "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" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "My views of the project's board, then the shared ones", + "tags": [ + "Saved filters" + ] + }, + "post": { + "description": "Project members only. `shared` makes it visible to the whole project (not for viewers).", + "operationId": "SavedFiltersController_save", + "parameters": [ + { + "name": "project", + "required": true, + "in": "path", + "description": "Project id or key", + "schema": { + "example": "WEB", + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SaveFilterDto" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SavedFilterIdDto" + } + } + } + }, + "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": "Save board filters under a name", + "tags": [ + "Saved filters" + ] + } + }, + "/api/filters/{id}": { + "delete": { + "operationId": "SavedFiltersController_delete", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "" + }, + "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": "Delete a saved view (whoever saved it, or an admin if shared)", + "tags": [ + "Saved filters" + ] + } } }, "info": { @@ -8267,6 +8606,174 @@ "required": [ "email" ] + }, + "SavedFilterCriteriaResponseDto": { + "type": "object", + "properties": { + "types": { + "type": "array", + "items": { + "type": "string" + } + }, + "areas": { + "type": "array", + "items": { + "type": "string" + } + }, + "priorities": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IssuePriority" + } + }, + "assigneeId": { + "type": "string", + "nullable": true + }, + "unassigned": { + "type": "boolean" + }, + "releaseId": { + "type": "string", + "nullable": true, + "format": "uuid" + }, + "labels": { + "type": "array", + "items": { + "type": "string" + } + }, + "search": { + "type": "string", + "nullable": true + } + } + }, + "SavedFilterDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "projectId": { + "type": "string", + "format": "uuid" + }, + "owner": { + "$ref": "#/components/schemas/UserRefDto" + }, + "name": { + "type": "string", + "example": "Bugs críticos de frontend" + }, + "shared": { + "type": "boolean", + "description": "Visible to the whole project" + }, + "criteria": { + "$ref": "#/components/schemas/SavedFilterCriteriaResponseDto" + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "projectId", + "owner", + "name", + "shared", + "criteria", + "createdAt" + ] + }, + "SavedFilterCriteriaDto": { + "type": "object", + "properties": { + "types": { + "description": "Ticket type ids", + "type": "array", + "items": { + "type": "string" + } + }, + "areas": { + "description": "Area ids", + "type": "array", + "items": { + "type": "string" + } + }, + "priorities": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IssuePriority" + } + }, + "assigneeId": { + "type": "string", + "nullable": true, + "description": "Pireon user id" + }, + "unassigned": { + "type": "boolean" + }, + "releaseId": { + "type": "string", + "nullable": true, + "format": "uuid" + }, + "labels": { + "type": "array", + "items": { + "type": "string" + } + }, + "search": { + "type": "string", + "nullable": true, + "maxLength": 200 + } + } + }, + "SaveFilterDto": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "Bugs críticos de frontend", + "maxLength": 60 + }, + "shared": { + "type": "object", + "default": false, + "description": "Visible to the whole project (members, not viewers, share)" + }, + "criteria": { + "$ref": "#/components/schemas/SavedFilterCriteriaDto" + } + }, + "required": [ + "name", + "criteria" + ] + }, + "SavedFilterIdDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "id" + ] } } } diff --git a/apps/api/schema.gql b/apps/api/schema.gql index 6f4c979..c2f521f 100644 --- a/apps/api/schema.gql +++ b/apps/api/schema.gql @@ -399,6 +399,9 @@ type Mutation { Delete the project (owner only), with its tickets, comments and releases. `confirmKey` must be the project key. """ deleteProject(confirmKey: String!, projectId: ID!): CommandResult! + + """Delete a view: whoever saved it, or an admin if it is shared""" + deleteSavedFilter(id: ID!): Boolean! editComment(body: String!, commentId: ID!): CommandResult! """Relate tickets, e.g. FE DEPENDS_ON BE, QA TESTS FE""" @@ -422,6 +425,9 @@ type Mutation { removeChecklistItem(issueId: ID!, itemId: ID!): CommandResult! removeProjectMember(projectId: ID!, userId: ID!): CommandResult! + """Save the current board filters under a name (members of the project)""" + saveFilter(input: SaveFilterInput!): ID! + """ Place a ticket under an epic or another ticket of the same project (null: back to the top). Three levels at most: epic → ticket → subtask. """ @@ -501,7 +507,19 @@ type Project { analytics(days: Int! = 90): ProjectAnalytics! """Kanban board, one column per status""" - board(area: String, assigneeId: ID, perColumn: Int! = 50, releaseId: ID): [BoardColumn!]! + board( + area: String + assigneeId: ID + labels: [String!] + perColumn: Int! = 50 + priorities: [IssuePriority!] + releaseId: ID + + """Matches key, title or description""" + search: String + types: [String!] + unassigned: Boolean + ): [BoardColumn!]! createdAt: String! description: String id: ID! @@ -648,6 +666,9 @@ type Query { publicProjects: [Project!]! release(id: ID!): Release! + """My views of the project's board, then the shared ones""" + savedFilters(projectId: ID!): [SavedFilter!]! + """Planned releases of all my projects, soonest target date first""" upcomingReleases(limit: Int! = 10): [Release!]! user(id: String!): User @@ -718,6 +739,51 @@ enum ReleaseStatus { RELEASED } +input SaveFilterInput { + criteria: SavedFilterCriteriaInput! + name: String! + projectId: ID! + + """Visible to the whole project (members, not viewers, share)""" + shared: Boolean! = false +} + +""" +A named view of the board: private to whoever saved it, or shared with the project +""" +type SavedFilter { + createdAt: String! + criteria: SavedFilterCriteria! + id: ID! + name: String! + owner: UserRef! + projectId: ID! + shared: Boolean! +} + +"""What a saved view narrows the board to; empty: everything""" +type SavedFilterCriteria { + areas: [String!] + assigneeId: ID + labels: [String!] + priorities: [IssuePriority!] + releaseId: ID + search: String + types: [String!] + unassigned: Boolean +} + +input SavedFilterCriteriaInput { + areas: [String!] + assigneeId: ID + labels: [String!] + priorities: [IssuePriority!] + releaseId: ID + search: String + types: [String!] + unassigned: Boolean +} + """ What a status means, whatever each project calls it. DONE and CANCELLED are finished. """ diff --git a/apps/mcp/src/app/api/taskstream.schema.ts b/apps/mcp/src/app/api/taskstream.schema.ts index f582aad..f0e0dab 100644 --- a/apps/mcp/src/app/api/taskstream.schema.ts +++ b/apps/mcp/src/app/api/taskstream.schema.ts @@ -871,6 +871,44 @@ export interface paths { patch?: never; trace?: never; }; + "/api/projects/{project}/filters": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** My views of the project's board, then the shared ones */ + get: operations["SavedFiltersController_list"]; + put?: never; + /** + * Save board filters under a name + * @description Project members only. `shared` makes it visible to the whole project (not for viewers). + */ + post: operations["SavedFiltersController_save"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/filters/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Delete a saved view (whoever saved it, or an admin if shared) */ + delete: operations["SavedFiltersController_delete"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -1616,6 +1654,59 @@ export interface components { /** @description Copies by e-mail */ email: boolean; }; + SavedFilterCriteriaResponseDto: { + types?: string[]; + areas?: string[]; + priorities?: components["schemas"]["IssuePriority"][]; + assigneeId?: string | null; + unassigned?: boolean; + /** Format: uuid */ + releaseId?: string | null; + labels?: string[]; + search?: string | null; + }; + SavedFilterDto: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + projectId: string; + owner: components["schemas"]["UserRefDto"]; + /** @example Bugs críticos de frontend */ + name: string; + /** @description Visible to the whole project */ + shared: boolean; + criteria: components["schemas"]["SavedFilterCriteriaResponseDto"]; + /** Format: date-time */ + createdAt: string; + }; + SavedFilterCriteriaDto: { + /** @description Ticket type ids */ + types?: string[]; + /** @description Area ids */ + areas?: string[]; + priorities?: components["schemas"]["IssuePriority"][]; + /** @description Pireon user id */ + assigneeId?: string | null; + unassigned?: boolean; + /** Format: uuid */ + releaseId?: string | null; + labels?: string[]; + search?: string | null; + }; + SaveFilterDto: { + /** @example Bugs críticos de frontend */ + name: string; + /** + * @description Visible to the whole project (members, not viewers, share) + * @default false + */ + shared: Record; + criteria: components["schemas"]["SavedFilterCriteriaDto"]; + }; + SavedFilterIdDto: { + /** Format: uuid */ + id: string; + }; }; responses: never; parameters: never; @@ -2339,6 +2430,14 @@ export interface operations { assigneeId?: string; /** @description An area id */ area?: string; + /** @description Ticket type ids */ + type?: string[]; + priority?: components["schemas"]["IssuePriority"][]; + label?: string[]; + /** @description Matches key, title or description */ + search?: string; + /** @description Only tickets without assignee */ + unassigned?: boolean; perColumn?: number; }; header?: never; @@ -5702,4 +5801,215 @@ export interface operations { }; }; }; + SavedFiltersController_list: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project id or key */ + project: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SavedFilterDto"][]; + }; + }; + /** @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"]; + }; + }; + }; + }; + SavedFiltersController_save: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project id or key */ + project: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SaveFilterDto"]; + }; + }; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SavedFilterIdDto"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + SavedFiltersController_delete: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @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"]; + }; + }; + }; + }; } diff --git a/docs/01-vision-general.md b/docs/01-vision-general.md index 39fda7a..99a6f67 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 | +| 🔎 Vistas | El tablero se filtra por texto, persona (o sin asignar), tipo, prioridad, área y release; los filtros se guardan como vistas propias o compartidas con el proyecto | | 📋 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 | diff --git a/docs/03-api.md b/docs/03-api.md index 907f28e..85cb667 100644 --- a/docs/03-api.md +++ b/docs/03-api.md @@ -157,6 +157,10 @@ stateDiagram-v2 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. +- **Vistas guardadas**: filtros del tablero con nombre. Las guardan los miembros del proyecto (los lectores, solo para + sí); compartir una la hace visible a todo el proyecto. Borra una su autor, o un admin si es compartida; como mucho + 50 por persona y proyecto. Son preferencias, no historia: viven en MongoDB (`saved_filters`), fuera del event store, + y se borran con el proyecto. - **Checklist**: hasta 100 pasos de hasta 300 caracteres por ticket; los cambia quien puede editar el ticket. ### Notificaciones @@ -225,21 +229,22 @@ que funcionan también sin token. ### Queries -| Query | Devuelve | -| ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `me` | El usuario autenticado, con sus roles en Pireon | -| `users(search, limit)` | Usuarios de Pireon (para asignar o añadir miembros) | -| `user(id)` | Un usuario | -| `myProjects` | Proyectos de los que soy miembro | -| `myIssues(assignedToMe, statuses, categories, limit, offset)` | «Mi trabajo»: tickets de todos mis proyectos, asignados a mí (por defecto) o de cualquiera (p. ej. `categories: [QA]`) | -| `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); `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` | -| `activity(projectId, issueId?, limit, before?)` | Feed de actividad, del más reciente al más antiguo | +| Query | Devuelve | +| ------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `me` | El usuario autenticado, con sus roles en Pireon | +| `users(search, limit)` | Usuarios de Pireon (para asignar o añadir miembros) | +| `user(id)` | Un usuario | +| `myProjects` | Proyectos de los que soy miembro | +| `myIssues(assignedToMe, statuses, categories, limit, offset)` | «Mi trabajo»: tickets de todos mis proyectos, asignados a mí (por defecto) o de cualquiera (p. ej. `categories: [QA]`) | +| `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, unassigned, area, types, priorities, labels, search, perColumn)`, `stats`, `analytics(days)`, `releases` | +| `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 | +| `savedFilters(projectId)` | Mis vistas guardadas del tablero y las compartidas con el proyecto | +| `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` | +| `activity(projectId, issueId?, limit, before?)` | Feed de actividad, del más reciente al más antiguo | ### Mutations @@ -250,6 +255,7 @@ Todas devuelven `CommandResult { id, key, version }`. | 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` | +| Vistas | `saveFilter(input)` (devuelve el id), `deleteSavedFilter(id)` | | Avisos | `markNotificationsRead(ids)`, `updateNotificationSettings(email)` | | Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `archiveRelease` | @@ -453,6 +459,7 @@ Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`). | 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}` | +| Vistas | `GET/POST /api/projects/{project}/filters`, `DELETE /api/filters/{id}` | | 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` | diff --git a/libs/api/tracker/application/src/index.ts b/libs/api/tracker/application/src/index.ts index 4ed4fb1..042a9ca 100644 --- a/libs/api/tracker/application/src/index.ts +++ b/libs/api/tracker/application/src/index.ts @@ -9,4 +9,6 @@ export * from './lib/changes.service'; export * from './lib/workflows.service'; export * from './lib/notifications.service'; export * from './lib/notifications'; +export * from './lib/saved-filters.service'; +export * from './lib/saved-filters/saved-filters'; export * from './lib/tracker-application.module'; diff --git a/libs/api/tracker/application/src/lib/ports/index.ts b/libs/api/tracker/application/src/lib/ports/index.ts index 1360193..a5e2517 100644 --- a/libs/api/tracker/application/src/lib/ports/index.ts +++ b/libs/api/tracker/application/src/lib/ports/index.ts @@ -20,3 +20,4 @@ export * from './tracker-streams.repository'; export * from './attachment-storage'; export * from './notifications.repository'; export * from './notification-mailer'; +export * from './saved-filters.repository'; diff --git a/libs/api/tracker/application/src/lib/ports/saved-filters.repository.ts b/libs/api/tracker/application/src/lib/ports/saved-filters.repository.ts new file mode 100644 index 0000000..591289a --- /dev/null +++ b/libs/api/tracker/application/src/lib/ports/saved-filters.repository.ts @@ -0,0 +1,14 @@ +import { SavedFilterReadModel } from '@task-stream/api/tracker/domain'; + +/** Saved board views: per person (or shared with a project), no history. */ +export abstract class SavedFiltersRepository { + /** Whoever's own views of the project, then the shared ones, by name. */ + abstract findVisible( + projectId: string, + userId: string, + ): Promise; + abstract findById(id: string): Promise; + abstract countOwned(projectId: string, userId: string): Promise; + abstract insert(filter: SavedFilterReadModel): Promise; + abstract delete(id: string): Promise; +} diff --git a/libs/api/tracker/application/src/lib/saved-filters.service.ts b/libs/api/tracker/application/src/lib/saved-filters.service.ts new file mode 100644 index 0000000..6108479 --- /dev/null +++ b/libs/api/tracker/application/src/lib/saved-filters.service.ts @@ -0,0 +1,49 @@ +import { Injectable } from '@nestjs/common'; +import { CommandBus, QueryBus } from '@nestjs/cqrs'; +import { + SavedFilterCriteriaReadModel, + SavedFilterReadModel, +} from '@task-stream/api/tracker/domain'; +import { + DeleteSavedFilterCommand, + GetSavedFiltersQuery, + SaveFilterCommand, +} from './saved-filters/saved-filters'; + +export interface SaveFilterInput { + projectId: string; + name: string; + shared: boolean; + criteria: SavedFilterCriteriaReadModel; +} + +/** Saved board views: mine and the ones shared with the project. */ +@Injectable() +export class SavedFiltersService { + constructor( + private readonly commandBus: CommandBus, + private readonly queryBus: QueryBus, + ) {} + + list(viewerId: string, projectId: string): Promise { + return this.queryBus.execute(new GetSavedFiltersQuery(viewerId, projectId)); + } + + save(actorId: string, input: SaveFilterInput): Promise<{ id: string }> { + return this.commandBus.execute( + new SaveFilterCommand( + actorId, + input.projectId, + input.name, + input.shared, + input.criteria, + ), + ); + } + + delete(actorId: string, filterId: string): Promise { + return this.commandBus.execute( + new DeleteSavedFilterCommand(actorId, filterId), + ); + } +} diff --git a/libs/api/tracker/application/src/lib/saved-filters/saved-filters.spec.ts b/libs/api/tracker/application/src/lib/saved-filters/saved-filters.spec.ts new file mode 100644 index 0000000..4e5dd61 --- /dev/null +++ b/libs/api/tracker/application/src/lib/saved-filters/saved-filters.spec.ts @@ -0,0 +1,145 @@ +import { CommandBus, CqrsModule, QueryBus } from '@nestjs/cqrs'; +import { Test } from '@nestjs/testing'; +import { + BusinessRuleError, + ForbiddenError, + NotFoundError, +} from '@task-stream/api/shared/domain'; +import { + IssuePriority, + ProjectRole, + SavedFilterReadModel, +} from '@task-stream/api/tracker/domain'; +import { FakeUserDirectory } from '../../testing/in-memory'; +import { FindProjectsRepository } from '../ports/find-projects.repository'; +import { SavedFiltersRepository } from '../ports/saved-filters.repository'; +import { UserDirectory } from '../ports/user-directory'; +import { ReadAccess } from '../queries/read-access'; +import { + DeleteSavedFilterCommand, + GetSavedFiltersQuery, + MAX_SAVED_FILTERS, + SAVED_FILTERS, + SaveFilterCommand, + normalizeCriteria, +} from './saved-filters'; + +class InMemorySavedFilters extends SavedFiltersRepository { + readonly rows = new Map(); + async findVisible(projectId: string, userId: string) { + return [...this.rows.values()].filter( + (f) => f.projectId === projectId && (f.owner.id === userId || f.shared), + ); + } + async findById(id: string) { + return this.rows.get(id) ?? null; + } + async countOwned(projectId: string, userId: string) { + return [...this.rows.values()].filter( + (f) => f.projectId === projectId && f.owner.id === userId, + ).length; + } + async insert(filter: SavedFilterReadModel) { + this.rows.set(filter.id, filter); + } + async delete(id: string) { + this.rows.delete(id); + } +} + +const ROLES: Record = { + owner: ProjectRole.OWNER, + dev: ProjectRole.MEMBER, + qa: ProjectRole.MEMBER, + pm: ProjectRole.VIEWER, +}; + +async function setup() { + const rows = new InMemorySavedFilters(); + const moduleRef = await Test.createTestingModule({ + imports: [CqrsModule.forRoot()], + providers: [ + ...SAVED_FILTERS, + ReadAccess, + { provide: SavedFiltersRepository, useValue: rows }, + { + provide: UserDirectory, + useValue: new FakeUserDirectory().add('owner').add('dev').add('qa'), + }, + { + provide: FindProjectsRepository, + useValue: { + findRole: async (_projectId: string, userId: string) => + ROLES[userId] ?? null, + findById: async () => ({ isPublic: true }), + }, + }, + ], + }).compile(); + await moduleRef.init(); + const commands = moduleRef.get(CommandBus); + const queries = moduleRef.get(QueryBus); + const save = (actor: string, name: string, shared = false) => + commands.execute( + new SaveFilterCommand(actor, 'p1', name, shared, { + priorities: [IssuePriority.HIGH, IssuePriority.HIGH], + labels: [' UI ', 'ui'], + search: ' ', + }), + ); + const list = (viewer: string) => + queries.execute(new GetSavedFiltersQuery(viewer, 'p1')); + const remove = (actor: string, id: string) => + commands.execute(new DeleteSavedFilterCommand(actor, id)); + return { rows, save, list, remove }; +} + +describe('saved board views', () => { + it('keeps private views private and shares the shared ones', async () => { + const { save, list } = await setup(); + await save('dev', 'Mis bugs'); + await save('qa', 'Para QA', true); + + expect((await list('dev')).map((f) => f.name).sort()).toEqual([ + 'Mis bugs', + 'Para QA', + ]); + expect((await list('qa')).map((f) => f.name)).toEqual(['Para QA']); + const [mine] = await list('dev'); + expect(mine.criteria).toEqual({ + priorities: [IssuePriority.HIGH], + labels: ['ui'], + }); + }); + + it('lets viewers keep views only for themselves, and outsiders none', async () => { + const { save } = await setup(); + await expect(save('pm', 'Mía')).resolves.toHaveProperty('id'); + await expect(save('pm', 'De todos', true)).rejects.toThrow(ForbiddenError); + await expect(save('stranger', 'Nope')).rejects.toThrow(ForbiddenError); + }); + + it('deletes: the author always, an admin a shared one, nobody else', async () => { + const { save, remove, rows } = await setup(); + const { id: priv } = await save('dev', 'Privada'); + const { id: shared } = await save('dev', 'Compartida', true); + + await expect(remove('qa', priv)).rejects.toThrow(NotFoundError); + await expect(remove('qa', shared)).rejects.toThrow(ForbiddenError); + await remove('owner', shared); + await remove('dev', priv); + expect(rows.rows.size).toBe(0); + }); + + it('caps how many views a person keeps per project', async () => { + const { save } = await setup(); + for (let i = 0; i < MAX_SAVED_FILTERS; i++) await save('dev', `#${i}`); + await expect(save('dev', 'Una más')).rejects.toThrow(BusinessRuleError); + }); + + it('drops empty criteria', () => { + expect( + normalizeCriteria({ types: [], assigneeId: 'u', unassigned: true }), + ).toEqual({ unassigned: true }); + }); +}); diff --git a/libs/api/tracker/application/src/lib/saved-filters/saved-filters.ts b/libs/api/tracker/application/src/lib/saved-filters/saved-filters.ts new file mode 100644 index 0000000..7fb0829 --- /dev/null +++ b/libs/api/tracker/application/src/lib/saved-filters/saved-filters.ts @@ -0,0 +1,187 @@ +import { randomUUID } from 'node:crypto'; +import { + Command, + CommandHandler, + ICommandHandler, + IQueryHandler, + Query, + QueryHandler, +} from '@nestjs/cqrs'; +import { + BusinessRuleError, + ForbiddenError, + NotFoundError, + assertThat, +} from '@task-stream/api/shared/domain'; +import { + ProjectRole, + SavedFilterCriteriaReadModel, + SavedFilterReadModel, +} from '@task-stream/api/tracker/domain'; +import { SavedFiltersRepository } from '../ports/saved-filters.repository'; +import { UserDirectory } from '../ports/user-directory'; +import { ReadAccess } from '../queries/read-access'; + +/* + * Saved board views. Preferences, like notification settings: they concern + * whoever saved them (or the project, when shared) and need no history. + */ + +/** A person keeps at most this many views per project. */ +export const MAX_SAVED_FILTERS = 50; + +const EDITORS = [ProjectRole.OWNER, ProjectRole.ADMIN, ProjectRole.MEMBER]; +const MANAGERS = [ProjectRole.OWNER, ProjectRole.ADMIN]; + +export class SaveFilterCommand extends Command<{ id: string }> { + constructor( + public readonly actorId: string, + public readonly projectId: string, + public readonly name: string, + /** Visible to the whole project, or just to whoever saved it. */ + public readonly shared: boolean, + public readonly criteria: SavedFilterCriteriaReadModel, + ) { + super(); + } +} + +export class DeleteSavedFilterCommand extends Command { + constructor( + public readonly actorId: string, + public readonly filterId: string, + ) { + super(); + } +} + +export class GetSavedFiltersQuery extends Query { + constructor( + public readonly viewerId: string, + public readonly projectId: string, + ) { + super(); + } +} + +/** Drops empty criteria, trims text and deduplicates lists. */ +export function normalizeCriteria( + criteria: SavedFilterCriteriaReadModel, +): SavedFilterCriteriaReadModel { + const list = (values?: T[] | null) => + values?.length ? [...new Set(values)] : undefined; + const out: SavedFilterCriteriaReadModel = { + types: list(criteria.types), + areas: list(criteria.areas), + priorities: list(criteria.priorities), + labels: list( + criteria.labels?.map((l) => l.trim().toLowerCase()).filter(Boolean), + ), + assigneeId: criteria.unassigned + ? undefined + : criteria.assigneeId || undefined, + unassigned: criteria.unassigned || undefined, + releaseId: criteria.releaseId || undefined, + search: criteria.search?.trim() || undefined, + }; + return Object.fromEntries( + Object.entries(out).filter(([, value]) => value !== undefined), + ) as SavedFilterCriteriaReadModel; +} + +@CommandHandler(SaveFilterCommand) +export class SaveFilterCommandHandler + implements ICommandHandler +{ + constructor( + private readonly filters: SavedFiltersRepository, + private readonly access: ReadAccess, + private readonly users: UserDirectory, + ) {} + + async execute(command: SaveFilterCommand): Promise<{ id: string }> { + const role = await this.access.assertCanView( + command.projectId, + command.actorId, + ); + if (!role) + throw new ForbiddenError('Only project members can save board views'); + if (command.shared && !EDITORS.includes(role)) + throw new ForbiddenError('Viewers can only save views for themselves'); + const name = command.name.trim(); + assertThat( + name.length > 0 && name.length <= 60, + 'A view needs a name (up to 60 characters)', + ); + const owned = await this.filters.countOwned( + command.projectId, + command.actorId, + ); + if (owned >= MAX_SAVED_FILTERS) + throw new BusinessRuleError( + `You already keep ${MAX_SAVED_FILTERS} views in this project; delete one first`, + ); + const users = await this.users.refs([command.actorId]); + const id = randomUUID(); + await this.filters.insert({ + id, + projectId: command.projectId, + owner: users.of(command.actorId), + name, + shared: command.shared, + criteria: normalizeCriteria(command.criteria), + createdAt: new Date().toISOString(), + }); + return { id }; + } +} + +@CommandHandler(DeleteSavedFilterCommand) +export class DeleteSavedFilterCommandHandler + implements ICommandHandler +{ + constructor( + private readonly filters: SavedFiltersRepository, + private readonly access: ReadAccess, + ) {} + + async execute(command: DeleteSavedFilterCommand): Promise { + const filter = await this.filters.findById(command.filterId); + const mine = filter?.owner.id === command.actorId; + // Someone else's private view: as if it did not exist. + if (!filter || (!mine && !filter.shared)) + throw new NotFoundError('SavedFilter', command.filterId); + if (!mine) { + const role = await this.access.assertCanView( + filter.projectId, + command.actorId, + ); + if (!role || !MANAGERS.includes(role)) + throw new ForbiddenError( + 'Only whoever saved a shared view, or a project admin, can delete it', + ); + } + await this.filters.delete(filter.id); + } +} + +@QueryHandler(GetSavedFiltersQuery) +export class GetSavedFiltersQueryHandler + implements IQueryHandler +{ + constructor( + private readonly filters: SavedFiltersRepository, + private readonly access: ReadAccess, + ) {} + + async execute(query: GetSavedFiltersQuery): Promise { + await this.access.assertCanView(query.projectId, query.viewerId); + return this.filters.findVisible(query.projectId, query.viewerId); + } +} + +export const SAVED_FILTERS = [ + SaveFilterCommandHandler, + DeleteSavedFilterCommandHandler, + GetSavedFiltersQueryHandler, +]; diff --git a/libs/api/tracker/application/src/lib/tracker-application.module.ts b/libs/api/tracker/application/src/lib/tracker-application.module.ts index 431df0d..811b8ee 100644 --- a/libs/api/tracker/application/src/lib/tracker-application.module.ts +++ b/libs/api/tracker/application/src/lib/tracker-application.module.ts @@ -29,6 +29,9 @@ import { UpdateNotificationSettingsCommandHandler, } from './notifications/notifications.commands'; +import { SAVED_FILTERS } from './saved-filters/saved-filters'; +import { SavedFiltersService } from './saved-filters.service'; + /** Notifications: derived from events, kept and dismissed per person. */ const NOTIFICATIONS = [ Notifier, @@ -47,6 +50,7 @@ const SERVICES = [ ChangesService, WorkflowsService, NotificationsService, + SavedFiltersService, ]; /** @@ -68,6 +72,7 @@ const SERVICES = [ ...EVENT_HANDLERS, ...SAGAS, ...NOTIFICATIONS, + ...SAVED_FILTERS, ], exports: SERVICES, }) diff --git a/libs/api/tracker/domain/src/lib/read-models/index.ts b/libs/api/tracker/domain/src/lib/read-models/index.ts index 1603135..129e599 100644 --- a/libs/api/tracker/domain/src/lib/read-models/index.ts +++ b/libs/api/tracker/domain/src/lib/read-models/index.ts @@ -7,3 +7,4 @@ export * from './activity.read-model'; export * from './project-stats.read-model'; export * from './analytics.read-model'; export * from './notification.read-model'; +export * from './saved-filter.read-model'; diff --git a/libs/api/tracker/domain/src/lib/read-models/saved-filter.read-model.ts b/libs/api/tracker/domain/src/lib/read-models/saved-filter.read-model.ts new file mode 100644 index 0000000..21ba0cb --- /dev/null +++ b/libs/api/tracker/domain/src/lib/read-models/saved-filter.read-model.ts @@ -0,0 +1,29 @@ +import { IssuePriority } from '../issue/issue.enums'; +import { UserRefReadModel } from './user-ref.read-model'; + +/** What a saved view of the board narrows the tickets to. Empty: everything. */ +export class SavedFilterCriteriaReadModel { + /** Ticket type ids of the project's settings. */ + types?: string[]; + areas?: string[]; + priorities?: IssuePriority[]; + assigneeId?: string | null; + unassigned?: boolean; + releaseId?: string | null; + labels?: string[]; + search?: string | null; +} + +/** + * A named set of board filters. Private to whoever saved it, or shared with + * the whole project. Preferences, not history: kept outside the event store. + */ +export class SavedFilterReadModel { + id!: string; + projectId!: string; + owner!: UserRefReadModel; + name!: string; + shared!: boolean; + criteria!: SavedFilterCriteriaReadModel; + createdAt!: string; +} 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 4a24069..93e8e15 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, + SavedFilterReadModel, implicitRank, } from '@task-stream/api/tracker/domain'; import { Schema } from 'mongoose'; @@ -26,6 +27,7 @@ export type ReleaseDoc = Doc; export type ActivityDoc = Doc; export type TimelineDoc = Doc; export type NotificationDoc = Doc; +export type SavedFilterDoc = Doc; export interface NotificationSettingsDoc { _id: string; email: boolean; @@ -40,6 +42,7 @@ export const ReadModels = { Timeline: 'IssueTimelineReadModel', Notification: 'NotificationReadModel', NotificationSettings: 'NotificationSettings', + SavedFilter: 'SavedFilter', } as const; const userRef = { _id: false, id: String, name: String }; @@ -160,6 +163,20 @@ export const NotificationSettingsSchema = new Schema( { ...loose, collection: 'notification_settings' }, ); +export const SavedFilterSchema = new Schema( + { + _id: String, + projectId: String, + owner: userRef, + name: String, + shared: Boolean, + createdAt: String, + }, + { ...loose, collection: 'saved_filters' }, +); +SavedFilterSchema.index({ projectId: 1, 'owner.id': 1 }); +SavedFilterSchema.index({ projectId: 1, shared: 1 }); + export const READ_MODEL_SCHEMAS = [ { name: ReadModels.Project, schema: ProjectSchema }, { name: ReadModels.Issue, schema: IssueSchema }, @@ -169,6 +186,7 @@ export const READ_MODEL_SCHEMAS = [ { name: ReadModels.Timeline, schema: TimelineSchema }, { name: ReadModels.Notification, schema: NotificationSchema }, { name: ReadModels.NotificationSettings, schema: NotificationSettingsSchema }, + { name: ReadModels.SavedFilter, schema: SavedFilterSchema }, ]; export function fromDoc( diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/saved-filters.repository.ts b/libs/api/tracker/infrastructure/src/lib/mongo/saved-filters.repository.ts new file mode 100644 index 0000000..54dabb4 --- /dev/null +++ b/libs/api/tracker/infrastructure/src/lib/mongo/saved-filters.repository.ts @@ -0,0 +1,49 @@ +import { Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { SavedFiltersRepository } from '@task-stream/api/tracker/application'; +import { SavedFilterReadModel } from '@task-stream/api/tracker/domain'; +import { Model } from 'mongoose'; +import { ReadModels, SavedFilterDoc, fromDoc } from './read-model.schemas'; + +@Injectable() +export class MongoSavedFiltersRepository extends SavedFiltersRepository { + constructor( + @InjectModel(ReadModels.SavedFilter) + private readonly filters: Model, + ) { + super(); + } + + async findVisible( + projectId: string, + userId: string, + ): Promise { + const docs = await this.filters + .find({ projectId, $or: [{ 'owner.id': userId }, { shared: true }] }) + .sort({ shared: 1, name: 1 }) + .lean(); + return docs.map((doc) => toFilter(doc)); + } + + async findById(id: string): Promise { + const doc = await this.filters.findById(id).lean(); + return doc ? toFilter(doc) : null; + } + + countOwned(projectId: string, userId: string): Promise { + return this.filters.countDocuments({ projectId, 'owner.id': userId }); + } + + async insert({ id, ...rest }: SavedFilterReadModel): Promise { + await this.filters.create({ _id: id, ...rest }); + } + + async delete(id: string): Promise { + await this.filters.deleteOne({ _id: id }); + } +} + +function toFilter(doc: SavedFilterDoc): SavedFilterReadModel { + const filter = fromDoc(doc) as SavedFilterReadModel; + return { ...filter, criteria: filter.criteria ?? {} }; +} diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/upsert-materialized.repositories.ts b/libs/api/tracker/infrastructure/src/lib/mongo/upsert-materialized.repositories.ts index 740ee7f..8898c46 100644 --- a/libs/api/tracker/infrastructure/src/lib/mongo/upsert-materialized.repositories.ts +++ b/libs/api/tracker/infrastructure/src/lib/mongo/upsert-materialized.repositories.ts @@ -29,6 +29,7 @@ import { ProjectDoc, ReadModels, ReleaseDoc, + SavedFilterDoc, TimelineDoc, } from './read-model.schemas'; import { upsertIfNewer } from './upsert-if-newer'; @@ -44,6 +45,8 @@ export class MongoUpsertMaterializedProjectRepository extends UpsertMaterialized private readonly activity: Model, @InjectModel(ReadModels.Timeline) private readonly timeline: Model, + @InjectModel(ReadModels.SavedFilter) + private readonly savedFilters: Model, ) { super(); } @@ -53,6 +56,8 @@ export class MongoUpsertMaterializedProjectRepository extends UpsertMaterialized this.projects.deleteOne({ _id: projectId }), this.activity.deleteMany({ projectId }), this.timeline.deleteMany({ projectId }), + // Views of the board of a project that is gone. + this.savedFilters.deleteMany({ projectId }), ]); } 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 00e4467..7c8d33a 100644 --- a/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts +++ b/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts @@ -7,6 +7,7 @@ import { ChangeFeed, NotificationMailer, NotificationsRepository, + SavedFiltersRepository, FindActivityRepository, FindCommentsRepository, FindIssuesRepository, @@ -56,6 +57,7 @@ import { PostgresReleaseIssuesRepository } from './postgres/release-issues.repos import { PostgresTrackerStreamsRepository } from './postgres/tracker-streams.repository'; import { RedisChangeFeed } from './realtime/redis-change-feed'; import { MongoNotificationsRepository } from './mongo/notifications.repository'; +import { MongoSavedFiltersRepository } from './mongo/saved-filters.repository'; import { SmtpNotificationMailer } from './mail/smtp-notification.mailer'; import { FileSystemAttachmentStorage } from './storage/file-system-attachment.storage'; @@ -107,6 +109,7 @@ const PORTS = [ { provide: AttachmentStorage, useClass: FileSystemAttachmentStorage }, // Notifications → MongoDB (per person), copies by e-mail → SMTP (optional) { provide: NotificationsRepository, useClass: MongoNotificationsRepository }, + { provide: SavedFiltersRepository, useClass: MongoSavedFiltersRepository }, { provide: NotificationMailer, useClass: SmtpNotificationMailer }, // Realtime → Redis pub/sub { provide: ChangeFeed, useClass: RedisChangeFeed }, diff --git a/libs/api/tracker/presenters/src/lib/graphql/inputs.ts b/libs/api/tracker/presenters/src/lib/graphql/inputs.ts index 52b5cef..f192bae 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/inputs.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/inputs.ts @@ -181,6 +181,57 @@ export class ChangeIssueStatusInput { reason?: string | null; } +@InputType() +export class SavedFilterCriteriaInput { + @Field(() => [String], { nullable: true }) + @IsOptional() + @Matches(SETTING_ID, { each: true }) + types?: string[]; + @Field(() => [String], { nullable: true }) + @IsOptional() + @Matches(SETTING_ID, { each: true }) + areas?: string[]; + @Field(() => [IssuePriority], { nullable: true }) + @IsOptional() + @IsEnum(IssuePriority, { each: true }) + priorities?: IssuePriority[]; + @Field(() => ID, { nullable: true }) + @IsOptional() + @IsString() + assigneeId?: string | null; + @Field({ nullable: true }) @IsOptional() @IsBoolean() unassigned?: boolean; + @Field(() => ID, { nullable: true }) + @IsOptional() + @IsUUID() + releaseId?: string | null; + @Field(() => [String], { nullable: true }) + @IsOptional() + @ArrayMaxSize(20) + @IsString({ each: true }) + labels?: string[]; + @Field(() => String, { nullable: true }) + @IsOptional() + @IsString() + @MaxLength(200) + search?: string | null; +} + +@InputType() +export class SaveFilterInput { + @Field(() => ID) @IsUUID() projectId!: string; + @Field() @IsString() @MinLength(1) @MaxLength(60) name!: string; + @Field({ + defaultValue: false, + description: 'Visible to the whole project (members, not viewers, share)', + }) + @IsBoolean() + shared!: boolean; + @Field(() => SavedFilterCriteriaInput) + @ValidateNested() + @Type(() => SavedFilterCriteriaInput) + criteria!: SavedFilterCriteriaInput; +} + @InputType() export class RankIssueInput { @Field(() => ID) @IsUUID() issueId!: string; diff --git a/libs/api/tracker/presenters/src/lib/graphql/project.resolver.ts b/libs/api/tracker/presenters/src/lib/graphql/project.resolver.ts index 9826fcd..63d0a55 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/project.resolver.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/project.resolver.ts @@ -15,7 +15,10 @@ import { OptionalAuth, } from '@task-stream/api/identity/presenters'; import { ProjectsService } from '@task-stream/api/tracker/application'; -import { ProjectReadModel } from '@task-stream/api/tracker/domain'; +import { + IssuePriority, + ProjectReadModel, +} from '@task-stream/api/tracker/domain'; import { viewerOf } from '../common/viewer'; import { fromSettingsPayload, toSettingsPayload } from '../common/settings'; import { @@ -108,6 +111,20 @@ export class ProjectResolver { @Args('assigneeId', { type: () => ID, nullable: true }) assigneeId?: string, @Args('area', { type: () => String, nullable: true }) area?: string, @Args('perColumn', { type: () => Int, defaultValue: 50 }) perColumn = 50, + @Args('types', { type: () => [String], nullable: true }) + types?: string[] | null, + @Args('priorities', { type: () => [IssuePriority], nullable: true }) + priorities?: IssuePriority[] | null, + @Args('labels', { type: () => [String], nullable: true }) + labels?: string[] | null, + @Args('search', { + type: () => String, + nullable: true, + description: 'Matches key, title or description', + }) + search?: string | null, + @Args('unassigned', { type: () => Boolean, nullable: true }) + unassigned?: boolean | null, ) { return this.projects.board( viewerOf(me), @@ -116,6 +133,11 @@ export class ProjectResolver { releaseId, assigneeId, areas: area ? [area] : undefined, + types: types ?? undefined, + priorities: priorities ?? undefined, + labels: labels ?? undefined, + search: search?.slice(0, 200) ?? undefined, + unassigned: unassigned ?? undefined, }, perColumn, ); diff --git a/libs/api/tracker/presenters/src/lib/graphql/saved-filter.resolver.ts b/libs/api/tracker/presenters/src/lib/graphql/saved-filter.resolver.ts new file mode 100644 index 0000000..ee729ff --- /dev/null +++ b/libs/api/tracker/presenters/src/lib/graphql/saved-filter.resolver.ts @@ -0,0 +1,44 @@ +import { Args, ID, Mutation, Query, Resolver } from '@nestjs/graphql'; +import { User } from '@task-stream/api/identity/domain'; +import { CurrentUser } from '@task-stream/api/identity/presenters'; +import { SavedFiltersService } from '@task-stream/api/tracker/application'; +import { SaveFilterInput } from './inputs'; +import { SavedFilterGql } from './types'; + +/** Saved views of a project's board. */ +@Resolver(() => SavedFilterGql) +export class SavedFilterResolver { + constructor(private readonly filters: SavedFiltersService) {} + + @Query(() => [SavedFilterGql], { + description: "My views of the project's board, then the shared ones", + }) + savedFilters( + @CurrentUser() me: User, + @Args('projectId', { type: () => ID }) projectId: string, + ) { + return this.filters.list(me.id, projectId); + } + + @Mutation(() => ID, { + description: + 'Save the current board filters under a name (members of the project)', + }) + async saveFilter( + @CurrentUser() me: User, + @Args('input') input: SaveFilterInput, + ) { + return (await this.filters.save(me.id, input)).id; + } + + @Mutation(() => Boolean, { + description: 'Delete a view: whoever saved it, or an admin if it is shared', + }) + async deleteSavedFilter( + @CurrentUser() me: User, + @Args('id', { type: () => ID }) id: string, + ) { + await this.filters.delete(me.id, id); + return true; + } +} diff --git a/libs/api/tracker/presenters/src/lib/graphql/types.ts b/libs/api/tracker/presenters/src/lib/graphql/types.ts index 87d6133..7b5b99e 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/types.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/types.ts @@ -198,6 +198,35 @@ export class EpicGql { @Field(() => ChildProgressGql) progress!: ChildProgressGql; } +@ObjectType('SavedFilterCriteria', { + description: 'What a saved view narrows the board to; empty: everything', +}) +export class SavedFilterCriteriaGql { + @Field(() => [String], { nullable: true }) types?: string[]; + @Field(() => [String], { nullable: true }) areas?: string[]; + @Field(() => [IssuePriority], { nullable: true }) + priorities?: IssuePriority[]; + @Field(() => ID, { nullable: true }) assigneeId?: string | null; + @Field({ nullable: true }) unassigned?: boolean; + @Field(() => ID, { nullable: true }) releaseId?: string | null; + @Field(() => [String], { nullable: true }) labels?: string[]; + @Field(() => String, { nullable: true }) search?: string | null; +} + +@ObjectType('SavedFilter', { + description: + 'A named view of the board: private to whoever saved it, or shared with the project', +}) +export class SavedFilterGql { + @Field(() => ID) id!: string; + @Field(() => ID) projectId!: string; + @Field(() => UserRefGql) owner!: UserRefGql; + @Field() name!: string; + @Field() shared!: boolean; + @Field(() => SavedFilterCriteriaGql) criteria!: SavedFilterCriteriaGql; + @Field() createdAt!: string; +} + @ObjectType('IssuePage') export class IssuePageGql { @Field(() => [IssueGql]) items!: IssueGql[]; 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 5bbc962..a2daa49 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 @@ -251,6 +251,40 @@ export class BoardQueryDto { @Matches(SETTING_ID) area?: string; + @ApiPropertyOptional({ type: [String], description: 'Ticket type ids' }) + @IsOptional() + @Transform(toList) + @Matches(SETTING_ID, { each: true }) + type?: string[]; + + @ApiPropertyOptional({ + enum: IssuePriority, + enumName: 'IssuePriority', + isArray: true, + }) + @IsOptional() + @Transform(toList) + @IsEnum(IssuePriority, { each: true }) + priority?: IssuePriority[]; + + @ApiPropertyOptional({ type: [String] }) + @IsOptional() + @Transform(toList) + @IsString({ each: true }) + label?: string[]; + + @ApiPropertyOptional({ description: 'Matches key, title or description' }) + @IsOptional() + @IsString() + @MaxLength(200) + search?: string; + + @ApiPropertyOptional({ description: 'Only tickets without assignee' }) + @IsOptional() + @Transform(toBoolean) + @IsBoolean() + unassigned?: boolean; + @ApiPropertyOptional({ type: Number, default: 50, minimum: 1, maximum: 200 }) @IsOptional() @Type(() => Number) @@ -589,6 +623,76 @@ export class SetIssueReleaseDto { releaseId!: string | null; } +export class SavedFilterCriteriaDto { + @ApiPropertyOptional({ type: [String], description: 'Ticket type ids' }) + @IsOptional() + @Matches(SETTING_ID, { each: true }) + types?: string[]; + + @ApiPropertyOptional({ type: [String], description: 'Area ids' }) + @IsOptional() + @Matches(SETTING_ID, { each: true }) + areas?: string[]; + + @ApiPropertyOptional({ + enum: IssuePriority, + enumName: 'IssuePriority', + isArray: true, + }) + @IsOptional() + @IsEnum(IssuePriority, { each: true }) + priorities?: IssuePriority[]; + + @ApiPropertyOptional({ + type: String, + nullable: true, + description: 'Pireon user id', + }) + @IsOptional() + @IsString() + assigneeId?: string | null; + + @ApiPropertyOptional() @IsOptional() @IsBoolean() unassigned?: boolean; + + @ApiPropertyOptional({ type: String, nullable: true, format: 'uuid' }) + @IsOptional() + @IsString() + releaseId?: string | null; + + @ApiPropertyOptional({ type: [String] }) + @IsOptional() + @ArrayMaxSize(20) + @IsString({ each: true }) + labels?: string[]; + + @ApiPropertyOptional({ type: String, nullable: true, maxLength: 200 }) + @IsOptional() + @IsString() + @MaxLength(200) + search?: string | null; +} + +export class SaveFilterDto { + @ApiProperty({ example: 'Bugs críticos de frontend', maxLength: 60 }) + @IsString() + @MinLength(1) + @MaxLength(60) + name!: string; + + @ApiPropertyOptional({ + default: false, + description: 'Visible to the whole project (members, not viewers, share)', + }) + @IsOptional() + @IsBoolean() + shared = false; + + @ApiProperty({ type: SavedFilterCriteriaDto }) + @ValidateNested() + @Type(() => SavedFilterCriteriaDto) + criteria!: SavedFilterCriteriaDto; +} + export class RankIssueDto { @ApiPropertyOptional({ 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 f3ee9b4..63f4090 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 @@ -191,6 +191,41 @@ export class IssueDto { @ApiProperty() version!: number; } +export class SavedFilterCriteriaResponseDto { + @ApiPropertyOptional({ type: [String] }) types?: string[]; + @ApiPropertyOptional({ type: [String] }) areas?: string[]; + @ApiPropertyOptional({ + enum: IssuePriority, + enumName: 'IssuePriority', + isArray: true, + }) + priorities?: IssuePriority[]; + @ApiPropertyOptional({ type: String, nullable: true }) + assigneeId?: string | null; + @ApiPropertyOptional() unassigned?: boolean; + @ApiPropertyOptional({ type: String, nullable: true, format: 'uuid' }) + releaseId?: string | null; + @ApiPropertyOptional({ type: [String] }) labels?: string[]; + @ApiPropertyOptional({ type: String, nullable: true }) + search?: string | null; +} + +export class SavedFilterDto { + @ApiProperty({ format: 'uuid' }) id!: string; + @ApiProperty({ format: 'uuid' }) projectId!: string; + @ApiProperty({ type: UserRefDto }) owner!: UserRefDto; + @ApiProperty({ example: 'Bugs críticos de frontend' }) name!: string; + @ApiProperty({ description: 'Visible to the whole project' }) + shared!: boolean; + @ApiProperty({ type: SavedFilterCriteriaResponseDto }) + criteria!: SavedFilterCriteriaResponseDto; + @ApiProperty({ format: 'date-time' }) createdAt!: string; +} + +export class SavedFilterIdDto { + @ApiProperty({ format: 'uuid' }) id!: string; +} + export class IssuePageDto { @ApiProperty({ type: [IssueDto] }) items!: IssueDto[]; @ApiProperty({ description: 'Total matches (for pagination)' }) diff --git a/libs/api/tracker/presenters/src/lib/http/projects.controller.ts b/libs/api/tracker/presenters/src/lib/http/projects.controller.ts index ae2dce3..572322d 100644 --- a/libs/api/tracker/presenters/src/lib/http/projects.controller.ts +++ b/libs/api/tracker/presenters/src/lib/http/projects.controller.ts @@ -182,6 +182,11 @@ export class ProjectsController { releaseId: query.releaseId, assigneeId: query.assigneeId, areas: query.area ? [query.area] : undefined, + types: query.type, + priorities: query.priority, + labels: query.label, + search: query.search, + unassigned: query.unassigned, }, query.perColumn, ); diff --git a/libs/api/tracker/presenters/src/lib/http/saved-filters.controller.ts b/libs/api/tracker/presenters/src/lib/http/saved-filters.controller.ts new file mode 100644 index 0000000..f3586b5 --- /dev/null +++ b/libs/api/tracker/presenters/src/lib/http/saved-filters.controller.ts @@ -0,0 +1,96 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + Param, + ParseUUIDPipe, + Post, +} from '@nestjs/common'; +import { + ApiCreatedResponse, + ApiNoContentResponse, + ApiOkResponse, + ApiOperation, + ApiParam, + ApiTags, +} from '@nestjs/swagger'; +import { User } from '@task-stream/api/identity/domain'; +import { + ApiPireonAuth, + CurrentUser, +} from '@task-stream/api/identity/presenters'; +import { + ApiCommandErrors, + ApiReadErrors, +} from '@task-stream/api/shared/presenters'; +import { SavedFiltersService } from '@task-stream/api/tracker/application'; +import { SaveFilterDto } from './dto/requests.dto'; +import { SavedFilterDto, SavedFilterIdDto } from './dto/responses.dto'; +import { Refs } from './refs'; + +/** Saved views of a project's board: mine, and the ones shared with it. */ +@ApiTags('Saved filters') +@ApiPireonAuth() +@Controller() +export class SavedFiltersController { + constructor( + private readonly filters: SavedFiltersService, + private readonly refs: Refs, + ) {} + + @Get('projects/:project/filters') + @ApiParam({ + name: 'project', + description: 'Project id or key', + example: 'WEB', + }) + @ApiOperation({ + summary: "My views of the project's board, then the shared ones", + }) + @ApiOkResponse({ type: [SavedFilterDto] }) + @ApiReadErrors() + async list(@CurrentUser() me: User, @Param('project') project: string) { + return this.filters.list(me.id, await this.refs.projectId(me.id, project)); + } + + @Post('projects/:project/filters') + @ApiParam({ + name: 'project', + description: 'Project id or key', + example: 'WEB', + }) + @ApiOperation({ + summary: 'Save board filters under a name', + description: + 'Project members only. `shared` makes it visible to the whole project (not for viewers).', + }) + @ApiCreatedResponse({ type: SavedFilterIdDto }) + @ApiCommandErrors() + async save( + @CurrentUser() me: User, + @Param('project') project: string, + @Body() body: SaveFilterDto, + ) { + return this.filters.save(me.id, { + ...body, + projectId: await this.refs.projectId(me.id, project), + }); + } + + @Delete('filters/:id') + @HttpCode(204) + @ApiParam({ name: 'id', format: 'uuid' }) + @ApiOperation({ + summary: 'Delete a saved view (whoever saved it, or an admin if shared)', + }) + @ApiNoContentResponse() + @ApiCommandErrors() + async delete( + @CurrentUser() me: User, + @Param('id', ParseUUIDPipe) id: string, + ) { + await this.filters.delete(me.id, id); + } +} diff --git a/libs/api/tracker/presenters/src/lib/tracker-presenters.module.ts b/libs/api/tracker/presenters/src/lib/tracker-presenters.module.ts index 79abce3..44e638d 100644 --- a/libs/api/tracker/presenters/src/lib/tracker-presenters.module.ts +++ b/libs/api/tracker/presenters/src/lib/tracker-presenters.module.ts @@ -16,6 +16,8 @@ import { ProjectsController } from './http/projects.controller'; import { Refs } from './http/refs'; import { ReleasesController } from './http/releases.controller'; import { NotificationsController } from './http/notifications.controller'; +import { SavedFiltersController } from './http/saved-filters.controller'; +import { SavedFilterResolver } from './graphql/saved-filter.resolver'; /** * Driving adapters of the tracker context: GraphQL (queries, mutations, @@ -45,6 +47,7 @@ import { NotificationsController } from './http/notifications.controller'; ReleasesController, ExploreController, NotificationsController, + SavedFiltersController, ], providers: [ Refs, @@ -54,6 +57,7 @@ import { NotificationsController } from './http/notifications.controller'; ReleaseResolver, SubscriptionResolver, NotificationResolver, + SavedFilterResolver, ], }) export class TrackerPresentersModule { diff --git a/libs/web/tracker/application/src/lib/events/board.events.ts b/libs/web/tracker/application/src/lib/events/board.events.ts index 22b29ad..135d575 100644 --- a/libs/web/tracker/application/src/lib/events/board.events.ts +++ b/libs/web/tracker/application/src/lib/events/board.events.ts @@ -6,6 +6,7 @@ import { CommandResult, CreateIssueInput, IssueChanged, + SavedFilter, } from '@task-stream/web/tracker/domain'; export const boardPageEvents = eventGroup({ @@ -20,6 +21,9 @@ export const boardPageEvents = eventGroup({ reason?: string | null; }>(), issueCreateRequested: type(), + /** Save the current filter under a name. */ + viewSaveRequested: type<{ name: string; shared: boolean }>(), + viewDeleteRequested: type<{ id: string }>(), }, }); @@ -31,6 +35,10 @@ export const boardApiEvents = eventGroup({ issueMoveFailed: type(), issueCreated: type(), issueCreateFailed: type(), + viewsLoaded: type(), + viewSaved: type(), + viewDeleted: type(), + viewFailed: type(), }, }); diff --git a/libs/web/tracker/application/src/lib/facades/board.facade.ts b/libs/web/tracker/application/src/lib/facades/board.facade.ts index 7cdd109..68c2114 100644 --- a/libs/web/tracker/application/src/lib/facades/board.facade.ts +++ b/libs/web/tracker/application/src/lib/facades/board.facade.ts @@ -1,6 +1,11 @@ -import { inject, Injectable, Provider } from '@angular/core'; +import { computed, inject, Injectable, Provider } from '@angular/core'; import { injectDispatch } from '@ngrx/signals/events'; -import { BoardFilter, CreateIssueInput } from '@task-stream/web/tracker/domain'; +import { + BoardFilter, + criteriaOf, + CreateIssueInput, + sameCriteria, +} from '@task-stream/web/tracker/domain'; import { boardPageEvents } from '../events/board.events'; import { BoardStore } from '../stores/board.store'; @@ -17,6 +22,17 @@ export class BoardFacade { readonly loading = this.store.loading; readonly creating = this.store.creating; readonly error = this.store.error; + /** Saved views of this board: mine, then the shared ones. */ + readonly views = this.store.views; + /** The saved view that shows exactly what the board shows now, if any. */ + readonly activeView = computed(() => { + const filter = this.store.filter(); + if (!filter) return null; + const criteria = criteriaOf(filter); + return ( + this.store.views().find((v) => sameCriteria(v.criteria, criteria)) ?? null + ); + }); /** Shows the board with this filter: opens it for a new project, refilters otherwise. */ show(filter: BoardFilter): void { @@ -33,6 +49,16 @@ export class BoardFacade { createIssue(input: CreateIssueInput): void { this.dispatch.issueCreateRequested(input); } + + /** Saves the current filter as a view: private, or shared with the project. */ + saveView(name: string, shared: boolean): void { + if (name.trim()) + this.dispatch.viewSaveRequested({ name: name.trim(), shared }); + } + + deleteView(id: string): void { + this.dispatch.viewDeleteRequested({ id }); + } } /** Store + facade of the board, for the board route's component. */ diff --git a/libs/web/tracker/application/src/lib/facades/facades.spec.ts b/libs/web/tracker/application/src/lib/facades/facades.spec.ts index 7095419..241e773 100644 --- a/libs/web/tracker/application/src/lib/facades/facades.spec.ts +++ b/libs/web/tracker/application/src/lib/facades/facades.spec.ts @@ -12,6 +12,7 @@ import { ProjectRole, ProjectsGateway, ReleasesGateway, + SavedFiltersGateway, TrackerRealtimeGateway, UsersGateway, } from '@task-stream/web/tracker/domain'; @@ -69,6 +70,11 @@ function gateways() { download: vi.fn(() => of(new Blob(['x']))), remove: vi.fn(() => of(ok)), }, + views: { + list: vi.fn(() => of([])), + save: vi.fn(() => of('v1')), + remove: vi.fn(() => of(undefined)), + }, realtime: { issueChanges: () => NEVER, commentChanges: () => NEVER, @@ -106,6 +112,7 @@ function setup() { { provide: AttachmentsGateway, useValue: fakes.attachments }, { provide: UsersGateway, useValue: { search: () => of([]) } }, { provide: TrackerRealtimeGateway, useValue: fakes.realtime }, + { provide: SavedFiltersGateway, useValue: fakes.views }, { provide: Notifications, useValue: { success: vi.fn(), error: vi.fn() }, @@ -141,6 +148,35 @@ describe('BoardFacade', () => { expect(fakes.issues.board).toHaveBeenLastCalledWith({ projectId: 'p2' }); expect(board.filter()).toEqual({ projectId: 'p2' }); }); + + it('saves the current filter as a view and knows when it is showing one', () => { + const { board, fakes } = setup(); + const view = { + id: 'v1', + projectId: 'p1', + name: 'Bugs', + shared: true, + owner: { id: 'me', name: 'Me' }, + createdAt: '', + criteria: { types: ['BUG'] }, + }; + fakes.views.list.mockReturnValue(of([view]) as never); + + board.show({ projectId: 'p1', types: ['BUG'], search: ' ' }); + board.saveView(' Bugs ', true); + + expect(fakes.views.save).toHaveBeenCalledWith({ + projectId: 'p1', + name: 'Bugs', + shared: true, + criteria: { types: ['BUG'] }, + }); + expect(board.activeView()?.id).toBe('v1'); + board.show({ projectId: 'p1' }); + expect(board.activeView()).toBeNull(); + board.deleteView('v1'); + expect(fakes.views.remove).toHaveBeenCalledWith('v1'); + }); }); describe('ProjectFacade', () => { 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 8321b81..8f30a38 100644 --- a/libs/web/tracker/application/src/lib/stores/board.reducers.ts +++ b/libs/web/tracker/application/src/lib/stores/board.reducers.ts @@ -22,10 +22,29 @@ export function matchesFilter( filter: BoardFilter | null, ): boolean { if (!filter) return true; - if (filter.assigneeId && issue.assignee?.id !== filter.assigneeId) + if (filter.unassigned) { + if (issue.assignee) return false; + } else if (filter.assigneeId && issue.assignee?.id !== filter.assigneeId) return false; if (filter.releaseId && issue.release?.id !== filter.releaseId) return false; if (filter.area && issue.area !== filter.area) return false; + if (filter.types?.length && !filter.types.includes(issue.type)) return false; + if (filter.priorities?.length && !filter.priorities.includes(issue.priority)) + return false; + if ( + filter.labels?.length && + !filter.labels.every((l) => issue.labels.includes(l.toLowerCase())) + ) + return false; + // Cards carry no description: a change that matched only by it drops off + // until the next load, which is the safe side. + const text = filter.search?.trim().toLowerCase(); + if ( + text && + !issue.key.toLowerCase().includes(text) && + !issue.title.toLowerCase().includes(text) + ) + return false; return true; } diff --git a/libs/web/tracker/application/src/lib/stores/board.store.ts b/libs/web/tracker/application/src/lib/stores/board.store.ts index 228c07c..7bd6237 100644 --- a/libs/web/tracker/application/src/lib/stores/board.store.ts +++ b/libs/web/tracker/application/src/lib/stores/board.store.ts @@ -10,11 +10,15 @@ import { import { BoardColumn, BoardFilter, + criteriaOf, IssuesGateway, + SavedFilter, + SavedFiltersGateway, TrackerRealtimeGateway, } from '@task-stream/web/tracker/domain'; import { distinctUntilChanged, + EMPTY, exhaustMap, map, merge, @@ -36,6 +40,8 @@ interface BoardState { loading: boolean; creating: boolean; error: string | null; + /** Saved views of this board: mine, then the shared ones. */ + views: SavedFilter[]; } /** Kanban board of a project, live. Scoped to the board route. */ @@ -46,6 +52,7 @@ export const BoardStore = signalStore( loading: false, creating: false, error: null, + views: [], }), withComputed(({ columns }) => ({ total: computed(() => columns().reduce((sum, c) => sum + c.total, 0)), @@ -70,6 +77,7 @@ export const BoardStore = signalStore( on(boardRealtimeEvents.issueChanged, ({ payload }, state) => ({ columns: applyIssueChange(state.columns, payload.issue, state.filter), })), + on(boardApiEvents.viewsLoaded, ({ payload }) => ({ views: payload })), on(boardPageEvents.issueCreateRequested, () => ({ creating: true })), on(boardApiEvents.issueCreated, boardApiEvents.issueCreateFailed, () => ({ creating: false, @@ -82,7 +90,16 @@ export const BoardStore = signalStore( issues = inject(IssuesGateway), realtime = inject(TrackerRealtimeGateway), notifications = inject(Notifications), + savedFilters = inject(SavedFiltersGateway), ) => { + const loadViews = (projectId: string) => + savedFilters.list(projectId).pipe( + mapResponse({ + next: (views) => boardApiEvents.viewsLoaded(views), + // Views are a convenience: without them the board still works. + error: () => boardApiEvents.viewsLoaded([]), + }), + ); const load = (filter: BoardFilter) => issues.board(filter).pipe( mapResponse({ @@ -101,6 +118,50 @@ export const BoardStore = signalStore( switchMap((projectId) => realtime.issueChanges(projectId)), map((change) => boardRealtimeEvents.issueChanged(change)), ), + views$: merge( + events.on(boardPageEvents.opened).pipe( + map(({ payload }) => payload.projectId), + distinctUntilChanged(), + ), + events + .on(boardApiEvents.viewSaved, boardApiEvents.viewDeleted) + .pipe(map(() => store.filter()?.projectId ?? null)), + ).pipe( + switchMap((projectId) => (projectId ? loadViews(projectId) : EMPTY)), + ), + saveView$: events.on(boardPageEvents.viewSaveRequested).pipe( + exhaustMap(({ payload }) => { + const filter = store.filter(); + if (!filter) return EMPTY; + return savedFilters + .save({ + projectId: filter.projectId, + name: payload.name, + shared: payload.shared, + criteria: criteriaOf(filter), + }) + .pipe( + mapResponse({ + next: () => boardApiEvents.viewSaved(payload.name), + error: (error) => + boardApiEvents.viewFailed(describeError(error)), + }), + ); + }), + ), + deleteView$: events.on(boardPageEvents.viewDeleteRequested).pipe( + mergeMap(({ payload }) => { + const name = + store.views().find((v) => v.id === payload.id)?.name ?? ''; + return savedFilters.remove(payload.id).pipe( + mapResponse({ + next: () => boardApiEvents.viewDeleted(name), + error: (error) => + boardApiEvents.viewFailed(describeError(error)), + }), + ); + }), + ), move$: events.on(boardPageEvents.issueMoved).pipe( mergeMap(({ payload }) => issues @@ -146,7 +207,25 @@ export const BoardStore = signalStore( ), ), events - .on(boardApiEvents.issueCreateFailed, boardApiEvents.loadFailed) + .on(boardApiEvents.viewSaved) + .pipe( + tap(({ payload }) => + notifications.success(`Vista «${payload}» guardada`), + ), + ), + events + .on(boardApiEvents.viewDeleted) + .pipe( + tap(({ payload }) => + notifications.success(`Vista «${payload}» eliminada`), + ), + ), + events + .on( + boardApiEvents.issueCreateFailed, + boardApiEvents.loadFailed, + boardApiEvents.viewFailed, + ) .pipe(tap(({ payload }) => notifications.error(payload))), ), }; diff --git a/libs/web/tracker/domain/src/index.ts b/libs/web/tracker/domain/src/index.ts index 133382a..b8f04d1 100644 --- a/libs/web/tracker/domain/src/index.ts +++ b/libs/web/tracker/domain/src/index.ts @@ -5,3 +5,4 @@ export * from './lib/models/workflow'; export * from './lib/ports/gateways'; export * from './lib/models/mentions'; export * from './lib/models/hierarchy'; +export * from './lib/models/views'; diff --git a/libs/web/tracker/domain/src/lib/models/inputs.ts b/libs/web/tracker/domain/src/lib/models/inputs.ts index a991bf0..6692274 100644 --- a/libs/web/tracker/domain/src/lib/models/inputs.ts +++ b/libs/web/tracker/domain/src/lib/models/inputs.ts @@ -76,6 +76,14 @@ export interface MyIssuesFilter { export interface BoardFilter { projectId: string; assigneeId?: string; + /** Only tickets nobody has (wins over `assigneeId`). */ + unassigned?: boolean; releaseId?: string; area?: string; + /** Ticket type ids. */ + types?: string[]; + priorities?: IssuePriority[]; + labels?: string[]; + /** Matches key, title or description. */ + search?: string; } diff --git a/libs/web/tracker/domain/src/lib/models/models.ts b/libs/web/tracker/domain/src/lib/models/models.ts index c303485..d9a48c2 100644 --- a/libs/web/tracker/domain/src/lib/models/models.ts +++ b/libs/web/tracker/domain/src/lib/models/models.ts @@ -70,6 +70,29 @@ export interface Epic { progress: ChildProgress; } +/** What a saved view narrows the board to; empty: everything. */ +export interface SavedFilterCriteria { + types?: string[] | null; + areas?: string[] | null; + priorities?: IssuePriority[] | null; + assigneeId?: string | null; + unassigned?: boolean | null; + releaseId?: string | null; + labels?: string[] | null; + search?: string | null; +} + +/** A named view of the board: private, or shared with the project. */ +export interface SavedFilter { + id: string; + projectId: string; + owner: UserRef; + name: string; + shared: boolean; + criteria: SavedFilterCriteria; + createdAt: string; +} + /** What boards and lists show. */ export interface IssueSummary { id: string; diff --git a/libs/web/tracker/domain/src/lib/models/views.spec.ts b/libs/web/tracker/domain/src/lib/models/views.spec.ts new file mode 100644 index 0000000..8ce300c --- /dev/null +++ b/libs/web/tracker/domain/src/lib/models/views.spec.ts @@ -0,0 +1,43 @@ +import { IssuePriority } from './enums'; +import { boardFilterOf, criteriaOf, sameCriteria } from './views'; + +describe('saved views', () => { + it('turns a board filter into criteria and back', () => { + const filter = { + projectId: 'p1', + assigneeId: 'u1', + area: 'FRONTEND', + types: ['BUG'], + priorities: [IssuePriority.HIGH], + search: ' login ', + }; + const criteria = criteriaOf(filter); + expect(criteria).toEqual({ + assigneeId: 'u1', + areas: ['FRONTEND'], + types: ['BUG'], + priorities: [IssuePriority.HIGH], + search: 'login', + }); + expect(boardFilterOf('p1', criteria)).toEqual({ + ...filter, + search: 'login', + }); + }); + + it('prefers "nobody" over a person', () => { + expect( + criteriaOf({ projectId: 'p', unassigned: true, assigneeId: 'u' }), + ).toEqual({ unassigned: true }); + }); + + it('compares criteria regardless of list order and empty values', () => { + expect( + sameCriteria( + { types: ['BUG', 'QA'], labels: [], search: null }, + { types: ['QA', 'BUG'] }, + ), + ).toBe(true); + expect(sameCriteria({ types: ['BUG'] }, {})).toBe(false); + }); +}); diff --git a/libs/web/tracker/domain/src/lib/models/views.ts b/libs/web/tracker/domain/src/lib/models/views.ts new file mode 100644 index 0000000..6265df2 --- /dev/null +++ b/libs/web/tracker/domain/src/lib/models/views.ts @@ -0,0 +1,57 @@ +import { BoardFilter } from './inputs'; +import { SavedFilterCriteria } from './models'; + +const has = (values?: T[] | null): values is T[] => !!values?.length; + +/** The board filter as saved criteria (no project, nothing empty). */ +export function criteriaOf(filter: BoardFilter): SavedFilterCriteria { + const criteria: SavedFilterCriteria = {}; + if (filter.unassigned) criteria.unassigned = true; + else if (filter.assigneeId) criteria.assigneeId = filter.assigneeId; + if (filter.releaseId) criteria.releaseId = filter.releaseId; + if (filter.area) criteria.areas = [filter.area]; + if (has(filter.types)) criteria.types = [...filter.types]; + if (has(filter.priorities)) criteria.priorities = [...filter.priorities]; + if (has(filter.labels)) criteria.labels = [...filter.labels]; + if (filter.search?.trim()) criteria.search = filter.search.trim(); + return criteria; +} + +/** Saved criteria as the board filter of a project. */ +export function boardFilterOf( + projectId: string, + criteria: SavedFilterCriteria, +): BoardFilter { + const filter: BoardFilter = { projectId }; + if (criteria.unassigned) filter.unassigned = true; + else if (criteria.assigneeId) filter.assigneeId = criteria.assigneeId; + if (criteria.releaseId) filter.releaseId = criteria.releaseId; + if (has(criteria.areas)) filter.area = criteria.areas[0]; + if (has(criteria.types)) filter.types = [...criteria.types]; + if (has(criteria.priorities)) filter.priorities = [...criteria.priorities]; + if (has(criteria.labels)) filter.labels = [...criteria.labels]; + if (criteria.search?.trim()) filter.search = criteria.search.trim(); + return filter; +} + +/** Whether two filters show the same tickets (order of lists aside). */ +export function sameCriteria( + a: SavedFilterCriteria, + b: SavedFilterCriteria, +): boolean { + const norm = (c: SavedFilterCriteria) => + JSON.stringify( + Object.entries(c) + .filter( + ([, v]) => + v !== null && + v !== undefined && + v !== '' && + v !== false && + !(Array.isArray(v) && !v.length), + ) + .map(([k, v]) => [k, Array.isArray(v) ? [...v].sort() : v]) + .sort(([x], [y]) => String(x).localeCompare(String(y))), + ); + return norm(a) === norm(b); +} diff --git a/libs/web/tracker/domain/src/lib/ports/gateways.ts b/libs/web/tracker/domain/src/lib/ports/gateways.ts index 6c0fcd5..88428dc 100644 --- a/libs/web/tracker/domain/src/lib/ports/gateways.ts +++ b/libs/web/tracker/domain/src/lib/ports/gateways.ts @@ -5,6 +5,8 @@ import { BoardColumn, Comment, Epic, + SavedFilter, + SavedFilterCriteria, CommandResult, Issue, IssuePage, @@ -124,6 +126,18 @@ export abstract class IssuesGateway { ): Observable; } +/** Saved views of a project's board (mine and the shared ones). */ +export abstract class SavedFiltersGateway { + abstract list(projectId: string): Observable; + abstract save(input: { + projectId: string; + name: string; + shared: boolean; + criteria: SavedFilterCriteria; + }): Observable; + abstract remove(id: string): Observable; +} + export abstract class CommentsGateway { abstract list(issueId: string): Observable; abstract add(issueId: string, body: string): Observable; 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 d207466..5f4fdfd 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 @@ -52,6 +52,13 @@ export class GraphqlIssuesGateway extends IssuesGateway { releaseId: filter.releaseId ?? null, assigneeId: filter.assigneeId ?? null, area: filter.area ?? null, + types: filter.types?.length ? filter.types : null, + priorities: filter.priorities?.length + ? (filter.priorities as never) + : null, + labels: filter.labels?.length ? filter.labels : null, + search: filter.search?.trim() || null, + unassigned: filter.unassigned ?? null, }) .pipe( map((d) => diff --git a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-saved-filters.gateway.ts b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-saved-filters.gateway.ts new file mode 100644 index 0000000..384bace --- /dev/null +++ b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-saved-filters.gateway.ts @@ -0,0 +1,41 @@ +import { inject, Injectable } from '@angular/core'; +import { + SavedFilter, + SavedFilterCriteria, + SavedFiltersGateway, +} from '@task-stream/web/tracker/domain'; +import { map, Observable } from 'rxjs'; +import { + DeleteSavedFilterDocument, + SavedFiltersDocument, + SaveFilterDocument, +} from '../graphql/generated'; +import { GraphqlOperations } from './graphql-operations'; + +@Injectable() +export class GraphqlSavedFiltersGateway extends SavedFiltersGateway { + private readonly gql = inject(GraphqlOperations); + + list(projectId: string): Observable { + return this.gql + .query(SavedFiltersDocument, { projectId }) + .pipe(map((d) => d.savedFilters as SavedFilter[])); + } + + save(input: { + projectId: string; + name: string; + shared: boolean; + criteria: SavedFilterCriteria; + }): Observable { + return this.gql + .mutate(SaveFilterDocument, { input: input as never }) + .pipe(map((d) => d.saveFilter)); + } + + remove(id: string): Observable { + return this.gql + .mutate(DeleteSavedFilterDocument, { id }) + .pipe(map(() => undefined)); + } +} diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts index ee3c327..2906bc7 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts +++ b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts @@ -147,6 +147,25 @@ export type RankIssueInput = { export type ReleaseStatus = 'ARCHIVED' | 'PLANNED' | 'RELEASED'; +export type SaveFilterInput = { + criteria: SavedFilterCriteriaInput; + name: string; + projectId: string; + /** Visible to the whole project (members, not viewers, share) */ + shared?: boolean; +}; + +export type SavedFilterCriteriaInput = { + areas?: Array | null | undefined; + assigneeId?: string | null | undefined; + labels?: Array | null | undefined; + priorities?: Array | null | undefined; + releaseId?: string | null | undefined; + search?: string | null | undefined; + types?: Array | null | undefined; + unassigned?: boolean | null | undefined; +}; + /** What a status means, whatever each project calls it. DONE and CANCELLED are finished. */ export type StatusCategory = | 'CANCELLED' @@ -335,6 +354,11 @@ export type BoardQueryVariables = Exact<{ releaseId?: string | null | undefined; assigneeId?: string | null | undefined; area?: string | null | undefined; + types?: Array | string | null | undefined; + priorities?: Array | IssuePriority | null | undefined; + labels?: Array | string | null | undefined; + search?: string | null | undefined; + unassigned?: boolean | null | undefined; }>; export type BoardQuery = { @@ -1138,6 +1162,62 @@ export type ReleaseBurnupQuery = { }; }; +export type SavedFilterFieldsFragment = { + id: string; + projectId: string; + name: string; + shared: boolean; + createdAt: string; + owner: { id: string; name: string }; + criteria: { + types: Array | null; + areas: Array | null; + priorities: Array | null; + assigneeId: string | null; + unassigned: boolean | null; + releaseId: string | null; + labels: Array | null; + search: string | null; + }; +}; + +export type SavedFiltersQueryVariables = Exact<{ + projectId: string; +}>; + +export type SavedFiltersQuery = { + savedFilters: Array<{ + id: string; + projectId: string; + name: string; + shared: boolean; + createdAt: string; + owner: { id: string; name: string }; + criteria: { + types: Array | null; + areas: Array | null; + priorities: Array | null; + assigneeId: string | null; + unassigned: boolean | null; + releaseId: string | null; + labels: Array | null; + search: string | null; + }; + }>; +}; + +export type SaveFilterMutationVariables = Exact<{ + input: SaveFilterInput; +}>; + +export type SaveFilterMutation = { saveFilter: string }; + +export type DeleteSavedFilterMutationVariables = Exact<{ + id: string; +}>; + +export type DeleteSavedFilterMutation = { deleteSavedFilter: boolean }; + export type IssueChangedSubscriptionVariables = Exact<{ projectId: string; }>; @@ -1949,6 +2029,74 @@ export const NotificationFieldsFragmentDoc = { }, ], } as unknown as DocumentNode; +export const SavedFilterFieldsFragmentDoc = { + kind: 'Document', + definitions: [ + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'SavedFilterFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'SavedFilter' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'projectId' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + { kind: 'Field', name: { kind: 'Name', value: 'shared' } }, + { kind: 'Field', name: { kind: 'Name', value: 'createdAt' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'owner' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'UserRefFields' }, + }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'criteria' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'types' } }, + { kind: 'Field', name: { kind: 'Name', value: 'areas' } }, + { kind: 'Field', name: { kind: 'Name', value: 'priorities' } }, + { kind: 'Field', name: { kind: 'Name', value: 'assigneeId' } }, + { kind: 'Field', name: { kind: 'Name', value: 'unassigned' } }, + { kind: 'Field', name: { kind: 'Name', value: 'releaseId' } }, + { kind: 'Field', name: { kind: 'Name', value: 'labels' } }, + { kind: 'Field', name: { kind: 'Name', value: 'search' } }, + ], + }, + }, + ], + }, + }, + { + 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' } }, + ], + }, + }, + ], +} as unknown as DocumentNode; export const BoardDocument = { kind: 'Document', definitions: [ @@ -1992,6 +2140,73 @@ export const BoardDocument = { variable: { kind: 'Variable', name: { kind: 'Name', value: 'area' } }, type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } }, }, + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'types' }, + }, + type: { + kind: 'ListType', + type: { + kind: 'NonNullType', + type: { + kind: 'NamedType', + name: { kind: 'Name', value: 'String' }, + }, + }, + }, + }, + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'priorities' }, + }, + type: { + kind: 'ListType', + type: { + kind: 'NonNullType', + type: { + kind: 'NamedType', + name: { kind: 'Name', value: 'IssuePriority' }, + }, + }, + }, + }, + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'labels' }, + }, + type: { + kind: 'ListType', + type: { + kind: 'NonNullType', + type: { + kind: 'NamedType', + name: { kind: 'Name', value: 'String' }, + }, + }, + }, + }, + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'search' }, + }, + type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } }, + }, + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'unassigned' }, + }, + type: { kind: 'NamedType', name: { kind: 'Name', value: 'Boolean' } }, + }, ], selectionSet: { kind: 'SelectionSet', @@ -2041,6 +2256,46 @@ export const BoardDocument = { name: { kind: 'Name', value: 'area' }, }, }, + { + kind: 'Argument', + name: { kind: 'Name', value: 'types' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'types' }, + }, + }, + { + kind: 'Argument', + name: { kind: 'Name', value: 'priorities' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'priorities' }, + }, + }, + { + kind: 'Argument', + name: { kind: 'Name', value: 'labels' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'labels' }, + }, + }, + { + kind: 'Argument', + name: { kind: 'Name', value: 'search' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'search' }, + }, + }, + { + kind: 'Argument', + name: { kind: 'Name', value: 'unassigned' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'unassigned' }, + }, + }, { kind: 'Argument', name: { kind: 'Name', value: 'perColumn' }, @@ -7168,6 +7423,207 @@ export const ReleaseBurnupDocument = { }, ], } as unknown as DocumentNode; +export const SavedFiltersDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: { kind: 'Name', value: 'SavedFilters' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'projectId' }, + }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'savedFilters' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'projectId' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'projectId' }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'SavedFilterFields' }, + }, + ], + }, + }, + ], + }, + }, + { + 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: 'SavedFilterFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'SavedFilter' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'projectId' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + { kind: 'Field', name: { kind: 'Name', value: 'shared' } }, + { kind: 'Field', name: { kind: 'Name', value: 'createdAt' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'owner' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'UserRefFields' }, + }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'criteria' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'types' } }, + { kind: 'Field', name: { kind: 'Name', value: 'areas' } }, + { kind: 'Field', name: { kind: 'Name', value: 'priorities' } }, + { kind: 'Field', name: { kind: 'Name', value: 'assigneeId' } }, + { kind: 'Field', name: { kind: 'Name', value: 'unassigned' } }, + { kind: 'Field', name: { kind: 'Name', value: 'releaseId' } }, + { kind: 'Field', name: { kind: 'Name', value: 'labels' } }, + { kind: 'Field', name: { kind: 'Name', value: 'search' } }, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode; +export const SaveFilterDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: { kind: 'Name', value: 'SaveFilter' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'input' }, + }, + type: { + kind: 'NonNullType', + type: { + kind: 'NamedType', + name: { kind: 'Name', value: 'SaveFilterInput' }, + }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'saveFilter' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'input' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'input' }, + }, + }, + ], + }, + ], + }, + }, + ], +} as unknown as DocumentNode; +export const DeleteSavedFilterDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: { kind: 'Name', value: 'DeleteSavedFilter' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { kind: 'Variable', name: { kind: 'Name', value: 'id' } }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'deleteSavedFilter' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'id' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'id' }, + }, + }, + ], + }, + ], + }, + }, + ], +} as unknown as DocumentNode< + DeleteSavedFilterMutation, + DeleteSavedFilterMutationVariables +>; export const IssueChangedDocument = { kind: 'Document', definitions: [ diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql index 92f63fb..bbe2a39 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql +++ b/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql @@ -1,10 +1,25 @@ -query Board($idOrKey: String!, $releaseId: ID, $assigneeId: ID, $area: String) { +query Board( + $idOrKey: String! + $releaseId: ID + $assigneeId: ID + $area: String + $types: [String!] + $priorities: [IssuePriority!] + $labels: [String!] + $search: String + $unassigned: Boolean +) { project(idOrKey: $idOrKey) { id board( releaseId: $releaseId assigneeId: $assigneeId area: $area + types: $types + priorities: $priorities + labels: $labels + search: $search + unassigned: $unassigned perColumn: 100 ) { status diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/saved-filters.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/saved-filters.graphql new file mode 100644 index 0000000..52fa614 --- /dev/null +++ b/libs/web/tracker/infrastructure/src/lib/graphql/saved-filters.graphql @@ -0,0 +1,34 @@ +fragment SavedFilterFields on SavedFilter { + id + projectId + name + shared + createdAt + owner { + ...UserRefFields + } + criteria { + types + areas + priorities + assigneeId + unassigned + releaseId + labels + search + } +} + +query SavedFilters($projectId: ID!) { + savedFilters(projectId: $projectId) { + ...SavedFilterFields + } +} + +mutation SaveFilter($input: SaveFilterInput!) { + saveFilter(input: $input) +} + +mutation DeleteSavedFilter($id: ID!) { + deleteSavedFilter(id: $id) +} diff --git a/libs/web/tracker/infrastructure/src/lib/tracker-infrastructure.providers.ts b/libs/web/tracker/infrastructure/src/lib/tracker-infrastructure.providers.ts index 7e94f14..53c7e99 100644 --- a/libs/web/tracker/infrastructure/src/lib/tracker-infrastructure.providers.ts +++ b/libs/web/tracker/infrastructure/src/lib/tracker-infrastructure.providers.ts @@ -10,6 +10,7 @@ import { NotificationsGateway, ProjectsGateway, ReleasesGateway, + SavedFiltersGateway, TrackerRealtimeGateway, UsersGateway, } from '@task-stream/web/tracker/domain'; @@ -24,6 +25,7 @@ import { GraphqlNotificationsGateway } from './adapters/graphql-notifications.ga import { HttpAttachmentsGateway } from './adapters/http-attachments.gateway'; import { GraphqlProjectsGateway } from './adapters/graphql-projects.gateway'; import { GraphqlRealtimeGateway } from './adapters/graphql-realtime.gateway'; +import { GraphqlSavedFiltersGateway } from './adapters/graphql-saved-filters.gateway'; /** Ports → GraphQL adapters (and REST for files). */ export function provideTrackerInfrastructure(): EnvironmentProviders { @@ -37,5 +39,6 @@ export function provideTrackerInfrastructure(): EnvironmentProviders { { provide: UsersGateway, useClass: GraphqlUsersGateway }, { provide: NotificationsGateway, useClass: GraphqlNotificationsGateway }, { provide: TrackerRealtimeGateway, useClass: GraphqlRealtimeGateway }, + { provide: SavedFiltersGateway, useClass: GraphqlSavedFiltersGateway }, ]); } diff --git a/libs/web/tracker/presenters/src/lib/pages/board.page.scss b/libs/web/tracker/presenters/src/lib/pages/board.page.scss index 35b9dfc..7c8322c 100644 --- a/libs/web/tracker/presenters/src/lib/pages/board.page.scss +++ b/libs/web/tracker/presenters/src/lib/pages/board.page.scss @@ -6,6 +6,14 @@ margin-bottom: 1.25rem; } +.ts-views { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + margin-bottom: 0.75rem; +} + .ts-filter { width: 13rem; diff --git a/libs/web/tracker/presenters/src/lib/pages/board.page.ts b/libs/web/tracker/presenters/src/lib/pages/board.page.ts index ccdcb3a..acac6af 100644 --- a/libs/web/tracker/presenters/src/lib/pages/board.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/board.page.ts @@ -23,11 +23,13 @@ import { PiSegmentedControlComponent, PiSelectFieldComponent, PiSkeletonComponent, + PiSlideToggleComponent, PiTextareaComponent, PiTextFieldComponent, type PiSelectFieldOption, type SegmentedControlOption, } from '@pi/ui'; +import { AuthFacade } from '@task-stream/web/identity/application'; import { DrawerService } from '@task-stream/web/shared/ui'; import { BoardFacade, @@ -36,14 +38,19 @@ import { } from '@task-stream/web/tracker/application'; import { BoardColumn, + BoardFilter, + boardFilterOf, IssuePriority, IssueSummary, ReleaseStatus, + SavedFilter, } from '@task-stream/web/tracker/domain'; import { IssueCardComponent } from '../ui/issue-card'; import { options, PRIORITY, statusLook } from '../ui/copy'; const ALL = ''; +/** "Sin asignar" in the person filter. */ +const NOBODY = '__nobody__'; @Component({ selector: 'ts-board-page', @@ -58,6 +65,7 @@ const ALL = ''; PiSelectFieldComponent, PiSegmentedControlComponent, PiSkeletonComponent, + PiSlideToggleComponent, PiTextFieldComponent, PiTextareaComponent, PiLabelDirective, @@ -65,13 +73,70 @@ const ALL = ''; providers: [provideBoardFacade()], styleUrl: './board.page.scss', template: ` + @if (project.myRole()) { +
+ + @if (!board.activeView() && isFiltered()) { + + } + @if (board.activeView(); as view) { + @if (canDeleteView(view)) { + + } + } + @if (isFiltered()) { + + } +
+ }
+ + + + +
+

+ Guarda los filtros de ahora para volver a ellos con un clic. +

+ + Nombre + + @if (project.canContribute()) { + + } + + +
+
this.auth.user()?.id ?? null); private readonly createForm = viewChild.required>('createForm'); private readonly reasonForm = viewChild.required>('reasonForm'); + private readonly viewForm = + viewChild.required>('viewForm'); /* Filters */ protected readonly assigneeFilter = signal(ALL); protected readonly releaseFilter = signal(ALL); protected readonly area = signal(ALL); + protected readonly typeFilter = signal(ALL); + protected readonly priorityFilter = signal(ALL); + /** What is typed; `search` follows it once typing pauses. */ + protected readonly searchInput = signal(''); + private readonly search = signal(''); + /** Labels only come from a saved view (no picker on the toolbar). */ + private readonly labels = signal([]); + protected readonly isFiltered = computed( + () => + !!( + this.assigneeFilter() || + this.releaseFilter() || + this.area() || + this.typeFilter() || + this.priorityFilter() || + this.search() || + this.labels().length + ), + ); + + /* Saved views */ + protected readonly viewName = signal(''); + protected readonly viewShared = signal(false); + protected readonly viewOptions = computed[]>( + () => [ + { value: ALL, label: 'Sin vista guardada' }, + ...this.board.views().map((v) => ({ + value: v.id, + label: v.shared ? `${v.name} · compartida` : v.name, + })), + ], + ); + protected readonly assigneeFilterOptions = computed< + PiSelectFieldOption[] + >(() => [ + { value: ALL, label: 'Todas las personas' }, + { value: NOBODY, label: 'Sin asignar' }, + ...this.project + .members() + .map((m) => ({ value: m.user.id, label: m.user.name })), + ]); + protected readonly typeFilterOptions = computed< + PiSelectFieldOption[] + >(() => [ + { value: ALL, label: 'Todos los tipos' }, + ...this.project + .workflow() + .settings.issueTypes.map((t) => ({ value: t.id, label: t.name })), + ]); + protected readonly priorityFilterOptions: PiSelectFieldOption[] = [ + { value: ALL, label: 'Todas las prioridades' }, + ...options(PRIORITY), + ]; protected readonly assigneeOptions = computed[]>( () => [ @@ -332,16 +485,36 @@ export class BoardPage { const assignee = this.assignee(); if (assignee) untracked(() => this.assigneeFilter.set(assignee)); }); + effect((onCleanup) => { + const text = this.searchInput().trim(); + const timer = setTimeout(() => this.search.set(text), 300); + onCleanup(() => clearTimeout(timer)); + }); // (Re)load whenever the project or a filter changes. effect(() => { const projectId = this.project.project()?.id; - const filter = { - assigneeId: this.assigneeFilter() || undefined, + const assignee = this.assigneeFilter(); + const type = this.typeFilter(); + const priority = this.priorityFilter() as IssuePriority | null; + const filter: Omit = { + unassigned: assignee === NOBODY || undefined, + assigneeId: (assignee !== NOBODY && assignee) || undefined, releaseId: this.releaseFilter() || undefined, area: this.area() || undefined, + types: type ? [type] : undefined, + priorities: priority ? [priority] : undefined, + labels: this.labels().length ? this.labels() : undefined, + search: this.search() || undefined, }; if (!projectId) return; - untracked(() => this.board.show({ projectId, ...filter })); + untracked(() => + this.board.show({ + projectId, + ...(Object.fromEntries( + Object.entries(filter).filter(([, v]) => v !== undefined), + ) as Omit), + }), + ); }); } @@ -383,6 +556,62 @@ export class BoardPage { this.drawer.closeAll(); } + /** Shows what a saved view shows (the toolbar picks the first of each list). */ + protected applyView(id: string | null): void { + const view = this.board.views().find((v) => v.id === id); + if (!view) { + if (!id) this.clearFilters(); + return; + } + const projectId = this.project.project()?.id ?? ''; + const filter = boardFilterOf(projectId, view.criteria); + this.assigneeFilter.set( + filter.unassigned ? NOBODY : (filter.assigneeId ?? ALL), + ); + this.releaseFilter.set(filter.releaseId ?? ALL); + this.area.set(filter.area ?? ALL); + this.typeFilter.set(filter.types?.[0] ?? ALL); + this.priorityFilter.set(filter.priorities?.[0] ?? ALL); + this.labels.set(filter.labels ?? []); + this.searchInput.set(filter.search ?? ''); + this.search.set(filter.search ?? ''); + } + + protected clearFilters(): void { + for (const filter of [ + this.assigneeFilter, + this.releaseFilter, + this.area, + this.typeFilter, + this.priorityFilter, + ]) + filter.set(ALL); + this.labels.set([]); + this.searchInput.set(''); + this.search.set(''); + } + + protected openSaveView(): void { + this.viewName.set(''); + this.viewShared.set(false); + this.drawer.open('Guardar vista', this.viewForm()); + } + + protected saveView(): void { + if (!this.viewName().trim()) return; + this.board.saveView( + this.viewName(), + this.viewShared() && this.project.canContribute(), + ); + this.drawer.closeAll(); + } + + protected canDeleteView(view: SavedFilter): boolean { + return ( + view.owner.id === this.me() || (view.shared && this.project.canManage()) + ); + } + protected openCreate(): void { const types = this.typeOptions(); if (!types.some((t) => t.value === this.newType()))