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

@@ -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[]) =>

View File

@@ -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),

View File

@@ -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,

View File

@@ -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. */

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. */

View File

@@ -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;

View File

@@ -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) => ({

View File

@@ -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) } : {}),
};
}

View File

@@ -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 ?? [],
})),
}
: {}),
};
}

View File

@@ -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:

View File

@@ -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({

View File

@@ -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;
}

View File

@@ -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')

View File

@@ -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 {

View File

@@ -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' })

View File

@@ -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,

View File

@@ -10,6 +10,16 @@ export enum StatusCategory {
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 {
LOWEST = 'LOWEST',
LOW = 'LOW',

View File

@@ -1,3 +1,4 @@
import { CustomFieldValue } from './workflow';
import {
IssueLinkType,
IssuePriority,
@@ -49,6 +50,8 @@ export interface CreateIssueInput {
storyPoints?: number | null;
startDate?: string | null;
dueDate?: string | null;
/** Only the fields to change; null or '' clears one. */
customFields?: { fieldId: string; value: string | null }[];
assigneeId?: string | null;
releaseId?: string | null;
/** Epic or ticket the new one breaks down. */
@@ -97,6 +100,8 @@ export interface IssueListFilter {
assigneeId?: string;
unassigned?: boolean;
releaseId?: string;
/** Only tickets holding these custom values. */
customFields?: CustomFieldValue[];
limit: number;
}

View File

@@ -7,7 +7,7 @@ import {
SprintStatus,
StatusCategory,
} from './enums';
import { ProjectSettings } from './workflow';
import { CustomFieldValue, ProjectSettings } from './workflow';
export interface UserRef {
id: string;
@@ -126,6 +126,8 @@ export interface IssueSummary {
/** Planned start and end (`YYYY-MM-DD`), for the roadmap. */
startDate: 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). */
trashedAt: string | null;
commentCount: number;

View File

@@ -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-*`). */
export const SETTING_COLORS = [
@@ -74,12 +74,29 @@ export interface AreaOption {
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 {
statuses: WorkflowStatus[];
transitions: StatusTransitions[];
issueTypes: IssueTypeOption[];
areas: AreaOption[];
customFields: CustomFieldDefinition[];
}
export const FINISHED_CATEGORIES: readonly StatusCategory[] = [
@@ -114,6 +131,7 @@ const EMPTY: ProjectSettings = {
transitions: [],
issueTypes: [],
areas: [],
customFields: [],
};
/**
@@ -167,6 +185,15 @@ export class Workflow {
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). */
wipLimit(id: string): number | null {
return this.status(id).wipLimit ?? null;

View File

@@ -98,5 +98,12 @@ export function toSettingsInput(
name: a.name,
archived: a.archived,
})),
customFields: (settings.customFields ?? []).map((f) => ({
id: f.id,
name: f.name,
type: f.type,
options: f.options,
archived: f.archived,
})),
};
}

View File

@@ -51,6 +51,13 @@ fragment ProjectFields on Project {
name
archived
}
customFields {
id
name
type
options
archived
}
}
}
@@ -95,6 +102,10 @@ fragment IssueSummaryFields on Issue {
storyPoints
startDate
dueDate
customFields {
fieldId
value
}
trashedAt
}

View File

@@ -89,6 +89,8 @@ export type CreateIssueInput = {
/** An area id */
area?: string | null | undefined;
assigneeId?: string | null | undefined;
/** Only the fields to change */
customFields?: Array<CustomFieldValueInput> | null | undefined;
description?: string | null | undefined;
dueDate?: string | null | undefined;
/** Figma frame for UI changes */
@@ -141,6 +143,24 @@ export type CreateWebhookInput = {
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 DevLinkState = 'CLOSED' | 'MERGED' | 'OPEN';
@@ -149,6 +169,8 @@ export type IssueFilterInput = {
areas?: Array<string> | null | undefined;
assigneeId?: string | 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) */
dueFrom?: string | null | undefined;
/** 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. */
export type ProjectSettingsInput = {
areas: Array<AreaOptionInput>;
/** Absent: unchanged. Fields cannot be removed: archive them. */
customFields?: Array<CustomFieldDefinitionInput> | null | undefined;
issueTypes: Array<IssueTypeOptionInput>;
statuses: Array<WorkflowStatusInput>;
transitions: Array<StatusTransitionsInput>;
@@ -298,6 +322,8 @@ export type UpdateChecklistItemInput = {
export type UpdateIssueInput = {
/** An area id */
area?: string | null | undefined;
/** Only the fields to change */
customFields?: Array<CustomFieldValueInput> | null | undefined;
description?: string | null | undefined;
dueDate?: string | null | undefined;
/** Figma frame for UI changes */
@@ -498,6 +524,13 @@ export type ProjectFieldsFragment = {
template: { description: string | null; checklist: Array<string> } | null;
}>;
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;
parent: { id: string; key: string; title: string } | null;
blockedBy: Array<{ id: string; key: string; title: string }>;
customFields: Array<{ fieldId: string; value: string }>;
};
export type IssueFieldsFragment = {
@@ -588,6 +622,7 @@ export type IssueFieldsFragment = {
sprint: { id: string; name: string } | null;
parent: { id: string; key: string; title: string } | null;
blockedBy: Array<{ id: string; key: string; title: string }>;
customFields: Array<{ fieldId: string; value: string }>;
};
export type CommentFieldsFragment = {
@@ -736,6 +771,7 @@ export type BoardQuery = {
sprint: { id: string; name: string } | null;
parent: { id: string; key: string; title: string } | null;
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;
parent: { id: string; key: string; title: string } | null;
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;
parent: { id: string; key: string; title: string } | null;
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;
parent: { id: string; key: string; title: string } | null;
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;
parent: { id: string; key: string; title: string } | null;
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;
parent: { id: string; key: string; title: string } | null;
blockedBy: Array<{ id: string; key: string; title: string }>;
customFields: Array<{ fieldId: string; value: string }>;
};
progress: {
total: number;
@@ -1158,6 +1199,7 @@ export type BacklogQuery = {
sprint: { id: string; name: string } | null;
parent: { id: string; key: string; title: string } | null;
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;
parent: { id: string; key: string; title: string } | null;
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;
parent: { id: string; key: string; title: string } | null;
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;
parent: { id: string; key: string; title: string } | null;
blockedBy: Array<{ id: string; key: string; title: string }>;
customFields: Array<{ fieldId: string; value: string }>;
}>;
};
};
@@ -1466,6 +1511,13 @@ export type MyProjectsQuery = {
} | null;
}>;
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;
}>;
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;
}>;
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;
parent: { id: string; key: string; title: string } | null;
blockedBy: Array<{ id: string; key: string; title: string }>;
customFields: Array<{ fieldId: string; value: string }>;
};
};
};
@@ -2115,6 +2182,13 @@ export type ProjectChangedSubscription = {
} | null;
}>;
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;
}>;
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: 'startDate' } },
{ 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' } },
],
},
@@ -2886,6 +2998,17 @@ export const IssueFieldsFragmentDoc = {
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
{ 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' } },
],
},
@@ -4600,6 +4723,17 @@ export const BoardDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
{ 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' } },
],
},
@@ -4761,6 +4895,17 @@ export const IssueDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
{ 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' } },
],
},
@@ -5057,6 +5202,17 @@ export const SearchIssuesDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
{ 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' } },
],
},
@@ -6375,6 +6531,17 @@ export const MyIssuesDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
{ 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' } },
],
},
@@ -6550,6 +6717,17 @@ export const IssueChildrenDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
{ 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' } },
],
},
@@ -6743,6 +6921,17 @@ export const EpicsDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
{ 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' } },
],
},
@@ -7240,6 +7429,17 @@ export const BacklogDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
{ 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' } },
],
},
@@ -7674,6 +7874,17 @@ export const TrashDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
{ 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' } },
],
},
@@ -7849,6 +8060,17 @@ export const DueIssuesDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
{ 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' } },
],
},
@@ -8039,6 +8261,17 @@ export const IssueListDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
{ 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' } },
],
},
@@ -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: 'startDate' } },
{ 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' } },
],
},
@@ -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' },
},
],
},
},
],
},
},

View File

@@ -43,6 +43,7 @@ import {
Attachment,
Comment,
DevLink,
Issue,
IssueLink,
IssueLinkType,
IssuePriority,
@@ -70,6 +71,7 @@ import {
MarkdownComponent,
} from '../ui/markdown';
import { RichEditorComponent } from '../ui/rich-editor';
import { CustomFieldComponent } from '../ui/custom-field';
const NONE = '';
/** Same ceiling as the API's ATTACHMENTS_MAX_MB default. */
@@ -80,6 +82,7 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
BlockedComponent,
CustomFieldComponent,
ReactionsComponent,
MarkdownComponent,
RichEditorComponent,
@@ -843,6 +846,16 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
>
<pi-label>Área</pi-label>
</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)) {
<pi-action-button
@@ -1314,10 +1327,26 @@ export class IssuePage {
storyPoints?: number | null;
startDate?: string | null;
dueDate?: string | null;
customFields?: { fieldId: string; value: string | null }[];
}): void {
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 {
const targetKey = this.linkTarget();
if (!targetKey) return;

View File

@@ -27,6 +27,7 @@ import {
} from '@task-stream/web/tracker/application';
import {
BulkChange,
CustomFieldType,
IssuePriority,
IssueSummary,
StatusCategory,
@@ -110,6 +111,13 @@ const numberOf = (key: string) => Number(key.split('-').pop());
[options]="releaseFilterOptions()"
[(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()) {
<span class="ts-muted ts-mono ts-list__count">
{{ facade.total() }} tickets{{
@@ -204,6 +212,9 @@ const numberOf = (key: string) => Number(key.split('-').pop());
↑ {{ parent.key }}</span
>
}
@if (fieldsOf(issue); as fields) {
<span class="ts-list__fields ts-muted">{{ fields }}</span>
}
</td>
<td class="ts-list__type">
<span
@@ -384,6 +395,10 @@ const numberOf = (key: string) => Number(key.split('-').pop());
}
.ts-list__title {
min-width: 16rem;
.ts-list__fields {
display: block;
font-size: 0.75rem;
}
a {
color: inherit;
text-decoration: none;
@@ -513,9 +528,44 @@ export class ListPage {
: `${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>(() => {
const scope = this.scope();
const assignee = this.assignee();
const fields = Object.entries(this.fieldFilters());
return {
search: this.search() || undefined,
categories:
@@ -527,6 +577,9 @@ export class ListPage {
unassigned: assignee === NOBODY || undefined,
assigneeId: assignee && assignee !== NOBODY ? assignee : undefined,
releaseId: this.releaseFilter() || undefined,
customFields: fields.length
? fields.map(([fieldId, value]) => ({ fieldId, value }))
: undefined,
};
});

View File

@@ -23,6 +23,7 @@ import { TransferComponent } from '../ui/transfer';
import { AutomationsComponent } from '../ui/automations';
import { ProjectFacade } from '@task-stream/web/tracker/application';
import {
CustomFieldType,
IssueTypeOption,
ProjectSettings,
SETTING_COLORS,
@@ -31,7 +32,14 @@ import {
} from '@task-stream/web/tracker/domain';
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);
@@ -75,8 +83,9 @@ function idFrom(name: string): string {
<div>
<h2>Configuración</h2>
<p>
Estados, transiciones, tipos de ticket y áreas de este proyecto. Lo
que ya se usa no se borra: se archiva y deja de ofrecerse.
Estados, transiciones, tipos de ticket, áreas y campos de este
proyecto. Lo que ya se usa no se borra: se archiva y deja de
ofrecerse.
</p>
</div>
</div>
@@ -520,6 +529,92 @@ function idFrom(name: string): string {
}
</ol>
</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>
</fieldset>
@@ -860,6 +955,36 @@ function idFrom(name: string): string {
align-items: center;
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 {
--tone: var(--ts-muted);
padding: 0.375rem 0.5rem;
@@ -1074,6 +1199,7 @@ export class SettingsPage {
protected readonly categories = CATEGORIES;
protected readonly colors = SETTING_COLORS;
protected readonly icons = TYPE_ICONS;
protected readonly fieldTypes = FIELD_TYPES;
/** What the API has; the draft is compared against it. */
private readonly saved = computed(
@@ -1098,7 +1224,7 @@ export class SettingsPage {
const d = this.draft();
if (!d) return [];
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())))
problems.push('Hay nombres vacíos');
if (
@@ -1117,6 +1243,12 @@ export class SettingsPage {
problems.push('Falta un estado activo «Hecho»');
if (!d.issueTypes.some((t) => !t.archived))
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;
});
@@ -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. */
protected remove(section: Section, index: number): void {
this.patch((d) => {
@@ -1284,10 +1443,19 @@ export class SettingsPage {
protected save(): void {
const draft = this.draft();
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,
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.
transitions: draft.transitions.filter((t) => t.to.length),
transitions: cleaned.transitions.filter((t) => t.to.length),
});
}

View 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);
}
}