feat: custom fields per project

Text, number, date or option-list fields defined in the project settings
(archived, never removed; their type is fixed once saved). Tickets hold
them, edited in the side panel, listed under the title and filtered by
option in the list view; also over GraphQL, REST (?field=ID:value) and MCP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 09:31:12 +02:00
parent 233c46e9fa
commit f7e850068b
44 changed files with 1909 additions and 96 deletions

View File

@@ -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');
});
});

View File

@@ -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",

View File

@@ -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

View File

@@ -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 */

View File

@@ -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<string, string | null>;
};
@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<string, string>;
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,

View File

@@ -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,
});

View File

@@ -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

View File

@@ -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
@@ -276,7 +281,7 @@ 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 |
@@ -286,7 +291,7 @@ visualización de la plataforma:
| `/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/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 |

View File

@@ -35,7 +35,7 @@ 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) |
@@ -50,10 +50,10 @@ flowchart LR
| | `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` |
| | `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… |
| | `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 |

View File

@@ -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).

Binary file not shown.

Binary file not shown.

View File

@@ -90,6 +90,7 @@ const FIELD: Record<string, string> = {
storyPoints: 'los puntos',
startDate: 'la fecha de inicio',
dueDate: 'la fecha límite',
customFields: 'los campos personalizados',
};
const list = (items: string[]) =>

View File

@@ -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),

View File

@@ -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,

View File

@@ -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<string, string>;
/** Only the ones the viewer follows (the query handler turns it into `watcherId`). */
watching?: boolean;
/** Only the ones this person follows. */

View File

@@ -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);
}

View File

@@ -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,

View File

@@ -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<string, string | number | null>;
}
/** What IssueUpdatedEvent carries: custom values as the whole new map. */
export type IssueChanges = Omit<IssueDetails, 'customFields'> & {
customFields?: Record<string, string>;
};
/** 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<string, string>;
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<string, string> = {};
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 })),
});
}

View File

@@ -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<string, string | number | null>) =>
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',
});
});
});
});

View File

@@ -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<string, string[]>;
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<string, string>,
changes: Record<string, string | number | null>,
): Record<string, string> {
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(

View File

@@ -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<string, string>;
links!: IssueLinkReadModel[];
attachments!: AttachmentReadModel[];
/** People following the ticket. */

View File

@@ -449,6 +449,10 @@ function toMongoFilter(filter: IssueFilter): FilterQuery<IssueDoc> {
// 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;

View File

@@ -199,6 +199,8 @@ class IssueDocument {
@Prop({ type: [SchemaFactory.createForClass(AttachmentProp)] })
attachments!: AttachmentProp[];
@Prop({ type: [String] }) labels!: string[];
/** Field id → value: filtered as `customFields.<id>`. */
@Prop({ type: Object }) customFields!: Record<string, string>;
@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) => ({

View File

@@ -1,10 +1,37 @@
import { IssueDetails } from '@task-stream/api/tracker/domain';
/** 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<string, string | number | null>,
): Record<string, string | number | null> {
return Array.isArray(values)
? Object.fromEntries(values.map((v) => [v.fieldId, v.value ?? null]))
: values;
}
type IssueInput = Partial<Omit<IssueDetails, 'customFields'>> & {
customFields?:
| CustomFieldValue[]
| Record<string, string | number | null>
| null;
};
/** Clients may send explicit nulls; the domain only accepts them on nullable fields. */
export function withoutNulls<T extends Partial<IssueDetails>>(input: T): T {
const details = { ...input };
for (const field of ['title', 'type', 'priority', 'labels'] as const) {
export function withoutNulls<T extends IssueInput>(
input: T,
): Omit<T, 'customFields'> & Pick<IssueDetails, 'customFields'> {
const { customFields, ...rest } = input;
const details: Record<string, unknown> = rest;
for (const field of ['title', 'type', 'priority', 'labels'])
if (details[field] === null) delete details[field];
}
return details;
return {
...(details as Omit<T, 'customFields'>),
...(customFields ? { customFields: customFieldsOf(customFields) } : {}),
};
}

View File

@@ -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 ?? [],
})),
}
: {}),
};
}

View File

@@ -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:

View File

@@ -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({

View File

@@ -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<string, string> | undefined {
const pairs = (values ?? []).filter((v) => v.value);
return pairs.length
? Object.fromEntries(pairs.map((v) => [v.fieldId, v.value as string]))
: undefined;
}

View File

@@ -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')

View File

@@ -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<string, string | number | null>;
}
export class CreateIssueDto extends IssueFieldsDto {

View File

@@ -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<string, string>;
@ApiProperty({ type: [IssueLinkDto] }) links!: IssueLinkDto[];
@ApiProperty({ type: [AttachmentDto] }) attachments!: AttachmentDto[];
@ApiProperty({ type: [UserRefDto], description: 'People following it' })

View File

@@ -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,

View File

@@ -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',

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,
})),
};
}

View File

@@ -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
}

View File

@@ -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<CustomFieldValueInput> | 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<string>;
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<string> | null | undefined;
assigneeId?: string | null | undefined;
categories?: Array<StatusCategory> | null | undefined;
/** Only tickets holding these custom values */
customFields?: Array<CustomFieldValueInput> | 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<AreaOptionInput>;
/** Absent: unchanged. Fields cannot be removed: archive them. */
customFields?: Array<CustomFieldDefinitionInput> | null | undefined;
issueTypes: Array<IssueTypeOptionInput>;
statuses: Array<WorkflowStatusInput>;
transitions: Array<StatusTransitionsInput>;
@@ -298,6 +322,8 @@ export type UpdateChecklistItemInput = {
export type UpdateIssueInput = {
/** An area id */
area?: string | null | undefined;
/** Only the fields to change */
customFields?: Array<CustomFieldValueInput> | 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<string> } | null;
}>;
areas: Array<{ id: string; name: string; archived: boolean }>;
customFields: Array<{
id: string;
name: string;
type: CustomFieldType;
options: Array<string>;
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<string>;
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<string>;
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<string>;
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<string>;
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<string>;
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' },
},
],
},
},
],
},
},

View File

@@ -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;
>
<pi-label>Área</pi-label>
</pi-select-field>
@for (field of customFields(); track field.id) {
<ts-custom-field
[field]="field"
[value]="customValue(issue, field.id)"
[disabled]="!project.canContribute() || field.archived"
(valueChange)="
update({ customFields: [{ fieldId: field.id, value: $event }] })
"
/>
}
@if (!issue.trashedAt && canTrash(issue)) {
<pi-action-button
@@ -1314,10 +1327,26 @@ export class IssuePage {
storyPoints?: number | null;
startDate?: string | null;
dueDate?: string | null;
customFields?: { fieldId: string; value: string | null }[];
}): void {
this.facade.update(changes);
}
/** The project's active fields, plus archived ones this ticket still has. */
protected readonly customFields = computed(() => {
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;

View File

@@ -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) {
<pi-select-field
[options]="field.options"
[value]="fieldFilters()[field.id] ?? ''"
(valueChange)="setFieldFilter(field.id, $event)"
/>
}
@if (facade.loaded()) {
<span class="ts-muted ts-mono ts-list__count">
{{ facade.total() }} tickets{{
@@ -204,6 +212,9 @@ const numberOf = (key: string) => Number(key.split('-').pop());
↑ {{ parent.key }}</span
>
}
@if (fieldsOf(issue); as fields) {
<span class="ts-list__fields ts-muted">{{ fields }}</span>
}
</td>
<td class="ts-list__type">
<span
@@ -384,6 +395,10 @@ const numberOf = (key: string) => 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<Record<string, string>>({});
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<string>[],
})),
);
/** «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<ListCriteria>(() => {
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,
};
});

View File

@@ -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 {
<div>
<h2>Configuración</h2>
<p>
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.
</p>
</div>
</div>
@@ -520,6 +529,92 @@ function idFrom(name: string): string {
}
</ol>
</section>
<!-- Custom fields -->
<section class="ts-panel ts-block" aria-labelledby="cf-title">
<header class="ts-block__head">
<div>
<h3 id="cf-title">Campos personalizados</h3>
<p class="ts-muted">
Datos propios de este proyecto en cada ticket (navegador,
cliente, coste…). El tipo no cambia una vez guardado.
</p>
</div>
<button
type="button"
class="ts-add"
[disabled]="d.customFields.length >= 30"
(click)="addField()"
>
+ Añadir campo
</button>
</header>
@if (!d.customFields.length) {
<p class="ts-muted ts-empty-fields">Ningún campo todavía.</p>
}
<ol class="ts-rows">
@for (field of d.customFields; track $index; let i = $index) {
<li
class="ts-row ts-field-grid"
[class.is-archived]="field.archived"
>
<span class="ts-name">
<input
class="ts-input"
maxlength="40"
[attr.aria-label]="'Nombre del campo ' + (i + 1)"
[value]="field.name"
(input)="
setName('customFields', i, $any($event.target).value)
"
/>
<code class="ts-id">{{ field.id }}</code>
</span>
<select
class="ts-input ts-field-type"
aria-label="Tipo de campo"
[disabled]="!isNew('customFields', field.id)"
[attr.title]="
isNew('customFields', field.id)
? null
: 'Ya guardado: el tipo no se puede cambiar'
"
(change)="setFieldType(i, $any($event.target).value)"
>
@for (t of fieldTypes; track t.id) {
<option [value]="t.id" [selected]="t.id === field.type">
{{ t.label }}
</option>
}
</select>
<ng-container
[ngTemplateOutlet]="archive"
[ngTemplateOutletContext]="{
section: 'customFields',
index: i,
item: field,
}"
/>
@if (field.type === 'SELECT') {
<label class="ts-field-options">
<span>Opciones (una por línea)</span>
<textarea
rows="3"
[attr.aria-label]="'Opciones de ' + field.name"
[value]="
field.options.join(
'
'
)
"
(input)="setOptions(i, $any($event.target).value)"
></textarea>
</label>
}
</li>
}
</ol>
</section>
</div>
</fieldset>
@@ -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),
});
}

View File

@@ -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) {
<pi-select-field
[options]="options()"
[disabled]="disabled()"
[value]="value() ?? ''"
(valueChange)="commit($event || null)"
>
<pi-label>{{ field().name }}</pi-label>
</pi-select-field>
}
@case (types.DATE) {
<pi-date-field
[disabled]="disabled()"
[value]="date()"
(valueChange)="commit($event ? dayOf($event) : null)"
>
<pi-label>{{ field().name }}</pi-label>
</pi-date-field>
}
@case (types.NUMBER) {
<pi-number-field
[disabled]="disabled()"
[(value)]="number"
(touch)="commit(number() === null ? null : '' + number())"
>
<pi-label>{{ field().name }}</pi-label>
</pi-number-field>
}
@default {
<pi-text-field
[disabled]="disabled()"
[(value)]="text"
(touch)="commit(text().trim() || null)"
>
<pi-label>{{ field().name }}</pi-label>
</pi-text-field>
}
}
`,
styles: `
:host {
display: block;
}
`,
})
export class CustomFieldComponent {
readonly field = input.required<CustomFieldDefinition>();
readonly value = input<string | null>(null);
readonly disabled = input(false);
readonly valueChange = output<string | null>();
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<PiSelectFieldOption<string>[]>(() => {
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);
}
}