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),
|
history.body.map((h: { actor: { name: string } }) => h.actor.name),
|
||||||
).toContain('TaskStream');
|
).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"
|
"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",
|
"name": "sort",
|
||||||
"required": false,
|
"required": false,
|
||||||
@@ -10460,6 +10475,54 @@
|
|||||||
"archived"
|
"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": {
|
"ProjectSettingsDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -10487,13 +10550,20 @@
|
|||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/components/schemas/AreaOptionDto"
|
"$ref": "#/components/schemas/AreaOptionDto"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"customFields": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/CustomFieldDefinitionDto"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"statuses",
|
"statuses",
|
||||||
"transitions",
|
"transitions",
|
||||||
"issueTypes",
|
"issueTypes",
|
||||||
"areas"
|
"areas",
|
||||||
|
"customFields"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"ProjectDto": {
|
"ProjectDto": {
|
||||||
@@ -10799,6 +10869,50 @@
|
|||||||
"name"
|
"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": {
|
"ProjectSettingsBodyDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -10826,6 +10940,13 @@
|
|||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/components/schemas/AreaOptionBodyDto"
|
"$ref": "#/components/schemas/AreaOptionBodyDto"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"customFields": {
|
||||||
|
"description": "Absent: unchanged. Fields cannot be removed: archive them.",
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/CustomFieldDefinitionBodyDto"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -11216,6 +11337,16 @@
|
|||||||
"nullable": true,
|
"nullable": true,
|
||||||
"format": "date"
|
"format": "date"
|
||||||
},
|
},
|
||||||
|
"customFields": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"BROWSER": "Safari"
|
||||||
|
},
|
||||||
|
"description": "Custom field id → value (only those set)"
|
||||||
|
},
|
||||||
"links": {
|
"links": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
@@ -11321,6 +11452,7 @@
|
|||||||
"storyPoints",
|
"storyPoints",
|
||||||
"startDate",
|
"startDate",
|
||||||
"dueDate",
|
"dueDate",
|
||||||
|
"customFields",
|
||||||
"links",
|
"links",
|
||||||
"attachments",
|
"attachments",
|
||||||
"watchers",
|
"watchers",
|
||||||
@@ -11823,6 +11955,17 @@
|
|||||||
"nullable": true,
|
"nullable": true,
|
||||||
"format": "date"
|
"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": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"example": "BUG",
|
"example": "BUG",
|
||||||
@@ -11950,6 +12093,17 @@
|
|||||||
"nullable": true,
|
"nullable": true,
|
||||||
"format": "date"
|
"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": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"example": "FEATURE",
|
"example": "FEATURE",
|
||||||
|
|||||||
@@ -279,6 +279,9 @@ input CreateIssueInput {
|
|||||||
"""An area id"""
|
"""An area id"""
|
||||||
area: String
|
area: String
|
||||||
assigneeId: ID
|
assigneeId: ID
|
||||||
|
|
||||||
|
"""Only the fields to change"""
|
||||||
|
customFields: [CustomFieldValueInput!]
|
||||||
description: String
|
description: String
|
||||||
dueDate: String
|
dueDate: String
|
||||||
|
|
||||||
@@ -352,6 +355,47 @@ type CumulativeFlow {
|
|||||||
series: [FlowSeries!]!
|
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"""
|
"""A commit, branch or pull request that mentions the ticket"""
|
||||||
type DevLink {
|
type DevLink {
|
||||||
at: String!
|
at: String!
|
||||||
@@ -443,6 +487,9 @@ type Issue {
|
|||||||
commentCount: Int!
|
commentCount: Int!
|
||||||
comments(limit: Int! = 50, offset: Int! = 0): CommentPage!
|
comments(limit: Int! = 50, offset: Int! = 0): CommentPage!
|
||||||
createdAt: String!
|
createdAt: String!
|
||||||
|
|
||||||
|
"""Values of the project's custom fields (only those set)"""
|
||||||
|
customFields: [CustomFieldValue!]!
|
||||||
description: String
|
description: String
|
||||||
|
|
||||||
"""Commits, branches and pull requests that mention it"""
|
"""Commits, branches and pull requests that mention it"""
|
||||||
@@ -516,6 +563,9 @@ input IssueFilterInput {
|
|||||||
assigneeId: ID
|
assigneeId: ID
|
||||||
categories: [StatusCategory!]
|
categories: [StatusCategory!]
|
||||||
|
|
||||||
|
"""Only tickets holding these custom values"""
|
||||||
|
customFields: [CustomFieldValueInput!]
|
||||||
|
|
||||||
"""Due on or after this day (YYYY-MM-DD)"""
|
"""Due on or after this day (YYYY-MM-DD)"""
|
||||||
dueFrom: String
|
dueFrom: String
|
||||||
|
|
||||||
@@ -961,9 +1011,12 @@ enum ProjectRole {
|
|||||||
VIEWER
|
VIEWER
|
||||||
}
|
}
|
||||||
|
|
||||||
"""Statuses (board order), transitions, ticket types and areas"""
|
"""
|
||||||
|
Statuses (board order), transitions, ticket types, areas and custom fields
|
||||||
|
"""
|
||||||
type ProjectSettings {
|
type ProjectSettings {
|
||||||
areas: [AreaOption!]!
|
areas: [AreaOption!]!
|
||||||
|
customFields: [CustomFieldDefinition!]!
|
||||||
issueTypes: [IssueTypeOption!]!
|
issueTypes: [IssueTypeOption!]!
|
||||||
statuses: [WorkflowStatus!]!
|
statuses: [WorkflowStatus!]!
|
||||||
transitions: [StatusTransitions!]!
|
transitions: [StatusTransitions!]!
|
||||||
@@ -974,6 +1027,9 @@ The whole configuration (replaces the previous one). Statuses go in board order.
|
|||||||
"""
|
"""
|
||||||
input ProjectSettingsInput {
|
input ProjectSettingsInput {
|
||||||
areas: [AreaOptionInput!]!
|
areas: [AreaOptionInput!]!
|
||||||
|
|
||||||
|
"""Absent: unchanged. Fields cannot be removed: archive them."""
|
||||||
|
customFields: [CustomFieldDefinitionInput!]
|
||||||
issueTypes: [IssueTypeOptionInput!]!
|
issueTypes: [IssueTypeOptionInput!]!
|
||||||
statuses: [WorkflowStatusInput!]!
|
statuses: [WorkflowStatusInput!]!
|
||||||
transitions: [StatusTransitionsInput!]!
|
transitions: [StatusTransitionsInput!]!
|
||||||
@@ -1327,6 +1383,9 @@ input UpdateChecklistItemInput {
|
|||||||
input UpdateIssueInput {
|
input UpdateIssueInput {
|
||||||
"""An area id"""
|
"""An area id"""
|
||||||
area: String
|
area: String
|
||||||
|
|
||||||
|
"""Only the fields to change"""
|
||||||
|
customFields: [CustomFieldValueInput!]
|
||||||
description: String
|
description: String
|
||||||
dueDate: String
|
dueDate: String
|
||||||
|
|
||||||
|
|||||||
@@ -1666,12 +1666,29 @@ export interface components {
|
|||||||
name: string;
|
name: string;
|
||||||
archived: boolean;
|
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: {
|
ProjectSettingsDto: {
|
||||||
/** @description In board order */
|
/** @description In board order */
|
||||||
statuses: components["schemas"]["WorkflowStatusDto"][];
|
statuses: components["schemas"]["WorkflowStatusDto"][];
|
||||||
transitions: components["schemas"]["StatusTransitionsDto"][];
|
transitions: components["schemas"]["StatusTransitionsDto"][];
|
||||||
issueTypes: components["schemas"]["IssueTypeOptionDto"][];
|
issueTypes: components["schemas"]["IssueTypeOptionDto"][];
|
||||||
areas: components["schemas"]["AreaOptionDto"][];
|
areas: components["schemas"]["AreaOptionDto"][];
|
||||||
|
customFields: components["schemas"]["CustomFieldDefinitionDto"][];
|
||||||
};
|
};
|
||||||
ProjectDto: {
|
ProjectDto: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
@@ -1788,12 +1805,33 @@ export interface components {
|
|||||||
/** @default false */
|
/** @default false */
|
||||||
archived: boolean;
|
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: {
|
ProjectSettingsBodyDto: {
|
||||||
/** @description In board order. Values cannot be removed: archive them instead. */
|
/** @description In board order. Values cannot be removed: archive them instead. */
|
||||||
statuses: components["schemas"]["WorkflowStatusBodyDto"][];
|
statuses: components["schemas"]["WorkflowStatusBodyDto"][];
|
||||||
transitions: components["schemas"]["StatusTransitionsBodyDto"][];
|
transitions: components["schemas"]["StatusTransitionsBodyDto"][];
|
||||||
issueTypes: components["schemas"]["IssueTypeOptionBodyDto"][];
|
issueTypes: components["schemas"]["IssueTypeOptionBodyDto"][];
|
||||||
areas: components["schemas"]["AreaOptionBodyDto"][];
|
areas: components["schemas"]["AreaOptionBodyDto"][];
|
||||||
|
/** @description Absent: unchanged. Fields cannot be removed: archive them. */
|
||||||
|
customFields?: components["schemas"]["CustomFieldDefinitionBodyDto"][];
|
||||||
};
|
};
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
IssuePriority: "LOWEST" | "LOW" | "MEDIUM" | "HIGH" | "CRITICAL";
|
IssuePriority: "LOWEST" | "LOW" | "MEDIUM" | "HIGH" | "CRITICAL";
|
||||||
@@ -1930,6 +1968,15 @@ export interface components {
|
|||||||
startDate: string | null;
|
startDate: string | null;
|
||||||
/** Format: date */
|
/** Format: date */
|
||||||
dueDate: string | null;
|
dueDate: string | null;
|
||||||
|
/**
|
||||||
|
* @description Custom field id → value (only those set)
|
||||||
|
* @example {
|
||||||
|
* "BROWSER": "Safari"
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
customFields: {
|
||||||
|
[key: string]: string;
|
||||||
|
};
|
||||||
links: components["schemas"]["IssueLinkDto"][];
|
links: components["schemas"]["IssueLinkDto"][];
|
||||||
attachments: components["schemas"]["AttachmentDto"][];
|
attachments: components["schemas"]["AttachmentDto"][];
|
||||||
/** @description People following it */
|
/** @description People following it */
|
||||||
@@ -2098,6 +2145,15 @@ export interface components {
|
|||||||
startDate?: string | null;
|
startDate?: string | null;
|
||||||
/** Format: date */
|
/** Format: date */
|
||||||
dueDate?: string | null;
|
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
|
* @description A ticket type id of the project's settings
|
||||||
* @example BUG
|
* @example BUG
|
||||||
@@ -2146,6 +2202,15 @@ export interface components {
|
|||||||
startDate?: string | null;
|
startDate?: string | null;
|
||||||
/** Format: date */
|
/** Format: date */
|
||||||
dueDate?: string | null;
|
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
|
* @description A ticket type id
|
||||||
* @example FEATURE
|
* @example FEATURE
|
||||||
@@ -4365,6 +4430,8 @@ export interface operations {
|
|||||||
dueFrom?: string;
|
dueFrom?: string;
|
||||||
/** @description Due on or before this day */
|
/** @description Due on or before this day */
|
||||||
dueTo?: string;
|
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. */
|
/** @description RECENT (default): last updated first. RANK: backlog order. */
|
||||||
sort?: components["schemas"]["IssueSort"];
|
sort?: components["schemas"]["IssueSort"];
|
||||||
/** @description Matches key, title or description */
|
/** @description Matches key, title or description */
|
||||||
|
|||||||
@@ -46,6 +46,12 @@ const issueFields = {
|
|||||||
.optional()
|
.optional()
|
||||||
.describe('ISO date when work should start (roadmap)'),
|
.describe('ISO date when work should start (roadmap)'),
|
||||||
dueDate: z.string().optional().describe('ISO date, e.g. 2026-11-14'),
|
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 = {
|
type IssueFields = {
|
||||||
@@ -57,6 +63,7 @@ type IssueFields = {
|
|||||||
storyPoints?: number;
|
storyPoints?: number;
|
||||||
startDate?: string;
|
startDate?: string;
|
||||||
dueDate?: string;
|
dueDate?: string;
|
||||||
|
customFields?: Record<string, string | null>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@Resolver()
|
@Resolver()
|
||||||
@@ -142,6 +149,12 @@ export class IssuesResolver {
|
|||||||
.describe('Only the tickets the user follows'),
|
.describe('Only the tickets the user follows'),
|
||||||
dueFrom: z.string().optional().describe('Due on or after (ISO date)'),
|
dueFrom: z.string().optional().describe('Due on or after (ISO date)'),
|
||||||
dueTo: z.string().optional().describe('Due on or before (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
|
sort: z
|
||||||
.enum(['RECENT', 'RANK'])
|
.enum(['RECENT', 'RANK'])
|
||||||
.default('RECENT')
|
.default('RECENT')
|
||||||
@@ -170,6 +183,7 @@ export class IssuesResolver {
|
|||||||
watching?: boolean;
|
watching?: boolean;
|
||||||
dueFrom?: string;
|
dueFrom?: string;
|
||||||
dueTo?: string;
|
dueTo?: string;
|
||||||
|
customFields?: Record<string, string>;
|
||||||
sort: 'RECENT' | 'RANK';
|
sort: 'RECENT' | 'RANK';
|
||||||
limit: number;
|
limit: number;
|
||||||
offset: number;
|
offset: number;
|
||||||
@@ -198,6 +212,11 @@ export class IssuesResolver {
|
|||||||
watching: params.watching,
|
watching: params.watching,
|
||||||
dueFrom: params.dueFrom,
|
dueFrom: params.dueFrom,
|
||||||
dueTo: params.dueTo,
|
dueTo: params.dueTo,
|
||||||
|
field: params.customFields
|
||||||
|
? Object.entries(params.customFields).map(
|
||||||
|
([id, value]) => `${id}:${value}`,
|
||||||
|
)
|
||||||
|
: undefined,
|
||||||
sort: params.sort,
|
sort: params.sort,
|
||||||
limit: params.limit,
|
limit: params.limit,
|
||||||
offset: params.offset,
|
offset: params.offset,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export const issueLine = (issue: Issue) => ({
|
|||||||
votes: issue.votes,
|
votes: issue.votes,
|
||||||
storyPoints: issue.storyPoints,
|
storyPoints: issue.storyPoints,
|
||||||
dueDate: issue.dueDate,
|
dueDate: issue.dueDate,
|
||||||
|
customFields: issue.customFields,
|
||||||
labels: issue.labels,
|
labels: issue.labels,
|
||||||
updatedAt: issue.updatedAt,
|
updatedAt: issue.updatedAt,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -82,7 +82,8 @@ store como auditoría, pero desde la API no se puede deshacer.
|
|||||||
|
|
||||||
### Configuración por proyecto
|
### 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
|
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`).
|
`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
|
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.
|
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
|
### Workflow por defecto
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
|
|||||||
@@ -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
|
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.
|
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
|
### Gráficas
|
||||||
|
|
||||||
Las de Estadísticas son SVG propios (`ui/series-chart.ts`), no `pi-line-chart`: hacen falta áreas apiladas y una
|
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
|
## 4.6 Rutas
|
||||||
|
|
||||||
| Ruta | Página |
|
| Ruta | Página |
|
||||||
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `/login` | Inicio de sesión (solo sin sesión) |
|
| `/login` | Inicio de sesión (solo sin sesión) |
|
||||||
| `/auth/callback` | Retorno de Pireon (PKCE) |
|
| `/auth/callback` | Retorno de Pireon (PKCE) |
|
||||||
| `/` | Inicio: «Ir a…» (`WEB-12`), mi trabajo (`ts-my-work`) y mis proyectos |
|
| `/` | 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/releases` | Releases |
|
||||||
| `/p/:key/dashboard` | Seguimiento: cifras, flujo por estado, equipo (cada persona enlaza a su tablero) y tipos |
|
| `/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/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/activity` | Actividad en vivo |
|
||||||
| `/p/:key/members` | Miembros |
|
| `/p/:key/members` | Miembros |
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ flowchart LR
|
|||||||
57 herramientas, agrupadas por ámbito:
|
57 herramientas, agrupadas por ámbito:
|
||||||
|
|
||||||
| Ámbito | Herramienta | Qué hace |
|
| Ámbito | Herramienta | Qué hace |
|
||||||
| ------------ | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------- |
|
| ------------ | ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------- |
|
||||||
| Usuario | `whoami` | El usuario del token |
|
| Usuario | `whoami` | El usuario del token |
|
||||||
| | `search_users` | Buscar personas de Pireon (para asignar) |
|
| | `search_users` | Buscar personas de Pireon (para asignar) |
|
||||||
| | `my_notifications` | Mis notificaciones (por defecto, las no leídas) |
|
| | `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_analytics` | Estadísticas de los últimos días (creados/resueltos, flujo, tiempos, throughput, QA) |
|
||||||
| | `get_project_activity` | Actividad reciente |
|
| | `get_project_activity` | Actividad reciente |
|
||||||
| Tickets | `my_issues` | Mi trabajo en todos los proyectos (o de cualquiera, por categoría; o los que sigo) |
|
| 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 |
|
| | `get_issue` | Detalle: épica, lista de comprobación, relaciones, commits, seguidores, transiciones, comentarios |
|
||||||
| | `create_issue` | Crear un ticket (también colgado de una épica) |
|
| | `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) |
|
| | `change_issue_status` | Mover de estado (con motivo cuando el estado lo pide) |
|
||||||
| | `assign_issue` | Asignar o desasignar |
|
| | `assign_issue` | Asignar o desasignar |
|
||||||
| | `set_issue_release` | Meter en una release o sacar |
|
| | `set_issue_release` | Meter en una release o sacar |
|
||||||
|
|||||||
@@ -28,14 +28,7 @@ cuando hay pistas, por dónde empezar.
|
|||||||
|
|
||||||
## 10.3 Siguientes funcionalidades
|
## 10.3 Siguientes funcionalidades
|
||||||
|
|
||||||
### Elegidas para la próxima tanda
|
### En curso, en este orden
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
4. **Dependencias en la hoja de ruta**: las relaciones «bloquea» / «depende de» entre épicas, como flechas en la vista
|
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é.
|
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
|
- Calendario suscribible (iCalendar) desde «Mi trabajo»: tickets con fecha, sprints y releases, con URL secreta
|
||||||
renovable.
|
renovable.
|
||||||
- Notificaciones push (Web Push) en cada dispositivo, con la web instalable.
|
- 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.
@@ -90,6 +90,7 @@ const FIELD: Record<string, string> = {
|
|||||||
storyPoints: 'los puntos',
|
storyPoints: 'los puntos',
|
||||||
startDate: 'la fecha de inicio',
|
startDate: 'la fecha de inicio',
|
||||||
dueDate: 'la fecha límite',
|
dueDate: 'la fecha límite',
|
||||||
|
customFields: 'los campos personalizados',
|
||||||
};
|
};
|
||||||
|
|
||||||
const list = (items: string[]) =>
|
const list = (items: string[]) =>
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export class IssueCreatedEventHandler
|
|||||||
storyPoints: issue.storyPoints,
|
storyPoints: issue.storyPoints,
|
||||||
startDate: issue.startDate ?? null,
|
startDate: issue.startDate ?? null,
|
||||||
dueDate: issue.dueDate,
|
dueDate: issue.dueDate,
|
||||||
|
customFields: issue.customFields ?? {},
|
||||||
createdAt: issue.createdAt,
|
createdAt: issue.createdAt,
|
||||||
updatedAt: issue.updatedAt,
|
updatedAt: issue.updatedAt,
|
||||||
version: versionOf(event),
|
version: versionOf(event),
|
||||||
|
|||||||
@@ -355,6 +355,7 @@ export class IssuesService {
|
|||||||
storyPoints: issue.storyPoints,
|
storyPoints: issue.storyPoints,
|
||||||
startDate: issue.startDate,
|
startDate: issue.startDate,
|
||||||
dueDate: issue.dueDate,
|
dueDate: issue.dueDate,
|
||||||
|
customFields: issue.customFields,
|
||||||
parentId: issue.parentId,
|
parentId: issue.parentId,
|
||||||
// The original's text is the content, not the type's template.
|
// The original's text is the content, not the type's template.
|
||||||
useTemplate: false,
|
useTemplate: false,
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ export interface IssueFilter {
|
|||||||
dueTo?: string;
|
dueTo?: string;
|
||||||
/** Only tickets in no sprint (wins over `sprintId`). */
|
/** Only tickets in no sprint (wins over `sprintId`). */
|
||||||
noSprint?: boolean;
|
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`). */
|
/** Only the ones the viewer follows (the query handler turns it into `watcherId`). */
|
||||||
watching?: boolean;
|
watching?: boolean;
|
||||||
/** Only the ones this person follows. */
|
/** Only the ones this person follows. */
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { AutowiredEvent } from '@task-stream/api/shared/domain';
|
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';
|
import { TrackerEvent } from './tracker.event';
|
||||||
|
|
||||||
/** Editable fields of a ticket changed. */
|
/** Editable fields of a ticket changed. */
|
||||||
@@ -9,7 +9,7 @@ export class IssueUpdatedEvent extends TrackerEvent {
|
|||||||
actorId: string,
|
actorId: string,
|
||||||
issueId: string,
|
issueId: string,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
readonly changes: IssueDetails,
|
readonly changes: IssueChanges,
|
||||||
) {
|
) {
|
||||||
super(actorId, issueId, projectId);
|
super(actorId, issueId, projectId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ export class IssueFactory {
|
|||||||
storyPoints: details.storyPoints ?? null,
|
storyPoints: details.storyPoints ?? null,
|
||||||
startDate: details.startDate ?? null,
|
startDate: details.startDate ?? null,
|
||||||
dueDate: details.dueDate ?? null,
|
dueDate: details.dueDate ?? null,
|
||||||
|
customFields: props.customFields
|
||||||
|
? workflow.customValues({}, props.customFields)
|
||||||
|
: {},
|
||||||
links: [],
|
links: [],
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
|
|||||||
@@ -63,8 +63,18 @@ export interface IssueDetails {
|
|||||||
/** When work on it should start (roadmap); `dueDate` is when it should end. */
|
/** When work on it should start (roadmap); `dueDate` is when it should end. */
|
||||||
startDate?: string | null;
|
startDate?: string | null;
|
||||||
dueDate?: 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). */
|
/** Data of a ticket (what IssueCreatedEvent carries). */
|
||||||
export interface IssueProps {
|
export interface IssueProps {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -86,6 +96,8 @@ export interface IssueProps {
|
|||||||
storyPoints: number | null;
|
storyPoints: number | null;
|
||||||
startDate?: string | null;
|
startDate?: string | null;
|
||||||
dueDate: string | null;
|
dueDate: string | null;
|
||||||
|
/** Custom field id → value; absent in tickets created before them. */
|
||||||
|
customFields?: Record<string, string>;
|
||||||
links: IssueLink[];
|
links: IssueLink[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
@@ -113,6 +125,8 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
|
|||||||
public storyPoints: number | null = null;
|
public storyPoints: number | null = null;
|
||||||
public startDate: string | null = null;
|
public startDate: string | null = null;
|
||||||
public dueDate: 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 links: IssueLink[] = [];
|
||||||
public attachments: IssueAttachment[] = [];
|
public attachments: IssueAttachment[] = [];
|
||||||
/** People who asked to be told about this ticket. */
|
/** People who asked to be told about this ticket. */
|
||||||
@@ -144,9 +158,17 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
|
|||||||
changes: IssueDetails,
|
changes: IssueDetails,
|
||||||
workflow: ProjectWorkflow = ProjectWorkflow.default(),
|
workflow: ProjectWorkflow = ProjectWorkflow.default(),
|
||||||
): void {
|
): void {
|
||||||
const next = normalizeIssueDetails(changes);
|
const { customFields: customChanges, ...rest } = changes;
|
||||||
const patch: IssueDetails = {};
|
const { customFields: _none, ...normalized } = normalizeIssueDetails(rest);
|
||||||
for (const field of Object.keys(next) as (keyof IssueDetails)[]) {
|
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];
|
const value = next[field];
|
||||||
if (value === undefined) continue;
|
if (value === undefined) continue;
|
||||||
if (JSON.stringify(value) === JSON.stringify(this[field])) continue;
|
if (JSON.stringify(value) === JSON.stringify(this[field])) continue;
|
||||||
@@ -524,6 +546,7 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
|
|||||||
Object.assign(this, {
|
Object.assign(this, {
|
||||||
...issue,
|
...issue,
|
||||||
labels: [...issue.labels],
|
labels: [...issue.labels],
|
||||||
|
customFields: { ...(issue.customFields ?? {}) },
|
||||||
links: issue.links.map((l) => ({ ...l })),
|
links: issue.links.map((l) => ({ ...l })),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,12 @@ import { IssueStatusChangedEvent } from '../events/issue-status-changed.event';
|
|||||||
import { IssueFactory } from '../factories/issue.factory';
|
import { IssueFactory } from '../factories/issue.factory';
|
||||||
import { ProjectFactory } from '../factories/project.factory';
|
import { ProjectFactory } from '../factories/project.factory';
|
||||||
import { replay } from '../testing';
|
import { replay } from '../testing';
|
||||||
|
import { Issue } from '../issue/issue';
|
||||||
import { Project } from './project';
|
import { Project } from './project';
|
||||||
import { ProjectRole } from './project-role';
|
import { ProjectRole } from './project-role';
|
||||||
|
import { IssueUpdatedEvent } from '../events/issue-updated.event';
|
||||||
import {
|
import {
|
||||||
|
CustomFieldType,
|
||||||
defaultProjectSettings,
|
defaultProjectSettings,
|
||||||
ProjectSettings,
|
ProjectSettings,
|
||||||
StatusCategory,
|
StatusCategory,
|
||||||
@@ -203,4 +206,151 @@ describe('Project settings', () => {
|
|||||||
),
|
),
|
||||||
).toThrow(/Unknown ticket type/);
|
).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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -94,6 +94,33 @@ export interface AreaOption {
|
|||||||
archived: boolean;
|
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. */
|
/** Per-project configuration of the tracker. */
|
||||||
export interface ProjectSettings {
|
export interface ProjectSettings {
|
||||||
/** In board order. New tickets start in the first active TODO status. */
|
/** In board order. New tickets start in the first active TODO status. */
|
||||||
@@ -102,6 +129,11 @@ export interface ProjectSettings {
|
|||||||
transitions: Record<string, string[]>;
|
transitions: Record<string, string[]>;
|
||||||
issueTypes: IssueTypeOption[];
|
issueTypes: IssueTypeOption[];
|
||||||
areas: AreaOption[];
|
areas: AreaOption[];
|
||||||
|
/**
|
||||||
|
* Absent in projects that never defined any. When saving, absent means
|
||||||
|
* unchanged (clients that predate them keep them).
|
||||||
|
*/
|
||||||
|
customFields?: CustomFieldDefinition[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const status = (
|
const status = (
|
||||||
@@ -291,6 +323,41 @@ export class ProjectWorkflow {
|
|||||||
throw new BusinessRuleError(`Ticket type ${type.name} is archived`);
|
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 {
|
assertActiveArea(id: string | null | undefined): void {
|
||||||
if (id === null || id === undefined) return;
|
if (id === null || id === undefined) return;
|
||||||
const area = this.settings.areas.find((a) => a.id === id);
|
const area = this.settings.areas.find((a) => a.id === id);
|
||||||
@@ -336,11 +403,26 @@ export function validateProjectSettings(
|
|||||||
name: normName(a.name),
|
name: normName(a.name),
|
||||||
archived: !!a.archived,
|
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 [
|
for (const [label, list, before] of [
|
||||||
['estado', statuses, previous.statuses],
|
['estado', statuses, previous.statuses],
|
||||||
['tipo', issueTypes, previous.issueTypes],
|
['tipo', issueTypes, previous.issueTypes],
|
||||||
['área', areas, previous.areas],
|
['área', areas, previous.areas],
|
||||||
|
['campo', customFields, previous.customFields ?? []],
|
||||||
] as const) {
|
] as const) {
|
||||||
const ids = list.map((item) => item.id);
|
const ids = list.map((item) => item.id);
|
||||||
const duplicate = ids.find((id, i) => ids.indexOf(id) !== i);
|
const duplicate = ids.find((id, i) => ids.indexOf(id) !== i);
|
||||||
@@ -414,7 +496,69 @@ export function validateProjectSettings(
|
|||||||
if (to.length) transitions[source] = to;
|
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(
|
function normTemplate(
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ export class IssueReadModel {
|
|||||||
storyPoints!: number | null;
|
storyPoints!: number | null;
|
||||||
startDate!: string | null;
|
startDate!: string | null;
|
||||||
dueDate!: string | null;
|
dueDate!: string | null;
|
||||||
|
/** The project's custom fields: field id → value (only those with one). */
|
||||||
|
customFields!: Record<string, string>;
|
||||||
links!: IssueLinkReadModel[];
|
links!: IssueLinkReadModel[];
|
||||||
attachments!: AttachmentReadModel[];
|
attachments!: AttachmentReadModel[];
|
||||||
/** People following the ticket. */
|
/** People following the ticket. */
|
||||||
|
|||||||
@@ -449,6 +449,10 @@ function toMongoFilter(filter: IssueFilter): FilterQuery<IssueDoc> {
|
|||||||
// Full ISO dates on the last day still count.
|
// Full ISO dates on the last day still count.
|
||||||
...(filter.dueTo ? { $lte: `${filter.dueTo}\uffff` } : {}),
|
...(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;
|
if (filter.noSprint) query['sprint'] = null;
|
||||||
else if (filter.sprintId) query['sprint.id'] = filter.sprintId;
|
else if (filter.sprintId) query['sprint.id'] = filter.sprintId;
|
||||||
if (filter.unassigned) query['assignee'] = null;
|
if (filter.unassigned) query['assignee'] = null;
|
||||||
|
|||||||
@@ -199,6 +199,8 @@ class IssueDocument {
|
|||||||
@Prop({ type: [SchemaFactory.createForClass(AttachmentProp)] })
|
@Prop({ type: [SchemaFactory.createForClass(AttachmentProp)] })
|
||||||
attachments!: AttachmentProp[];
|
attachments!: AttachmentProp[];
|
||||||
@Prop({ type: [String] }) labels!: string[];
|
@Prop({ type: [String] }) labels!: string[];
|
||||||
|
/** Field id → value: filtered as `customFields.<id>`. */
|
||||||
|
@Prop({ type: Object }) customFields!: Record<string, string>;
|
||||||
@Prop({ type: Number }) version!: number;
|
@Prop({ type: Number }) version!: number;
|
||||||
}
|
}
|
||||||
export const IssueSchema = SchemaFactory.createForClass(IssueDocument);
|
export const IssueSchema = SchemaFactory.createForClass(IssueDocument);
|
||||||
@@ -413,6 +415,7 @@ export function issueFromDoc(doc: IssueDoc): IssueReadModel {
|
|||||||
parent: rest.parent?.id ? rest.parent : null,
|
parent: rest.parent?.id ? rest.parent : null,
|
||||||
rank: rest.rank ?? implicitRank(rest.number),
|
rank: rest.rank ?? implicitRank(rest.number),
|
||||||
startDate: rest.startDate ?? null,
|
startDate: rest.startDate ?? null,
|
||||||
|
customFields: rest.customFields ?? {},
|
||||||
sprint: rest.sprint?.id ? rest.sprint : null,
|
sprint: rest.sprint?.id ? rest.sprint : null,
|
||||||
// Links recorded before projects had several repositories name none.
|
// Links recorded before projects had several repositories name none.
|
||||||
devLinks: (rest.devLinks ?? []).map((link) => ({
|
devLinks: (rest.devLinks ?? []).map((link) => ({
|
||||||
|
|||||||
@@ -1,10 +1,37 @@
|
|||||||
import { IssueDetails } from '@task-stream/api/tracker/domain';
|
import { IssueDetails } from '@task-stream/api/tracker/domain';
|
||||||
|
|
||||||
/** Clients may send explicit nulls; the domain only accepts them on nullable fields. */
|
/** A custom value as GraphQL carries it (a map is not a GraphQL type). */
|
||||||
export function withoutNulls<T extends Partial<IssueDetails>>(input: T): T {
|
export interface CustomFieldValue {
|
||||||
const details = { ...input };
|
fieldId: string;
|
||||||
for (const field of ['title', 'type', 'priority', 'labels'] as const) {
|
value?: string | null;
|
||||||
if (details[field] === null) delete details[field];
|
}
|
||||||
}
|
|
||||||
return details;
|
/** 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 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 as Omit<T, 'customFields'>),
|
||||||
|
...(customFields ? { customFields: customFieldsOf(customFields) } : {}),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
CustomFieldType,
|
||||||
ProjectSettings,
|
ProjectSettings,
|
||||||
SettingColor,
|
SettingColor,
|
||||||
StatusCategory,
|
StatusCategory,
|
||||||
@@ -26,11 +27,20 @@ export interface SettingsPayload {
|
|||||||
template?: { description?: string | null; checklist?: string[] } | null;
|
template?: { description?: string | null; checklist?: string[] } | null;
|
||||||
}[];
|
}[];
|
||||||
areas: { id: string; name: string; archived: boolean }[];
|
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 {
|
export function toSettingsPayload(settings: ProjectSettings): SettingsPayload {
|
||||||
return {
|
return {
|
||||||
...settings,
|
...settings,
|
||||||
|
customFields: settings.customFields ?? [],
|
||||||
transitions: settings.statuses.map((s) => ({
|
transitions: settings.statuses.map((s) => ({
|
||||||
from: s.id,
|
from: s.id,
|
||||||
to: settings.transitions[s.id] ?? [],
|
to: settings.transitions[s.id] ?? [],
|
||||||
@@ -62,5 +72,14 @@ export function fromSettingsPayload(payload: SettingsPayload): ProjectSettings {
|
|||||||
: {}),
|
: {}),
|
||||||
})),
|
})),
|
||||||
areas: payload.areas,
|
areas: payload.areas,
|
||||||
|
...(payload.customFields
|
||||||
|
? {
|
||||||
|
customFields: payload.customFields.map((f) => ({
|
||||||
|
...f,
|
||||||
|
type: f.type as CustomFieldType,
|
||||||
|
options: f.options ?? [],
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
IssueSort,
|
IssueSort,
|
||||||
} from '@task-stream/api/tracker/application';
|
} from '@task-stream/api/tracker/application';
|
||||||
import {
|
import {
|
||||||
|
CustomFieldType,
|
||||||
IssueLinkType,
|
IssueLinkType,
|
||||||
IssuePriority,
|
IssuePriority,
|
||||||
NotificationKind,
|
NotificationKind,
|
||||||
@@ -22,6 +23,10 @@ export enum LinkDirection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
registerEnumType(IssuePriority, { name: 'IssuePriority' });
|
registerEnumType(IssuePriority, { name: 'IssuePriority' });
|
||||||
|
registerEnumType(CustomFieldType, {
|
||||||
|
name: 'CustomFieldType',
|
||||||
|
description: 'What a custom field holds (DATE: YYYY-MM-DD)',
|
||||||
|
});
|
||||||
registerEnumType(StatusCategory, {
|
registerEnumType(StatusCategory, {
|
||||||
name: 'StatusCategory',
|
name: 'StatusCategory',
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Field, ID, InputType, Int } from '@nestjs/graphql';
|
import { Field, ID, InputType, Int } from '@nestjs/graphql';
|
||||||
import {
|
import {
|
||||||
|
CustomFieldType,
|
||||||
IssueLinkType,
|
IssueLinkType,
|
||||||
IssuePriority,
|
IssuePriority,
|
||||||
ProjectRole,
|
ProjectRole,
|
||||||
@@ -95,6 +96,16 @@ export class ProjectMemberInput {
|
|||||||
role!: ProjectRole;
|
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 })
|
@InputType({ isAbstract: true })
|
||||||
abstract class IssueFieldsInput {
|
abstract class IssueFieldsInput {
|
||||||
@Field(() => String, { nullable: true })
|
@Field(() => String, { nullable: true })
|
||||||
@@ -146,6 +157,17 @@ abstract class IssueFieldsInput {
|
|||||||
@Field(() => String, { nullable: true }) @IsOptional() @IsISO8601() dueDate?:
|
@Field(() => String, { nullable: true }) @IsOptional() @IsISO8601() dueDate?:
|
||||||
| string
|
| string
|
||||||
| null;
|
| null;
|
||||||
|
|
||||||
|
@Field(() => [CustomFieldValueInput], {
|
||||||
|
nullable: true,
|
||||||
|
description: 'Only the fields to change',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMaxSize(30)
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => CustomFieldValueInput)
|
||||||
|
customFields?: CustomFieldValueInput[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@InputType()
|
@InputType()
|
||||||
@@ -537,6 +559,16 @@ export class IssueFilterInput {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||||
dueTo?: string;
|
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 })
|
@Field(() => IssueSort, { nullable: true, defaultValue: IssueSort.RECENT })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEnum(IssueSort)
|
@IsEnum(IssueSort)
|
||||||
@@ -656,6 +688,23 @@ export class AreaOptionInput {
|
|||||||
@Field({ defaultValue: false }) @IsBoolean() archived!: boolean;
|
@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({
|
@InputType({
|
||||||
description:
|
description:
|
||||||
'The whole configuration (replaces the previous one). Statuses go in board order.',
|
'The whole configuration (replaces the previous one). Statuses go in board order.',
|
||||||
@@ -688,6 +737,17 @@ export class ProjectSettingsInput {
|
|||||||
@ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
@Type(() => AreaOptionInput)
|
@Type(() => AreaOptionInput)
|
||||||
areas!: 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({
|
@InputType({
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import {
|
|||||||
CommandResultGql,
|
CommandResultGql,
|
||||||
CommentPageGql,
|
CommentPageGql,
|
||||||
EpicGql,
|
EpicGql,
|
||||||
|
CustomFieldValueGql,
|
||||||
IssueGql,
|
IssueGql,
|
||||||
IssuePageGql,
|
IssuePageGql,
|
||||||
IssueParentGql,
|
IssueParentGql,
|
||||||
@@ -75,8 +76,10 @@ export class IssueResolver {
|
|||||||
@Args('limit', { type: () => Int, defaultValue: 50 }) limit: number,
|
@Args('limit', { type: () => Int, defaultValue: 50 }) limit: number,
|
||||||
@Args('offset', { type: () => Int, defaultValue: 0 }) offset: number,
|
@Args('offset', { type: () => Int, defaultValue: 0 }) offset: number,
|
||||||
) {
|
) {
|
||||||
|
const { customFields, ...rest } = filter;
|
||||||
return this.issuesService.search(viewerOf(me), {
|
return this.issuesService.search(viewerOf(me), {
|
||||||
...filter,
|
...rest,
|
||||||
|
customFields: onlyValues(customFields),
|
||||||
limit,
|
limit,
|
||||||
offset,
|
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], {
|
@ResolveField(() => [String], {
|
||||||
description: "Statuses this ticket can move to next (project's workflow)",
|
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);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Field, Float, ID, Int, ObjectType } from '@nestjs/graphql';
|
import { Field, Float, ID, Int, ObjectType } from '@nestjs/graphql';
|
||||||
import {
|
import {
|
||||||
|
CustomFieldType,
|
||||||
IssueLinkType,
|
IssueLinkType,
|
||||||
IssuePriority,
|
IssuePriority,
|
||||||
NotificationKind,
|
NotificationKind,
|
||||||
@@ -84,14 +85,36 @@ export class AreaOptionGql {
|
|||||||
@Field() archived!: boolean;
|
@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', {
|
@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 {
|
export class ProjectSettingsGql {
|
||||||
@Field(() => [WorkflowStatusGql]) statuses!: WorkflowStatusGql[];
|
@Field(() => [WorkflowStatusGql]) statuses!: WorkflowStatusGql[];
|
||||||
@Field(() => [StatusTransitionsGql]) transitions!: StatusTransitionsGql[];
|
@Field(() => [StatusTransitionsGql]) transitions!: StatusTransitionsGql[];
|
||||||
@Field(() => [IssueTypeOptionGql]) issueTypes!: IssueTypeOptionGql[];
|
@Field(() => [IssueTypeOptionGql]) issueTypes!: IssueTypeOptionGql[];
|
||||||
@Field(() => [AreaOptionGql]) areas!: AreaOptionGql[];
|
@Field(() => [AreaOptionGql]) areas!: AreaOptionGql[];
|
||||||
|
@Field(() => [CustomFieldDefinitionGql])
|
||||||
|
customFields!: CustomFieldDefinitionGql[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ObjectType('Project')
|
@ObjectType('Project')
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
PartialType,
|
PartialType,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
import {
|
import {
|
||||||
|
CustomFieldType,
|
||||||
IssueLinkType,
|
IssueLinkType,
|
||||||
IssuePriority,
|
IssuePriority,
|
||||||
ProjectRole,
|
ProjectRole,
|
||||||
@@ -23,6 +24,7 @@ import {
|
|||||||
IsIn,
|
IsIn,
|
||||||
IsInt,
|
IsInt,
|
||||||
IsISO8601,
|
IsISO8601,
|
||||||
|
IsObject,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsUUID,
|
IsUUID,
|
||||||
IsString,
|
IsString,
|
||||||
@@ -230,6 +232,38 @@ export class AreaOptionBodyDto {
|
|||||||
archived = false;
|
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 {
|
export class ProjectSettingsBodyDto {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
type: [WorkflowStatusBodyDto],
|
type: [WorkflowStatusBodyDto],
|
||||||
@@ -262,6 +296,17 @@ export class ProjectSettingsBodyDto {
|
|||||||
@ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
@Type(() => AreaOptionBodyDto)
|
@Type(() => AreaOptionBodyDto)
|
||||||
areas!: 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 {
|
export class AnalyticsQueryDto {
|
||||||
@@ -553,6 +598,23 @@ export class IssueSearchQueryDto extends PageQueryDto {
|
|||||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||||
dueTo?: string;
|
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({
|
@ApiPropertyOptional({
|
||||||
enum: IssueSort,
|
enum: IssueSort,
|
||||||
enumName: 'IssueSort',
|
enumName: 'IssueSort',
|
||||||
@@ -633,6 +695,17 @@ class IssueFieldsDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsISO8601()
|
@IsISO8601()
|
||||||
dueDate?: string | null;
|
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 {
|
export class CreateIssueDto extends IssueFieldsDto {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import {
|
import {
|
||||||
|
CustomFieldType,
|
||||||
IssueLinkType,
|
IssueLinkType,
|
||||||
IssuePriority,
|
IssuePriority,
|
||||||
NotificationKind,
|
NotificationKind,
|
||||||
@@ -78,6 +79,20 @@ export class AreaOptionDto {
|
|||||||
@ApiProperty() archived!: boolean;
|
@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 {
|
export class ProjectSettingsDto {
|
||||||
@ApiProperty({ type: [WorkflowStatusDto], description: 'In board order' })
|
@ApiProperty({ type: [WorkflowStatusDto], description: 'In board order' })
|
||||||
statuses!: WorkflowStatusDto[];
|
statuses!: WorkflowStatusDto[];
|
||||||
@@ -86,6 +101,8 @@ export class ProjectSettingsDto {
|
|||||||
@ApiProperty({ type: [IssueTypeOptionDto] })
|
@ApiProperty({ type: [IssueTypeOptionDto] })
|
||||||
issueTypes!: IssueTypeOptionDto[];
|
issueTypes!: IssueTypeOptionDto[];
|
||||||
@ApiProperty({ type: [AreaOptionDto] }) areas!: AreaOptionDto[];
|
@ApiProperty({ type: [AreaOptionDto] }) areas!: AreaOptionDto[];
|
||||||
|
@ApiProperty({ type: [CustomFieldDefinitionDto] })
|
||||||
|
customFields!: CustomFieldDefinitionDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ProjectDto {
|
export class ProjectDto {
|
||||||
@@ -426,6 +443,13 @@ export class IssueDto {
|
|||||||
startDate!: string | null;
|
startDate!: string | null;
|
||||||
@ApiProperty({ type: String, nullable: true, format: 'date' })
|
@ApiProperty({ type: String, nullable: true, format: 'date' })
|
||||||
dueDate!: string | null;
|
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: [IssueLinkDto] }) links!: IssueLinkDto[];
|
||||||
@ApiProperty({ type: [AttachmentDto] }) attachments!: AttachmentDto[];
|
@ApiProperty({ type: [AttachmentDto] }) attachments!: AttachmentDto[];
|
||||||
@ApiProperty({ type: [UserRefDto], description: 'People following it' })
|
@ApiProperty({ type: [UserRefDto], description: 'People following it' })
|
||||||
|
|||||||
@@ -171,6 +171,14 @@ export class IssuesController {
|
|||||||
watching: query.watching,
|
watching: query.watching,
|
||||||
dueFrom: query.dueFrom,
|
dueFrom: query.dueFrom,
|
||||||
dueTo: query.dueTo,
|
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,
|
sort: query.sort,
|
||||||
search: query.search,
|
search: query.search,
|
||||||
limit: query.limit,
|
limit: query.limit,
|
||||||
|
|||||||
@@ -10,6 +10,16 @@ export enum StatusCategory {
|
|||||||
CANCELLED = 'CANCELLED',
|
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 {
|
export enum IssuePriority {
|
||||||
LOWEST = 'LOWEST',
|
LOWEST = 'LOWEST',
|
||||||
LOW = 'LOW',
|
LOW = 'LOW',
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { CustomFieldValue } from './workflow';
|
||||||
import {
|
import {
|
||||||
IssueLinkType,
|
IssueLinkType,
|
||||||
IssuePriority,
|
IssuePriority,
|
||||||
@@ -49,6 +50,8 @@ export interface CreateIssueInput {
|
|||||||
storyPoints?: number | null;
|
storyPoints?: number | null;
|
||||||
startDate?: string | null;
|
startDate?: string | null;
|
||||||
dueDate?: string | null;
|
dueDate?: string | null;
|
||||||
|
/** Only the fields to change; null or '' clears one. */
|
||||||
|
customFields?: { fieldId: string; value: string | null }[];
|
||||||
assigneeId?: string | null;
|
assigneeId?: string | null;
|
||||||
releaseId?: string | null;
|
releaseId?: string | null;
|
||||||
/** Epic or ticket the new one breaks down. */
|
/** Epic or ticket the new one breaks down. */
|
||||||
@@ -97,6 +100,8 @@ export interface IssueListFilter {
|
|||||||
assigneeId?: string;
|
assigneeId?: string;
|
||||||
unassigned?: boolean;
|
unassigned?: boolean;
|
||||||
releaseId?: string;
|
releaseId?: string;
|
||||||
|
/** Only tickets holding these custom values. */
|
||||||
|
customFields?: CustomFieldValue[];
|
||||||
limit: number;
|
limit: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
SprintStatus,
|
SprintStatus,
|
||||||
StatusCategory,
|
StatusCategory,
|
||||||
} from './enums';
|
} from './enums';
|
||||||
import { ProjectSettings } from './workflow';
|
import { CustomFieldValue, ProjectSettings } from './workflow';
|
||||||
|
|
||||||
export interface UserRef {
|
export interface UserRef {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -126,6 +126,8 @@ export interface IssueSummary {
|
|||||||
/** Planned start and end (`YYYY-MM-DD`), for the roadmap. */
|
/** Planned start and end (`YYYY-MM-DD`), for the roadmap. */
|
||||||
startDate: string | null;
|
startDate: string | null;
|
||||||
dueDate: 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). */
|
/** In the trash since then (null: not in it). */
|
||||||
trashedAt: string | null;
|
trashedAt: string | null;
|
||||||
commentCount: number;
|
commentCount: number;
|
||||||
|
|||||||
@@ -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-*`). */
|
/** Colour tokens a status or ticket type can use (`--ts-c-*`). */
|
||||||
export const SETTING_COLORS = [
|
export const SETTING_COLORS = [
|
||||||
@@ -74,12 +74,29 @@ export interface AreaOption {
|
|||||||
archived: boolean;
|
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 {
|
export interface ProjectSettings {
|
||||||
statuses: WorkflowStatus[];
|
statuses: WorkflowStatus[];
|
||||||
transitions: StatusTransitions[];
|
transitions: StatusTransitions[];
|
||||||
issueTypes: IssueTypeOption[];
|
issueTypes: IssueTypeOption[];
|
||||||
areas: AreaOption[];
|
areas: AreaOption[];
|
||||||
|
customFields: CustomFieldDefinition[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FINISHED_CATEGORIES: readonly StatusCategory[] = [
|
export const FINISHED_CATEGORIES: readonly StatusCategory[] = [
|
||||||
@@ -114,6 +131,7 @@ const EMPTY: ProjectSettings = {
|
|||||||
transitions: [],
|
transitions: [],
|
||||||
issueTypes: [],
|
issueTypes: [],
|
||||||
areas: [],
|
areas: [],
|
||||||
|
customFields: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -167,6 +185,15 @@ export class Workflow {
|
|||||||
return this.settings.areas.filter((a) => !a.archived);
|
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). */
|
/** Most tickets the status should hold (null: no limit). */
|
||||||
wipLimit(id: string): number | null {
|
wipLimit(id: string): number | null {
|
||||||
return this.status(id).wipLimit ?? null;
|
return this.status(id).wipLimit ?? null;
|
||||||
|
|||||||
@@ -98,5 +98,12 @@ export function toSettingsInput(
|
|||||||
name: a.name,
|
name: a.name,
|
||||||
archived: a.archived,
|
archived: a.archived,
|
||||||
})),
|
})),
|
||||||
|
customFields: (settings.customFields ?? []).map((f) => ({
|
||||||
|
id: f.id,
|
||||||
|
name: f.name,
|
||||||
|
type: f.type,
|
||||||
|
options: f.options,
|
||||||
|
archived: f.archived,
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,13 @@ fragment ProjectFields on Project {
|
|||||||
name
|
name
|
||||||
archived
|
archived
|
||||||
}
|
}
|
||||||
|
customFields {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
type
|
||||||
|
options
|
||||||
|
archived
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,6 +102,10 @@ fragment IssueSummaryFields on Issue {
|
|||||||
storyPoints
|
storyPoints
|
||||||
startDate
|
startDate
|
||||||
dueDate
|
dueDate
|
||||||
|
customFields {
|
||||||
|
fieldId
|
||||||
|
value
|
||||||
|
}
|
||||||
trashedAt
|
trashedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,8 @@ export type CreateIssueInput = {
|
|||||||
/** An area id */
|
/** An area id */
|
||||||
area?: string | null | undefined;
|
area?: string | null | undefined;
|
||||||
assigneeId?: string | null | undefined;
|
assigneeId?: string | null | undefined;
|
||||||
|
/** Only the fields to change */
|
||||||
|
customFields?: Array<CustomFieldValueInput> | null | undefined;
|
||||||
description?: string | null | undefined;
|
description?: string | null | undefined;
|
||||||
dueDate?: string | null | undefined;
|
dueDate?: string | null | undefined;
|
||||||
/** Figma frame for UI changes */
|
/** Figma frame for UI changes */
|
||||||
@@ -141,6 +143,24 @@ export type CreateWebhookInput = {
|
|||||||
url: string;
|
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 DevLinkKind = 'BRANCH' | 'COMMIT' | 'PULL_REQUEST';
|
||||||
|
|
||||||
export type DevLinkState = 'CLOSED' | 'MERGED' | 'OPEN';
|
export type DevLinkState = 'CLOSED' | 'MERGED' | 'OPEN';
|
||||||
@@ -149,6 +169,8 @@ export type IssueFilterInput = {
|
|||||||
areas?: Array<string> | null | undefined;
|
areas?: Array<string> | null | undefined;
|
||||||
assigneeId?: string | null | undefined;
|
assigneeId?: string | null | undefined;
|
||||||
categories?: Array<StatusCategory> | 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) */
|
/** Due on or after this day (YYYY-MM-DD) */
|
||||||
dueFrom?: string | null | undefined;
|
dueFrom?: string | null | undefined;
|
||||||
/** Due on or before this day (YYYY-MM-DD) */
|
/** 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. */
|
/** The whole configuration (replaces the previous one). Statuses go in board order. */
|
||||||
export type ProjectSettingsInput = {
|
export type ProjectSettingsInput = {
|
||||||
areas: Array<AreaOptionInput>;
|
areas: Array<AreaOptionInput>;
|
||||||
|
/** Absent: unchanged. Fields cannot be removed: archive them. */
|
||||||
|
customFields?: Array<CustomFieldDefinitionInput> | null | undefined;
|
||||||
issueTypes: Array<IssueTypeOptionInput>;
|
issueTypes: Array<IssueTypeOptionInput>;
|
||||||
statuses: Array<WorkflowStatusInput>;
|
statuses: Array<WorkflowStatusInput>;
|
||||||
transitions: Array<StatusTransitionsInput>;
|
transitions: Array<StatusTransitionsInput>;
|
||||||
@@ -298,6 +322,8 @@ export type UpdateChecklistItemInput = {
|
|||||||
export type UpdateIssueInput = {
|
export type UpdateIssueInput = {
|
||||||
/** An area id */
|
/** An area id */
|
||||||
area?: string | null | undefined;
|
area?: string | null | undefined;
|
||||||
|
/** Only the fields to change */
|
||||||
|
customFields?: Array<CustomFieldValueInput> | null | undefined;
|
||||||
description?: string | null | undefined;
|
description?: string | null | undefined;
|
||||||
dueDate?: string | null | undefined;
|
dueDate?: string | null | undefined;
|
||||||
/** Figma frame for UI changes */
|
/** Figma frame for UI changes */
|
||||||
@@ -498,6 +524,13 @@ export type ProjectFieldsFragment = {
|
|||||||
template: { description: string | null; checklist: Array<string> } | null;
|
template: { description: string | null; checklist: Array<string> } | null;
|
||||||
}>;
|
}>;
|
||||||
areas: Array<{ id: string; name: string; archived: boolean }>;
|
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;
|
sprint: { id: string; name: string } | null;
|
||||||
parent: { id: string; key: string; title: string } | null;
|
parent: { id: string; key: string; title: string } | null;
|
||||||
blockedBy: Array<{ id: string; key: string; title: string }>;
|
blockedBy: Array<{ id: string; key: string; title: string }>;
|
||||||
|
customFields: Array<{ fieldId: string; value: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type IssueFieldsFragment = {
|
export type IssueFieldsFragment = {
|
||||||
@@ -588,6 +622,7 @@ export type IssueFieldsFragment = {
|
|||||||
sprint: { id: string; name: string } | null;
|
sprint: { id: string; name: string } | null;
|
||||||
parent: { id: string; key: string; title: string } | null;
|
parent: { id: string; key: string; title: string } | null;
|
||||||
blockedBy: Array<{ id: string; key: string; title: string }>;
|
blockedBy: Array<{ id: string; key: string; title: string }>;
|
||||||
|
customFields: Array<{ fieldId: string; value: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CommentFieldsFragment = {
|
export type CommentFieldsFragment = {
|
||||||
@@ -736,6 +771,7 @@ export type BoardQuery = {
|
|||||||
sprint: { id: string; name: string } | null;
|
sprint: { id: string; name: string } | null;
|
||||||
parent: { id: string; key: string; title: string } | null;
|
parent: { id: string; key: string; title: string } | null;
|
||||||
blockedBy: Array<{ id: string; key: string; title: string }>;
|
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;
|
sprint: { id: string; name: string } | null;
|
||||||
parent: { id: string; key: string; title: string } | null;
|
parent: { id: string; key: string; title: string } | null;
|
||||||
blockedBy: Array<{ id: string; key: string; title: string }>;
|
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;
|
sprint: { id: string; name: string } | null;
|
||||||
parent: { id: string; key: string; title: string } | null;
|
parent: { id: string; key: string; title: string } | null;
|
||||||
blockedBy: Array<{ id: string; key: string; title: string }>;
|
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;
|
sprint: { id: string; name: string } | null;
|
||||||
parent: { id: string; key: string; title: string } | null;
|
parent: { id: string; key: string; title: string } | null;
|
||||||
blockedBy: Array<{ id: string; key: string; title: string }>;
|
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;
|
sprint: { id: string; name: string } | null;
|
||||||
parent: { id: string; key: string; title: string } | null;
|
parent: { id: string; key: string; title: string } | null;
|
||||||
blockedBy: Array<{ id: string; key: string; title: string }>;
|
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;
|
sprint: { id: string; name: string } | null;
|
||||||
parent: { id: string; key: string; title: string } | null;
|
parent: { id: string; key: string; title: string } | null;
|
||||||
blockedBy: Array<{ id: string; key: string; title: string }>;
|
blockedBy: Array<{ id: string; key: string; title: string }>;
|
||||||
|
customFields: Array<{ fieldId: string; value: string }>;
|
||||||
};
|
};
|
||||||
progress: {
|
progress: {
|
||||||
total: number;
|
total: number;
|
||||||
@@ -1158,6 +1199,7 @@ export type BacklogQuery = {
|
|||||||
sprint: { id: string; name: string } | null;
|
sprint: { id: string; name: string } | null;
|
||||||
parent: { id: string; key: string; title: string } | null;
|
parent: { id: string; key: string; title: string } | null;
|
||||||
blockedBy: Array<{ id: string; key: string; title: string }>;
|
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;
|
sprint: { id: string; name: string } | null;
|
||||||
parent: { id: string; key: string; title: string } | null;
|
parent: { id: string; key: string; title: string } | null;
|
||||||
blockedBy: Array<{ id: string; key: string; title: string }>;
|
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;
|
sprint: { id: string; name: string } | null;
|
||||||
parent: { id: string; key: string; title: string } | null;
|
parent: { id: string; key: string; title: string } | null;
|
||||||
blockedBy: Array<{ id: string; key: string; title: string }>;
|
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;
|
sprint: { id: string; name: string } | null;
|
||||||
parent: { id: string; key: string; title: string } | null;
|
parent: { id: string; key: string; title: string } | null;
|
||||||
blockedBy: Array<{ id: string; key: string; title: string }>;
|
blockedBy: Array<{ id: string; key: string; title: string }>;
|
||||||
|
customFields: Array<{ fieldId: string; value: string }>;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -1466,6 +1511,13 @@ export type MyProjectsQuery = {
|
|||||||
} | null;
|
} | null;
|
||||||
}>;
|
}>;
|
||||||
areas: Array<{ id: string; name: string; archived: boolean }>;
|
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;
|
} | null;
|
||||||
}>;
|
}>;
|
||||||
areas: Array<{ id: string; name: string; archived: boolean }>;
|
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;
|
} | null;
|
||||||
}>;
|
}>;
|
||||||
areas: Array<{ id: string; name: string; archived: boolean }>;
|
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;
|
sprint: { id: string; name: string } | null;
|
||||||
parent: { id: string; key: string; title: string } | null;
|
parent: { id: string; key: string; title: string } | null;
|
||||||
blockedBy: Array<{ id: string; key: string; title: string }>;
|
blockedBy: Array<{ id: string; key: string; title: string }>;
|
||||||
|
customFields: Array<{ fieldId: string; value: string }>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -2115,6 +2182,13 @@ export type ProjectChangedSubscription = {
|
|||||||
} | null;
|
} | null;
|
||||||
}>;
|
}>;
|
||||||
areas: Array<{ id: string; name: string; archived: boolean }>;
|
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;
|
} | null;
|
||||||
}>;
|
}>;
|
||||||
areas: Array<{ id: string; name: string; archived: boolean }>;
|
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: 'storyPoints' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
|
{ 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' } },
|
{ 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: 'storyPoints' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
|
{ 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' } },
|
{ 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: 'storyPoints' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
|
{ 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' } },
|
{ 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: 'storyPoints' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
|
{ 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' } },
|
{ 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: 'storyPoints' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
|
{ 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' } },
|
{ 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: 'storyPoints' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
|
{ 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' } },
|
{ 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: 'storyPoints' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
|
{ 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' } },
|
{ 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: 'storyPoints' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
|
{ 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' } },
|
{ 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: 'storyPoints' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
|
{ 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' } },
|
{ 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: 'storyPoints' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
|
{ 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' } },
|
{ 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: 'storyPoints' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
|
{ 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' } },
|
{ 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: 'storyPoints' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
|
{ 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' } },
|
{ 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: 'storyPoints' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
|
{ 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' } },
|
{ 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' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import {
|
|||||||
Attachment,
|
Attachment,
|
||||||
Comment,
|
Comment,
|
||||||
DevLink,
|
DevLink,
|
||||||
|
Issue,
|
||||||
IssueLink,
|
IssueLink,
|
||||||
IssueLinkType,
|
IssueLinkType,
|
||||||
IssuePriority,
|
IssuePriority,
|
||||||
@@ -70,6 +71,7 @@ import {
|
|||||||
MarkdownComponent,
|
MarkdownComponent,
|
||||||
} from '../ui/markdown';
|
} from '../ui/markdown';
|
||||||
import { RichEditorComponent } from '../ui/rich-editor';
|
import { RichEditorComponent } from '../ui/rich-editor';
|
||||||
|
import { CustomFieldComponent } from '../ui/custom-field';
|
||||||
|
|
||||||
const NONE = '';
|
const NONE = '';
|
||||||
/** Same ceiling as the API's ATTACHMENTS_MAX_MB default. */
|
/** Same ceiling as the API's ATTACHMENTS_MAX_MB default. */
|
||||||
@@ -80,6 +82,7 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
|||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
imports: [
|
imports: [
|
||||||
BlockedComponent,
|
BlockedComponent,
|
||||||
|
CustomFieldComponent,
|
||||||
ReactionsComponent,
|
ReactionsComponent,
|
||||||
MarkdownComponent,
|
MarkdownComponent,
|
||||||
RichEditorComponent,
|
RichEditorComponent,
|
||||||
@@ -843,6 +846,16 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
|||||||
>
|
>
|
||||||
<pi-label>Área</pi-label>
|
<pi-label>Área</pi-label>
|
||||||
</pi-select-field>
|
</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)) {
|
@if (!issue.trashedAt && canTrash(issue)) {
|
||||||
<pi-action-button
|
<pi-action-button
|
||||||
@@ -1314,10 +1327,26 @@ export class IssuePage {
|
|||||||
storyPoints?: number | null;
|
storyPoints?: number | null;
|
||||||
startDate?: string | null;
|
startDate?: string | null;
|
||||||
dueDate?: string | null;
|
dueDate?: string | null;
|
||||||
|
customFields?: { fieldId: string; value: string | null }[];
|
||||||
}): void {
|
}): void {
|
||||||
this.facade.update(changes);
|
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 {
|
protected link(): void {
|
||||||
const targetKey = this.linkTarget();
|
const targetKey = this.linkTarget();
|
||||||
if (!targetKey) return;
|
if (!targetKey) return;
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
} from '@task-stream/web/tracker/application';
|
} from '@task-stream/web/tracker/application';
|
||||||
import {
|
import {
|
||||||
BulkChange,
|
BulkChange,
|
||||||
|
CustomFieldType,
|
||||||
IssuePriority,
|
IssuePriority,
|
||||||
IssueSummary,
|
IssueSummary,
|
||||||
StatusCategory,
|
StatusCategory,
|
||||||
@@ -110,6 +111,13 @@ const numberOf = (key: string) => Number(key.split('-').pop());
|
|||||||
[options]="releaseFilterOptions()"
|
[options]="releaseFilterOptions()"
|
||||||
[(value)]="releaseFilter"
|
[(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()) {
|
@if (facade.loaded()) {
|
||||||
<span class="ts-muted ts-mono ts-list__count">
|
<span class="ts-muted ts-mono ts-list__count">
|
||||||
{{ facade.total() }} tickets{{
|
{{ facade.total() }} tickets{{
|
||||||
@@ -204,6 +212,9 @@ const numberOf = (key: string) => Number(key.split('-').pop());
|
|||||||
↑ {{ parent.key }}</span
|
↑ {{ parent.key }}</span
|
||||||
>
|
>
|
||||||
}
|
}
|
||||||
|
@if (fieldsOf(issue); as fields) {
|
||||||
|
<span class="ts-list__fields ts-muted">{{ fields }}</span>
|
||||||
|
}
|
||||||
</td>
|
</td>
|
||||||
<td class="ts-list__type">
|
<td class="ts-list__type">
|
||||||
<span
|
<span
|
||||||
@@ -384,6 +395,10 @@ const numberOf = (key: string) => Number(key.split('-').pop());
|
|||||||
}
|
}
|
||||||
.ts-list__title {
|
.ts-list__title {
|
||||||
min-width: 16rem;
|
min-width: 16rem;
|
||||||
|
.ts-list__fields {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
a {
|
a {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -513,9 +528,44 @@ export class ListPage {
|
|||||||
: `${r.name} (${r.status === 'RELEASED' ? 'publicada' : 'archivada'})`,
|
: `${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>(() => {
|
private readonly criteria = computed<ListCriteria>(() => {
|
||||||
const scope = this.scope();
|
const scope = this.scope();
|
||||||
const assignee = this.assignee();
|
const assignee = this.assignee();
|
||||||
|
const fields = Object.entries(this.fieldFilters());
|
||||||
return {
|
return {
|
||||||
search: this.search() || undefined,
|
search: this.search() || undefined,
|
||||||
categories:
|
categories:
|
||||||
@@ -527,6 +577,9 @@ export class ListPage {
|
|||||||
unassigned: assignee === NOBODY || undefined,
|
unassigned: assignee === NOBODY || undefined,
|
||||||
assigneeId: assignee && assignee !== NOBODY ? assignee : undefined,
|
assigneeId: assignee && assignee !== NOBODY ? assignee : undefined,
|
||||||
releaseId: this.releaseFilter() || undefined,
|
releaseId: this.releaseFilter() || undefined,
|
||||||
|
customFields: fields.length
|
||||||
|
? fields.map(([fieldId, value]) => ({ fieldId, value }))
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { TransferComponent } from '../ui/transfer';
|
|||||||
import { AutomationsComponent } from '../ui/automations';
|
import { AutomationsComponent } from '../ui/automations';
|
||||||
import { ProjectFacade } from '@task-stream/web/tracker/application';
|
import { ProjectFacade } from '@task-stream/web/tracker/application';
|
||||||
import {
|
import {
|
||||||
|
CustomFieldType,
|
||||||
IssueTypeOption,
|
IssueTypeOption,
|
||||||
ProjectSettings,
|
ProjectSettings,
|
||||||
SETTING_COLORS,
|
SETTING_COLORS,
|
||||||
@@ -31,7 +32,14 @@ import {
|
|||||||
} from '@task-stream/web/tracker/domain';
|
} from '@task-stream/web/tracker/domain';
|
||||||
import { CATEGORY, COLOR_LABEL, ICON_LABEL, tone } from '../ui/copy';
|
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);
|
const CATEGORIES = Object.values(StatusCategory);
|
||||||
|
|
||||||
@@ -75,8 +83,9 @@ function idFrom(name: string): string {
|
|||||||
<div>
|
<div>
|
||||||
<h2>Configuración</h2>
|
<h2>Configuración</h2>
|
||||||
<p>
|
<p>
|
||||||
Estados, transiciones, tipos de ticket y áreas de este proyecto. Lo
|
Estados, transiciones, tipos de ticket, áreas y campos de este
|
||||||
que ya se usa no se borra: se archiva y deja de ofrecerse.
|
proyecto. Lo que ya se usa no se borra: se archiva y deja de
|
||||||
|
ofrecerse.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -520,6 +529,92 @@ function idFrom(name: string): string {
|
|||||||
}
|
}
|
||||||
</ol>
|
</ol>
|
||||||
</section>
|
</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>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
@@ -860,6 +955,36 @@ function idFrom(name: string): string {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
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 {
|
.ts-row {
|
||||||
--tone: var(--ts-muted);
|
--tone: var(--ts-muted);
|
||||||
padding: 0.375rem 0.5rem;
|
padding: 0.375rem 0.5rem;
|
||||||
@@ -1074,6 +1199,7 @@ export class SettingsPage {
|
|||||||
protected readonly categories = CATEGORIES;
|
protected readonly categories = CATEGORIES;
|
||||||
protected readonly colors = SETTING_COLORS;
|
protected readonly colors = SETTING_COLORS;
|
||||||
protected readonly icons = TYPE_ICONS;
|
protected readonly icons = TYPE_ICONS;
|
||||||
|
protected readonly fieldTypes = FIELD_TYPES;
|
||||||
|
|
||||||
/** What the API has; the draft is compared against it. */
|
/** What the API has; the draft is compared against it. */
|
||||||
private readonly saved = computed(
|
private readonly saved = computed(
|
||||||
@@ -1098,7 +1224,7 @@ export class SettingsPage {
|
|||||||
const d = this.draft();
|
const d = this.draft();
|
||||||
if (!d) return [];
|
if (!d) return [];
|
||||||
const problems: string[] = [];
|
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())))
|
if (lists.some((list) => list.some((item) => !item.name.trim())))
|
||||||
problems.push('Hay nombres vacíos');
|
problems.push('Hay nombres vacíos');
|
||||||
if (
|
if (
|
||||||
@@ -1117,6 +1243,12 @@ export class SettingsPage {
|
|||||||
problems.push('Falta un estado activo «Hecho»');
|
problems.push('Falta un estado activo «Hecho»');
|
||||||
if (!d.issueTypes.some((t) => !t.archived))
|
if (!d.issueTypes.some((t) => !t.archived))
|
||||||
problems.push('Hace falta al menos un tipo de ticket activo');
|
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;
|
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. */
|
/** Only for values never saved; saved ones are archived instead. */
|
||||||
protected remove(section: Section, index: number): void {
|
protected remove(section: Section, index: number): void {
|
||||||
this.patch((d) => {
|
this.patch((d) => {
|
||||||
@@ -1284,10 +1443,19 @@ export class SettingsPage {
|
|||||||
protected save(): void {
|
protected save(): void {
|
||||||
const draft = this.draft();
|
const draft = this.draft();
|
||||||
if (!draft || !this.dirty() || this.problems().length) return;
|
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,
|
...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.
|
// 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),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
125
libs/web/tracker/presenters/src/lib/ui/custom-field.ts
Normal file
125
libs/web/tracker/presenters/src/lib/ui/custom-field.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user