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

@@ -43,6 +43,7 @@ import {
Attachment,
Comment,
DevLink,
Issue,
IssueLink,
IssueLinkType,
IssuePriority,
@@ -70,6 +71,7 @@ import {
MarkdownComponent,
} from '../ui/markdown';
import { RichEditorComponent } from '../ui/rich-editor';
import { CustomFieldComponent } from '../ui/custom-field';
const NONE = '';
/** Same ceiling as the API's ATTACHMENTS_MAX_MB default. */
@@ -80,6 +82,7 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
BlockedComponent,
CustomFieldComponent,
ReactionsComponent,
MarkdownComponent,
RichEditorComponent,
@@ -843,6 +846,16 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
>
<pi-label>Área</pi-label>
</pi-select-field>
@for (field of customFields(); track field.id) {
<ts-custom-field
[field]="field"
[value]="customValue(issue, field.id)"
[disabled]="!project.canContribute() || field.archived"
(valueChange)="
update({ customFields: [{ fieldId: field.id, value: $event }] })
"
/>
}
@if (!issue.trashedAt && canTrash(issue)) {
<pi-action-button
@@ -1314,10 +1327,26 @@ export class IssuePage {
storyPoints?: number | null;
startDate?: string | null;
dueDate?: string | null;
customFields?: { fieldId: string; value: string | null }[];
}): void {
this.facade.update(changes);
}
/** The project's active fields, plus archived ones this ticket still has. */
protected readonly customFields = computed(() => {
const issue = this.facade.issue();
return (this.project.workflow().settings.customFields ?? []).filter(
(f) =>
!f.archived || !!issue?.customFields?.some((v) => v.fieldId === f.id),
);
});
protected customValue(issue: Issue, fieldId: string): string | null {
return (
issue.customFields?.find((v) => v.fieldId === fieldId)?.value ?? null
);
}
protected link(): void {
const targetKey = this.linkTarget();
if (!targetKey) return;