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:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user