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:
@@ -8,9 +8,12 @@ import { IssueStatusChangedEvent } from '../events/issue-status-changed.event';
|
||||
import { IssueFactory } from '../factories/issue.factory';
|
||||
import { ProjectFactory } from '../factories/project.factory';
|
||||
import { replay } from '../testing';
|
||||
import { Issue } from '../issue/issue';
|
||||
import { Project } from './project';
|
||||
import { ProjectRole } from './project-role';
|
||||
import { IssueUpdatedEvent } from '../events/issue-updated.event';
|
||||
import {
|
||||
CustomFieldType,
|
||||
defaultProjectSettings,
|
||||
ProjectSettings,
|
||||
StatusCategory,
|
||||
@@ -203,4 +206,151 @@ describe('Project settings', () => {
|
||||
),
|
||||
).toThrow(/Unknown ticket type/);
|
||||
});
|
||||
|
||||
describe('custom fields', () => {
|
||||
const withFields = (): ProjectSettings => ({
|
||||
...defaultProjectSettings(),
|
||||
customFields: [
|
||||
{
|
||||
id: 'browser',
|
||||
name: ' Navegador ',
|
||||
type: CustomFieldType.SELECT,
|
||||
options: ['Chrome', ' Safari ', 'Chrome', ''],
|
||||
archived: false,
|
||||
},
|
||||
{
|
||||
id: 'COST',
|
||||
name: 'Coste',
|
||||
type: CustomFieldType.NUMBER,
|
||||
options: ['ignored'],
|
||||
archived: false,
|
||||
},
|
||||
{
|
||||
id: 'GO_LIVE',
|
||||
name: 'Salida',
|
||||
type: CustomFieldType.DATE,
|
||||
options: [],
|
||||
archived: false,
|
||||
},
|
||||
{
|
||||
id: 'CLIENT',
|
||||
name: 'Cliente',
|
||||
type: CustomFieldType.TEXT,
|
||||
options: [],
|
||||
archived: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const newIssue = (project: Project) =>
|
||||
new IssueFactory().create(
|
||||
{
|
||||
projectId: project.id,
|
||||
projectKey: 'WEB',
|
||||
number: 1,
|
||||
title: 'Pago roto',
|
||||
type: 'BUG',
|
||||
reporterId: 'dev',
|
||||
customFields: { BROWSER: 'Safari', COST: '1,5' },
|
||||
},
|
||||
project.workflow,
|
||||
);
|
||||
|
||||
it('are normalised and kept; saving them unchanged is no change', () => {
|
||||
const project = newProject();
|
||||
project.updateSettings('owner', withFields());
|
||||
expect(project.workflow.customFields[0]).toEqual({
|
||||
id: 'BROWSER',
|
||||
name: 'Navegador',
|
||||
type: CustomFieldType.SELECT,
|
||||
options: ['Chrome', 'Safari'],
|
||||
archived: false,
|
||||
});
|
||||
expect(project.workflow.customFields[1].options).toEqual([]);
|
||||
|
||||
const before = project.getUncommittedEvents().length;
|
||||
project.updateSettings('owner', structuredClone(project.settings));
|
||||
// Clients that do not know about them leave them as they are.
|
||||
const { customFields: _absent, ...older } = structuredClone(
|
||||
project.settings,
|
||||
);
|
||||
project.updateSettings('owner', older);
|
||||
expect(project.getUncommittedEvents()).toHaveLength(before);
|
||||
expect(project.workflow.customFields).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('cannot be removed, change type or be a SELECT without options', () => {
|
||||
const project = newProject();
|
||||
project.updateSettings('owner', withFields());
|
||||
const removed = { ...withFields(), customFields: [] };
|
||||
expect(() => project.updateSettings('owner', removed)).toThrow(
|
||||
/archívalo/i,
|
||||
);
|
||||
const retyped = withFields();
|
||||
retyped.customFields![1].type = CustomFieldType.TEXT;
|
||||
expect(() => project.updateSettings('owner', retyped)).toThrow(
|
||||
/tipo no se puede cambiar/,
|
||||
);
|
||||
const empty = withFields();
|
||||
empty.customFields![0].options = [' '];
|
||||
expect(() => project.updateSettings('owner', empty)).toThrow(/opciones/);
|
||||
});
|
||||
|
||||
it('check what a ticket holds, by type', () => {
|
||||
const project = newProject();
|
||||
project.updateSettings('owner', withFields());
|
||||
const issue = newIssue(project);
|
||||
expect(issue.customFields).toEqual({ BROWSER: 'Safari', COST: '1.5' });
|
||||
|
||||
const workflow = project.workflow;
|
||||
const set = (customFields: Record<string, string | number | null>) =>
|
||||
issue.updateDetails('dev', { customFields }, workflow);
|
||||
expect(() => set({ BROWSER: 'Edge' })).toThrow(/no option/);
|
||||
expect(() => set({ COST: 'mucho' })).toThrow(/number/);
|
||||
expect(() => set({ GO_LIVE: '20/10/2026' })).toThrow(/date/);
|
||||
expect(() => set({ CLIENT: 'x'.repeat(501) })).toThrow(/500/);
|
||||
expect(() => set({ NOPE: 'x' })).toThrow(ValidationError);
|
||||
|
||||
set({ GO_LIVE: '2026-11-02', COST: 3, BROWSER: null });
|
||||
const event = issue.getUncommittedEvents().at(-1) as IssueUpdatedEvent;
|
||||
// The whole map, sorted: the projection just sets it.
|
||||
expect(event.changes).toEqual({
|
||||
customFields: { COST: '3', GO_LIVE: '2026-11-02' },
|
||||
});
|
||||
expect(Object.keys(event.changes.customFields!)).toEqual([
|
||||
'COST',
|
||||
'GO_LIVE',
|
||||
]);
|
||||
// Same values again: nothing happens.
|
||||
const count = issue.getUncommittedEvents().length;
|
||||
set({ COST: '3' });
|
||||
expect(issue.getUncommittedEvents()).toHaveLength(count);
|
||||
});
|
||||
|
||||
it('archived ones can be cleared but not set', () => {
|
||||
const project = newProject();
|
||||
project.updateSettings('owner', withFields());
|
||||
const issue = newIssue(project);
|
||||
const archived = withFields();
|
||||
archived.customFields![0].archived = true;
|
||||
project.updateSettings('owner', archived);
|
||||
expect(() =>
|
||||
issue.updateDetails(
|
||||
'dev',
|
||||
{ customFields: { BROWSER: 'Chrome' } },
|
||||
project.workflow,
|
||||
),
|
||||
).toThrow(BusinessRuleError);
|
||||
issue.updateDetails(
|
||||
'dev',
|
||||
{ customFields: { BROWSER: '' } },
|
||||
project.workflow,
|
||||
);
|
||||
expect(issue.customFields).toEqual({ COST: '1.5' });
|
||||
// Replaying the events gives the same ticket.
|
||||
expect(replay(issue, Issue).rebuilt.customFields).toEqual({
|
||||
COST: '1.5',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user