feat: custom fields per project

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

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

View File

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