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

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