Files
task-stream/libs/web/tracker/presenters/src/lib/ui/custom-field.ts
Josep Piguillem f7e850068b 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>
2026-10-15 09:31:12 +02:00

126 lines
3.5 KiB
TypeScript

import {
ChangeDetectionStrategy,
Component,
computed,
input,
linkedSignal,
output,
} from '@angular/core';
import {
PiDateFieldComponent,
PiLabelDirective,
PiNumberFieldComponent,
PiSelectFieldComponent,
PiTextFieldComponent,
type PiSelectFieldOption,
} from '@pi/ui';
import {
CustomFieldDefinition,
CustomFieldType,
} from '@task-stream/web/tracker/domain';
/** `YYYY-MM-DD` of a local date. */
const dayOf = (date: Date) =>
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
/**
* One custom field of a ticket, with the control its type asks for. Lists
* and dates are saved when picked; text and numbers when leaving the field.
* Emits the new value (null clears it) only when it changed.
*/
@Component({
selector: 'ts-custom-field',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
PiDateFieldComponent,
PiLabelDirective,
PiNumberFieldComponent,
PiSelectFieldComponent,
PiTextFieldComponent,
],
template: `
@switch (field().type) {
@case (types.SELECT) {
<pi-select-field
[options]="options()"
[disabled]="disabled()"
[value]="value() ?? ''"
(valueChange)="commit($event || null)"
>
<pi-label>{{ field().name }}</pi-label>
</pi-select-field>
}
@case (types.DATE) {
<pi-date-field
[disabled]="disabled()"
[value]="date()"
(valueChange)="commit($event ? dayOf($event) : null)"
>
<pi-label>{{ field().name }}</pi-label>
</pi-date-field>
}
@case (types.NUMBER) {
<pi-number-field
[disabled]="disabled()"
[(value)]="number"
(touch)="commit(number() === null ? null : '' + number())"
>
<pi-label>{{ field().name }}</pi-label>
</pi-number-field>
}
@default {
<pi-text-field
[disabled]="disabled()"
[(value)]="text"
(touch)="commit(text().trim() || null)"
>
<pi-label>{{ field().name }}</pi-label>
</pi-text-field>
}
}
`,
styles: `
:host {
display: block;
}
`,
})
export class CustomFieldComponent {
readonly field = input.required<CustomFieldDefinition>();
readonly value = input<string | null>(null);
readonly disabled = input(false);
readonly valueChange = output<string | null>();
protected readonly types = CustomFieldType;
protected readonly dayOf = dayOf;
/** What is being typed; follows the saved value when it changes. */
protected readonly text = linkedSignal(() => this.value() ?? '');
protected readonly number = linkedSignal(() => {
const value = this.value();
return value === null || value === '' ? null : Number(value);
});
protected readonly date = computed(() => {
const day = this.value();
return day ? new Date(`${day.slice(0, 10)}T00:00:00`) : null;
});
/** The choices; one no longer offered still shows while the ticket has it. */
protected readonly options = computed<PiSelectFieldOption<string>[]>(() => {
const current = this.value();
const choices = this.field().options;
return [
{ value: '', label: 'Sin valor' },
...choices.map((o) => ({ value: o, label: o })),
...(current && !choices.includes(current)
? [{ value: current, label: current }]
: []),
];
});
protected commit(next: string | null): void {
if (next !== (this.value() || null)) this.valueChange.emit(next);
}
}