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

@@ -10,6 +10,16 @@ export enum StatusCategory {
CANCELLED = 'CANCELLED',
}
/** What a custom field holds. */
export enum CustomFieldType {
TEXT = 'TEXT',
NUMBER = 'NUMBER',
/** `YYYY-MM-DD`. */
DATE = 'DATE',
/** One of its options. */
SELECT = 'SELECT',
}
export enum IssuePriority {
LOWEST = 'LOWEST',
LOW = 'LOW',

View File

@@ -1,3 +1,4 @@
import { CustomFieldValue } from './workflow';
import {
IssueLinkType,
IssuePriority,
@@ -49,6 +50,8 @@ export interface CreateIssueInput {
storyPoints?: number | null;
startDate?: string | null;
dueDate?: string | null;
/** Only the fields to change; null or '' clears one. */
customFields?: { fieldId: string; value: string | null }[];
assigneeId?: string | null;
releaseId?: string | null;
/** Epic or ticket the new one breaks down. */
@@ -97,6 +100,8 @@ export interface IssueListFilter {
assigneeId?: string;
unassigned?: boolean;
releaseId?: string;
/** Only tickets holding these custom values. */
customFields?: CustomFieldValue[];
limit: number;
}

View File

@@ -7,7 +7,7 @@ import {
SprintStatus,
StatusCategory,
} from './enums';
import { ProjectSettings } from './workflow';
import { CustomFieldValue, ProjectSettings } from './workflow';
export interface UserRef {
id: string;
@@ -126,6 +126,8 @@ export interface IssueSummary {
/** Planned start and end (`YYYY-MM-DD`), for the roadmap. */
startDate: string | null;
dueDate: string | null;
/** Values of the project's custom fields (only those set). */
customFields: CustomFieldValue[];
/** In the trash since then (null: not in it). */
trashedAt: string | null;
commentCount: number;

View File

@@ -1,4 +1,4 @@
import { IssuePriority, StatusCategory } from './enums';
import { CustomFieldType, IssuePriority, StatusCategory } from './enums';
/** Colour tokens a status or ticket type can use (`--ts-c-*`). */
export const SETTING_COLORS = [
@@ -74,12 +74,29 @@ export interface AreaOption {
archived: boolean;
}
/** Per-project configuration: statuses (board order), transitions, types and areas. */
/** A field the project adds to its tickets; its type never changes once saved. */
export interface CustomFieldDefinition {
id: string;
name: string;
type: CustomFieldType;
/** The choices of a SELECT field. */
options: string[];
archived: boolean;
}
/** A ticket's value of a custom field. */
export interface CustomFieldValue {
fieldId: string;
value: string;
}
/** Per-project configuration: statuses (board order), transitions, types, areas and custom fields. */
export interface ProjectSettings {
statuses: WorkflowStatus[];
transitions: StatusTransitions[];
issueTypes: IssueTypeOption[];
areas: AreaOption[];
customFields: CustomFieldDefinition[];
}
export const FINISHED_CATEGORIES: readonly StatusCategory[] = [
@@ -114,6 +131,7 @@ const EMPTY: ProjectSettings = {
transitions: [],
issueTypes: [],
areas: [],
customFields: [],
};
/**
@@ -167,6 +185,15 @@ export class Workflow {
return this.settings.areas.filter((a) => !a.archived);
}
/** Custom fields to fill in (archived ones only show where they have a value). */
activeCustomFields(): CustomFieldDefinition[] {
return (this.settings.customFields ?? []).filter((f) => !f.archived);
}
customField(id: string): CustomFieldDefinition | undefined {
return this.settings.customFields?.find((f) => f.id === id);
}
/** Most tickets the status should hold (null: no limit). */
wipLimit(id: string): number | null {
return this.status(id).wipLimit ?? null;