diff --git a/apps/api/e2e/tracker.e2e-spec.ts b/apps/api/e2e/tracker.e2e-spec.ts index 9748a4d..a4e9223 100644 --- a/apps/api/e2e/tracker.e2e-spec.ts +++ b/apps/api/e2e/tracker.e2e-spec.ts @@ -1168,4 +1168,115 @@ describe('task-stream (e2e)', () => { history.body.map((h: { actor: { name: string } }) => h.actor.name), ).toContain('TaskStream'); }); + + it('adds custom fields to a project and lets tickets hold and filter by them', async () => { + const key = 'C' + Date.now().toString(36).slice(-5).toUpperCase(); + await rest('token-ana', 'POST', '/projects', { key, name: key }); + const project = await eventually( + () => rest('token-ana', 'GET', `/projects/${key}`), + (r) => r.status === 200, + ); + const settings = project.body['settings']; + expect(settings.customFields).toEqual([]); + + // REST: define them (a client that leaves them out keeps them). + const fields = [ + { + id: 'BROWSER', + name: 'Navegador', + type: 'SELECT', + options: ['Chrome', 'Safari'], + }, + { id: 'COST', name: 'Coste', type: 'NUMBER' }, + ]; + expect( + ( + await rest('token-ana', 'PUT', `/projects/${key}/settings`, { + ...settings, + customFields: fields, + }) + ).status, + ).toBe(200); + await eventually( + () => rest('token-ana', 'GET', `/projects/${key}`), + (r) => r.body['settings'].customFields.length === 2, + ); + const { customFields: _left, ...older } = settings; + expect( + (await rest('token-ana', 'PUT', `/projects/${key}/settings`, older)) + .status, + ).toBe(200); + + // REST: a ticket with values; a wrong one is refused. + const created = await rest('token-ana', 'POST', `/projects/${key}/issues`, { + type: 'BUG', + title: 'Pago roto', + customFields: { BROWSER: 'Safari', COST: '1,5' }, + }); + expect(created.status).toBe(201); + const ticket = created.body['key'] as string; + const issue = await eventually( + () => rest('token-ana', 'GET', `/issues/${ticket}`), + (r) => r.status === 200, + ); + expect(issue.body['customFields']).toEqual({ + BROWSER: 'Safari', + COST: '1.5', + }); + expect( + ( + await rest('token-ana', 'PATCH', `/issues/${ticket}`, { + customFields: { BROWSER: 'Edge' }, + }) + ).status, + ).toBe(400); + await rest('token-ana', 'POST', `/projects/${key}/issues`, { + type: 'BUG', + title: 'Otro', + customFields: { BROWSER: 'Chrome' }, + }); + + // GraphQL: change one, clear another; filter by value. + await gql( + 'token-ana', + `mutation($i: UpdateIssueInput!) { updateIssue(input: $i) { version } }`, + { + i: { + issueId: issue.body['id'], + customFields: [ + { fieldId: 'BROWSER', value: 'Chrome' }, + { fieldId: 'COST', value: null }, + ], + }, + }, + ); + const filtered = await eventually( + () => + gql( + 'token-ana', + `query($f: IssueFilterInput!) { issues(filter: $f) { total items { key customFields { fieldId value } } } }`, + { + f: { + projectId: project.body['id'], + customFields: [{ fieldId: 'BROWSER', value: 'Chrome' }], + }, + }, + ), + (d) => d['issues'].total === 2, + ); + expect( + filtered['issues'].items.find((i: { key: string }) => i.key === ticket) + .customFields, + ).toEqual([{ fieldId: 'BROWSER', value: 'Chrome' }]); + const bySafari = await rest( + 'token-ana', + 'GET', + `/projects/${key}/issues?field=BROWSER:Safari`, + ); + expect(bySafari.body['total']).toBe(0); + const history = await rest('token-ana', 'GET', `/issues/${ticket}/history`); + expect( + history.body.map((h: { summary: string }) => h.summary).join(' | '), + ).toContain('los campos personalizados'); + }); }); diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 1036596..7ef4b44 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -2424,6 +2424,21 @@ "type": "string" } }, + { + "name": "field", + "required": false, + "in": "query", + "description": "Custom values the tickets must hold, as FIELD:value. Repeat the parameter for several", + "schema": { + "example": [ + "BROWSER:Safari" + ], + "type": "array", + "items": { + "type": "string" + } + } + }, { "name": "sort", "required": false, @@ -10460,6 +10475,54 @@ "archived" ] }, + "CustomFieldType": { + "type": "string", + "enum": [ + "TEXT", + "NUMBER", + "DATE", + "SELECT" + ], + "description": "DATE values are YYYY-MM-DD" + }, + "CustomFieldDefinitionDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "BROWSER" + }, + "name": { + "type": "string", + "example": "Navegador" + }, + "type": { + "description": "DATE values are YYYY-MM-DD", + "allOf": [ + { + "$ref": "#/components/schemas/CustomFieldType" + } + ] + }, + "options": { + "description": "The choices of a SELECT field", + "type": "array", + "items": { + "type": "string" + } + }, + "archived": { + "type": "boolean" + } + }, + "required": [ + "id", + "name", + "type", + "options", + "archived" + ] + }, "ProjectSettingsDto": { "type": "object", "properties": { @@ -10487,13 +10550,20 @@ "items": { "$ref": "#/components/schemas/AreaOptionDto" } + }, + "customFields": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CustomFieldDefinitionDto" + } } }, "required": [ "statuses", "transitions", "issueTypes", - "areas" + "areas", + "customFields" ] }, "ProjectDto": { @@ -10799,6 +10869,50 @@ "name" ] }, + "CustomFieldDefinitionBodyDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "BROWSER" + }, + "name": { + "type": "string", + "example": "Navegador", + "maxLength": 40 + }, + "type": { + "description": "Cannot change once the field exists. DATE: YYYY-MM-DD", + "allOf": [ + { + "$ref": "#/components/schemas/CustomFieldType" + } + ] + }, + "options": { + "maxItems": 50, + "example": [ + "Chrome", + "Safari", + "Firefox" + ], + "description": "The choices of a SELECT field", + "type": "array", + "items": { + "type": "string" + } + }, + "archived": { + "type": "boolean", + "default": false + } + }, + "required": [ + "id", + "name", + "type" + ] + }, "ProjectSettingsBodyDto": { "type": "object", "properties": { @@ -10826,6 +10940,13 @@ "items": { "$ref": "#/components/schemas/AreaOptionBodyDto" } + }, + "customFields": { + "description": "Absent: unchanged. Fields cannot be removed: archive them.", + "type": "array", + "items": { + "$ref": "#/components/schemas/CustomFieldDefinitionBodyDto" + } } }, "required": [ @@ -11216,6 +11337,16 @@ "nullable": true, "format": "date" }, + "customFields": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "example": { + "BROWSER": "Safari" + }, + "description": "Custom field id → value (only those set)" + }, "links": { "type": "array", "items": { @@ -11321,6 +11452,7 @@ "storyPoints", "startDate", "dueDate", + "customFields", "links", "attachments", "watchers", @@ -11823,6 +11955,17 @@ "nullable": true, "format": "date" }, + "customFields": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "example": { + "BROWSER": "Safari" + }, + "description": "Custom field id → value. Only the fields to change; null or '' clears one" + }, "type": { "type": "string", "example": "BUG", @@ -11950,6 +12093,17 @@ "nullable": true, "format": "date" }, + "customFields": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "example": { + "BROWSER": "Safari" + }, + "description": "Custom field id → value. Only the fields to change; null or '' clears one" + }, "type": { "type": "string", "example": "FEATURE", diff --git a/apps/api/schema.gql b/apps/api/schema.gql index 57f63f9..e2f3bc8 100644 --- a/apps/api/schema.gql +++ b/apps/api/schema.gql @@ -279,6 +279,9 @@ input CreateIssueInput { """An area id""" area: String assigneeId: ID + + """Only the fields to change""" + customFields: [CustomFieldValueInput!] description: String dueDate: String @@ -352,6 +355,47 @@ type CumulativeFlow { series: [FlowSeries!]! } +"""A field the project adds to its tickets; its type never changes""" +type CustomFieldDefinition { + archived: Boolean! + id: String! + name: String! + + """The choices of a SELECT field""" + options: [String!]! + type: CustomFieldType! +} + +input CustomFieldDefinitionInput { + archived: Boolean! = false + id: String! + name: String! + + """The choices of a SELECT field""" + options: [String!]! = [] + type: CustomFieldType! +} + +"""What a custom field holds (DATE: YYYY-MM-DD)""" +enum CustomFieldType { + DATE + NUMBER + SELECT + TEXT +} + +"""A ticket's value of a field""" +type CustomFieldValue { + fieldId: String! + value: String! +} + +"""A custom field's value; null or '' clears it""" +input CustomFieldValueInput { + fieldId: String! + value: String +} + """A commit, branch or pull request that mentions the ticket""" type DevLink { at: String! @@ -443,6 +487,9 @@ type Issue { commentCount: Int! comments(limit: Int! = 50, offset: Int! = 0): CommentPage! createdAt: String! + + """Values of the project's custom fields (only those set)""" + customFields: [CustomFieldValue!]! description: String """Commits, branches and pull requests that mention it""" @@ -516,6 +563,9 @@ input IssueFilterInput { assigneeId: ID categories: [StatusCategory!] + """Only tickets holding these custom values""" + customFields: [CustomFieldValueInput!] + """Due on or after this day (YYYY-MM-DD)""" dueFrom: String @@ -961,9 +1011,12 @@ enum ProjectRole { VIEWER } -"""Statuses (board order), transitions, ticket types and areas""" +""" +Statuses (board order), transitions, ticket types, areas and custom fields +""" type ProjectSettings { areas: [AreaOption!]! + customFields: [CustomFieldDefinition!]! issueTypes: [IssueTypeOption!]! statuses: [WorkflowStatus!]! transitions: [StatusTransitions!]! @@ -974,6 +1027,9 @@ The whole configuration (replaces the previous one). Statuses go in board order. """ input ProjectSettingsInput { areas: [AreaOptionInput!]! + + """Absent: unchanged. Fields cannot be removed: archive them.""" + customFields: [CustomFieldDefinitionInput!] issueTypes: [IssueTypeOptionInput!]! statuses: [WorkflowStatusInput!]! transitions: [StatusTransitionsInput!]! @@ -1327,6 +1383,9 @@ input UpdateChecklistItemInput { input UpdateIssueInput { """An area id""" area: String + + """Only the fields to change""" + customFields: [CustomFieldValueInput!] description: String dueDate: String diff --git a/apps/mcp/src/app/api/taskstream.schema.ts b/apps/mcp/src/app/api/taskstream.schema.ts index c3d32d8..23fd92e 100644 --- a/apps/mcp/src/app/api/taskstream.schema.ts +++ b/apps/mcp/src/app/api/taskstream.schema.ts @@ -1666,12 +1666,29 @@ export interface components { name: string; archived: boolean; }; + /** + * @description DATE values are YYYY-MM-DD + * @enum {string} + */ + CustomFieldType: "TEXT" | "NUMBER" | "DATE" | "SELECT"; + CustomFieldDefinitionDto: { + /** @example BROWSER */ + id: string; + /** @example Navegador */ + name: string; + /** @description DATE values are YYYY-MM-DD */ + type: components["schemas"]["CustomFieldType"]; + /** @description The choices of a SELECT field */ + options: string[]; + archived: boolean; + }; ProjectSettingsDto: { /** @description In board order */ statuses: components["schemas"]["WorkflowStatusDto"][]; transitions: components["schemas"]["StatusTransitionsDto"][]; issueTypes: components["schemas"]["IssueTypeOptionDto"][]; areas: components["schemas"]["AreaOptionDto"][]; + customFields: components["schemas"]["CustomFieldDefinitionDto"][]; }; ProjectDto: { /** Format: uuid */ @@ -1788,12 +1805,33 @@ export interface components { /** @default false */ archived: boolean; }; + CustomFieldDefinitionBodyDto: { + /** @example BROWSER */ + id: string; + /** @example Navegador */ + name: string; + /** @description Cannot change once the field exists. DATE: YYYY-MM-DD */ + type: components["schemas"]["CustomFieldType"]; + /** + * @description The choices of a SELECT field + * @example [ + * "Chrome", + * "Safari", + * "Firefox" + * ] + */ + options?: string[]; + /** @default false */ + archived: boolean; + }; ProjectSettingsBodyDto: { /** @description In board order. Values cannot be removed: archive them instead. */ statuses: components["schemas"]["WorkflowStatusBodyDto"][]; transitions: components["schemas"]["StatusTransitionsBodyDto"][]; issueTypes: components["schemas"]["IssueTypeOptionBodyDto"][]; areas: components["schemas"]["AreaOptionBodyDto"][]; + /** @description Absent: unchanged. Fields cannot be removed: archive them. */ + customFields?: components["schemas"]["CustomFieldDefinitionBodyDto"][]; }; /** @enum {string} */ IssuePriority: "LOWEST" | "LOW" | "MEDIUM" | "HIGH" | "CRITICAL"; @@ -1930,6 +1968,15 @@ export interface components { startDate: string | null; /** Format: date */ dueDate: string | null; + /** + * @description Custom field id → value (only those set) + * @example { + * "BROWSER": "Safari" + * } + */ + customFields: { + [key: string]: string; + }; links: components["schemas"]["IssueLinkDto"][]; attachments: components["schemas"]["AttachmentDto"][]; /** @description People following it */ @@ -2098,6 +2145,15 @@ export interface components { startDate?: string | null; /** Format: date */ dueDate?: string | null; + /** + * @description Custom field id → value. Only the fields to change; null or '' clears one + * @example { + * "BROWSER": "Safari" + * } + */ + customFields?: { + [key: string]: string | null; + }; /** * @description A ticket type id of the project's settings * @example BUG @@ -2146,6 +2202,15 @@ export interface components { startDate?: string | null; /** Format: date */ dueDate?: string | null; + /** + * @description Custom field id → value. Only the fields to change; null or '' clears one + * @example { + * "BROWSER": "Safari" + * } + */ + customFields?: { + [key: string]: string | null; + }; /** * @description A ticket type id * @example FEATURE @@ -4365,6 +4430,8 @@ export interface operations { dueFrom?: string; /** @description Due on or before this day */ dueTo?: string; + /** @description Custom values the tickets must hold, as FIELD:value. Repeat the parameter for several */ + field?: string[]; /** @description RECENT (default): last updated first. RANK: backlog order. */ sort?: components["schemas"]["IssueSort"]; /** @description Matches key, title or description */ diff --git a/apps/mcp/src/app/tools/issues.resolver.ts b/apps/mcp/src/app/tools/issues.resolver.ts index 8b96c65..73f9c38 100644 --- a/apps/mcp/src/app/tools/issues.resolver.ts +++ b/apps/mcp/src/app/tools/issues.resolver.ts @@ -46,6 +46,12 @@ const issueFields = { .optional() .describe('ISO date when work should start (roadmap)'), dueDate: z.string().optional().describe('ISO date, e.g. 2026-11-14'), + customFields: z + .record(z.string(), z.string().nullable()) + .optional() + .describe( + 'Custom field id → value (see get_project settings.customFields): SELECT one of its options, DATE YYYY-MM-DD, NUMBER a number. Only the fields to change; null clears one', + ), }; type IssueFields = { @@ -57,6 +63,7 @@ type IssueFields = { storyPoints?: number; startDate?: string; dueDate?: string; + customFields?: Record; }; @Resolver() @@ -142,6 +149,12 @@ export class IssuesResolver { .describe('Only the tickets the user follows'), dueFrom: z.string().optional().describe('Due on or after (ISO date)'), dueTo: z.string().optional().describe('Due on or before (ISO date)'), + customFields: z + .record(z.string(), z.string()) + .optional() + .describe( + 'Only tickets holding these custom values (field id → value)', + ), sort: z .enum(['RECENT', 'RANK']) .default('RECENT') @@ -170,6 +183,7 @@ export class IssuesResolver { watching?: boolean; dueFrom?: string; dueTo?: string; + customFields?: Record; sort: 'RECENT' | 'RANK'; limit: number; offset: number; @@ -198,6 +212,11 @@ export class IssuesResolver { watching: params.watching, dueFrom: params.dueFrom, dueTo: params.dueTo, + field: params.customFields + ? Object.entries(params.customFields).map( + ([id, value]) => `${id}:${value}`, + ) + : undefined, sort: params.sort, limit: params.limit, offset: params.offset, diff --git a/apps/mcp/src/app/tools/summaries.ts b/apps/mcp/src/app/tools/summaries.ts index bfdf45f..55b21bc 100644 --- a/apps/mcp/src/app/tools/summaries.ts +++ b/apps/mcp/src/app/tools/summaries.ts @@ -27,6 +27,7 @@ export const issueLine = (issue: Issue) => ({ votes: issue.votes, storyPoints: issue.storyPoints, dueDate: issue.dueDate, + customFields: issue.customFields, labels: issue.labels, updatedAt: issue.updatedAt, }); diff --git a/docs/03-api.md b/docs/03-api.md index d9e9aff..317a0d6 100644 --- a/docs/03-api.md +++ b/docs/03-api.md @@ -82,7 +82,8 @@ store como auditoría, pero desde la API no se puede deshacer. ### Configuración por proyecto -Cada proyecto tiene sus **estados**, **transiciones**, **tipos de ticket** y **áreas** (`Project.settings`). Todos +Cada proyecto tiene sus **estados**, **transiciones**, **tipos de ticket**, **áreas** y **campos personalizados** +(`Project.settings`). Todos empiezan con los valores por defecto de abajo, y el propietario o un admin los cambia con `updateProjectSettings` (o `PUT /api/projects/{project}/settings`); el cambio es un evento más (`ProjectSettingsChangedEvent`). @@ -112,6 +113,29 @@ Reglas: Los proyectos creados antes de que existiera la configuración usan los valores por defecto (sus eventos no la llevan). Al arrancar, la API rellena la categoría de los tickets del read model que no la tienen. +### Campos personalizados + +Datos propios del proyecto en cada ticket («Navegador», «Cliente», «Coste»…), en `settings.customFields`: `id`, `name`, +`type` y `archived`, más `options` si es una lista. Un proyecto tiene como mucho 30, y empieza sin ninguno. + +| Tipo | Valor | +| -------- | ---------------------------------------------------------------- | +| `TEXT` | Hasta 500 caracteres | +| `NUMBER` | Un número (se guarda en forma canónica: `1,5` → `1.5`) | +| `DATE` | `YYYY-MM-DD` | +| `SELECT` | Una de sus `options` (de 1 a 50, de hasta 60 caracteres, únicas) | + +- Como el resto de la configuración, un campo no se borra: se archiva. Su **tipo no cambia** una vez guardado, para que + los valores que ya hay sigan teniendo sentido. Un campo archivado se puede vaciar en un ticket, pero no rellenar. +- Al guardar la configuración, si `customFields` no viene se quedan como estaban (los clientes que no los conocen no + los pierden). +- En el ticket: GraphQL `Issue.customFields` es una lista `{ fieldId, value }` (solo los que tienen valor) y se cambia + con `customFields: [{ fieldId, value }]` en `createIssue` / `updateIssue`; por REST es un objeto + `{ "BROWSER": "Safari" }`. En ambos se mandan solo los campos que cambian, y `null` o `""` vacía uno. +- Filtrar: `issues(filter: { customFields: [{ fieldId, value }] })` o `GET /api/projects/{project}/issues?field=BROWSER:Safari` + (se puede repetir). +- `IssueUpdatedEvent` lleva el mapa entero de valores ya validado, así la proyección solo lo copia. + ### Workflow por defecto ```mermaid diff --git a/docs/04-cliente-web.md b/docs/04-cliente-web.md index 8c0a0f2..9dff7fc 100644 --- a/docs/04-cliente-web.md +++ b/docs/04-cliente-web.md @@ -245,6 +245,11 @@ proyectos: cada fila usa el workflow de su proyecto y los grupos son por categor En el tablero, un ticket solo se puede soltar en las columnas a las que su workflow le deja pasar, y los tipos archivados no se ofrecen al crear. Los tipos con icono `palette` piden un frame de Figma. +Los campos personalizados se definen en Configuración (el tipo se elige al crearlos y luego queda fijo; las opciones +de una lista, una por línea). En la ficha, `ui/custom-field.ts` pone el control de cada tipo: las listas y las fechas +se guardan al elegir; el texto y los números, al salir del campo. En la lista, cada campo de opciones añade un filtro y +los valores del ticket se ven bajo su título. + ### Gráficas Las de Estadísticas son SVG propios (`ui/series-chart.ts`), no `pi-line-chart`: hacen falta áreas apiladas y una @@ -275,20 +280,20 @@ visualización de la plataforma: ## 4.6 Rutas -| Ruta | Página | -| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/login` | Inicio de sesión (solo sin sesión) | -| `/auth/callback` | Retorno de Pireon (PKCE) | -| `/` | Inicio: «Ir a…» (`WEB-12`), mi trabajo (`ts-my-work`) y mis proyectos | -| `/explorar` | Proyectos públicos (sin sesión) | -| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros) | -| `/p/:key/issues/:issueKey` | Ticket | -| `/p/:key/releases` | Releases | -| `/p/:key/dashboard` | Seguimiento: cifras, flujo por estado, equipo (cada persona enlaza a su tablero) y tipos | -| `/p/:key/analytics` | Estadísticas: creados vs. resueltos, flujo acumulado, burnup de una release, tiempos de ciclo y entrega, QA y resueltos por persona (30 días a 1 año) | -| `/p/:key/settings` | Configuración (propietario y admins; pestaña oculta para el resto): estados (orden, categoría, color, motivo), transiciones, tipos y áreas; archivar en lugar de borrar | -| `/p/:key/activity` | Actividad en vivo | -| `/p/:key/members` | Miembros | +| Ruta | Página | +| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/login` | Inicio de sesión (solo sin sesión) | +| `/auth/callback` | Retorno de Pireon (PKCE) | +| `/` | Inicio: «Ir a…» (`WEB-12`), mi trabajo (`ts-my-work`) y mis proyectos | +| `/explorar` | Proyectos públicos (sin sesión) | +| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros) | +| `/p/:key/issues/:issueKey` | Ticket | +| `/p/:key/releases` | Releases | +| `/p/:key/dashboard` | Seguimiento: cifras, flujo por estado, equipo (cada persona enlaza a su tablero) y tipos | +| `/p/:key/analytics` | Estadísticas: creados vs. resueltos, flujo acumulado, burnup de una release, tiempos de ciclo y entrega, QA y resueltos por persona (30 días a 1 año) | +| `/p/:key/settings` | Configuración (propietario y admins; pestaña oculta para el resto): estados (orden, categoría, color, motivo), transiciones, tipos, áreas y campos personalizados; archivar en lugar de borrar | +| `/p/:key/activity` | Actividad en vivo | +| `/p/:key/members` | Miembros | Solo `/` está protegida por `authGuard` (redirige a `/login?returnUrl=…`); `/login` y `/auth/callback` son para quien no tiene sesión, y el resto se puede abrir sin ella si el proyecto es público. Los parámetros de ruta llegan como inputs de diff --git a/docs/09-mcp.md b/docs/09-mcp.md index 50ae36e..185cf56 100644 --- a/docs/09-mcp.md +++ b/docs/09-mcp.md @@ -34,53 +34,53 @@ flowchart LR 57 herramientas, agrupadas por ámbito: -| Ámbito | Herramienta | Qué hace | -| ------------ | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------- | -| Usuario | `whoami` | El usuario del token | -| | `search_users` | Buscar personas de Pireon (para asignar) | -| | `my_notifications` | Mis notificaciones (por defecto, las no leídas) | -| | `mark_notifications_read` | Marcar como leídas unas o todas | -| Proyectos | `list_my_projects` | Mis proyectos | -| | `list_public_projects` | Proyectos públicos | -| | `create_project` | Crear un proyecto (quien lo crea es su dueño) | -| | `get_project` | Equipo y configuración: ids de estados, tipos y áreas que aceptan las demás herramientas | -| | `get_project_showcase` | El escaparate público: hoja de ruta, releases, sprint en curso y tickets para empezar | -| | `get_board` | Tablero por columnas (filtros por persona, release, área, sprint) | -| | `get_project_stats` | Cifras de seguimiento | -| | `get_project_analytics` | Estadísticas de los últimos días (creados/resueltos, flujo, tiempos, throughput, QA) | -| | `get_project_activity` | Actividad reciente | -| Tickets | `my_issues` | Mi trabajo en todos los proyectos (o de cualquiera, por categoría; o los que sigo) | -| | `search_issues` | Buscar por texto y filtros (épica, sprint, fechas…); el backlog: `noSprint` y orden `RANK` | -| | `get_issue` | Detalle: épica, lista de comprobación, relaciones, commits, seguidores, transiciones, comentarios | -| | `create_issue` | Crear un ticket (también colgado de una épica) | -| | `update_issue` | Cambiar título, descripción, tipo, área, prioridad, etiquetas, fechas… | -| | `change_issue_status` | Mover de estado (con motivo cuando el estado lo pide) | -| | `assign_issue` | Asignar o desasignar | -| | `set_issue_release` | Meter en una release o sacar | -| | `add_comment` / `edit_comment` / `delete_comment` | Comentar (o responder a un comentario), corregir o borrar | -| | `link_issues` / `unlink_issues` | Relacionar tickets (`DEPENDS_ON`, `BLOCKS`, `TESTS`…) o quitar la relación | -| | `watch_issue` | Seguir un ticket o dejar de seguirlo | -| | `vote_issue` | Votar un ticket o quitar el voto | -| | `duplicate_issue` | Crear otro ticket con lo mismo (sin estado, persona ni comentarios) | -| | `react_to_comment` | Reaccionar a un comentario (👍 🎉…) o quitar la reacción | -| | `add_checklist_item` / `update_checklist_item` / `remove_checklist_item` | La lista de comprobación: añadir, marcar o reescribir, quitar | -| Organización | `list_epics` | Épicas con el progreso de lo que cuelga de cada una | -| | `set_issue_parent` | Colgar un ticket de una épica u otro ticket, o soltarlo | -| | `rank_issue` | Mover un ticket en el backlog | -| | `bulk_update_issues` | El mismo cambio en hasta 100 tickets (por clave); dice cuáles no se dejaron | -| | `delete_issue` / `restore_issue` / `list_trash` | Papelera: borrar (30 días para recuperar), recuperar y ver | -| Sprints | `list_sprints` | Sprints de un proyecto | -| | `get_sprint` | Progreso, tickets y burndown | -| | `create_sprint` / `update_sprint` / `delete_sprint` | Planificar, cambiar o borrar (si no empezó) un sprint | -| | `start_sprint` / `close_sprint` | Empezarlo; cerrarlo pasando lo pendiente a otro sprint o al backlog | -| | `set_issue_sprint` | Meter un ticket en un sprint o devolverlo al backlog | -| Releases | `list_releases` | Releases de un proyecto | -| | `upcoming_releases` | Próximas releases de mis proyectos | -| | `get_release` | Progreso, notas, tickets y burnup | -| | `create_release` / `update_release` | Planificar una release o cambiar su nombre, descripción o fecha | -| | `draft_release_notes` / `set_release_notes` | Borrador de las notas desde los tickets terminados, y guardarlas | -| | `publish_release` | Publicar (con `force` si quedan tickets sin terminar) | -| | `archive_release` | Archivar una release que no saldrá; lo pendiente pasa a `moveTo` (otra release abierta) o la deja | +| Ámbito | Herramienta | Qué hace | +| ------------ | ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------- | +| Usuario | `whoami` | El usuario del token | +| | `search_users` | Buscar personas de Pireon (para asignar) | +| | `my_notifications` | Mis notificaciones (por defecto, las no leídas) | +| | `mark_notifications_read` | Marcar como leídas unas o todas | +| Proyectos | `list_my_projects` | Mis proyectos | +| | `list_public_projects` | Proyectos públicos | +| | `create_project` | Crear un proyecto (quien lo crea es su dueño) | +| | `get_project` | Equipo y configuración: ids de estados, tipos y áreas que aceptan las demás herramientas | +| | `get_project_showcase` | El escaparate público: hoja de ruta, releases, sprint en curso y tickets para empezar | +| | `get_board` | Tablero por columnas (filtros por persona, release, área, sprint) | +| | `get_project_stats` | Cifras de seguimiento | +| | `get_project_analytics` | Estadísticas de los últimos días (creados/resueltos, flujo, tiempos, throughput, QA) | +| | `get_project_activity` | Actividad reciente | +| Tickets | `my_issues` | Mi trabajo en todos los proyectos (o de cualquiera, por categoría; o los que sigo) | +| | `search_issues` | Buscar por texto y filtros (épica, sprint, fechas, campos…); el backlog: `noSprint` y orden `RANK` | +| | `get_issue` | Detalle: épica, lista de comprobación, relaciones, commits, seguidores, transiciones, comentarios | +| | `create_issue` | Crear un ticket (también colgado de una épica) | +| | `update_issue` | Cambiar título, descripción, tipo, área, prioridad, etiquetas, fechas, campos personalizados… | +| | `change_issue_status` | Mover de estado (con motivo cuando el estado lo pide) | +| | `assign_issue` | Asignar o desasignar | +| | `set_issue_release` | Meter en una release o sacar | +| | `add_comment` / `edit_comment` / `delete_comment` | Comentar (o responder a un comentario), corregir o borrar | +| | `link_issues` / `unlink_issues` | Relacionar tickets (`DEPENDS_ON`, `BLOCKS`, `TESTS`…) o quitar la relación | +| | `watch_issue` | Seguir un ticket o dejar de seguirlo | +| | `vote_issue` | Votar un ticket o quitar el voto | +| | `duplicate_issue` | Crear otro ticket con lo mismo (sin estado, persona ni comentarios) | +| | `react_to_comment` | Reaccionar a un comentario (👍 🎉…) o quitar la reacción | +| | `add_checklist_item` / `update_checklist_item` / `remove_checklist_item` | La lista de comprobación: añadir, marcar o reescribir, quitar | +| Organización | `list_epics` | Épicas con el progreso de lo que cuelga de cada una | +| | `set_issue_parent` | Colgar un ticket de una épica u otro ticket, o soltarlo | +| | `rank_issue` | Mover un ticket en el backlog | +| | `bulk_update_issues` | El mismo cambio en hasta 100 tickets (por clave); dice cuáles no se dejaron | +| | `delete_issue` / `restore_issue` / `list_trash` | Papelera: borrar (30 días para recuperar), recuperar y ver | +| Sprints | `list_sprints` | Sprints de un proyecto | +| | `get_sprint` | Progreso, tickets y burndown | +| | `create_sprint` / `update_sprint` / `delete_sprint` | Planificar, cambiar o borrar (si no empezó) un sprint | +| | `start_sprint` / `close_sprint` | Empezarlo; cerrarlo pasando lo pendiente a otro sprint o al backlog | +| | `set_issue_sprint` | Meter un ticket en un sprint o devolverlo al backlog | +| Releases | `list_releases` | Releases de un proyecto | +| | `upcoming_releases` | Próximas releases de mis proyectos | +| | `get_release` | Progreso, notas, tickets y burnup | +| | `create_release` / `update_release` | Planificar una release o cambiar su nombre, descripción o fecha | +| | `draft_release_notes` / `set_release_notes` | Borrador de las notas desde los tickets terminados, y guardarlas | +| | `publish_release` | Publicar (con `force` si quedan tickets sin terminar) | +| | `archive_release` | Archivar una release que no saldrá; lo pendiente pasa a `moveTo` (otra release abierta) o la deja | Las de lectura van marcadas como `readOnlyHint`, para que el cliente no pida confirmación al usarlas, y las que borran o archivan, como `destructiveHint`, para que la pida. Lo que es de administración (miembros, configuración, visibilidad, diff --git a/docs/10-futuro.md b/docs/10-futuro.md index 8b22c34..bdded90 100644 --- a/docs/10-futuro.md +++ b/docs/10-futuro.md @@ -28,14 +28,7 @@ cuando hay pistas, por dónde empezar. ## 10.3 Siguientes funcionalidades -### Elegidas para la próxima tanda - -En este orden: - -1. **Campos personalizados por proyecto**: «Navegador», «Cliente», «Entorno»… de texto, número, fecha o lista de - opciones, definidos en Configuración; editables en la ficha, filtrables y como columna en la vista de lista. - -### Valoradas para después +### En curso, en este orden 4. **Dependencias en la hoja de ruta**: las relaciones «bloquea» / «depende de» entre épicas, como flechas en la vista de calendario, para ver qué retrasa a qué. @@ -100,3 +93,6 @@ Mucho trabajo para lo que aportan hoy; se pueden recuperar si alguien los necesi - Calendario suscribible (iCalendar) desde «Mi trabajo»: tickets con fecha, sprints y releases, con URL secreta renovable. - Notificaciones push (Web Push) en cada dispositivo, con la web instalable. +- Campos personalizados por proyecto (texto, número, fecha o lista de opciones): se definen en Configuración, se + editan en la ficha, se ven bajo el título en la lista y se filtran por los de opciones (también por GraphQL, REST y + MCP). diff --git a/docs/TaskStream-Documentacion.docx b/docs/TaskStream-Documentacion.docx index 0597c32..1f7c728 100644 Binary files a/docs/TaskStream-Documentacion.docx and b/docs/TaskStream-Documentacion.docx differ diff --git a/docs/TaskStream-Documentacion.pdf b/docs/TaskStream-Documentacion.pdf index 41e8e9c..67395b9 100644 Binary files a/docs/TaskStream-Documentacion.pdf and b/docs/TaskStream-Documentacion.pdf differ diff --git a/libs/api/tracker/application/src/lib/event-handlers/activity-summary.ts b/libs/api/tracker/application/src/lib/event-handlers/activity-summary.ts index 53456e6..1dc68b8 100644 --- a/libs/api/tracker/application/src/lib/event-handlers/activity-summary.ts +++ b/libs/api/tracker/application/src/lib/event-handlers/activity-summary.ts @@ -90,6 +90,7 @@ const FIELD: Record = { storyPoints: 'los puntos', startDate: 'la fecha de inicio', dueDate: 'la fecha límite', + customFields: 'los campos personalizados', }; const list = (items: string[]) => diff --git a/libs/api/tracker/application/src/lib/event-handlers/issue-created.event-handler.ts b/libs/api/tracker/application/src/lib/event-handlers/issue-created.event-handler.ts index 8b2d322..7e0f6c1 100644 --- a/libs/api/tracker/application/src/lib/event-handlers/issue-created.event-handler.ts +++ b/libs/api/tracker/application/src/lib/event-handlers/issue-created.event-handler.ts @@ -57,6 +57,7 @@ export class IssueCreatedEventHandler storyPoints: issue.storyPoints, startDate: issue.startDate ?? null, dueDate: issue.dueDate, + customFields: issue.customFields ?? {}, createdAt: issue.createdAt, updatedAt: issue.updatedAt, version: versionOf(event), diff --git a/libs/api/tracker/application/src/lib/issues.service.ts b/libs/api/tracker/application/src/lib/issues.service.ts index 6e39805..dd82709 100644 --- a/libs/api/tracker/application/src/lib/issues.service.ts +++ b/libs/api/tracker/application/src/lib/issues.service.ts @@ -355,6 +355,7 @@ export class IssuesService { storyPoints: issue.storyPoints, startDate: issue.startDate, dueDate: issue.dueDate, + customFields: issue.customFields, parentId: issue.parentId, // The original's text is the content, not the type's template. useTemplate: false, diff --git a/libs/api/tracker/application/src/lib/ports/find-issues.repository.ts b/libs/api/tracker/application/src/lib/ports/find-issues.repository.ts index b9c5cd1..e5d5e75 100644 --- a/libs/api/tracker/application/src/lib/ports/find-issues.repository.ts +++ b/libs/api/tracker/application/src/lib/ports/find-issues.repository.ts @@ -33,6 +33,8 @@ export interface IssueFilter { dueTo?: string; /** Only tickets in no sprint (wins over `sprintId`). */ noSprint?: boolean; + /** Custom field id → the value it must hold. */ + customFields?: Record; /** Only the ones the viewer follows (the query handler turns it into `watcherId`). */ watching?: boolean; /** Only the ones this person follows. */ diff --git a/libs/api/tracker/domain/src/lib/events/issue-updated.event.ts b/libs/api/tracker/domain/src/lib/events/issue-updated.event.ts index 7225bf7..be16843 100644 --- a/libs/api/tracker/domain/src/lib/events/issue-updated.event.ts +++ b/libs/api/tracker/domain/src/lib/events/issue-updated.event.ts @@ -1,5 +1,5 @@ import { AutowiredEvent } from '@task-stream/api/shared/domain'; -import type { IssueDetails } from '../issue/issue'; +import type { IssueChanges } from '../issue/issue'; import { TrackerEvent } from './tracker.event'; /** Editable fields of a ticket changed. */ @@ -9,7 +9,7 @@ export class IssueUpdatedEvent extends TrackerEvent { actorId: string, issueId: string, projectId: string, - readonly changes: IssueDetails, + readonly changes: IssueChanges, ) { super(actorId, issueId, projectId); } diff --git a/libs/api/tracker/domain/src/lib/factories/issue.factory.ts b/libs/api/tracker/domain/src/lib/factories/issue.factory.ts index af3568c..12568a4 100644 --- a/libs/api/tracker/domain/src/lib/factories/issue.factory.ts +++ b/libs/api/tracker/domain/src/lib/factories/issue.factory.ts @@ -72,6 +72,9 @@ export class IssueFactory { storyPoints: details.storyPoints ?? null, startDate: details.startDate ?? null, dueDate: details.dueDate ?? null, + customFields: props.customFields + ? workflow.customValues({}, props.customFields) + : {}, links: [], createdAt: now, updatedAt: now, diff --git a/libs/api/tracker/domain/src/lib/issue/issue.ts b/libs/api/tracker/domain/src/lib/issue/issue.ts index 4c9c03e..1f06207 100644 --- a/libs/api/tracker/domain/src/lib/issue/issue.ts +++ b/libs/api/tracker/domain/src/lib/issue/issue.ts @@ -63,8 +63,18 @@ export interface IssueDetails { /** When work on it should start (roadmap); `dueDate` is when it should end. */ startDate?: string | null; dueDate?: string | null; + /** + * Values of the project's custom fields, by field id. A change only names + * the fields it touches; null or '' clears one. + */ + customFields?: Record; } +/** What IssueUpdatedEvent carries: custom values as the whole new map. */ +export type IssueChanges = Omit & { + customFields?: Record; +}; + /** Data of a ticket (what IssueCreatedEvent carries). */ export interface IssueProps { id: string; @@ -86,6 +96,8 @@ export interface IssueProps { storyPoints: number | null; startDate?: string | null; dueDate: string | null; + /** Custom field id → value; absent in tickets created before them. */ + customFields?: Record; links: IssueLink[]; createdAt: string; updatedAt: string; @@ -113,6 +125,8 @@ export class Issue extends VersionedAggregateRoot implements IssueProps { public storyPoints: number | null = null; public startDate: string | null = null; public dueDate: string | null = null; + /** Custom field id → value (tickets created before them have none). */ + public customFields: Record = {}; public links: IssueLink[] = []; public attachments: IssueAttachment[] = []; /** People who asked to be told about this ticket. */ @@ -144,9 +158,17 @@ export class Issue extends VersionedAggregateRoot implements IssueProps { changes: IssueDetails, workflow: ProjectWorkflow = ProjectWorkflow.default(), ): void { - const next = normalizeIssueDetails(changes); - const patch: IssueDetails = {}; - for (const field of Object.keys(next) as (keyof IssueDetails)[]) { + const { customFields: customChanges, ...rest } = changes; + const { customFields: _none, ...normalized } = normalizeIssueDetails(rest); + const next: IssueChanges = normalized; + // Custom values go whole into the event: the projection just sets them. + if (customChanges && Object.keys(customChanges).length) + next.customFields = workflow.customValues( + this.customFields ?? {}, + customChanges, + ); + const patch: IssueChanges = {}; + for (const field of Object.keys(next) as (keyof IssueChanges)[]) { const value = next[field]; if (value === undefined) continue; if (JSON.stringify(value) === JSON.stringify(this[field])) continue; @@ -524,6 +546,7 @@ export class Issue extends VersionedAggregateRoot implements IssueProps { Object.assign(this, { ...issue, labels: [...issue.labels], + customFields: { ...(issue.customFields ?? {}) }, links: issue.links.map((l) => ({ ...l })), }); } diff --git a/libs/api/tracker/domain/src/lib/project/project-settings.spec.ts b/libs/api/tracker/domain/src/lib/project/project-settings.spec.ts index 048ef5b..891ff5a 100644 --- a/libs/api/tracker/domain/src/lib/project/project-settings.spec.ts +++ b/libs/api/tracker/domain/src/lib/project/project-settings.spec.ts @@ -8,9 +8,12 @@ import { IssueStatusChangedEvent } from '../events/issue-status-changed.event'; import { IssueFactory } from '../factories/issue.factory'; import { ProjectFactory } from '../factories/project.factory'; import { replay } from '../testing'; +import { Issue } from '../issue/issue'; import { Project } from './project'; import { ProjectRole } from './project-role'; +import { IssueUpdatedEvent } from '../events/issue-updated.event'; import { + CustomFieldType, defaultProjectSettings, ProjectSettings, StatusCategory, @@ -203,4 +206,151 @@ describe('Project settings', () => { ), ).toThrow(/Unknown ticket type/); }); + + describe('custom fields', () => { + const withFields = (): ProjectSettings => ({ + ...defaultProjectSettings(), + customFields: [ + { + id: 'browser', + name: ' Navegador ', + type: CustomFieldType.SELECT, + options: ['Chrome', ' Safari ', 'Chrome', ''], + archived: false, + }, + { + id: 'COST', + name: 'Coste', + type: CustomFieldType.NUMBER, + options: ['ignored'], + archived: false, + }, + { + id: 'GO_LIVE', + name: 'Salida', + type: CustomFieldType.DATE, + options: [], + archived: false, + }, + { + id: 'CLIENT', + name: 'Cliente', + type: CustomFieldType.TEXT, + options: [], + archived: false, + }, + ], + }); + + const newIssue = (project: Project) => + new IssueFactory().create( + { + projectId: project.id, + projectKey: 'WEB', + number: 1, + title: 'Pago roto', + type: 'BUG', + reporterId: 'dev', + customFields: { BROWSER: 'Safari', COST: '1,5' }, + }, + project.workflow, + ); + + it('are normalised and kept; saving them unchanged is no change', () => { + const project = newProject(); + project.updateSettings('owner', withFields()); + expect(project.workflow.customFields[0]).toEqual({ + id: 'BROWSER', + name: 'Navegador', + type: CustomFieldType.SELECT, + options: ['Chrome', 'Safari'], + archived: false, + }); + expect(project.workflow.customFields[1].options).toEqual([]); + + const before = project.getUncommittedEvents().length; + project.updateSettings('owner', structuredClone(project.settings)); + // Clients that do not know about them leave them as they are. + const { customFields: _absent, ...older } = structuredClone( + project.settings, + ); + project.updateSettings('owner', older); + expect(project.getUncommittedEvents()).toHaveLength(before); + expect(project.workflow.customFields).toHaveLength(4); + }); + + it('cannot be removed, change type or be a SELECT without options', () => { + const project = newProject(); + project.updateSettings('owner', withFields()); + const removed = { ...withFields(), customFields: [] }; + expect(() => project.updateSettings('owner', removed)).toThrow( + /archívalo/i, + ); + const retyped = withFields(); + retyped.customFields![1].type = CustomFieldType.TEXT; + expect(() => project.updateSettings('owner', retyped)).toThrow( + /tipo no se puede cambiar/, + ); + const empty = withFields(); + empty.customFields![0].options = [' ']; + expect(() => project.updateSettings('owner', empty)).toThrow(/opciones/); + }); + + it('check what a ticket holds, by type', () => { + const project = newProject(); + project.updateSettings('owner', withFields()); + const issue = newIssue(project); + expect(issue.customFields).toEqual({ BROWSER: 'Safari', COST: '1.5' }); + + const workflow = project.workflow; + const set = (customFields: Record) => + issue.updateDetails('dev', { customFields }, workflow); + expect(() => set({ BROWSER: 'Edge' })).toThrow(/no option/); + expect(() => set({ COST: 'mucho' })).toThrow(/number/); + expect(() => set({ GO_LIVE: '20/10/2026' })).toThrow(/date/); + expect(() => set({ CLIENT: 'x'.repeat(501) })).toThrow(/500/); + expect(() => set({ NOPE: 'x' })).toThrow(ValidationError); + + set({ GO_LIVE: '2026-11-02', COST: 3, BROWSER: null }); + const event = issue.getUncommittedEvents().at(-1) as IssueUpdatedEvent; + // The whole map, sorted: the projection just sets it. + expect(event.changes).toEqual({ + customFields: { COST: '3', GO_LIVE: '2026-11-02' }, + }); + expect(Object.keys(event.changes.customFields!)).toEqual([ + 'COST', + 'GO_LIVE', + ]); + // Same values again: nothing happens. + const count = issue.getUncommittedEvents().length; + set({ COST: '3' }); + expect(issue.getUncommittedEvents()).toHaveLength(count); + }); + + it('archived ones can be cleared but not set', () => { + const project = newProject(); + project.updateSettings('owner', withFields()); + const issue = newIssue(project); + const archived = withFields(); + archived.customFields![0].archived = true; + project.updateSettings('owner', archived); + expect(() => + issue.updateDetails( + 'dev', + { customFields: { BROWSER: 'Chrome' } }, + project.workflow, + ), + ).toThrow(BusinessRuleError); + issue.updateDetails( + 'dev', + { customFields: { BROWSER: '' } }, + project.workflow, + ); + expect(issue.customFields).toEqual({ COST: '1.5' }); + // Replaying the events gives the same ticket. + expect(replay(issue, Issue).rebuilt.customFields).toEqual({ + COST: '1.5', + }); + }); + }); }); diff --git a/libs/api/tracker/domain/src/lib/project/project-settings.ts b/libs/api/tracker/domain/src/lib/project/project-settings.ts index 7d4c0be..fc2bc06 100644 --- a/libs/api/tracker/domain/src/lib/project/project-settings.ts +++ b/libs/api/tracker/domain/src/lib/project/project-settings.ts @@ -94,6 +94,33 @@ export interface AreaOption { archived: boolean; } +/** What a custom field holds. */ +export enum CustomFieldType { + TEXT = 'TEXT', + NUMBER = 'NUMBER', + /** `YYYY-MM-DD`. */ + DATE = 'DATE', + /** One of `options`. */ + SELECT = 'SELECT', +} + +/** + * A field the project adds to its tickets («Navegador», «Cliente»…). Its + * type never changes once created, so values stay readable. + */ +export interface CustomFieldDefinition { + id: string; + name: string; + type: CustomFieldType; + /** The choices of a SELECT field; empty for the other types. */ + options: string[]; + archived: boolean; +} + +export const MAX_CUSTOM_FIELDS = 30; +export const MAX_CUSTOM_OPTIONS = 50; +export const MAX_CUSTOM_TEXT = 500; + /** Per-project configuration of the tracker. */ export interface ProjectSettings { /** In board order. New tickets start in the first active TODO status. */ @@ -102,6 +129,11 @@ export interface ProjectSettings { transitions: Record; issueTypes: IssueTypeOption[]; areas: AreaOption[]; + /** + * Absent in projects that never defined any. When saving, absent means + * unchanged (clients that predate them keep them). + */ + customFields?: CustomFieldDefinition[]; } const status = ( @@ -291,6 +323,41 @@ export class ProjectWorkflow { throw new BusinessRuleError(`Ticket type ${type.name} is archived`); } + /** The project's custom fields, archived ones included. */ + get customFields(): CustomFieldDefinition[] { + return this.settings.customFields ?? []; + } + + /** + * A ticket's custom values after `changes`: each value checked against + * its field (null or empty clears it). Archived fields can be cleared but + * not set. + */ + customValues( + current: Record, + changes: Record, + ): Record { + const next = { ...current }; + for (const [id, raw] of Object.entries(changes)) { + const field = this.customFields.find((f) => f.id === id); + if (!field) throw new ValidationError(`Unknown field ${id}`); + if (raw !== null && typeof raw !== 'string' && typeof raw !== 'number') + throw new ValidationError(`${field.name} has to be text`); + const value = raw === null ? null : String(raw).trim(); + if (!value) { + delete next[id]; + continue; + } + if (field.archived) + throw new BusinessRuleError(`Field ${field.name} is archived`); + next[id] = customValue(field, value); + } + // Sorted, so the same values always compare equal. + return Object.fromEntries( + Object.entries(next).sort(([a], [b]) => a.localeCompare(b)), + ); + } + assertActiveArea(id: string | null | undefined): void { if (id === null || id === undefined) return; const area = this.settings.areas.find((a) => a.id === id); @@ -336,11 +403,26 @@ export function validateProjectSettings( name: normName(a.name), archived: !!a.archived, })); + const customFields = (next.customFields ?? previous.customFields ?? []).map( + normCustomField, + ); + assertThat( + customFields.length <= MAX_CUSTOM_FIELDS, + `Un proyecto tiene como mucho ${MAX_CUSTOM_FIELDS} campos`, + ); + for (const field of customFields) { + const before = previous.customFields?.find((f) => f.id === field.id); + assertThat( + !before || before.type === field.type, + `El campo «${field.name}» ya tiene valores: su tipo no se puede cambiar`, + ); + } for (const [label, list, before] of [ ['estado', statuses, previous.statuses], ['tipo', issueTypes, previous.issueTypes], ['área', areas, previous.areas], + ['campo', customFields, previous.customFields ?? []], ] as const) { const ids = list.map((item) => item.id); const duplicate = ids.find((id, i) => ids.indexOf(id) !== i); @@ -414,7 +496,69 @@ export function validateProjectSettings( if (to.length) transitions[source] = to; } - return { statuses, transitions, issueTypes, areas }; + return { + statuses, + transitions, + issueTypes, + areas, + // Only when there are some: settings saved unchanged must compare equal. + ...(customFields.length ? { customFields } : {}), + }; +} + +function normCustomField(field: CustomFieldDefinition): CustomFieldDefinition { + const name = normName(field.name); + assertThat( + Object.values(CustomFieldType).includes(field.type), + `Tipo no válido en el campo ${name}`, + ); + const options = + field.type === CustomFieldType.SELECT + ? [...new Set((field.options ?? []).map((o) => o.trim()).filter(Boolean))] + : []; + if (field.type === CustomFieldType.SELECT) + assertThat( + options.length > 0 && + options.length <= MAX_CUSTOM_OPTIONS && + options.every((o) => o.length <= 60), + `El campo «${name}» necesita de 1 a ${MAX_CUSTOM_OPTIONS} opciones de hasta 60 caracteres`, + ); + return { + id: normId(field.id), + name, + type: field.type, + options, + archived: !!field.archived, + }; +} + +/** A value as its field keeps it (numbers and dates in a canonical form). */ +function customValue(field: CustomFieldDefinition, value: string): string { + switch (field.type) { + case CustomFieldType.NUMBER: { + const number = Number(value.replace(',', '.')); + assertThat(Number.isFinite(number), `${field.name} has to be a number`); + return String(number); + } + case CustomFieldType.DATE: + assertThat( + /^\d{4}-\d{2}-\d{2}$/.test(value) && !Number.isNaN(Date.parse(value)), + `${field.name} has to be a date (YYYY-MM-DD)`, + ); + return value; + case CustomFieldType.SELECT: + assertThat( + field.options.includes(value), + `${field.name} has no option «${value}»`, + ); + return value; + default: + assertThat( + value.length <= MAX_CUSTOM_TEXT, + `${field.name} holds ${MAX_CUSTOM_TEXT} characters at most`, + ); + return value; + } } function normTemplate( diff --git a/libs/api/tracker/domain/src/lib/read-models/issue.read-model.ts b/libs/api/tracker/domain/src/lib/read-models/issue.read-model.ts index e3ee7ae..993d547 100644 --- a/libs/api/tracker/domain/src/lib/read-models/issue.read-model.ts +++ b/libs/api/tracker/domain/src/lib/read-models/issue.read-model.ts @@ -77,6 +77,8 @@ export class IssueReadModel { storyPoints!: number | null; startDate!: string | null; dueDate!: string | null; + /** The project's custom fields: field id → value (only those with one). */ + customFields!: Record; links!: IssueLinkReadModel[]; attachments!: AttachmentReadModel[]; /** People following the ticket. */ diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts b/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts index 91a210c..5da81c9 100644 --- a/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts +++ b/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts @@ -449,6 +449,10 @@ function toMongoFilter(filter: IssueFilter): FilterQuery { // Full ISO dates on the last day still count. ...(filter.dueTo ? { $lte: `${filter.dueTo}\uffff` } : {}), }; + // Only field ids: a key never becomes an operator. + for (const [field, value] of Object.entries(filter.customFields ?? {})) + if (/^[A-Z][A-Z0-9_]{0,29}$/.test(field) && typeof value === 'string') + query[`customFields.${field}`] = value; if (filter.noSprint) query['sprint'] = null; else if (filter.sprintId) query['sprint.id'] = filter.sprintId; if (filter.unassigned) query['assignee'] = null; 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 3dc4322..5a084af 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 @@ -199,6 +199,8 @@ class IssueDocument { @Prop({ type: [SchemaFactory.createForClass(AttachmentProp)] }) attachments!: AttachmentProp[]; @Prop({ type: [String] }) labels!: string[]; + /** Field id → value: filtered as `customFields.`. */ + @Prop({ type: Object }) customFields!: Record; @Prop({ type: Number }) version!: number; } export const IssueSchema = SchemaFactory.createForClass(IssueDocument); @@ -413,6 +415,7 @@ export function issueFromDoc(doc: IssueDoc): IssueReadModel { parent: rest.parent?.id ? rest.parent : null, rank: rest.rank ?? implicitRank(rest.number), startDate: rest.startDate ?? null, + customFields: rest.customFields ?? {}, sprint: rest.sprint?.id ? rest.sprint : null, // Links recorded before projects had several repositories name none. devLinks: (rest.devLinks ?? []).map((link) => ({ diff --git a/libs/api/tracker/presenters/src/lib/common/issue-input.ts b/libs/api/tracker/presenters/src/lib/common/issue-input.ts index 52befc7..2e95dde 100644 --- a/libs/api/tracker/presenters/src/lib/common/issue-input.ts +++ b/libs/api/tracker/presenters/src/lib/common/issue-input.ts @@ -1,10 +1,37 @@ import { IssueDetails } from '@task-stream/api/tracker/domain'; -/** Clients may send explicit nulls; the domain only accepts them on nullable fields. */ -export function withoutNulls>(input: T): T { - const details = { ...input }; - for (const field of ['title', 'type', 'priority', 'labels'] as const) { - if (details[field] === null) delete details[field]; - } - return details; +/** A custom value as GraphQL carries it (a map is not a GraphQL type). */ +export interface CustomFieldValue { + fieldId: string; + value?: string | null; +} + +/** Custom values as pairs (GraphQL) or as a map (REST) → the domain's map. */ +export function customFieldsOf( + values: CustomFieldValue[] | Record, +): Record { + return Array.isArray(values) + ? Object.fromEntries(values.map((v) => [v.fieldId, v.value ?? null])) + : values; +} + +type IssueInput = Partial> & { + customFields?: + | CustomFieldValue[] + | Record + | null; +}; + +/** Clients may send explicit nulls; the domain only accepts them on nullable fields. */ +export function withoutNulls( + input: T, +): Omit & Pick { + const { customFields, ...rest } = input; + const details: Record = rest; + for (const field of ['title', 'type', 'priority', 'labels']) + if (details[field] === null) delete details[field]; + return { + ...(details as Omit), + ...(customFields ? { customFields: customFieldsOf(customFields) } : {}), + }; } diff --git a/libs/api/tracker/presenters/src/lib/common/settings.ts b/libs/api/tracker/presenters/src/lib/common/settings.ts index 3ff3de7..d5bed5a 100644 --- a/libs/api/tracker/presenters/src/lib/common/settings.ts +++ b/libs/api/tracker/presenters/src/lib/common/settings.ts @@ -1,4 +1,5 @@ import { + CustomFieldType, ProjectSettings, SettingColor, StatusCategory, @@ -26,11 +27,20 @@ export interface SettingsPayload { template?: { description?: string | null; checklist?: string[] } | null; }[]; areas: { id: string; name: string; archived: boolean }[]; + /** Absent when saving: unchanged. */ + customFields?: { + id: string; + name: string; + type: string; + options?: string[]; + archived: boolean; + }[]; } export function toSettingsPayload(settings: ProjectSettings): SettingsPayload { return { ...settings, + customFields: settings.customFields ?? [], transitions: settings.statuses.map((s) => ({ from: s.id, to: settings.transitions[s.id] ?? [], @@ -62,5 +72,14 @@ export function fromSettingsPayload(payload: SettingsPayload): ProjectSettings { : {}), })), areas: payload.areas, + ...(payload.customFields + ? { + customFields: payload.customFields.map((f) => ({ + ...f, + type: f.type as CustomFieldType, + options: f.options ?? [], + })), + } + : {}), }; } diff --git a/libs/api/tracker/presenters/src/lib/graphql/enums.ts b/libs/api/tracker/presenters/src/lib/graphql/enums.ts index 29a65bf..af53657 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/enums.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/enums.ts @@ -5,6 +5,7 @@ import { IssueSort, } from '@task-stream/api/tracker/application'; import { + CustomFieldType, IssueLinkType, IssuePriority, NotificationKind, @@ -22,6 +23,10 @@ export enum LinkDirection { } registerEnumType(IssuePriority, { name: 'IssuePriority' }); +registerEnumType(CustomFieldType, { + name: 'CustomFieldType', + description: 'What a custom field holds (DATE: YYYY-MM-DD)', +}); registerEnumType(StatusCategory, { name: 'StatusCategory', description: diff --git a/libs/api/tracker/presenters/src/lib/graphql/inputs.ts b/libs/api/tracker/presenters/src/lib/graphql/inputs.ts index 9aa7e0c..b360ed7 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/inputs.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/inputs.ts @@ -1,5 +1,6 @@ import { Field, ID, InputType, Int } from '@nestjs/graphql'; import { + CustomFieldType, IssueLinkType, IssuePriority, ProjectRole, @@ -95,6 +96,16 @@ export class ProjectMemberInput { role!: ProjectRole; } +@InputType({ description: "A custom field's value; null or '' clears it" }) +export class CustomFieldValueInput { + @Field() @Matches(SETTING_ID) fieldId!: string; + @Field(() => String, { nullable: true }) + @IsOptional() + @IsString() + @MaxLength(500) + value?: string | null; +} + @InputType({ isAbstract: true }) abstract class IssueFieldsInput { @Field(() => String, { nullable: true }) @@ -146,6 +157,17 @@ abstract class IssueFieldsInput { @Field(() => String, { nullable: true }) @IsOptional() @IsISO8601() dueDate?: | string | null; + + @Field(() => [CustomFieldValueInput], { + nullable: true, + description: 'Only the fields to change', + }) + @IsOptional() + @IsArray() + @ArrayMaxSize(30) + @ValidateNested({ each: true }) + @Type(() => CustomFieldValueInput) + customFields?: CustomFieldValueInput[]; } @InputType() @@ -537,6 +559,16 @@ export class IssueFilterInput { @IsOptional() @Matches(/^\d{4}-\d{2}-\d{2}$/) dueTo?: string; + @Field(() => [CustomFieldValueInput], { + nullable: true, + description: 'Only tickets holding these custom values', + }) + @IsOptional() + @IsArray() + @ArrayMaxSize(30) + @ValidateNested({ each: true }) + @Type(() => CustomFieldValueInput) + customFields?: CustomFieldValueInput[]; @Field(() => IssueSort, { nullable: true, defaultValue: IssueSort.RECENT }) @IsOptional() @IsEnum(IssueSort) @@ -656,6 +688,23 @@ export class AreaOptionInput { @Field({ defaultValue: false }) @IsBoolean() archived!: boolean; } +@InputType() +export class CustomFieldDefinitionInput { + @Field() @Matches(SETTING_ID) id!: string; + @Field() @IsString() @MinLength(1) @MaxLength(40) name!: string; + @Field(() => CustomFieldType) @IsEnum(CustomFieldType) type!: CustomFieldType; + @Field(() => [String], { + defaultValue: [], + description: 'The choices of a SELECT field', + }) + @IsArray() + @ArrayMaxSize(50) + @IsString({ each: true }) + @MaxLength(60, { each: true }) + options!: string[]; + @Field({ defaultValue: false }) @IsBoolean() archived!: boolean; +} + @InputType({ description: 'The whole configuration (replaces the previous one). Statuses go in board order.', @@ -688,6 +737,17 @@ export class ProjectSettingsInput { @ValidateNested({ each: true }) @Type(() => AreaOptionInput) areas!: AreaOptionInput[]; + + @Field(() => [CustomFieldDefinitionInput], { + nullable: true, + description: 'Absent: unchanged. Fields cannot be removed: archive them.', + }) + @IsOptional() + @IsArray() + @ArrayMaxSize(30) + @ValidateNested({ each: true }) + @Type(() => CustomFieldDefinitionInput) + customFields?: CustomFieldDefinitionInput[]; } @InputType({ diff --git a/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts b/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts index 7672b4d..18b9116 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts @@ -43,6 +43,7 @@ import { CommandResultGql, CommentPageGql, EpicGql, + CustomFieldValueGql, IssueGql, IssuePageGql, IssueParentGql, @@ -75,8 +76,10 @@ export class IssueResolver { @Args('limit', { type: () => Int, defaultValue: 50 }) limit: number, @Args('offset', { type: () => Int, defaultValue: 0 }) offset: number, ) { + const { customFields, ...rest } = filter; return this.issuesService.search(viewerOf(me), { - ...filter, + ...rest, + customFields: onlyValues(customFields), limit, offset, }); @@ -150,6 +153,16 @@ export class IssueResolver { }); } + @ResolveField(() => [CustomFieldValueGql], { + description: "Values of the project's custom fields (only those set)", + }) + customFields(@Parent() issue: IssueReadModel): CustomFieldValueGql[] { + return Object.entries(issue.customFields ?? {}).map(([fieldId, value]) => ({ + fieldId, + value, + })); + } + @ResolveField(() => [String], { description: "Statuses this ticket can move to next (project's workflow)", }) @@ -432,3 +445,13 @@ export class IssueResolver { return this.issuesService.setRelease(me.id, issueId, releaseId ?? null); } } + +/** A filter's custom values: the ones that name something to match. */ +function onlyValues( + values: { fieldId: string; value?: string | null }[] | undefined, +): Record | undefined { + const pairs = (values ?? []).filter((v) => v.value); + return pairs.length + ? Object.fromEntries(pairs.map((v) => [v.fieldId, v.value as string])) + : undefined; +} diff --git a/libs/api/tracker/presenters/src/lib/graphql/types.ts b/libs/api/tracker/presenters/src/lib/graphql/types.ts index 4a785f0..f1d1f6b 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/types.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/types.ts @@ -1,5 +1,6 @@ import { Field, Float, ID, Int, ObjectType } from '@nestjs/graphql'; import { + CustomFieldType, IssueLinkType, IssuePriority, NotificationKind, @@ -84,14 +85,36 @@ export class AreaOptionGql { @Field() archived!: boolean; } +@ObjectType('CustomFieldDefinition', { + description: + 'A field the project adds to its tickets; its type never changes', +}) +export class CustomFieldDefinitionGql { + @Field() id!: string; + @Field() name!: string; + @Field(() => CustomFieldType) type!: CustomFieldType; + @Field(() => [String], { description: 'The choices of a SELECT field' }) + options!: string[]; + @Field() archived!: boolean; +} + +@ObjectType('CustomFieldValue', { description: "A ticket's value of a field" }) +export class CustomFieldValueGql { + @Field() fieldId!: string; + @Field() value!: string; +} + @ObjectType('ProjectSettings', { - description: 'Statuses (board order), transitions, ticket types and areas', + description: + 'Statuses (board order), transitions, ticket types, areas and custom fields', }) export class ProjectSettingsGql { @Field(() => [WorkflowStatusGql]) statuses!: WorkflowStatusGql[]; @Field(() => [StatusTransitionsGql]) transitions!: StatusTransitionsGql[]; @Field(() => [IssueTypeOptionGql]) issueTypes!: IssueTypeOptionGql[]; @Field(() => [AreaOptionGql]) areas!: AreaOptionGql[]; + @Field(() => [CustomFieldDefinitionGql]) + customFields!: CustomFieldDefinitionGql[]; } @ObjectType('Project') 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 e9a1ecc..d505c8f 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 @@ -5,6 +5,7 @@ import { PartialType, } from '@nestjs/swagger'; import { + CustomFieldType, IssueLinkType, IssuePriority, ProjectRole, @@ -23,6 +24,7 @@ import { IsIn, IsInt, IsISO8601, + IsObject, IsOptional, IsUUID, IsString, @@ -230,6 +232,38 @@ export class AreaOptionBodyDto { archived = false; } +export class CustomFieldDefinitionBodyDto { + @ApiProperty({ example: 'BROWSER' }) @Matches(SETTING_ID) id!: string; + @ApiProperty({ example: 'Navegador', maxLength: 40 }) + @IsString() + @MinLength(1) + @MaxLength(40) + name!: string; + @ApiProperty({ + enum: CustomFieldType, + enumName: 'CustomFieldType', + description: 'Cannot change once the field exists. DATE: YYYY-MM-DD', + }) + @IsEnum(CustomFieldType) + type!: CustomFieldType; + @ApiPropertyOptional({ + type: [String], + maxItems: 50, + example: ['Chrome', 'Safari', 'Firefox'], + description: 'The choices of a SELECT field', + }) + @IsOptional() + @IsArray() + @ArrayMaxSize(50) + @IsString({ each: true }) + @MaxLength(60, { each: true }) + options: string[] = []; + @ApiPropertyOptional({ type: Boolean, default: false }) + @IsOptional() + @IsBoolean() + archived = false; +} + export class ProjectSettingsBodyDto { @ApiProperty({ type: [WorkflowStatusBodyDto], @@ -262,6 +296,17 @@ export class ProjectSettingsBodyDto { @ValidateNested({ each: true }) @Type(() => AreaOptionBodyDto) areas!: AreaOptionBodyDto[]; + + @ApiPropertyOptional({ + type: [CustomFieldDefinitionBodyDto], + description: 'Absent: unchanged. Fields cannot be removed: archive them.', + }) + @IsOptional() + @IsArray() + @ArrayMaxSize(30) + @ValidateNested({ each: true }) + @Type(() => CustomFieldDefinitionBodyDto) + customFields?: CustomFieldDefinitionBodyDto[]; } export class AnalyticsQueryDto { @@ -553,6 +598,23 @@ export class IssueSearchQueryDto extends PageQueryDto { @Matches(/^\d{4}-\d{2}-\d{2}$/) dueTo?: string; + @ApiPropertyOptional({ + type: [String], + example: ['BROWSER:Safari'], + description: + 'Custom values the tickets must hold, as FIELD:value. Repeat the parameter for several', + }) + @IsOptional() + @Transform(({ value }) => + value === undefined + ? undefined + : ([] as unknown[]).concat(value).map(String), + ) + @IsArray() + @ArrayMaxSize(30) + @Matches(/^[A-Za-z][A-Za-z0-9_]{0,29}:[\s\S]+$/, { each: true }) + field?: string[]; + @ApiPropertyOptional({ enum: IssueSort, enumName: 'IssueSort', @@ -633,6 +695,17 @@ class IssueFieldsDto { @IsOptional() @IsISO8601() dueDate?: string | null; + + @ApiPropertyOptional({ + type: 'object', + additionalProperties: { type: 'string', nullable: true }, + example: { BROWSER: 'Safari' }, + description: + "Custom field id → value. Only the fields to change; null or '' clears one", + }) + @IsOptional() + @IsObject() + customFields?: Record; } export class CreateIssueDto extends IssueFieldsDto { 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 d87214d..9dfee2f 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 @@ -1,5 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { + CustomFieldType, IssueLinkType, IssuePriority, NotificationKind, @@ -78,6 +79,20 @@ export class AreaOptionDto { @ApiProperty() archived!: boolean; } +export class CustomFieldDefinitionDto { + @ApiProperty({ example: 'BROWSER' }) id!: string; + @ApiProperty({ example: 'Navegador' }) name!: string; + @ApiProperty({ + enum: CustomFieldType, + enumName: 'CustomFieldType', + description: 'DATE values are YYYY-MM-DD', + }) + type!: CustomFieldType; + @ApiProperty({ type: [String], description: 'The choices of a SELECT field' }) + options!: string[]; + @ApiProperty() archived!: boolean; +} + export class ProjectSettingsDto { @ApiProperty({ type: [WorkflowStatusDto], description: 'In board order' }) statuses!: WorkflowStatusDto[]; @@ -86,6 +101,8 @@ export class ProjectSettingsDto { @ApiProperty({ type: [IssueTypeOptionDto] }) issueTypes!: IssueTypeOptionDto[]; @ApiProperty({ type: [AreaOptionDto] }) areas!: AreaOptionDto[]; + @ApiProperty({ type: [CustomFieldDefinitionDto] }) + customFields!: CustomFieldDefinitionDto[]; } export class ProjectDto { @@ -426,6 +443,13 @@ export class IssueDto { startDate!: string | null; @ApiProperty({ type: String, nullable: true, format: 'date' }) dueDate!: string | null; + @ApiProperty({ + type: 'object', + additionalProperties: { type: 'string' }, + example: { BROWSER: 'Safari' }, + description: 'Custom field id → value (only those set)', + }) + customFields!: Record; @ApiProperty({ type: [IssueLinkDto] }) links!: IssueLinkDto[]; @ApiProperty({ type: [AttachmentDto] }) attachments!: AttachmentDto[]; @ApiProperty({ type: [UserRefDto], description: 'People following it' }) diff --git a/libs/api/tracker/presenters/src/lib/http/issues.controller.ts b/libs/api/tracker/presenters/src/lib/http/issues.controller.ts index fd6418b..5899547 100644 --- a/libs/api/tracker/presenters/src/lib/http/issues.controller.ts +++ b/libs/api/tracker/presenters/src/lib/http/issues.controller.ts @@ -171,6 +171,14 @@ export class IssuesController { watching: query.watching, dueFrom: query.dueFrom, dueTo: query.dueTo, + customFields: query.field?.length + ? Object.fromEntries( + query.field.map((f) => [ + f.slice(0, f.indexOf(':')), + f.slice(f.indexOf(':') + 1), + ]), + ) + : undefined, sort: query.sort, search: query.search, limit: query.limit, diff --git a/libs/web/tracker/domain/src/lib/models/enums.ts b/libs/web/tracker/domain/src/lib/models/enums.ts index 652edb4..fc1be61 100644 --- a/libs/web/tracker/domain/src/lib/models/enums.ts +++ b/libs/web/tracker/domain/src/lib/models/enums.ts @@ -10,6 +10,16 @@ export enum StatusCategory { CANCELLED = 'CANCELLED', } +/** What a custom field holds. */ +export enum CustomFieldType { + TEXT = 'TEXT', + NUMBER = 'NUMBER', + /** `YYYY-MM-DD`. */ + DATE = 'DATE', + /** One of its options. */ + SELECT = 'SELECT', +} + export enum IssuePriority { LOWEST = 'LOWEST', LOW = 'LOW', diff --git a/libs/web/tracker/domain/src/lib/models/inputs.ts b/libs/web/tracker/domain/src/lib/models/inputs.ts index 398b796..47463e6 100644 --- a/libs/web/tracker/domain/src/lib/models/inputs.ts +++ b/libs/web/tracker/domain/src/lib/models/inputs.ts @@ -1,3 +1,4 @@ +import { CustomFieldValue } from './workflow'; import { IssueLinkType, IssuePriority, @@ -49,6 +50,8 @@ export interface CreateIssueInput { storyPoints?: number | null; startDate?: string | null; dueDate?: string | null; + /** Only the fields to change; null or '' clears one. */ + customFields?: { fieldId: string; value: string | null }[]; assigneeId?: string | null; releaseId?: string | null; /** Epic or ticket the new one breaks down. */ @@ -97,6 +100,8 @@ export interface IssueListFilter { assigneeId?: string; unassigned?: boolean; releaseId?: string; + /** Only tickets holding these custom values. */ + customFields?: CustomFieldValue[]; limit: number; } diff --git a/libs/web/tracker/domain/src/lib/models/models.ts b/libs/web/tracker/domain/src/lib/models/models.ts index 848a1e0..dd04971 100644 --- a/libs/web/tracker/domain/src/lib/models/models.ts +++ b/libs/web/tracker/domain/src/lib/models/models.ts @@ -7,7 +7,7 @@ import { SprintStatus, StatusCategory, } from './enums'; -import { ProjectSettings } from './workflow'; +import { CustomFieldValue, ProjectSettings } from './workflow'; export interface UserRef { id: string; @@ -126,6 +126,8 @@ export interface IssueSummary { /** Planned start and end (`YYYY-MM-DD`), for the roadmap. */ startDate: string | null; dueDate: string | null; + /** Values of the project's custom fields (only those set). */ + customFields: CustomFieldValue[]; /** In the trash since then (null: not in it). */ trashedAt: string | null; commentCount: number; diff --git a/libs/web/tracker/domain/src/lib/models/workflow.ts b/libs/web/tracker/domain/src/lib/models/workflow.ts index 153e3f0..51234ae 100644 --- a/libs/web/tracker/domain/src/lib/models/workflow.ts +++ b/libs/web/tracker/domain/src/lib/models/workflow.ts @@ -1,4 +1,4 @@ -import { IssuePriority, StatusCategory } from './enums'; +import { CustomFieldType, IssuePriority, StatusCategory } from './enums'; /** Colour tokens a status or ticket type can use (`--ts-c-*`). */ export const SETTING_COLORS = [ @@ -74,12 +74,29 @@ export interface AreaOption { archived: boolean; } -/** Per-project configuration: statuses (board order), transitions, types and areas. */ +/** A field the project adds to its tickets; its type never changes once saved. */ +export interface CustomFieldDefinition { + id: string; + name: string; + type: CustomFieldType; + /** The choices of a SELECT field. */ + options: string[]; + archived: boolean; +} + +/** A ticket's value of a custom field. */ +export interface CustomFieldValue { + fieldId: string; + value: string; +} + +/** Per-project configuration: statuses (board order), transitions, types, areas and custom fields. */ export interface ProjectSettings { statuses: WorkflowStatus[]; transitions: StatusTransitions[]; issueTypes: IssueTypeOption[]; areas: AreaOption[]; + customFields: CustomFieldDefinition[]; } export const FINISHED_CATEGORIES: readonly StatusCategory[] = [ @@ -114,6 +131,7 @@ const EMPTY: ProjectSettings = { transitions: [], issueTypes: [], areas: [], + customFields: [], }; /** @@ -167,6 +185,15 @@ export class Workflow { return this.settings.areas.filter((a) => !a.archived); } + /** Custom fields to fill in (archived ones only show where they have a value). */ + activeCustomFields(): CustomFieldDefinition[] { + return (this.settings.customFields ?? []).filter((f) => !f.archived); + } + + customField(id: string): CustomFieldDefinition | undefined { + return this.settings.customFields?.find((f) => f.id === id); + } + /** Most tickets the status should hold (null: no limit). */ wipLimit(id: string): number | null { return this.status(id).wipLimit ?? null; diff --git a/libs/web/tracker/infrastructure/src/lib/adapters/mappers.ts b/libs/web/tracker/infrastructure/src/lib/adapters/mappers.ts index 88077bd..4315d11 100644 --- a/libs/web/tracker/infrastructure/src/lib/adapters/mappers.ts +++ b/libs/web/tracker/infrastructure/src/lib/adapters/mappers.ts @@ -98,5 +98,12 @@ export function toSettingsInput( name: a.name, archived: a.archived, })), + customFields: (settings.customFields ?? []).map((f) => ({ + id: f.id, + name: f.name, + type: f.type, + options: f.options, + archived: f.archived, + })), }; } diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql index bf73046..fd3daed 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql +++ b/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql @@ -51,6 +51,13 @@ fragment ProjectFields on Project { name archived } + customFields { + id + name + type + options + archived + } } } @@ -95,6 +102,10 @@ fragment IssueSummaryFields on Issue { storyPoints startDate dueDate + customFields { + fieldId + value + } trashedAt } diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts index d056431..effdce7 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts +++ b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts @@ -89,6 +89,8 @@ export type CreateIssueInput = { /** An area id */ area?: string | null | undefined; assigneeId?: string | null | undefined; + /** Only the fields to change */ + customFields?: Array | null | undefined; description?: string | null | undefined; dueDate?: string | null | undefined; /** Figma frame for UI changes */ @@ -141,6 +143,24 @@ export type CreateWebhookInput = { url: string; }; +export type CustomFieldDefinitionInput = { + archived?: boolean; + id: string; + name: string; + /** The choices of a SELECT field */ + options?: Array; + type: CustomFieldType; +}; + +/** What a custom field holds (DATE: YYYY-MM-DD) */ +export type CustomFieldType = 'DATE' | 'NUMBER' | 'SELECT' | 'TEXT'; + +/** A custom field's value; null or '' clears it */ +export type CustomFieldValueInput = { + fieldId: string; + value?: string | null | undefined; +}; + export type DevLinkKind = 'BRANCH' | 'COMMIT' | 'PULL_REQUEST'; export type DevLinkState = 'CLOSED' | 'MERGED' | 'OPEN'; @@ -149,6 +169,8 @@ export type IssueFilterInput = { areas?: Array | null | undefined; assigneeId?: string | null | undefined; categories?: Array | null | undefined; + /** Only tickets holding these custom values */ + customFields?: Array | null | undefined; /** Due on or after this day (YYYY-MM-DD) */ dueFrom?: string | null | undefined; /** Due on or before this day (YYYY-MM-DD) */ @@ -231,6 +253,8 @@ export type ProjectRole = 'ADMIN' | 'MEMBER' | 'OWNER' | 'VIEWER'; /** The whole configuration (replaces the previous one). Statuses go in board order. */ export type ProjectSettingsInput = { areas: Array; + /** Absent: unchanged. Fields cannot be removed: archive them. */ + customFields?: Array | null | undefined; issueTypes: Array; statuses: Array; transitions: Array; @@ -298,6 +322,8 @@ export type UpdateChecklistItemInput = { export type UpdateIssueInput = { /** An area id */ area?: string | null | undefined; + /** Only the fields to change */ + customFields?: Array | null | undefined; description?: string | null | undefined; dueDate?: string | null | undefined; /** Figma frame for UI changes */ @@ -498,6 +524,13 @@ export type ProjectFieldsFragment = { template: { description: string | null; checklist: Array } | null; }>; areas: Array<{ id: string; name: string; archived: boolean }>; + customFields: Array<{ + id: string; + name: string; + type: CustomFieldType; + options: Array; + archived: boolean; + }>; }; }; @@ -527,6 +560,7 @@ export type IssueSummaryFieldsFragment = { sprint: { id: string; name: string } | null; parent: { id: string; key: string; title: string } | null; blockedBy: Array<{ id: string; key: string; title: string }>; + customFields: Array<{ fieldId: string; value: string }>; }; export type IssueFieldsFragment = { @@ -588,6 +622,7 @@ export type IssueFieldsFragment = { sprint: { id: string; name: string } | null; parent: { id: string; key: string; title: string } | null; blockedBy: Array<{ id: string; key: string; title: string }>; + customFields: Array<{ fieldId: string; value: string }>; }; export type CommentFieldsFragment = { @@ -736,6 +771,7 @@ export type BoardQuery = { sprint: { id: string; name: string } | null; parent: { id: string; key: string; title: string } | null; blockedBy: Array<{ id: string; key: string; title: string }>; + customFields: Array<{ fieldId: string; value: string }>; }>; }>; }; @@ -805,6 +841,7 @@ export type IssueQuery = { sprint: { id: string; name: string } | null; parent: { id: string; key: string; title: string } | null; blockedBy: Array<{ id: string; key: string; title: string }>; + customFields: Array<{ fieldId: string; value: string }>; }; }; @@ -840,6 +877,7 @@ export type SearchIssuesQuery = { sprint: { id: string; name: string } | null; parent: { id: string; key: string; title: string } | null; blockedBy: Array<{ id: string; key: string; title: string }>; + customFields: Array<{ fieldId: string; value: string }>; }>; }; }; @@ -1008,6 +1046,7 @@ export type MyIssuesQuery = { sprint: { id: string; name: string } | null; parent: { id: string; key: string; title: string } | null; blockedBy: Array<{ id: string; key: string; title: string }>; + customFields: Array<{ fieldId: string; value: string }>; }>; }; }; @@ -1044,6 +1083,7 @@ export type IssueChildrenQuery = { sprint: { id: string; name: string } | null; parent: { id: string; key: string; title: string } | null; blockedBy: Array<{ id: string; key: string; title: string }>; + customFields: Array<{ fieldId: string; value: string }>; }>; }; }; @@ -1080,6 +1120,7 @@ export type EpicsQuery = { sprint: { id: string; name: string } | null; parent: { id: string; key: string; title: string } | null; blockedBy: Array<{ id: string; key: string; title: string }>; + customFields: Array<{ fieldId: string; value: string }>; }; progress: { total: number; @@ -1158,6 +1199,7 @@ export type BacklogQuery = { sprint: { id: string; name: string } | null; parent: { id: string; key: string; title: string } | null; blockedBy: Array<{ id: string; key: string; title: string }>; + customFields: Array<{ fieldId: string; value: string }>; }>; }; }; @@ -1228,6 +1270,7 @@ export type TrashQuery = { sprint: { id: string; name: string } | null; parent: { id: string; key: string; title: string } | null; blockedBy: Array<{ id: string; key: string; title: string }>; + customFields: Array<{ fieldId: string; value: string }>; }>; }; @@ -1263,6 +1306,7 @@ export type DueIssuesQuery = { sprint: { id: string; name: string } | null; parent: { id: string; key: string; title: string } | null; blockedBy: Array<{ id: string; key: string; title: string }>; + customFields: Array<{ fieldId: string; value: string }>; }>; }; }; @@ -1301,6 +1345,7 @@ export type IssueListQuery = { sprint: { id: string; name: string } | null; parent: { id: string; key: string; title: string } | null; blockedBy: Array<{ id: string; key: string; title: string }>; + customFields: Array<{ fieldId: string; value: string }>; }>; }; }; @@ -1466,6 +1511,13 @@ export type MyProjectsQuery = { } | null; }>; areas: Array<{ id: string; name: string; archived: boolean }>; + customFields: Array<{ + id: string; + name: string; + type: CustomFieldType; + options: Array; + archived: boolean; + }>; }; }>; }; @@ -1509,6 +1561,13 @@ export type ProjectQuery = { } | null; }>; areas: Array<{ id: string; name: string; archived: boolean }>; + customFields: Array<{ + id: string; + name: string; + type: CustomFieldType; + options: Array; + archived: boolean; + }>; }; }; }; @@ -1662,6 +1721,13 @@ export type PublicProjectsQuery = { } | null; }>; areas: Array<{ id: string; name: string; archived: boolean }>; + customFields: Array<{ + id: string; + name: string; + type: CustomFieldType; + options: Array; + archived: boolean; + }>; }; }>; }; @@ -2010,6 +2076,7 @@ export type IssueChangedSubscription = { sprint: { id: string; name: string } | null; parent: { id: string; key: string; title: string } | null; blockedBy: Array<{ id: string; key: string; title: string }>; + customFields: Array<{ fieldId: string; value: string }>; }; }; }; @@ -2115,6 +2182,13 @@ export type ProjectChangedSubscription = { } | null; }>; areas: Array<{ id: string; name: string; archived: boolean }>; + customFields: Array<{ + id: string; + name: string; + type: CustomFieldType; + options: Array; + archived: boolean; + }>; }; }; }; @@ -2161,6 +2235,13 @@ export type MyProjectsChangedSubscription = { } | null; }>; areas: Array<{ id: string; name: string; archived: boolean }>; + customFields: Array<{ + id: string; + name: string; + type: CustomFieldType; + options: Array; + archived: boolean; + }>; }; }; }; @@ -2524,6 +2605,26 @@ export const ProjectFieldsFragmentDoc = { ], }, }, + { + kind: 'Field', + name: { kind: 'Name', value: 'customFields' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + { kind: 'Field', name: { kind: 'Name', value: 'type' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'options' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'archived' }, + }, + ], + }, + }, ], }, }, @@ -2638,6 +2739,17 @@ export const IssueSummaryFieldsFragmentDoc = { { kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } }, { kind: 'Field', name: { kind: 'Name', value: 'startDate' } }, { kind: 'Field', name: { kind: 'Name', value: 'dueDate' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'customFields' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'fieldId' } }, + { kind: 'Field', name: { kind: 'Name', value: 'value' } }, + ], + }, + }, { kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } }, ], }, @@ -2886,6 +2998,17 @@ export const IssueFieldsFragmentDoc = { { kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } }, { kind: 'Field', name: { kind: 'Name', value: 'startDate' } }, { kind: 'Field', name: { kind: 'Name', value: 'dueDate' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'customFields' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'fieldId' } }, + { kind: 'Field', name: { kind: 'Name', value: 'value' } }, + ], + }, + }, { kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } }, ], }, @@ -4600,6 +4723,17 @@ export const BoardDocument = { { kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } }, { kind: 'Field', name: { kind: 'Name', value: 'startDate' } }, { kind: 'Field', name: { kind: 'Name', value: 'dueDate' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'customFields' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'fieldId' } }, + { kind: 'Field', name: { kind: 'Name', value: 'value' } }, + ], + }, + }, { kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } }, ], }, @@ -4761,6 +4895,17 @@ export const IssueDocument = { { kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } }, { kind: 'Field', name: { kind: 'Name', value: 'startDate' } }, { kind: 'Field', name: { kind: 'Name', value: 'dueDate' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'customFields' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'fieldId' } }, + { kind: 'Field', name: { kind: 'Name', value: 'value' } }, + ], + }, + }, { kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } }, ], }, @@ -5057,6 +5202,17 @@ export const SearchIssuesDocument = { { kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } }, { kind: 'Field', name: { kind: 'Name', value: 'startDate' } }, { kind: 'Field', name: { kind: 'Name', value: 'dueDate' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'customFields' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'fieldId' } }, + { kind: 'Field', name: { kind: 'Name', value: 'value' } }, + ], + }, + }, { kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } }, ], }, @@ -6375,6 +6531,17 @@ export const MyIssuesDocument = { { kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } }, { kind: 'Field', name: { kind: 'Name', value: 'startDate' } }, { kind: 'Field', name: { kind: 'Name', value: 'dueDate' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'customFields' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'fieldId' } }, + { kind: 'Field', name: { kind: 'Name', value: 'value' } }, + ], + }, + }, { kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } }, ], }, @@ -6550,6 +6717,17 @@ export const IssueChildrenDocument = { { kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } }, { kind: 'Field', name: { kind: 'Name', value: 'startDate' } }, { kind: 'Field', name: { kind: 'Name', value: 'dueDate' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'customFields' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'fieldId' } }, + { kind: 'Field', name: { kind: 'Name', value: 'value' } }, + ], + }, + }, { kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } }, ], }, @@ -6743,6 +6921,17 @@ export const EpicsDocument = { { kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } }, { kind: 'Field', name: { kind: 'Name', value: 'startDate' } }, { kind: 'Field', name: { kind: 'Name', value: 'dueDate' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'customFields' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'fieldId' } }, + { kind: 'Field', name: { kind: 'Name', value: 'value' } }, + ], + }, + }, { kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } }, ], }, @@ -7240,6 +7429,17 @@ export const BacklogDocument = { { kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } }, { kind: 'Field', name: { kind: 'Name', value: 'startDate' } }, { kind: 'Field', name: { kind: 'Name', value: 'dueDate' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'customFields' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'fieldId' } }, + { kind: 'Field', name: { kind: 'Name', value: 'value' } }, + ], + }, + }, { kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } }, ], }, @@ -7674,6 +7874,17 @@ export const TrashDocument = { { kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } }, { kind: 'Field', name: { kind: 'Name', value: 'startDate' } }, { kind: 'Field', name: { kind: 'Name', value: 'dueDate' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'customFields' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'fieldId' } }, + { kind: 'Field', name: { kind: 'Name', value: 'value' } }, + ], + }, + }, { kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } }, ], }, @@ -7849,6 +8060,17 @@ export const DueIssuesDocument = { { kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } }, { kind: 'Field', name: { kind: 'Name', value: 'startDate' } }, { kind: 'Field', name: { kind: 'Name', value: 'dueDate' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'customFields' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'fieldId' } }, + { kind: 'Field', name: { kind: 'Name', value: 'value' } }, + ], + }, + }, { kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } }, ], }, @@ -8039,6 +8261,17 @@ export const IssueListDocument = { { kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } }, { kind: 'Field', name: { kind: 'Name', value: 'startDate' } }, { kind: 'Field', name: { kind: 'Name', value: 'dueDate' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'customFields' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'fieldId' } }, + { kind: 'Field', name: { kind: 'Name', value: 'value' } }, + ], + }, + }, { kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } }, ], }, @@ -8957,6 +9190,26 @@ export const MyProjectsDocument = { ], }, }, + { + kind: 'Field', + name: { kind: 'Name', value: 'customFields' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + { kind: 'Field', name: { kind: 'Name', value: 'type' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'options' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'archived' }, + }, + ], + }, + }, ], }, }, @@ -9180,6 +9433,26 @@ export const ProjectDocument = { ], }, }, + { + kind: 'Field', + name: { kind: 'Name', value: 'customFields' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + { kind: 'Field', name: { kind: 'Name', value: 'type' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'options' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'archived' }, + }, + ], + }, + }, ], }, }, @@ -10254,6 +10527,26 @@ export const PublicProjectsDocument = { ], }, }, + { + kind: 'Field', + name: { kind: 'Name', value: 'customFields' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + { kind: 'Field', name: { kind: 'Name', value: 'type' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'options' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'archived' }, + }, + ], + }, + }, ], }, }, @@ -12224,6 +12517,17 @@ export const IssueChangedDocument = { { kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } }, { kind: 'Field', name: { kind: 'Name', value: 'startDate' } }, { kind: 'Field', name: { kind: 'Name', value: 'dueDate' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'customFields' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'fieldId' } }, + { kind: 'Field', name: { kind: 'Name', value: 'value' } }, + ], + }, + }, { kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } }, ], }, @@ -12776,6 +13080,26 @@ export const ProjectChangedDocument = { ], }, }, + { + kind: 'Field', + name: { kind: 'Name', value: 'customFields' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + { kind: 'Field', name: { kind: 'Name', value: 'type' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'options' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'archived' }, + }, + ], + }, + }, ], }, }, @@ -12986,6 +13310,26 @@ export const MyProjectsChangedDocument = { ], }, }, + { + kind: 'Field', + name: { kind: 'Name', value: 'customFields' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + { kind: 'Field', name: { kind: 'Name', value: 'type' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'options' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'archived' }, + }, + ], + }, + }, ], }, }, diff --git a/libs/web/tracker/presenters/src/lib/pages/issue.page.ts b/libs/web/tracker/presenters/src/lib/pages/issue.page.ts index f51a26f..9307975 100644 --- a/libs/web/tracker/presenters/src/lib/pages/issue.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/issue.page.ts @@ -43,6 +43,7 @@ import { Attachment, Comment, DevLink, + Issue, IssueLink, IssueLinkType, IssuePriority, @@ -70,6 +71,7 @@ import { MarkdownComponent, } from '../ui/markdown'; import { RichEditorComponent } from '../ui/rich-editor'; +import { CustomFieldComponent } from '../ui/custom-field'; const NONE = ''; /** Same ceiling as the API's ATTACHMENTS_MAX_MB default. */ @@ -80,6 +82,7 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024; changeDetection: ChangeDetectionStrategy.OnPush, imports: [ BlockedComponent, + CustomFieldComponent, ReactionsComponent, MarkdownComponent, RichEditorComponent, @@ -843,6 +846,16 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024; > Área + @for (field of customFields(); track field.id) { + + } @if (!issue.trashedAt && canTrash(issue)) { { + const issue = this.facade.issue(); + return (this.project.workflow().settings.customFields ?? []).filter( + (f) => + !f.archived || !!issue?.customFields?.some((v) => v.fieldId === f.id), + ); + }); + + protected customValue(issue: Issue, fieldId: string): string | null { + return ( + issue.customFields?.find((v) => v.fieldId === fieldId)?.value ?? null + ); + } + protected link(): void { const targetKey = this.linkTarget(); if (!targetKey) return; diff --git a/libs/web/tracker/presenters/src/lib/pages/list.page.ts b/libs/web/tracker/presenters/src/lib/pages/list.page.ts index 99e197b..254743b 100644 --- a/libs/web/tracker/presenters/src/lib/pages/list.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/list.page.ts @@ -27,6 +27,7 @@ import { } from '@task-stream/web/tracker/application'; import { BulkChange, + CustomFieldType, IssuePriority, IssueSummary, StatusCategory, @@ -110,6 +111,13 @@ const numberOf = (key: string) => Number(key.split('-').pop()); [options]="releaseFilterOptions()" [(value)]="releaseFilter" /> + @for (field of listFields(); track field.id) { + + } @if (facade.loaded()) { {{ facade.total() }} tickets{{ @@ -204,6 +212,9 @@ const numberOf = (key: string) => Number(key.split('-').pop()); ↑ {{ parent.key }} } + @if (fieldsOf(issue); as fields) { + {{ fields }} + } Number(key.split('-').pop()); } .ts-list__title { min-width: 16rem; + .ts-list__fields { + display: block; + font-size: 0.75rem; + } a { color: inherit; text-decoration: none; @@ -513,9 +528,44 @@ export class ListPage { : `${r.name} (${r.status === 'RELEASED' ? 'publicada' : 'archivada'})`, })), ]); + /** Custom list fields filter too: field id → the option picked. */ + protected readonly fieldFilters = signal>({}); + protected readonly listFields = computed(() => + this.project + .workflow() + .activeCustomFields() + .filter((f) => f.type === CustomFieldType.SELECT) + .map((f) => ({ + id: f.id, + options: [ + { value: ALL, label: `${f.name}: todos` }, + ...f.options.map((o) => ({ value: o, label: `${f.name}: ${o}` })), + ] as PiSelectFieldOption[], + })), + ); + + /** «Navegador: Safari · Coste: 120», in the project's order of fields. */ + protected fieldsOf(issue: IssueSummary): string { + const values = new Map( + (issue.customFields ?? []).map((v) => [v.fieldId, v.value]), + ); + return (this.project.workflow().settings.customFields ?? []) + .filter((f) => values.has(f.id)) + .map((f) => `${f.name}: ${values.get(f.id)}`) + .join(' · '); + } + + protected setFieldFilter(fieldId: string, value: string | null): void { + this.fieldFilters.update((current) => { + const { [fieldId]: _previous, ...rest } = current; + return value ? { ...rest, [fieldId]: value } : rest; + }); + } + private readonly criteria = computed(() => { const scope = this.scope(); const assignee = this.assignee(); + const fields = Object.entries(this.fieldFilters()); return { search: this.search() || undefined, categories: @@ -527,6 +577,9 @@ export class ListPage { unassigned: assignee === NOBODY || undefined, assigneeId: assignee && assignee !== NOBODY ? assignee : undefined, releaseId: this.releaseFilter() || undefined, + customFields: fields.length + ? fields.map(([fieldId, value]) => ({ fieldId, value })) + : undefined, }; }); diff --git a/libs/web/tracker/presenters/src/lib/pages/settings.page.ts b/libs/web/tracker/presenters/src/lib/pages/settings.page.ts index d7479e9..5b0c966 100644 --- a/libs/web/tracker/presenters/src/lib/pages/settings.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/settings.page.ts @@ -23,6 +23,7 @@ import { TransferComponent } from '../ui/transfer'; import { AutomationsComponent } from '../ui/automations'; import { ProjectFacade } from '@task-stream/web/tracker/application'; import { + CustomFieldType, IssueTypeOption, ProjectSettings, SETTING_COLORS, @@ -31,7 +32,14 @@ import { } from '@task-stream/web/tracker/domain'; import { CATEGORY, COLOR_LABEL, ICON_LABEL, tone } from '../ui/copy'; -type Section = 'statuses' | 'issueTypes' | 'areas'; +type Section = 'statuses' | 'issueTypes' | 'areas' | 'customFields'; + +const FIELD_TYPES: { id: CustomFieldType; label: string }[] = [ + { id: CustomFieldType.TEXT, label: 'Texto' }, + { id: CustomFieldType.NUMBER, label: 'Número' }, + { id: CustomFieldType.DATE, label: 'Fecha' }, + { id: CustomFieldType.SELECT, label: 'Lista de opciones' }, +]; const CATEGORIES = Object.values(StatusCategory); @@ -75,8 +83,9 @@ function idFrom(name: string): string {

Configuración

- Estados, transiciones, tipos de ticket y áreas de este proyecto. Lo - que ya se usa no se borra: se archiva y deja de ofrecerse. + Estados, transiciones, tipos de ticket, áreas y campos de este + proyecto. Lo que ya se usa no se borra: se archiva y deja de + ofrecerse.

@@ -520,6 +529,92 @@ function idFrom(name: string): string { } + + +
+
+
+

Campos personalizados

+

+ Datos propios de este proyecto en cada ticket (navegador, + cliente, coste…). El tipo no cambia una vez guardado. +

+
+ +
+ @if (!d.customFields.length) { +

Ningún campo todavía.

+ } +
    + @for (field of d.customFields; track $index; let i = $index) { +
  1. + + + {{ field.id }} + + + + @if (field.type === 'SELECT') { + + } +
  2. + } +
+
@@ -860,6 +955,36 @@ function idFrom(name: string): string { align-items: center; gap: 0.5rem; } + /* Name and archive on top; the type (and options) below. */ + .ts-field-grid { + display: grid; + grid-template-columns: minmax(8rem, 1fr) 6.5rem; + align-items: center; + gap: 0.5rem; + .ts-field-type { + grid-row: 2; + grid-column: 1 / -1; + } + } + .ts-field-options { + grid-column: 1 / -1; + display: grid; + gap: 0.25rem; + font-size: 0.8125rem; + color: var(--ts-muted); + textarea { + font: inherit; + color: var(--ts-ink); + background: var(--ts-surface); + border: 1px solid var(--ts-line); + border-radius: 0.5rem; + padding: 0.375rem 0.5rem; + resize: vertical; + } + } + .ts-empty-fields { + margin: 0; + } .ts-row { --tone: var(--ts-muted); padding: 0.375rem 0.5rem; @@ -1074,6 +1199,7 @@ export class SettingsPage { protected readonly categories = CATEGORIES; protected readonly colors = SETTING_COLORS; protected readonly icons = TYPE_ICONS; + protected readonly fieldTypes = FIELD_TYPES; /** What the API has; the draft is compared against it. */ private readonly saved = computed( @@ -1098,7 +1224,7 @@ export class SettingsPage { const d = this.draft(); if (!d) return []; const problems: string[] = []; - const lists = [d.statuses, d.issueTypes, d.areas]; + const lists = [d.statuses, d.issueTypes, d.areas, d.customFields]; if (lists.some((list) => list.some((item) => !item.name.trim()))) problems.push('Hay nombres vacíos'); if ( @@ -1117,6 +1243,12 @@ export class SettingsPage { problems.push('Falta un estado activo «Hecho»'); if (!d.issueTypes.some((t) => !t.archived)) problems.push('Hace falta al menos un tipo de ticket activo'); + for (const field of d.customFields) + if ( + field.type === CustomFieldType.SELECT && + !field.options.some((o) => o.trim()) + ) + problems.push(`El campo «${field.name}» necesita alguna opción`); return problems; }); @@ -1264,6 +1396,33 @@ export class SettingsPage { ); } + protected addField(): void { + this.patch((d) => + d.customFields.push({ + id: this.uniqueId(d.customFields, 'NUEVO_CAMPO'), + name: 'Nuevo campo', + type: CustomFieldType.TEXT, + options: [], + archived: false, + }), + ); + } + + protected setFieldType(index: number, type: CustomFieldType): void { + this.patch((d) => { + const field = d.customFields[index]; + field.type = type; + if (type !== CustomFieldType.SELECT) field.options = []; + }); + } + + /** One per line; blank lines and repeats go when saved. */ + protected setOptions(index: number, text: string): void { + this.patch((d) => { + d.customFields[index].options = text.split('\n').slice(0, 50); + }); + } + /** Only for values never saved; saved ones are archived instead. */ protected remove(section: Section, index: number): void { this.patch((d) => { @@ -1284,10 +1443,19 @@ export class SettingsPage { protected save(): void { const draft = this.draft(); if (!draft || !this.dirty() || this.problems().length) return; - this.facade.saveSettings({ + // Options as the API keeps them, so the draft is clean once saved. + const cleaned: ProjectSettings = { ...draft, + customFields: draft.customFields.map((f) => ({ + ...f, + options: [...new Set(f.options.map((o) => o.trim()).filter(Boolean))], + })), + }; + this.draft.set(cleaned); + this.facade.saveSettings({ + ...cleaned, // Only rows with targets; unknown ids would be rejected anyway. - transitions: draft.transitions.filter((t) => t.to.length), + transitions: cleaned.transitions.filter((t) => t.to.length), }); } diff --git a/libs/web/tracker/presenters/src/lib/ui/custom-field.ts b/libs/web/tracker/presenters/src/lib/ui/custom-field.ts new file mode 100644 index 0000000..e26ea36 --- /dev/null +++ b/libs/web/tracker/presenters/src/lib/ui/custom-field.ts @@ -0,0 +1,125 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + input, + linkedSignal, + output, +} from '@angular/core'; +import { + PiDateFieldComponent, + PiLabelDirective, + PiNumberFieldComponent, + PiSelectFieldComponent, + PiTextFieldComponent, + type PiSelectFieldOption, +} from '@pi/ui'; +import { + CustomFieldDefinition, + CustomFieldType, +} from '@task-stream/web/tracker/domain'; + +/** `YYYY-MM-DD` of a local date. */ +const dayOf = (date: Date) => + `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; + +/** + * One custom field of a ticket, with the control its type asks for. Lists + * and dates are saved when picked; text and numbers when leaving the field. + * Emits the new value (null clears it) only when it changed. + */ +@Component({ + selector: 'ts-custom-field', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + PiDateFieldComponent, + PiLabelDirective, + PiNumberFieldComponent, + PiSelectFieldComponent, + PiTextFieldComponent, + ], + template: ` + @switch (field().type) { + @case (types.SELECT) { + + {{ field().name }} + + } + @case (types.DATE) { + + {{ field().name }} + + } + @case (types.NUMBER) { + + {{ field().name }} + + } + @default { + + {{ field().name }} + + } + } + `, + styles: ` + :host { + display: block; + } + `, +}) +export class CustomFieldComponent { + readonly field = input.required(); + readonly value = input(null); + readonly disabled = input(false); + readonly valueChange = output(); + + protected readonly types = CustomFieldType; + protected readonly dayOf = dayOf; + + /** What is being typed; follows the saved value when it changes. */ + protected readonly text = linkedSignal(() => this.value() ?? ''); + protected readonly number = linkedSignal(() => { + const value = this.value(); + return value === null || value === '' ? null : Number(value); + }); + + protected readonly date = computed(() => { + const day = this.value(); + return day ? new Date(`${day.slice(0, 10)}T00:00:00`) : null; + }); + + /** The choices; one no longer offered still shows while the ticket has it. */ + protected readonly options = computed[]>(() => { + const current = this.value(); + const choices = this.field().options; + return [ + { value: '', label: 'Sin valor' }, + ...choices.map((o) => ({ value: o, label: o })), + ...(current && !choices.includes(current) + ? [{ value: current, label: current }] + : []), + ]; + }); + + protected commit(next: string | null): void { + if (next !== (this.value() || null)) this.valueChange.emit(next); + } +}