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:
@@ -90,6 +90,7 @@ const FIELD: Record<string, string> = {
|
||||
storyPoints: 'los puntos',
|
||||
startDate: 'la fecha de inicio',
|
||||
dueDate: 'la fecha límite',
|
||||
customFields: 'los campos personalizados',
|
||||
};
|
||||
|
||||
const list = (items: string[]) =>
|
||||
|
||||
@@ -57,6 +57,7 @@ export class IssueCreatedEventHandler
|
||||
storyPoints: issue.storyPoints,
|
||||
startDate: issue.startDate ?? null,
|
||||
dueDate: issue.dueDate,
|
||||
customFields: issue.customFields ?? {},
|
||||
createdAt: issue.createdAt,
|
||||
updatedAt: issue.updatedAt,
|
||||
version: versionOf(event),
|
||||
|
||||
@@ -355,6 +355,7 @@ export class IssuesService {
|
||||
storyPoints: issue.storyPoints,
|
||||
startDate: issue.startDate,
|
||||
dueDate: issue.dueDate,
|
||||
customFields: issue.customFields,
|
||||
parentId: issue.parentId,
|
||||
// The original's text is the content, not the type's template.
|
||||
useTemplate: false,
|
||||
|
||||
@@ -33,6 +33,8 @@ export interface IssueFilter {
|
||||
dueTo?: string;
|
||||
/** Only tickets in no sprint (wins over `sprintId`). */
|
||||
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`). */
|
||||
watching?: boolean;
|
||||
/** Only the ones this person follows. */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -449,6 +449,10 @@ function toMongoFilter(filter: IssueFilter): FilterQuery<IssueDoc> {
|
||||
// Full ISO dates on the last day still count.
|
||||
...(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;
|
||||
else if (filter.sprintId) query['sprint.id'] = filter.sprintId;
|
||||
if (filter.unassigned) query['assignee'] = null;
|
||||
|
||||
@@ -199,6 +199,8 @@ class IssueDocument {
|
||||
@Prop({ type: [SchemaFactory.createForClass(AttachmentProp)] })
|
||||
attachments!: AttachmentProp[];
|
||||
@Prop({ type: [String] }) labels!: string[];
|
||||
/** Field id → value: filtered as `customFields.<id>`. */
|
||||
@Prop({ type: Object }) customFields!: Record<string, string>;
|
||||
@Prop({ type: Number }) version!: number;
|
||||
}
|
||||
export const IssueSchema = SchemaFactory.createForClass(IssueDocument);
|
||||
@@ -413,6 +415,7 @@ export function issueFromDoc(doc: IssueDoc): IssueReadModel {
|
||||
parent: rest.parent?.id ? rest.parent : null,
|
||||
rank: rest.rank ?? implicitRank(rest.number),
|
||||
startDate: rest.startDate ?? null,
|
||||
customFields: rest.customFields ?? {},
|
||||
sprint: rest.sprint?.id ? rest.sprint : null,
|
||||
// Links recorded before projects had several repositories name none.
|
||||
devLinks: (rest.devLinks ?? []).map((link) => ({
|
||||
|
||||
@@ -1,10 +1,37 @@
|
||||
import { IssueDetails } from '@task-stream/api/tracker/domain';
|
||||
|
||||
/** Clients may send explicit nulls; the domain only accepts them on nullable fields. */
|
||||
export function withoutNulls<T extends Partial<IssueDetails>>(input: T): T {
|
||||
const details = { ...input };
|
||||
for (const field of ['title', 'type', 'priority', 'labels'] as const) {
|
||||
if (details[field] === null) delete details[field];
|
||||
}
|
||||
return details;
|
||||
/** A custom value as GraphQL carries it (a map is not a GraphQL type). */
|
||||
export interface CustomFieldValue {
|
||||
fieldId: string;
|
||||
value?: string | null;
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
CustomFieldType,
|
||||
ProjectSettings,
|
||||
SettingColor,
|
||||
StatusCategory,
|
||||
@@ -26,11 +27,20 @@ export interface SettingsPayload {
|
||||
template?: { description?: string | null; checklist?: string[] } | null;
|
||||
}[];
|
||||
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 {
|
||||
return {
|
||||
...settings,
|
||||
customFields: settings.customFields ?? [],
|
||||
transitions: settings.statuses.map((s) => ({
|
||||
from: s.id,
|
||||
to: settings.transitions[s.id] ?? [],
|
||||
@@ -62,5 +72,14 @@ export function fromSettingsPayload(payload: SettingsPayload): ProjectSettings {
|
||||
: {}),
|
||||
})),
|
||||
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,
|
||||
} from '@task-stream/api/tracker/application';
|
||||
import {
|
||||
CustomFieldType,
|
||||
IssueLinkType,
|
||||
IssuePriority,
|
||||
NotificationKind,
|
||||
@@ -22,6 +23,10 @@ export enum LinkDirection {
|
||||
}
|
||||
|
||||
registerEnumType(IssuePriority, { name: 'IssuePriority' });
|
||||
registerEnumType(CustomFieldType, {
|
||||
name: 'CustomFieldType',
|
||||
description: 'What a custom field holds (DATE: YYYY-MM-DD)',
|
||||
});
|
||||
registerEnumType(StatusCategory, {
|
||||
name: 'StatusCategory',
|
||||
description:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Field, ID, InputType, Int } from '@nestjs/graphql';
|
||||
import {
|
||||
CustomFieldType,
|
||||
IssueLinkType,
|
||||
IssuePriority,
|
||||
ProjectRole,
|
||||
@@ -95,6 +96,16 @@ export class ProjectMemberInput {
|
||||
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 })
|
||||
abstract class IssueFieldsInput {
|
||||
@Field(() => String, { nullable: true })
|
||||
@@ -146,6 +157,17 @@ abstract class IssueFieldsInput {
|
||||
@Field(() => String, { nullable: true }) @IsOptional() @IsISO8601() dueDate?:
|
||||
| string
|
||||
| null;
|
||||
|
||||
@Field(() => [CustomFieldValueInput], {
|
||||
nullable: true,
|
||||
description: 'Only the fields to change',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(30)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CustomFieldValueInput)
|
||||
customFields?: CustomFieldValueInput[];
|
||||
}
|
||||
|
||||
@InputType()
|
||||
@@ -537,6 +559,16 @@ export class IssueFilterInput {
|
||||
@IsOptional()
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||
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 })
|
||||
@IsOptional()
|
||||
@IsEnum(IssueSort)
|
||||
@@ -656,6 +688,23 @@ export class AreaOptionInput {
|
||||
@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({
|
||||
description:
|
||||
'The whole configuration (replaces the previous one). Statuses go in board order.',
|
||||
@@ -688,6 +737,17 @@ export class ProjectSettingsInput {
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => 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({
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
CommandResultGql,
|
||||
CommentPageGql,
|
||||
EpicGql,
|
||||
CustomFieldValueGql,
|
||||
IssueGql,
|
||||
IssuePageGql,
|
||||
IssueParentGql,
|
||||
@@ -75,8 +76,10 @@ export class IssueResolver {
|
||||
@Args('limit', { type: () => Int, defaultValue: 50 }) limit: number,
|
||||
@Args('offset', { type: () => Int, defaultValue: 0 }) offset: number,
|
||||
) {
|
||||
const { customFields, ...rest } = filter;
|
||||
return this.issuesService.search(viewerOf(me), {
|
||||
...filter,
|
||||
...rest,
|
||||
customFields: onlyValues(customFields),
|
||||
limit,
|
||||
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], {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
CustomFieldType,
|
||||
IssueLinkType,
|
||||
IssuePriority,
|
||||
NotificationKind,
|
||||
@@ -84,14 +85,36 @@ export class AreaOptionGql {
|
||||
@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', {
|
||||
description: 'Statuses (board order), transitions, ticket types and areas',
|
||||
description:
|
||||
'Statuses (board order), transitions, ticket types, areas and custom fields',
|
||||
})
|
||||
export class ProjectSettingsGql {
|
||||
@Field(() => [WorkflowStatusGql]) statuses!: WorkflowStatusGql[];
|
||||
@Field(() => [StatusTransitionsGql]) transitions!: StatusTransitionsGql[];
|
||||
@Field(() => [IssueTypeOptionGql]) issueTypes!: IssueTypeOptionGql[];
|
||||
@Field(() => [AreaOptionGql]) areas!: AreaOptionGql[];
|
||||
@Field(() => [CustomFieldDefinitionGql])
|
||||
customFields!: CustomFieldDefinitionGql[];
|
||||
}
|
||||
|
||||
@ObjectType('Project')
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
PartialType,
|
||||
} from '@nestjs/swagger';
|
||||
import {
|
||||
CustomFieldType,
|
||||
IssueLinkType,
|
||||
IssuePriority,
|
||||
ProjectRole,
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsISO8601,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsUUID,
|
||||
IsString,
|
||||
@@ -230,6 +232,38 @@ export class AreaOptionBodyDto {
|
||||
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 {
|
||||
@ApiProperty({
|
||||
type: [WorkflowStatusBodyDto],
|
||||
@@ -262,6 +296,17 @@ export class ProjectSettingsBodyDto {
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => 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 {
|
||||
@@ -553,6 +598,23 @@ export class IssueSearchQueryDto extends PageQueryDto {
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||
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({
|
||||
enum: IssueSort,
|
||||
enumName: 'IssueSort',
|
||||
@@ -633,6 +695,17 @@ class IssueFieldsDto {
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
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 {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
CustomFieldType,
|
||||
IssueLinkType,
|
||||
IssuePriority,
|
||||
NotificationKind,
|
||||
@@ -78,6 +79,20 @@ export class AreaOptionDto {
|
||||
@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 {
|
||||
@ApiProperty({ type: [WorkflowStatusDto], description: 'In board order' })
|
||||
statuses!: WorkflowStatusDto[];
|
||||
@@ -86,6 +101,8 @@ export class ProjectSettingsDto {
|
||||
@ApiProperty({ type: [IssueTypeOptionDto] })
|
||||
issueTypes!: IssueTypeOptionDto[];
|
||||
@ApiProperty({ type: [AreaOptionDto] }) areas!: AreaOptionDto[];
|
||||
@ApiProperty({ type: [CustomFieldDefinitionDto] })
|
||||
customFields!: CustomFieldDefinitionDto[];
|
||||
}
|
||||
|
||||
export class ProjectDto {
|
||||
@@ -426,6 +443,13 @@ export class IssueDto {
|
||||
startDate!: string | null;
|
||||
@ApiProperty({ type: String, nullable: true, format: 'date' })
|
||||
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: [AttachmentDto] }) attachments!: AttachmentDto[];
|
||||
@ApiProperty({ type: [UserRefDto], description: 'People following it' })
|
||||
|
||||
@@ -171,6 +171,14 @@ export class IssuesController {
|
||||
watching: query.watching,
|
||||
dueFrom: query.dueFrom,
|
||||
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,
|
||||
search: query.search,
|
||||
limit: query.limit,
|
||||
|
||||
Reference in New Issue
Block a user