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 {
|
||||
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:
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user