feat: custom fields per project

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

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

View File

@@ -1,5 +1,5 @@
import { AutowiredEvent } from '@task-stream/api/shared/domain';
import type { IssueDetails } from '../issue/issue';
import type { IssueChanges } from '../issue/issue';
import { TrackerEvent } from './tracker.event';
/** Editable fields of a ticket changed. */
@@ -9,7 +9,7 @@ export class IssueUpdatedEvent extends TrackerEvent {
actorId: string,
issueId: string,
projectId: string,
readonly changes: IssueDetails,
readonly changes: IssueChanges,
) {
super(actorId, issueId, projectId);
}

View File

@@ -72,6 +72,9 @@ export class IssueFactory {
storyPoints: details.storyPoints ?? null,
startDate: details.startDate ?? null,
dueDate: details.dueDate ?? null,
customFields: props.customFields
? workflow.customValues({}, props.customFields)
: {},
links: [],
createdAt: now,
updatedAt: now,

View File

@@ -63,8 +63,18 @@ export interface IssueDetails {
/** When work on it should start (roadmap); `dueDate` is when it should end. */
startDate?: string | null;
dueDate?: string | null;
/**
* Values of the project's custom fields, by field id. A change only names
* the fields it touches; null or '' clears one.
*/
customFields?: Record<string, string | number | null>;
}
/** What IssueUpdatedEvent carries: custom values as the whole new map. */
export type IssueChanges = Omit<IssueDetails, 'customFields'> & {
customFields?: Record<string, string>;
};
/** Data of a ticket (what IssueCreatedEvent carries). */
export interface IssueProps {
id: string;
@@ -86,6 +96,8 @@ export interface IssueProps {
storyPoints: number | null;
startDate?: string | null;
dueDate: string | null;
/** Custom field id → value; absent in tickets created before them. */
customFields?: Record<string, string>;
links: IssueLink[];
createdAt: string;
updatedAt: string;
@@ -113,6 +125,8 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
public storyPoints: number | null = null;
public startDate: string | null = null;
public dueDate: string | null = null;
/** Custom field id → value (tickets created before them have none). */
public customFields: Record<string, string> = {};
public links: IssueLink[] = [];
public attachments: IssueAttachment[] = [];
/** People who asked to be told about this ticket. */
@@ -144,9 +158,17 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
changes: IssueDetails,
workflow: ProjectWorkflow = ProjectWorkflow.default(),
): void {
const next = normalizeIssueDetails(changes);
const patch: IssueDetails = {};
for (const field of Object.keys(next) as (keyof IssueDetails)[]) {
const { customFields: customChanges, ...rest } = changes;
const { customFields: _none, ...normalized } = normalizeIssueDetails(rest);
const next: IssueChanges = normalized;
// Custom values go whole into the event: the projection just sets them.
if (customChanges && Object.keys(customChanges).length)
next.customFields = workflow.customValues(
this.customFields ?? {},
customChanges,
);
const patch: IssueChanges = {};
for (const field of Object.keys(next) as (keyof IssueChanges)[]) {
const value = next[field];
if (value === undefined) continue;
if (JSON.stringify(value) === JSON.stringify(this[field])) continue;
@@ -524,6 +546,7 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
Object.assign(this, {
...issue,
labels: [...issue.labels],
customFields: { ...(issue.customFields ?? {}) },
links: issue.links.map((l) => ({ ...l })),
});
}

View File

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

View File

@@ -94,6 +94,33 @@ export interface AreaOption {
archived: boolean;
}
/** What a custom field holds. */
export enum CustomFieldType {
TEXT = 'TEXT',
NUMBER = 'NUMBER',
/** `YYYY-MM-DD`. */
DATE = 'DATE',
/** One of `options`. */
SELECT = 'SELECT',
}
/**
* A field the project adds to its tickets («Navegador», «Cliente»…). Its
* type never changes once created, so values stay readable.
*/
export interface CustomFieldDefinition {
id: string;
name: string;
type: CustomFieldType;
/** The choices of a SELECT field; empty for the other types. */
options: string[];
archived: boolean;
}
export const MAX_CUSTOM_FIELDS = 30;
export const MAX_CUSTOM_OPTIONS = 50;
export const MAX_CUSTOM_TEXT = 500;
/** Per-project configuration of the tracker. */
export interface ProjectSettings {
/** In board order. New tickets start in the first active TODO status. */
@@ -102,6 +129,11 @@ export interface ProjectSettings {
transitions: Record<string, string[]>;
issueTypes: IssueTypeOption[];
areas: AreaOption[];
/**
* Absent in projects that never defined any. When saving, absent means
* unchanged (clients that predate them keep them).
*/
customFields?: CustomFieldDefinition[];
}
const status = (
@@ -291,6 +323,41 @@ export class ProjectWorkflow {
throw new BusinessRuleError(`Ticket type ${type.name} is archived`);
}
/** The project's custom fields, archived ones included. */
get customFields(): CustomFieldDefinition[] {
return this.settings.customFields ?? [];
}
/**
* A ticket's custom values after `changes`: each value checked against
* its field (null or empty clears it). Archived fields can be cleared but
* not set.
*/
customValues(
current: Record<string, string>,
changes: Record<string, string | number | null>,
): Record<string, string> {
const next = { ...current };
for (const [id, raw] of Object.entries(changes)) {
const field = this.customFields.find((f) => f.id === id);
if (!field) throw new ValidationError(`Unknown field ${id}`);
if (raw !== null && typeof raw !== 'string' && typeof raw !== 'number')
throw new ValidationError(`${field.name} has to be text`);
const value = raw === null ? null : String(raw).trim();
if (!value) {
delete next[id];
continue;
}
if (field.archived)
throw new BusinessRuleError(`Field ${field.name} is archived`);
next[id] = customValue(field, value);
}
// Sorted, so the same values always compare equal.
return Object.fromEntries(
Object.entries(next).sort(([a], [b]) => a.localeCompare(b)),
);
}
assertActiveArea(id: string | null | undefined): void {
if (id === null || id === undefined) return;
const area = this.settings.areas.find((a) => a.id === id);
@@ -336,11 +403,26 @@ export function validateProjectSettings(
name: normName(a.name),
archived: !!a.archived,
}));
const customFields = (next.customFields ?? previous.customFields ?? []).map(
normCustomField,
);
assertThat(
customFields.length <= MAX_CUSTOM_FIELDS,
`Un proyecto tiene como mucho ${MAX_CUSTOM_FIELDS} campos`,
);
for (const field of customFields) {
const before = previous.customFields?.find((f) => f.id === field.id);
assertThat(
!before || before.type === field.type,
`El campo «${field.name}» ya tiene valores: su tipo no se puede cambiar`,
);
}
for (const [label, list, before] of [
['estado', statuses, previous.statuses],
['tipo', issueTypes, previous.issueTypes],
['área', areas, previous.areas],
['campo', customFields, previous.customFields ?? []],
] as const) {
const ids = list.map((item) => item.id);
const duplicate = ids.find((id, i) => ids.indexOf(id) !== i);
@@ -414,7 +496,69 @@ export function validateProjectSettings(
if (to.length) transitions[source] = to;
}
return { statuses, transitions, issueTypes, areas };
return {
statuses,
transitions,
issueTypes,
areas,
// Only when there are some: settings saved unchanged must compare equal.
...(customFields.length ? { customFields } : {}),
};
}
function normCustomField(field: CustomFieldDefinition): CustomFieldDefinition {
const name = normName(field.name);
assertThat(
Object.values(CustomFieldType).includes(field.type),
`Tipo no válido en el campo ${name}`,
);
const options =
field.type === CustomFieldType.SELECT
? [...new Set((field.options ?? []).map((o) => o.trim()).filter(Boolean))]
: [];
if (field.type === CustomFieldType.SELECT)
assertThat(
options.length > 0 &&
options.length <= MAX_CUSTOM_OPTIONS &&
options.every((o) => o.length <= 60),
`El campo «${name}» necesita de 1 a ${MAX_CUSTOM_OPTIONS} opciones de hasta 60 caracteres`,
);
return {
id: normId(field.id),
name,
type: field.type,
options,
archived: !!field.archived,
};
}
/** A value as its field keeps it (numbers and dates in a canonical form). */
function customValue(field: CustomFieldDefinition, value: string): string {
switch (field.type) {
case CustomFieldType.NUMBER: {
const number = Number(value.replace(',', '.'));
assertThat(Number.isFinite(number), `${field.name} has to be a number`);
return String(number);
}
case CustomFieldType.DATE:
assertThat(
/^\d{4}-\d{2}-\d{2}$/.test(value) && !Number.isNaN(Date.parse(value)),
`${field.name} has to be a date (YYYY-MM-DD)`,
);
return value;
case CustomFieldType.SELECT:
assertThat(
field.options.includes(value),
`${field.name} has no option «${value}»`,
);
return value;
default:
assertThat(
value.length <= MAX_CUSTOM_TEXT,
`${field.name} holds ${MAX_CUSTOM_TEXT} characters at most`,
);
return value;
}
}
function normTemplate(

View File

@@ -77,6 +77,8 @@ export class IssueReadModel {
storyPoints!: number | null;
startDate!: string | null;
dueDate!: string | null;
/** The project's custom fields: field id → value (only those with one). */
customFields!: Record<string, string>;
links!: IssueLinkReadModel[];
attachments!: AttachmentReadModel[];
/** People following the ticket. */