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:
@@ -23,6 +23,7 @@ import { TransferComponent } from '../ui/transfer';
|
||||
import { AutomationsComponent } from '../ui/automations';
|
||||
import { ProjectFacade } from '@task-stream/web/tracker/application';
|
||||
import {
|
||||
CustomFieldType,
|
||||
IssueTypeOption,
|
||||
ProjectSettings,
|
||||
SETTING_COLORS,
|
||||
@@ -31,7 +32,14 @@ import {
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { CATEGORY, COLOR_LABEL, ICON_LABEL, tone } from '../ui/copy';
|
||||
|
||||
type Section = 'statuses' | 'issueTypes' | 'areas';
|
||||
type Section = 'statuses' | 'issueTypes' | 'areas' | 'customFields';
|
||||
|
||||
const FIELD_TYPES: { id: CustomFieldType; label: string }[] = [
|
||||
{ id: CustomFieldType.TEXT, label: 'Texto' },
|
||||
{ id: CustomFieldType.NUMBER, label: 'Número' },
|
||||
{ id: CustomFieldType.DATE, label: 'Fecha' },
|
||||
{ id: CustomFieldType.SELECT, label: 'Lista de opciones' },
|
||||
];
|
||||
|
||||
const CATEGORIES = Object.values(StatusCategory);
|
||||
|
||||
@@ -75,8 +83,9 @@ function idFrom(name: string): string {
|
||||
<div>
|
||||
<h2>Configuración</h2>
|
||||
<p>
|
||||
Estados, transiciones, tipos de ticket y áreas de este proyecto. Lo
|
||||
que ya se usa no se borra: se archiva y deja de ofrecerse.
|
||||
Estados, transiciones, tipos de ticket, áreas y campos de este
|
||||
proyecto. Lo que ya se usa no se borra: se archiva y deja de
|
||||
ofrecerse.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -520,6 +529,92 @@ function idFrom(name: string): string {
|
||||
}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<!-- Custom fields -->
|
||||
<section class="ts-panel ts-block" aria-labelledby="cf-title">
|
||||
<header class="ts-block__head">
|
||||
<div>
|
||||
<h3 id="cf-title">Campos personalizados</h3>
|
||||
<p class="ts-muted">
|
||||
Datos propios de este proyecto en cada ticket (navegador,
|
||||
cliente, coste…). El tipo no cambia una vez guardado.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="ts-add"
|
||||
[disabled]="d.customFields.length >= 30"
|
||||
(click)="addField()"
|
||||
>
|
||||
+ Añadir campo
|
||||
</button>
|
||||
</header>
|
||||
@if (!d.customFields.length) {
|
||||
<p class="ts-muted ts-empty-fields">Ningún campo todavía.</p>
|
||||
}
|
||||
<ol class="ts-rows">
|
||||
@for (field of d.customFields; track $index; let i = $index) {
|
||||
<li
|
||||
class="ts-row ts-field-grid"
|
||||
[class.is-archived]="field.archived"
|
||||
>
|
||||
<span class="ts-name">
|
||||
<input
|
||||
class="ts-input"
|
||||
maxlength="40"
|
||||
[attr.aria-label]="'Nombre del campo ' + (i + 1)"
|
||||
[value]="field.name"
|
||||
(input)="
|
||||
setName('customFields', i, $any($event.target).value)
|
||||
"
|
||||
/>
|
||||
<code class="ts-id">{{ field.id }}</code>
|
||||
</span>
|
||||
<select
|
||||
class="ts-input ts-field-type"
|
||||
aria-label="Tipo de campo"
|
||||
[disabled]="!isNew('customFields', field.id)"
|
||||
[attr.title]="
|
||||
isNew('customFields', field.id)
|
||||
? null
|
||||
: 'Ya guardado: el tipo no se puede cambiar'
|
||||
"
|
||||
(change)="setFieldType(i, $any($event.target).value)"
|
||||
>
|
||||
@for (t of fieldTypes; track t.id) {
|
||||
<option [value]="t.id" [selected]="t.id === field.type">
|
||||
{{ t.label }}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
<ng-container
|
||||
[ngTemplateOutlet]="archive"
|
||||
[ngTemplateOutletContext]="{
|
||||
section: 'customFields',
|
||||
index: i,
|
||||
item: field,
|
||||
}"
|
||||
/>
|
||||
@if (field.type === 'SELECT') {
|
||||
<label class="ts-field-options">
|
||||
<span>Opciones (una por línea)</span>
|
||||
<textarea
|
||||
rows="3"
|
||||
[attr.aria-label]="'Opciones de ' + field.name"
|
||||
[value]="
|
||||
field.options.join(
|
||||
'
|
||||
'
|
||||
)
|
||||
"
|
||||
(input)="setOptions(i, $any($event.target).value)"
|
||||
></textarea>
|
||||
</label>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
</section>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
@@ -860,6 +955,36 @@ function idFrom(name: string): string {
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
/* Name and archive on top; the type (and options) below. */
|
||||
.ts-field-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(8rem, 1fr) 6.5rem;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
.ts-field-type {
|
||||
grid-row: 2;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
.ts-field-options {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--ts-muted);
|
||||
textarea {
|
||||
font: inherit;
|
||||
color: var(--ts-ink);
|
||||
background: var(--ts-surface);
|
||||
border: 1px solid var(--ts-line);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.375rem 0.5rem;
|
||||
resize: vertical;
|
||||
}
|
||||
}
|
||||
.ts-empty-fields {
|
||||
margin: 0;
|
||||
}
|
||||
.ts-row {
|
||||
--tone: var(--ts-muted);
|
||||
padding: 0.375rem 0.5rem;
|
||||
@@ -1074,6 +1199,7 @@ export class SettingsPage {
|
||||
protected readonly categories = CATEGORIES;
|
||||
protected readonly colors = SETTING_COLORS;
|
||||
protected readonly icons = TYPE_ICONS;
|
||||
protected readonly fieldTypes = FIELD_TYPES;
|
||||
|
||||
/** What the API has; the draft is compared against it. */
|
||||
private readonly saved = computed(
|
||||
@@ -1098,7 +1224,7 @@ export class SettingsPage {
|
||||
const d = this.draft();
|
||||
if (!d) return [];
|
||||
const problems: string[] = [];
|
||||
const lists = [d.statuses, d.issueTypes, d.areas];
|
||||
const lists = [d.statuses, d.issueTypes, d.areas, d.customFields];
|
||||
if (lists.some((list) => list.some((item) => !item.name.trim())))
|
||||
problems.push('Hay nombres vacíos');
|
||||
if (
|
||||
@@ -1117,6 +1243,12 @@ export class SettingsPage {
|
||||
problems.push('Falta un estado activo «Hecho»');
|
||||
if (!d.issueTypes.some((t) => !t.archived))
|
||||
problems.push('Hace falta al menos un tipo de ticket activo');
|
||||
for (const field of d.customFields)
|
||||
if (
|
||||
field.type === CustomFieldType.SELECT &&
|
||||
!field.options.some((o) => o.trim())
|
||||
)
|
||||
problems.push(`El campo «${field.name}» necesita alguna opción`);
|
||||
return problems;
|
||||
});
|
||||
|
||||
@@ -1264,6 +1396,33 @@ export class SettingsPage {
|
||||
);
|
||||
}
|
||||
|
||||
protected addField(): void {
|
||||
this.patch((d) =>
|
||||
d.customFields.push({
|
||||
id: this.uniqueId(d.customFields, 'NUEVO_CAMPO'),
|
||||
name: 'Nuevo campo',
|
||||
type: CustomFieldType.TEXT,
|
||||
options: [],
|
||||
archived: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
protected setFieldType(index: number, type: CustomFieldType): void {
|
||||
this.patch((d) => {
|
||||
const field = d.customFields[index];
|
||||
field.type = type;
|
||||
if (type !== CustomFieldType.SELECT) field.options = [];
|
||||
});
|
||||
}
|
||||
|
||||
/** One per line; blank lines and repeats go when saved. */
|
||||
protected setOptions(index: number, text: string): void {
|
||||
this.patch((d) => {
|
||||
d.customFields[index].options = text.split('\n').slice(0, 50);
|
||||
});
|
||||
}
|
||||
|
||||
/** Only for values never saved; saved ones are archived instead. */
|
||||
protected remove(section: Section, index: number): void {
|
||||
this.patch((d) => {
|
||||
@@ -1284,10 +1443,19 @@ export class SettingsPage {
|
||||
protected save(): void {
|
||||
const draft = this.draft();
|
||||
if (!draft || !this.dirty() || this.problems().length) return;
|
||||
this.facade.saveSettings({
|
||||
// Options as the API keeps them, so the draft is clean once saved.
|
||||
const cleaned: ProjectSettings = {
|
||||
...draft,
|
||||
customFields: draft.customFields.map((f) => ({
|
||||
...f,
|
||||
options: [...new Set(f.options.map((o) => o.trim()).filter(Boolean))],
|
||||
})),
|
||||
};
|
||||
this.draft.set(cleaned);
|
||||
this.facade.saveSettings({
|
||||
...cleaned,
|
||||
// Only rows with targets; unknown ids would be rejected anyway.
|
||||
transitions: draft.transitions.filter((t) => t.to.length),
|
||||
transitions: cleaned.transitions.filter((t) => t.to.length),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user