feat: custom fields per project

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

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

View File

@@ -1,5 +1,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({