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>
126 lines
3.5 KiB
TypeScript
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);
|
|
}
|
|
}
|