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