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:
@@ -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 & 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>}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user