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