feat: canonical custom field types, validation, and type-aware querying

- Unify field types across all layers: Text, Textarea, SelectOne, SelectMultiple, Date, DateTime, Number
- Add CustomFieldValidationConfig with type-specific Zod schemas
- Add validateCustomFieldValue() dispatching per-type validation
- Add validation_config (JSONB) and default_value columns to custom_fields
- Replace ad-hoc date/datetime/pattern checks with centralized validator
- Type-aware cf.* query operators: gt:, gte:, lt:, lte:, before:, after:, contains:
- Type-aware admin builder UI with inline option editor, min/max, date ranges, constraints
- Type-aware ticket detail rendering: Number inputs, Textarea, SelectMultiple checkbox groups
- Backward compatible: legacy type names mapped to canonical; old pattern field still checked
- Update seed data to canonical PascalCase types
- Migration 0018: add validation_config and default_value to custom_fields

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Gjermund Høsøien Wiggen
2026-06-15 21:29:28 +02:00
parent 70f0924d4b
commit 9679734e3f
15 changed files with 4501 additions and 125 deletions

View File

@@ -2479,48 +2479,134 @@ function CustomFieldsTab() {
await fetchQueueFields();
};
// Type-specific config state
const [selectOptions, setSelectOptions] = useState<string[]>([]);
const [numberMin, setNumberMin] = useState("");
const [numberMax, setNumberMax] = useState("");
const [dateMin, setDateMin] = useState("");
const [dateMax, setDateMax] = useState("");
const [textMinLength, setTextMinLength] = useState("");
const [textMaxLength, setTextMaxLength] = useState("");
const [defaultValue, setDefaultValue] = useState("");
const resetBuilder = () => {
setEditingId(null);
setKey("");
setName("");
setFieldType("text");
setFieldType("Text");
setValues("");
setMaxValues(0);
setPattern("");
setSelectOptions([]);
setNumberMin("");
setNumberMax("");
setDateMin("");
setDateMax("");
setTextMinLength("");
setTextMaxLength("");
setDefaultValue("");
setSaveError(null);
};
// Map legacy field types to canonical names
function canonicalType(t: string): string {
const map: Record<string, string> = {
'text': 'Text',
'textarea': 'Textarea',
'select': 'SelectOne',
'multiselect': 'SelectMultiple',
'date': 'Date',
'datetime': 'DateTime',
'number': 'Number',
};
return map[t.toLowerCase()] ?? t;
}
const hydrateConfig = (field: CustomField) => {
const cfg = field.validation_config ?? {};
const ft = canonicalType(field.field_type);
setSelectOptions([]);
setNumberMin("");
setNumberMax("");
setDateMin("");
setDateMax("");
setTextMinLength("");
setTextMaxLength("");
setDefaultValue(field.default_value ?? "");
if (ft === 'SelectOne' || ft === 'SelectMultiple') {
if (Array.isArray(cfg.options)) {
setSelectOptions(cfg.options.map(String));
} else if (Array.isArray(field.values)) {
setSelectOptions(field.values.map(String));
}
} else if (ft === 'Number') {
if (cfg.min !== undefined) setNumberMin(String(cfg.min));
if (cfg.max !== undefined) setNumberMax(String(cfg.max));
} else if (ft === 'Date' || ft === 'DateTime') {
if (cfg.min_date) setDateMin(String(cfg.min_date));
if (cfg.max_date) setDateMax(String(cfg.max_date));
} else if (ft === 'Text' || ft === 'Textarea') {
if (cfg.min_length !== undefined) setTextMinLength(String(cfg.min_length));
if (cfg.max_length !== undefined) setTextMaxLength(String(cfg.max_length));
}
};
const selectField = (field: CustomField) => {
setEditingId(field.id);
setKey(field.key);
setName(field.name);
setFieldType(field.field_type);
setFieldType(canonicalType(field.field_type));
setValues(field.values ? JSON.stringify(field.values, null, 2) : "");
setMaxValues(field.max_values);
setPattern(field.pattern ?? "");
setSaveError(null);
hydrateConfig(field);
};
const buildValidationConfig = (): Record<string, unknown> | null => {
if (fieldType === 'SelectOne' || fieldType === 'SelectMultiple') {
const filtered = selectOptions.filter((o) => o.trim());
if (filtered.length > 0) return { options: filtered };
return null;
}
if (fieldType === 'Number') {
const cfg: Record<string, unknown> = {};
if (numberMin.trim()) cfg.min = Number(numberMin);
if (numberMax.trim()) cfg.max = Number(numberMax);
return Object.keys(cfg).length > 0 ? cfg : null;
}
if (fieldType === 'Date' || fieldType === 'DateTime') {
const cfg: Record<string, unknown> = {};
if (dateMin.trim()) cfg.min_date = dateMin.trim();
if (dateMax.trim()) cfg.max_date = dateMax.trim();
return Object.keys(cfg).length > 0 ? cfg : null;
}
if (fieldType === 'Text' || fieldType === 'Textarea') {
const cfg: Record<string, unknown> = {};
if (textMinLength.trim()) cfg.min_length = Number(textMinLength);
if (textMaxLength.trim()) cfg.max_length = Number(textMaxLength);
if (pattern.trim()) cfg.pattern = pattern.trim();
return Object.keys(cfg).length > 0 ? cfg : null;
}
return null;
};
const handleSave = async () => {
if (!name.trim()) return;
let parsedValues: unknown = null;
if (values.trim()) {
try {
parsedValues = JSON.parse(values);
} catch {
setSaveError("Invalid JSON in values.");
return;
}
}
const validationConfig = buildValidationConfig();
setSaving(true);
setSaveError(null);
const payload = {
key: key.trim() || undefined,
name: name.trim(),
field_type: fieldType,
values: parsedValues,
values: selectOptions.length > 0 ? selectOptions : null,
max_values: maxValues,
pattern: pattern.trim() || null,
validation_config: validationConfig,
default_value: defaultValue.trim() || null,
};
const { data, error } = editingId
? await updateCustomField(editingId, payload)
@@ -2619,12 +2705,16 @@ function CustomFieldsTab() {
<div className="grid gap-3 md:grid-cols-2">
<div className="grid gap-1.5">
<Label htmlFor="cf-type">Field type</Label>
<Select value={fieldType} onValueChange={(value) => setFieldType(value ?? "text")}>
<Select value={fieldType} onValueChange={(value) => { setFieldType(value ?? "Text"); setSaveError(null); }}>
<SelectTrigger id="cf-type"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="text">Text</SelectItem>
<SelectItem value="select">Select</SelectItem>
<SelectItem value="multiselect">Multi-select</SelectItem>
<SelectItem value="Text">Text</SelectItem>
<SelectItem value="Textarea">Textarea</SelectItem>
<SelectItem value="SelectOne">Select one</SelectItem>
<SelectItem value="SelectMultiple">Select multiple</SelectItem>
<SelectItem value="Date">Date</SelectItem>
<SelectItem value="DateTime">Date &amp; time</SelectItem>
<SelectItem value="Number">Number</SelectItem>
</SelectContent>
</Select>
</div>
@@ -2634,14 +2724,103 @@ function CustomFieldsTab() {
</div>
</div>
</ScripFlowNode>
<ScripFlowNode label="02" title="Validation and values" description="Define optional select values and pattern validation.">
<ScripFlowNode label="02" title="Validation" description="Type-specific constraints and default value.">
{(fieldType === 'SelectOne' || fieldType === 'SelectMultiple') && (
<div className="grid gap-1.5">
<Label>Options</Label>
<div className="flex flex-wrap gap-1.5">
{selectOptions.map((opt, i) => (
<span key={i} className="inline-flex items-center gap-1 rounded-md border border-border/50 bg-card px-2 py-1 text-xs">
{opt}
<button
type="button"
onClick={() => setSelectOptions((prev) => prev.filter((_, j) => j !== i))}
className="ml-0.5 flex h-4 w-4 items-center justify-center rounded text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
>
×
</button>
</span>
))}
</div>
<div className="flex gap-1.5">
<Input
placeholder="Add option…"
className="flex-1 font-mono text-xs"
onKeyDown={(event) => {
if (event.key === 'Enter') {
const input = event.currentTarget;
const val = input.value.trim();
if (val && !selectOptions.includes(val)) {
setSelectOptions((prev) => [...prev, val]);
}
input.value = '';
}
}}
/>
<Button
type="button"
variant="outline"
size="sm"
className="h-8"
onClick={(event) => {
const input = (event.currentTarget as HTMLButtonElement).previousElementSibling as HTMLInputElement;
const val = input?.value?.trim();
if (val && !selectOptions.includes(val)) {
setSelectOptions((prev) => [...prev, val]);
}
if (input) input.value = '';
}}
>
Add
</Button>
</div>
</div>
)}
{fieldType === 'Number' && (
<div className="grid gap-3 md:grid-cols-2">
<div className="grid gap-1.5">
<Label htmlFor="cf-num-min">Minimum</Label>
<Input id="cf-num-min" type="number" placeholder="No minimum" value={numberMin} onChange={(event) => setNumberMin(event.target.value)} />
</div>
<div className="grid gap-1.5">
<Label htmlFor="cf-num-max">Maximum</Label>
<Input id="cf-num-max" type="number" placeholder="No maximum" value={numberMax} onChange={(event) => setNumberMax(event.target.value)} />
</div>
</div>
)}
{(fieldType === 'Date' || fieldType === 'DateTime') && (
<div className="grid gap-3 md:grid-cols-2">
<div className="grid gap-1.5">
<Label htmlFor="cf-date-min">Minimum date</Label>
<Input id="cf-date-min" type={fieldType === 'DateTime' ? 'datetime-local' : 'date'} value={dateMin} onChange={(event) => setDateMin(event.target.value)} />
</div>
<div className="grid gap-1.5">
<Label htmlFor="cf-date-max">Maximum date</Label>
<Input id="cf-date-max" type={fieldType === 'DateTime' ? 'datetime-local' : 'date'} value={dateMax} onChange={(event) => setDateMax(event.target.value)} />
</div>
</div>
)}
{(fieldType === 'Text' || fieldType === 'Textarea') && (
<>
<div className="grid gap-3 md:grid-cols-2">
<div className="grid gap-1.5">
<Label htmlFor="cf-text-min">Min length</Label>
<Input id="cf-text-min" type="number" min={0} placeholder="No minimum" value={textMinLength} onChange={(event) => setTextMinLength(event.target.value)} />
</div>
<div className="grid gap-1.5">
<Label htmlFor="cf-text-max">Max length</Label>
<Input id="cf-text-max" type="number" min={0} placeholder="No maximum" value={textMaxLength} onChange={(event) => setTextMaxLength(event.target.value)} />
</div>
</div>
<div className="grid gap-1.5">
<Label htmlFor="cf-pattern">Pattern</Label>
<Input id="cf-pattern" placeholder="Optional regular expression" value={pattern} onChange={(event) => setPattern(event.target.value)} className="font-mono text-xs" />
</div>
</>
)}
<div className="grid gap-1.5">
<Label htmlFor="cf-values">Values JSON</Label>
<Textarea id="cf-values" placeholder='["Low", "Medium", "High"]' value={values} onChange={(event) => setValues(event.target.value)} className="min-h-28 font-mono text-xs" />
</div>
<div className="grid gap-1.5">
<Label htmlFor="cf-pattern">Pattern</Label>
<Input id="cf-pattern" placeholder="Optional regular expression" value={pattern} onChange={(event) => setPattern(event.target.value)} className="font-mono text-xs" />
<Label htmlFor="cf-default">Default value</Label>
<Input id="cf-default" placeholder="Optional default" value={defaultValue} onChange={(event) => setDefaultValue(event.target.value)} />
</div>
</ScripFlowNode>
{saveError && <div className="text-sm text-destructive">{saveError}</div>}

View File

@@ -115,20 +115,30 @@ function userLabel(users: User[], userId: string | null) {
function formatCfValue(value: string, fieldType: string): string {
if (!value) return "";
if (fieldType === "date") {
const ft = fieldType.toLowerCase();
if (ft === "date") {
try {
return format(new Date(value), "MMM d, yyyy");
} catch {
return value;
}
}
if (fieldType === "datetime") {
if (ft === "datetime") {
try {
return format(new Date(value), "MMM d, yyyy HH:mm");
} catch {
return value;
}
}
if (ft === "number") {
const num = Number(value);
if (!isNaN(num)) {
return new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(num);
}
}
if (ft === "selectmultiple" || ft === "multiselect") {
return value || "None selected";
}
return value;
}
@@ -1307,19 +1317,40 @@ export default function TicketDetailPage({
{queueFields.map((assignment) => {
const field = assignment.custom_field;
const fieldId = assignment.custom_field_id;
const options = Array.isArray(field?.values) ? field.values.map((v) => String(v)) : [];
// Resolve select options: validation_config.options first, then field.values
const cfg = (field?.validation_config ?? {}) as Record<string, unknown>;
const selectOptions: string[] = Array.isArray(cfg.options)
? cfg.options.map(String)
: Array.isArray(field?.values)
? field.values.map((v) => String(v))
: [];
const fieldType = field?.field_type ?? '';
const currentValue = ticket?.custom_fields?.find((item) => item.custom_field_id === fieldId)?.value ?? "";
const isEditing = editingFieldId === fieldId;
const draftValue = customFieldDrafts[fieldId] ?? currentValue;
const isSaving = customFieldSaving === fieldId;
// Normalize legacy type names for comparison
const ft = fieldType.toLowerCase();
const isSelect = ft === 'selectone' || ft === 'select' || selectOptions.length > 0;
const isMultiSelect = ft === 'selectmultiple' || ft === 'multiselect';
const isDateType = ft === 'date';
const isDateTimeType = ft === 'datetime';
const isNumberType = ft === 'number';
const isTextareaType = ft === 'textarea';
const isTextType = ft === 'text' || ft === '';
// Validation config values
const numMin = cfg.min !== undefined ? Number(cfg.min) : undefined;
const numMax = cfg.max !== undefined ? Number(cfg.max) : undefined;
return (
<div key={assignment.id}>
<label className="mb-1 block text-[10px] font-medium text-muted-foreground">{field?.name ?? fieldId}</label>
{isEditing ? (
<div className="flex items-center gap-1.5">
{options.length > 0 ? (
<div className="flex items-start gap-1.5">
{isSelect && selectOptions.length > 0 && !isMultiSelect ? (
<select
value={draftValue}
onChange={(event) => {
@@ -1332,13 +1363,38 @@ export default function TicketDetailPage({
className="h-8 flex-1 rounded-md border border-ring bg-card px-2 text-sm text-foreground outline-none"
>
<option value="">Not set</option>
{options.map((option) => (
{selectOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
) : field?.field_type === 'date' ? (
) : isMultiSelect && selectOptions.length > 0 ? (
<div className="flex-1 space-y-1">
{selectOptions.map((option) => {
const selected = draftValue.split(',').map((s) => s.trim()).includes(option);
return (
<label key={option} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={selected}
onChange={() => {
const parts = draftValue ? draftValue.split(',').map((s) => s.trim()).filter(Boolean) : [];
const next = selected
? parts.filter((p) => p !== option)
: [...parts, option];
const nextValue = next.join(', ');
setCustomFieldDrafts((c) => ({ ...c, [fieldId]: nextValue }));
void handleCustomFieldSave(fieldId, nextValue);
}}
className="size-4 rounded border-border accent-primary"
/>
{option}
</label>
);
})}
</div>
) : isDateType ? (
<input
type="date"
value={draftValue}
@@ -1350,7 +1406,7 @@ export default function TicketDetailPage({
autoFocus
className="h-8 flex-1 rounded-md border border-ring bg-card px-2 text-sm text-foreground outline-none"
/>
) : field?.field_type === 'datetime' ? (
) : isDateTimeType ? (
<input
type="datetime-local"
value={draftValue ? draftValue.slice(0, 16) : ""}
@@ -1363,6 +1419,58 @@ export default function TicketDetailPage({
autoFocus
className="h-8 flex-1 rounded-md border border-ring bg-card px-2 text-sm text-foreground outline-none"
/>
) : isNumberType ? (
<input
type="number"
min={numMin}
max={numMax}
value={draftValue}
onChange={(event) => {
setCustomFieldDrafts((c) => ({ ...c, [fieldId]: event.target.value }));
}}
onBlur={() => {
if (draftValue !== currentValue) {
void handleCustomFieldSave(fieldId);
} else {
setEditingFieldId(null);
}
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
void handleCustomFieldSave(fieldId);
} else if (event.key === "Escape") {
setCustomFieldDrafts((c) => ({ ...c, [fieldId]: currentValue }));
setEditingFieldId(null);
}
}}
autoFocus
placeholder="Not set"
className="h-8 flex-1 rounded-md border border-ring bg-card px-2 text-sm text-foreground outline-none placeholder:text-muted-foreground"
/>
) : isTextareaType ? (
<textarea
value={draftValue}
onChange={(event) =>
setCustomFieldDrafts((c) => ({ ...c, [fieldId]: event.target.value }))
}
onBlur={() => {
if (draftValue.trim() !== currentValue) {
void handleCustomFieldSave(fieldId);
} else {
setEditingFieldId(null);
}
}}
onKeyDown={(event) => {
if (event.key === "Escape") {
setCustomFieldDrafts((c) => ({ ...c, [fieldId]: currentValue }));
setEditingFieldId(null);
}
}}
autoFocus
placeholder="Not set"
rows={3}
className="flex-1 rounded-md border border-ring bg-card px-2 py-1.5 text-sm text-foreground outline-none placeholder:text-muted-foreground resize-y"
/>
) : (
<input
value={draftValue}

View File

@@ -120,7 +120,10 @@ export function ScripWizard({ open, onClose, onCreate, queues, customFields, tem
const stepLabels = ["Trigger", "Action", "Configure", "Review"];
const selectedQueue = queueId ? queues.find((q) => q.id === queueId) : null;
const dateFields = customFields.filter((cf) => cf.field_type === "date" || cf.field_type === "datetime");
const dateFields = customFields.filter((cf) => {
const ft = cf.field_type.toLowerCase();
return ft === "date" || ft === "datetime";
});
const emailTemplates = templates.filter((t) => !t.queue_id || t.queue_id === queueId);
return (

View File

@@ -340,6 +340,8 @@ export async function createCustomField(data: {
values?: unknown | null;
max_values?: number;
pattern?: string | null;
validation_config?: Record<string, unknown> | null;
default_value?: string | null;
}): Promise<{ data: CustomField | null; error: string | null }> {
return request<CustomField>("/custom-fields", { method: "POST", body: JSON.stringify(data) });
}
@@ -351,6 +353,8 @@ export async function updateCustomField(id: string, data: {
values?: unknown | null;
max_values?: number;
pattern?: string | null;
validation_config?: Record<string, unknown> | null;
default_value?: string | null;
}): Promise<{ data: CustomField | null; error: string | null }> {
return request<CustomField>(`/custom-fields/${id}`, { method: "PATCH", body: JSON.stringify(data) });
}

View File

@@ -100,8 +100,22 @@ export interface CustomField {
values: unknown | null;
max_values: number;
pattern: string | null;
validation_config: Record<string, unknown> | null;
default_value: string | null;
}
export const CUSTOM_FIELD_TYPES = [
'Text',
'Textarea',
'SelectOne',
'SelectMultiple',
'Date',
'DateTime',
'Number',
] as const;
export type CustomFieldType = (typeof CUSTOM_FIELD_TYPES)[number];
export interface QueueCustomField {
id: string;
queue_id: string;