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:
2
drizzle/migrations/0018_dapper_jack_power.sql
Normal file
2
drizzle/migrations/0018_dapper_jack_power.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "custom_fields" ADD COLUMN "validation_config" jsonb DEFAULT '{}'::jsonb;--> statement-breakpoint
|
||||||
|
ALTER TABLE "custom_fields" ADD COLUMN "default_value" text;
|
||||||
2018
drizzle/migrations/meta/0018_snapshot.json
Normal file
2018
drizzle/migrations/meta/0018_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -127,6 +127,13 @@
|
|||||||
"when": 1781095552496,
|
"when": 1781095552496,
|
||||||
"tag": "0017_redundant_the_renegades",
|
"tag": "0017_redundant_the_renegades",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 18,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1781551130161,
|
||||||
|
"tag": "0018_dapper_jack_power",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
1783
package-lock.json
generated
Normal file
1783
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -95,6 +95,8 @@ export const customFields = pgTable('custom_fields', {
|
|||||||
values: jsonb('values'),
|
values: jsonb('values'),
|
||||||
max_values: integer('max_values').notNull().default(1),
|
max_values: integer('max_values').notNull().default(1),
|
||||||
pattern: text('pattern'),
|
pattern: text('pattern'),
|
||||||
|
validation_config: jsonb('validation_config').default(sql`'{}'::jsonb`),
|
||||||
|
default_value: text('default_value'),
|
||||||
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -441,30 +441,30 @@ async function main() {
|
|||||||
const impactField = await ensureCustomField(db, {
|
const impactField = await ensureCustomField(db, {
|
||||||
key: 'impact',
|
key: 'impact',
|
||||||
name: 'Impact',
|
name: 'Impact',
|
||||||
field_type: 'select',
|
field_type: 'SelectOne',
|
||||||
values: ['Low', 'Medium', 'High', 'Critical'],
|
values: ['Low', 'Medium', 'High', 'Critical'],
|
||||||
});
|
});
|
||||||
const locationField = await ensureCustomField(db, {
|
const locationField = await ensureCustomField(db, {
|
||||||
key: 'location',
|
key: 'location',
|
||||||
name: 'Location',
|
name: 'Location',
|
||||||
field_type: 'text',
|
field_type: 'Text',
|
||||||
});
|
});
|
||||||
const assetField = await ensureCustomField(db, {
|
const assetField = await ensureCustomField(db, {
|
||||||
key: 'asset_tag',
|
key: 'asset_tag',
|
||||||
name: 'Asset tag',
|
name: 'Asset tag',
|
||||||
field_type: 'text',
|
field_type: 'Text',
|
||||||
pattern: '^ASSET-[0-9]{4}$',
|
pattern: '^ASSET-[0-9]{4}$',
|
||||||
});
|
});
|
||||||
const channelField = await ensureCustomField(db, {
|
const channelField = await ensureCustomField(db, {
|
||||||
key: 'channel',
|
key: 'channel',
|
||||||
name: 'Channel',
|
name: 'Channel',
|
||||||
field_type: 'select',
|
field_type: 'SelectOne',
|
||||||
values: ['Portal', 'Email', 'Phone', 'Walk-up', 'Monitoring'],
|
values: ['Portal', 'Email', 'Phone', 'Walk-up', 'Monitoring'],
|
||||||
});
|
});
|
||||||
const outcomeField = await ensureCustomField(db, {
|
const outcomeField = await ensureCustomField(db, {
|
||||||
key: 'resolution_outcome',
|
key: 'resolution_outcome',
|
||||||
name: 'Resolution outcome',
|
name: 'Resolution outcome',
|
||||||
field_type: 'select',
|
field_type: 'SelectOne',
|
||||||
values: ['Completed', 'Workaround', 'Duplicate', 'Declined'],
|
values: ['Completed', 'Workaround', 'Duplicate', 'Declined'],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
157
src/models/custom-field-validation.ts
Normal file
157
src/models/custom-field-validation.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import type { CustomField } from './custom-field.ts';
|
||||||
|
|
||||||
|
export interface ValidationResult {
|
||||||
|
valid: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch to the correct type-specific validator based on field.field_type.
|
||||||
|
*/
|
||||||
|
export function validateCustomFieldValue(
|
||||||
|
field: CustomField,
|
||||||
|
value: string,
|
||||||
|
): ValidationResult {
|
||||||
|
if (!value) return { valid: true };
|
||||||
|
|
||||||
|
const config = (field.validation_config ?? {}) as Record<string, unknown>;
|
||||||
|
|
||||||
|
// Backward-compatible pattern check (field-level pattern, not validation_config)
|
||||||
|
if (field.pattern) {
|
||||||
|
try {
|
||||||
|
const regex = new RegExp(field.pattern);
|
||||||
|
if (!regex.test(value)) {
|
||||||
|
return { valid: false, error: 'Value does not match the required pattern' };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Invalid regex — skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (field.field_type) {
|
||||||
|
case 'Number':
|
||||||
|
return validateNumberValue(value, config);
|
||||||
|
case 'Date':
|
||||||
|
return validateDateValue(value, config);
|
||||||
|
case 'DateTime':
|
||||||
|
return validateDateTimeValue(value, config);
|
||||||
|
case 'SelectOne':
|
||||||
|
case 'SelectMultiple':
|
||||||
|
return validateSelectValue(value, field, config);
|
||||||
|
case 'Text':
|
||||||
|
case 'Textarea':
|
||||||
|
return validateTextValue(value, config);
|
||||||
|
default:
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateNumberValue(
|
||||||
|
value: string,
|
||||||
|
config: Record<string, unknown>,
|
||||||
|
): ValidationResult {
|
||||||
|
const num = Number(value);
|
||||||
|
if (isNaN(num)) {
|
||||||
|
return { valid: false, error: 'Value must be a number' };
|
||||||
|
}
|
||||||
|
if (config.min !== undefined && num < Number(config.min)) {
|
||||||
|
return { valid: false, error: `Value must be at least ${config.min}` };
|
||||||
|
}
|
||||||
|
if (config.max !== undefined && num > Number(config.max)) {
|
||||||
|
return { valid: false, error: `Value must be at most ${config.max}` };
|
||||||
|
}
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateDateValue(
|
||||||
|
value: string,
|
||||||
|
config: Record<string, unknown>,
|
||||||
|
): ValidationResult {
|
||||||
|
// Accept YYYY-MM-DD format
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
||||||
|
return { valid: false, error: 'Value must be a date in YYYY-MM-DD format' };
|
||||||
|
}
|
||||||
|
const parsed = new Date(value);
|
||||||
|
if (isNaN(parsed.getTime())) {
|
||||||
|
return { valid: false, error: 'Invalid date' };
|
||||||
|
}
|
||||||
|
if (config.min_date) {
|
||||||
|
const minDate = new Date(String(config.min_date));
|
||||||
|
if (!isNaN(minDate.getTime()) && parsed < minDate) {
|
||||||
|
return { valid: false, error: `Date must be on or after ${config.min_date}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (config.max_date) {
|
||||||
|
const maxDate = new Date(String(config.max_date));
|
||||||
|
if (!isNaN(maxDate.getTime()) && parsed > maxDate) {
|
||||||
|
return { valid: false, error: `Date must be on or before ${config.max_date}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateDateTimeValue(
|
||||||
|
value: string,
|
||||||
|
config: Record<string, unknown>,
|
||||||
|
): ValidationResult {
|
||||||
|
const parsed = new Date(value);
|
||||||
|
if (isNaN(parsed.getTime())) {
|
||||||
|
return { valid: false, error: 'Value must be a valid ISO 8601 datetime' };
|
||||||
|
}
|
||||||
|
if (config.min_date) {
|
||||||
|
const minDate = new Date(String(config.min_date));
|
||||||
|
if (!isNaN(minDate.getTime()) && parsed < minDate) {
|
||||||
|
return { valid: false, error: `Datetime must be on or after ${config.min_date}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (config.max_date) {
|
||||||
|
const maxDate = new Date(String(config.max_date));
|
||||||
|
if (!isNaN(maxDate.getTime()) && parsed > maxDate) {
|
||||||
|
return { valid: false, error: `Datetime must be on or before ${config.max_date}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSelectValue(
|
||||||
|
value: string,
|
||||||
|
field: CustomField,
|
||||||
|
config: Record<string, unknown>,
|
||||||
|
): ValidationResult {
|
||||||
|
const allowed: string[] = [];
|
||||||
|
|
||||||
|
// Check validation_config.options first, then fall back to field.values
|
||||||
|
if (Array.isArray(config.options)) {
|
||||||
|
allowed.push(...config.options.map(String));
|
||||||
|
} else if (Array.isArray(field.values)) {
|
||||||
|
allowed.push(...field.values.map(String));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowed.length > 0 && !allowed.includes(value)) {
|
||||||
|
return { valid: false, error: `Value is not an allowed option. Allowed: ${allowed.join(', ')}` };
|
||||||
|
}
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateTextValue(
|
||||||
|
value: string,
|
||||||
|
config: Record<string, unknown>,
|
||||||
|
): ValidationResult {
|
||||||
|
if (config.min_length !== undefined && value.length < Number(config.min_length)) {
|
||||||
|
return { valid: false, error: `Value must be at least ${config.min_length} characters` };
|
||||||
|
}
|
||||||
|
if (config.max_length !== undefined && value.length > Number(config.max_length)) {
|
||||||
|
return { valid: false, error: `Value must be at most ${config.max_length} characters` };
|
||||||
|
}
|
||||||
|
if (config.pattern && typeof config.pattern === 'string') {
|
||||||
|
try {
|
||||||
|
const regex = new RegExp(config.pattern);
|
||||||
|
if (!regex.test(value)) {
|
||||||
|
return { valid: false, error: 'Value does not match the required pattern' };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Invalid regex — skip validation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
@@ -1,13 +1,71 @@
|
|||||||
import type { InferSelectModel } from 'drizzle-orm';
|
import type { InferSelectModel } from 'drizzle-orm';
|
||||||
|
import { z } from 'zod';
|
||||||
import { customFields } from '../db/schema.ts';
|
import { customFields } from '../db/schema.ts';
|
||||||
|
|
||||||
export type CustomField = InferSelectModel<typeof customFields>;
|
export type CustomField = InferSelectModel<typeof customFields>;
|
||||||
|
|
||||||
export const CustomFieldType = {
|
export const CustomFieldType = {
|
||||||
|
Text: 'Text',
|
||||||
|
Textarea: 'Textarea',
|
||||||
SelectOne: 'SelectOne',
|
SelectOne: 'SelectOne',
|
||||||
SelectMultiple: 'SelectMultiple',
|
SelectMultiple: 'SelectMultiple',
|
||||||
Text: 'Text',
|
|
||||||
Date: 'Date',
|
Date: 'Date',
|
||||||
|
DateTime: 'DateTime',
|
||||||
|
Number: 'Number',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type CustomFieldType = (typeof CustomFieldType)[keyof typeof CustomFieldType];
|
export type CustomFieldType = (typeof CustomFieldType)[keyof typeof CustomFieldType];
|
||||||
|
|
||||||
|
export const CUSTOM_FIELD_TYPES = Object.values(CustomFieldType) as [string, ...string[]];
|
||||||
|
|
||||||
|
// Validation config per type
|
||||||
|
export const NumberValidationConfig = z.object({
|
||||||
|
min: z.number().optional(),
|
||||||
|
max: z.number().optional(),
|
||||||
|
}).optional();
|
||||||
|
|
||||||
|
export const DateValidationConfig = z.object({
|
||||||
|
min_date: z.string().optional(),
|
||||||
|
max_date: z.string().optional(),
|
||||||
|
}).optional();
|
||||||
|
|
||||||
|
export const DateTimeValidationConfig = z.object({
|
||||||
|
min_date: z.string().optional(),
|
||||||
|
max_date: z.string().optional(),
|
||||||
|
}).optional();
|
||||||
|
|
||||||
|
export const TextValidationConfig = z.object({
|
||||||
|
min_length: z.number().int().min(0).optional(),
|
||||||
|
max_length: z.number().int().min(0).optional(),
|
||||||
|
pattern: z.string().optional(),
|
||||||
|
}).optional();
|
||||||
|
|
||||||
|
export const SelectValidationConfig = z.object({
|
||||||
|
options: z.array(z.string()).optional(),
|
||||||
|
}).optional();
|
||||||
|
|
||||||
|
// Generic validation config — permissive at the API boundary, refined per type in validation
|
||||||
|
export const CustomFieldValidationConfig = z.record(z.unknown()).nullable().default(null);
|
||||||
|
|
||||||
|
// Schemas for create/update
|
||||||
|
export const CreateCustomFieldSchema = z.object({
|
||||||
|
key: z.string().optional(),
|
||||||
|
name: z.string().min(1),
|
||||||
|
field_type: z.enum(CUSTOM_FIELD_TYPES as [string, ...string[]]),
|
||||||
|
values: z.unknown().nullable().optional(),
|
||||||
|
max_values: z.number().int().min(1).optional().default(1),
|
||||||
|
pattern: z.string().nullable().optional(),
|
||||||
|
validation_config: z.record(z.unknown()).nullable().optional(),
|
||||||
|
default_value: z.string().nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const UpdateCustomFieldSchema = z.object({
|
||||||
|
key: z.string().optional(),
|
||||||
|
name: z.string().min(1).optional(),
|
||||||
|
field_type: z.enum(CUSTOM_FIELD_TYPES as [string, ...string[]]).optional(),
|
||||||
|
values: z.unknown().nullable().optional(),
|
||||||
|
max_values: z.number().int().min(1).optional(),
|
||||||
|
pattern: z.string().nullable().optional(),
|
||||||
|
validation_config: z.record(z.unknown()).nullable().optional(),
|
||||||
|
default_value: z.string().nullable().optional(),
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { HTTPException } from 'hono/http-exception';
|
|||||||
import type { Db } from '../db/index.ts';
|
import type { Db } from '../db/index.ts';
|
||||||
import { customFields, queueCustomFields } from '../db/schema.ts';
|
import { customFields, queueCustomFields } from '../db/schema.ts';
|
||||||
import { and, asc, eq } from 'drizzle-orm';
|
import { and, asc, eq } from 'drizzle-orm';
|
||||||
|
import { CreateCustomFieldSchema, UpdateCustomFieldSchema } from '../models/custom-field.ts';
|
||||||
|
import { ZodError } from 'zod';
|
||||||
|
|
||||||
function makeFieldKey(value: string): string {
|
function makeFieldKey(value: string): string {
|
||||||
const key = value
|
const key = value
|
||||||
@@ -13,6 +15,10 @@ function makeFieldKey(value: string): string {
|
|||||||
return key || 'field';
|
return key || 'field';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatZodError(err: ZodError): string {
|
||||||
|
return err.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ');
|
||||||
|
}
|
||||||
|
|
||||||
export function createCustomFieldsRouter(db: Db): Hono {
|
export function createCustomFieldsRouter(db: Db): Hono {
|
||||||
const router = new Hono();
|
const router = new Hono();
|
||||||
|
|
||||||
@@ -25,20 +31,28 @@ export function createCustomFieldsRouter(db: Db): Hono {
|
|||||||
|
|
||||||
router.post('/', async (c) => {
|
router.post('/', async (c) => {
|
||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
const { name, field_type, values, max_values, pattern } = body;
|
|
||||||
const key = makeFieldKey(String(body.key ?? name ?? ''));
|
|
||||||
|
|
||||||
if (!name || !field_type) {
|
let parsed;
|
||||||
throw new HTTPException(400, { message: 'name and field_type are required' });
|
try {
|
||||||
|
parsed = CreateCustomFieldSchema.parse(body);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ZodError) {
|
||||||
|
throw new HTTPException(400, { message: formatZodError(err) });
|
||||||
}
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = makeFieldKey(String(parsed.key ?? parsed.name ?? ''));
|
||||||
|
|
||||||
const [cf] = await db.insert(customFields).values({
|
const [cf] = await db.insert(customFields).values({
|
||||||
key,
|
key,
|
||||||
name,
|
name: parsed.name,
|
||||||
field_type,
|
field_type: parsed.field_type,
|
||||||
values: values ?? null,
|
values: (parsed.values as any) ?? null,
|
||||||
max_values: max_values ?? 1,
|
max_values: parsed.max_values ?? 1,
|
||||||
pattern: pattern ?? null,
|
pattern: parsed.pattern ?? null,
|
||||||
|
validation_config: (parsed.validation_config as any) ?? null,
|
||||||
|
default_value: parsed.default_value ?? null,
|
||||||
}).returning();
|
}).returning();
|
||||||
|
|
||||||
if (!cf) {
|
if (!cf) {
|
||||||
@@ -52,6 +66,16 @@ export function createCustomFieldsRouter(db: Db): Hono {
|
|||||||
const id = c.req.param('id');
|
const id = c.req.param('id');
|
||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
|
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = UpdateCustomFieldSchema.parse(body);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ZodError) {
|
||||||
|
throw new HTTPException(400, { message: formatZodError(err) });
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
const existing = await db.query.customFields.findFirst({
|
const existing = await db.query.customFields.findFirst({
|
||||||
where: eq(customFields.id, id),
|
where: eq(customFields.id, id),
|
||||||
});
|
});
|
||||||
@@ -61,12 +85,18 @@ export function createCustomFieldsRouter(db: Db): Hono {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updateData: Partial<typeof customFields.$inferInsert> = {};
|
const updateData: Partial<typeof customFields.$inferInsert> = {};
|
||||||
if (body.key !== undefined) updateData.key = makeFieldKey(String(body.key));
|
if (parsed.key !== undefined) updateData.key = makeFieldKey(String(parsed.key));
|
||||||
if (body.name !== undefined) updateData.name = String(body.name);
|
if (parsed.name !== undefined) updateData.name = String(parsed.name);
|
||||||
if (body.field_type !== undefined) updateData.field_type = String(body.field_type);
|
if (parsed.field_type !== undefined) updateData.field_type = String(parsed.field_type);
|
||||||
if (body.values !== undefined) updateData.values = body.values ?? null;
|
if (parsed.values !== undefined) updateData.values = parsed.values ?? null;
|
||||||
if (body.max_values !== undefined) updateData.max_values = Number(body.max_values);
|
if (parsed.max_values !== undefined) updateData.max_values = Number(parsed.max_values);
|
||||||
if (body.pattern !== undefined) updateData.pattern = body.pattern ? String(body.pattern) : null;
|
if (parsed.pattern !== undefined) updateData.pattern = parsed.pattern ? String(parsed.pattern) : null;
|
||||||
|
if (parsed.validation_config !== undefined) {
|
||||||
|
updateData.validation_config = (parsed.validation_config as any) ?? null;
|
||||||
|
}
|
||||||
|
if (parsed.default_value !== undefined) {
|
||||||
|
updateData.default_value = parsed.default_value ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
const [updated] = await db.update(customFields)
|
const [updated] = await db.update(customFields)
|
||||||
.set(updateData)
|
.set(updateData)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { CreateTicketSchema, UpdateTicketSchema, CommentSchema } from '../models
|
|||||||
import { ScripEngine } from '../scrip/engine.ts';
|
import { ScripEngine } from '../scrip/engine.ts';
|
||||||
import { LifecycleValidator } from '../lifecycle/validator.ts';
|
import { LifecycleValidator } from '../lifecycle/validator.ts';
|
||||||
import type { LifecycleDefinition } from '../lifecycle/validator.ts';
|
import type { LifecycleDefinition } from '../lifecycle/validator.ts';
|
||||||
|
import { validateCustomFieldValue } from '../models/custom-field-validation.ts';
|
||||||
|
|
||||||
export function createTicketsRouter(db: Db): Hono {
|
export function createTicketsRouter(db: Db): Hono {
|
||||||
const router = new Hono();
|
const router = new Hono();
|
||||||
@@ -43,12 +44,18 @@ export function createTicketsRouter(db: Db): Hono {
|
|||||||
const teamId = c.req.query('team_id');
|
const teamId = c.req.query('team_id');
|
||||||
const query = c.req.query('q')?.trim() ?? '';
|
const query = c.req.query('q')?.trim() ?? '';
|
||||||
const limit = c.req.query('limit') ? Number(c.req.query('limit')) : undefined;
|
const limit = c.req.query('limit') ? Number(c.req.query('limit')) : undefined;
|
||||||
|
// Parse cf.* filters with operator support: cf.budget=gt:100, cf.due_date=before:2026-07-01
|
||||||
const cfFilters = [...params.entries()]
|
const cfFilters = [...params.entries()]
|
||||||
.filter(([key, value]) => key.startsWith('cf.') && value.trim())
|
.filter(([key, value]) => key.startsWith('cf.') && value.trim())
|
||||||
.map(([key, value]) => ({
|
.map(([key, value]) => {
|
||||||
key: key.slice(3),
|
const raw = value.trim();
|
||||||
value: value.trim(),
|
// Check for operator prefixes
|
||||||
}));
|
const opMatch = raw.match(/^(gt|gte|lt|lte|before|after|contains):(.+)$/);
|
||||||
|
if (opMatch) {
|
||||||
|
return { key: key.slice(3), operator: opMatch[1], value: opMatch[2] };
|
||||||
|
}
|
||||||
|
return { key: key.slice(3), operator: 'eq' as const, value: raw };
|
||||||
|
});
|
||||||
|
|
||||||
// Build SQL WHERE conditions
|
// Build SQL WHERE conditions
|
||||||
const conditions: ReturnType<typeof eq>[] = [];
|
const conditions: ReturnType<typeof eq>[] = [];
|
||||||
@@ -143,8 +150,55 @@ export function createTicketsRouter(db: Db): Hono {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Custom field filters: use EXISTS subquery
|
// Custom field filters: type-aware operators
|
||||||
|
if (cfFilters.length > 0) {
|
||||||
|
// Fetch the custom fields to know their types
|
||||||
|
const cfKeys = cfFilters.map((cf) => cf.key);
|
||||||
|
const cfRecords = await db.query.customFields.findMany({
|
||||||
|
where: (table, { inArray }) => inArray(table.key, cfKeys),
|
||||||
|
});
|
||||||
|
const cfByKey = new Map(cfRecords.map((cf) => [cf.key, cf]));
|
||||||
|
|
||||||
for (const cf of cfFilters) {
|
for (const cf of cfFilters) {
|
||||||
|
const field = cfByKey.get(cf.key);
|
||||||
|
const fieldType = field?.field_type;
|
||||||
|
|
||||||
|
// Build the value comparison based on operator and field type
|
||||||
|
let valueCondition;
|
||||||
|
if (cf.operator === 'eq') {
|
||||||
|
valueCondition = eq(customFieldValues.value, cf.value);
|
||||||
|
} else if (cf.operator === 'contains') {
|
||||||
|
valueCondition = ilike(customFieldValues.value, `%${cf.value}%`);
|
||||||
|
} else if ((cf.operator === 'gt' || cf.operator === 'gte' ||
|
||||||
|
cf.operator === 'lt' || cf.operator === 'lte') &&
|
||||||
|
fieldType === 'Number') {
|
||||||
|
const numVal = Number(cf.value);
|
||||||
|
if (!isNaN(numVal)) {
|
||||||
|
if (cf.operator === 'gt') {
|
||||||
|
valueCondition = sql`${customFieldValues.value}::numeric > ${numVal}`;
|
||||||
|
} else if (cf.operator === 'gte') {
|
||||||
|
valueCondition = sql`${customFieldValues.value}::numeric >= ${numVal}`;
|
||||||
|
} else if (cf.operator === 'lt') {
|
||||||
|
valueCondition = sql`${customFieldValues.value}::numeric < ${numVal}`;
|
||||||
|
} else if (cf.operator === 'lte') {
|
||||||
|
valueCondition = sql`${customFieldValues.value}::numeric <= ${numVal}`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
valueCondition = sql`FALSE`; // Invalid number
|
||||||
|
}
|
||||||
|
} else if ((cf.operator === 'before' || cf.operator === 'after') &&
|
||||||
|
(fieldType === 'Date' || fieldType === 'DateTime')) {
|
||||||
|
const dateVal = cf.value;
|
||||||
|
if (cf.operator === 'before') {
|
||||||
|
valueCondition = sql`${customFieldValues.value} <= ${dateVal}`;
|
||||||
|
} else {
|
||||||
|
valueCondition = sql`${customFieldValues.value} >= ${dateVal}`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback: exact match for unsupported operator/type combos
|
||||||
|
valueCondition = eq(customFieldValues.value, cf.value);
|
||||||
|
}
|
||||||
|
|
||||||
conditions.push(
|
conditions.push(
|
||||||
exists(
|
exists(
|
||||||
db.select({ n: sql`1` })
|
db.select({ n: sql`1` })
|
||||||
@@ -154,12 +208,13 @@ export function createTicketsRouter(db: Db): Hono {
|
|||||||
and(
|
and(
|
||||||
eq(customFieldValues.ticket_id, tickets.id),
|
eq(customFieldValues.ticket_id, tickets.id),
|
||||||
eq(customFields.key, cf.key),
|
eq(customFields.key, cf.key),
|
||||||
eq(customFieldValues.value, cf.value)
|
valueCondition!
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const result = await db.query.tickets.findMany({
|
const result = await db.query.tickets.findMany({
|
||||||
where: conditions.length > 0 ? and(...conditions) : undefined,
|
where: conditions.length > 0 ? and(...conditions) : undefined,
|
||||||
@@ -198,6 +253,8 @@ export function createTicketsRouter(db: Db): Hono {
|
|||||||
values: fieldMap.get(v.custom_field_id)!.values,
|
values: fieldMap.get(v.custom_field_id)!.values,
|
||||||
max_values: fieldMap.get(v.custom_field_id)!.max_values,
|
max_values: fieldMap.get(v.custom_field_id)!.max_values,
|
||||||
pattern: fieldMap.get(v.custom_field_id)!.pattern,
|
pattern: fieldMap.get(v.custom_field_id)!.pattern,
|
||||||
|
validation_config: fieldMap.get(v.custom_field_id)!.validation_config,
|
||||||
|
default_value: fieldMap.get(v.custom_field_id)!.default_value,
|
||||||
} : undefined,
|
} : undefined,
|
||||||
}));
|
}));
|
||||||
return { ...ticket, custom_fields: cfs };
|
return { ...ticket, custom_fields: cfs };
|
||||||
@@ -261,32 +318,9 @@ export function createTicketsRouter(db: Db): Hono {
|
|||||||
if (!field) {
|
if (!field) {
|
||||||
throw new HTTPException(422, { message: 'Custom field not found' });
|
throw new HTTPException(422, { message: 'Custom field not found' });
|
||||||
}
|
}
|
||||||
if (Array.isArray(field.values) && field.values.length > 0) {
|
const validation = validateCustomFieldValue(field, value);
|
||||||
const allowed = new Set(field.values.map((option) => String(option)));
|
if (!validation.valid) {
|
||||||
if (!allowed.has(value)) {
|
throw new HTTPException(422, { message: `${field.name}: ${validation.error}` });
|
||||||
throw new HTTPException(422, { message: `${field.name}: value is not an allowed option` });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (field.field_type === 'date') {
|
|
||||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
|
||||||
throw new HTTPException(422, { message: `${field.name}: value must be a date in YYYY-MM-DD format` });
|
|
||||||
}
|
|
||||||
const parsed = new Date(value);
|
|
||||||
if (isNaN(parsed.getTime())) {
|
|
||||||
throw new HTTPException(422, { message: `${field.name}: invalid date` });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (field.field_type === 'datetime') {
|
|
||||||
const parsed = new Date(value);
|
|
||||||
if (isNaN(parsed.getTime())) {
|
|
||||||
throw new HTTPException(422, { message: `${field.name}: value must be a valid ISO 8601 datetime` });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (field.pattern) {
|
|
||||||
const regex = new RegExp(field.pattern);
|
|
||||||
if (!regex.test(value)) {
|
|
||||||
throw new HTTPException(422, { message: `${field.name}: value does not match the required pattern` });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1263,33 +1297,10 @@ export function createTicketsRouter(db: Db): Hono {
|
|||||||
throw new HTTPException(404, { message: 'Custom field not found' });
|
throw new HTTPException(404, { message: 'Custom field not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (value && Array.isArray(field.values) && field.values.length > 0) {
|
if (value) {
|
||||||
const allowed = new Set(field.values.map((option) => String(option)));
|
const validation = validateCustomFieldValue(field, value);
|
||||||
if (!allowed.has(value)) {
|
if (!validation.valid) {
|
||||||
throw new HTTPException(422, { message: `${field.name}: value is not an allowed option` });
|
throw new HTTPException(422, { message: `${field.name}: ${validation.error}` });
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value && field.field_type === 'date') {
|
|
||||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
|
||||||
throw new HTTPException(422, { message: `${field.name}: value must be a date in YYYY-MM-DD format` });
|
|
||||||
}
|
|
||||||
const parsed = new Date(value);
|
|
||||||
if (isNaN(parsed.getTime())) {
|
|
||||||
throw new HTTPException(422, { message: `${field.name}: invalid date` });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (value && field.field_type === 'datetime') {
|
|
||||||
const parsed = new Date(value);
|
|
||||||
if (isNaN(parsed.getTime())) {
|
|
||||||
throw new HTTPException(422, { message: `${field.name}: value must be a valid ISO 8601 datetime` });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value && field.pattern) {
|
|
||||||
const regex = new RegExp(field.pattern);
|
|
||||||
if (!regex.test(value)) {
|
|
||||||
throw new HTTPException(422, { message: `${field.name}: value does not match the required pattern` });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2479,48 +2479,134 @@ function CustomFieldsTab() {
|
|||||||
await fetchQueueFields();
|
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 = () => {
|
const resetBuilder = () => {
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
setKey("");
|
setKey("");
|
||||||
setName("");
|
setName("");
|
||||||
setFieldType("text");
|
setFieldType("Text");
|
||||||
setValues("");
|
setValues("");
|
||||||
setMaxValues(0);
|
setMaxValues(0);
|
||||||
setPattern("");
|
setPattern("");
|
||||||
|
setSelectOptions([]);
|
||||||
|
setNumberMin("");
|
||||||
|
setNumberMax("");
|
||||||
|
setDateMin("");
|
||||||
|
setDateMax("");
|
||||||
|
setTextMinLength("");
|
||||||
|
setTextMaxLength("");
|
||||||
|
setDefaultValue("");
|
||||||
setSaveError(null);
|
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) => {
|
const selectField = (field: CustomField) => {
|
||||||
setEditingId(field.id);
|
setEditingId(field.id);
|
||||||
setKey(field.key);
|
setKey(field.key);
|
||||||
setName(field.name);
|
setName(field.name);
|
||||||
setFieldType(field.field_type);
|
setFieldType(canonicalType(field.field_type));
|
||||||
setValues(field.values ? JSON.stringify(field.values, null, 2) : "");
|
setValues(field.values ? JSON.stringify(field.values, null, 2) : "");
|
||||||
setMaxValues(field.max_values);
|
setMaxValues(field.max_values);
|
||||||
setPattern(field.pattern ?? "");
|
setPattern(field.pattern ?? "");
|
||||||
setSaveError(null);
|
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 () => {
|
const handleSave = async () => {
|
||||||
if (!name.trim()) return;
|
if (!name.trim()) return;
|
||||||
let parsedValues: unknown = null;
|
const validationConfig = buildValidationConfig();
|
||||||
if (values.trim()) {
|
|
||||||
try {
|
|
||||||
parsedValues = JSON.parse(values);
|
|
||||||
} catch {
|
|
||||||
setSaveError("Invalid JSON in values.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setSaveError(null);
|
setSaveError(null);
|
||||||
const payload = {
|
const payload = {
|
||||||
key: key.trim() || undefined,
|
key: key.trim() || undefined,
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
field_type: fieldType,
|
field_type: fieldType,
|
||||||
values: parsedValues,
|
values: selectOptions.length > 0 ? selectOptions : null,
|
||||||
max_values: maxValues,
|
max_values: maxValues,
|
||||||
pattern: pattern.trim() || null,
|
pattern: pattern.trim() || null,
|
||||||
|
validation_config: validationConfig,
|
||||||
|
default_value: defaultValue.trim() || null,
|
||||||
};
|
};
|
||||||
const { data, error } = editingId
|
const { data, error } = editingId
|
||||||
? await updateCustomField(editingId, payload)
|
? await updateCustomField(editingId, payload)
|
||||||
@@ -2619,12 +2705,16 @@ function CustomFieldsTab() {
|
|||||||
<div className="grid gap-3 md:grid-cols-2">
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
<Label htmlFor="cf-type">Field type</Label>
|
<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>
|
<SelectTrigger id="cf-type"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="text">Text</SelectItem>
|
<SelectItem value="Text">Text</SelectItem>
|
||||||
<SelectItem value="select">Select</SelectItem>
|
<SelectItem value="Textarea">Textarea</SelectItem>
|
||||||
<SelectItem value="multiselect">Multi-select</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>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -2634,15 +2724,104 @@ function CustomFieldsTab() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ScripFlowNode>
|
</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">
|
<div className="grid gap-1.5">
|
||||||
<Label htmlFor="cf-values">Values JSON</Label>
|
<Label>Options</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 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>
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
<Label htmlFor="cf-pattern">Pattern</Label>
|
<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" />
|
<Input id="cf-pattern" placeholder="Optional regular expression" value={pattern} onChange={(event) => setPattern(event.target.value)} className="font-mono text-xs" />
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label htmlFor="cf-default">Default value</Label>
|
||||||
|
<Input id="cf-default" placeholder="Optional default" value={defaultValue} onChange={(event) => setDefaultValue(event.target.value)} />
|
||||||
|
</div>
|
||||||
</ScripFlowNode>
|
</ScripFlowNode>
|
||||||
{saveError && <div className="text-sm text-destructive">{saveError}</div>}
|
{saveError && <div className="text-sm text-destructive">{saveError}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -115,20 +115,30 @@ function userLabel(users: User[], userId: string | null) {
|
|||||||
|
|
||||||
function formatCfValue(value: string, fieldType: string): string {
|
function formatCfValue(value: string, fieldType: string): string {
|
||||||
if (!value) return "";
|
if (!value) return "";
|
||||||
if (fieldType === "date") {
|
const ft = fieldType.toLowerCase();
|
||||||
|
if (ft === "date") {
|
||||||
try {
|
try {
|
||||||
return format(new Date(value), "MMM d, yyyy");
|
return format(new Date(value), "MMM d, yyyy");
|
||||||
} catch {
|
} catch {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (fieldType === "datetime") {
|
if (ft === "datetime") {
|
||||||
try {
|
try {
|
||||||
return format(new Date(value), "MMM d, yyyy HH:mm");
|
return format(new Date(value), "MMM d, yyyy HH:mm");
|
||||||
} catch {
|
} catch {
|
||||||
return value;
|
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;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1307,19 +1317,40 @@ export default function TicketDetailPage({
|
|||||||
{queueFields.map((assignment) => {
|
{queueFields.map((assignment) => {
|
||||||
const field = assignment.custom_field;
|
const field = assignment.custom_field;
|
||||||
const fieldId = assignment.custom_field_id;
|
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 currentValue = ticket?.custom_fields?.find((item) => item.custom_field_id === fieldId)?.value ?? "";
|
||||||
const isEditing = editingFieldId === fieldId;
|
const isEditing = editingFieldId === fieldId;
|
||||||
const draftValue = customFieldDrafts[fieldId] ?? currentValue;
|
const draftValue = customFieldDrafts[fieldId] ?? currentValue;
|
||||||
const isSaving = customFieldSaving === fieldId;
|
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 (
|
return (
|
||||||
<div key={assignment.id}>
|
<div key={assignment.id}>
|
||||||
<label className="mb-1 block text-[10px] font-medium text-muted-foreground">{field?.name ?? fieldId}</label>
|
<label className="mb-1 block text-[10px] font-medium text-muted-foreground">{field?.name ?? fieldId}</label>
|
||||||
|
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-start gap-1.5">
|
||||||
{options.length > 0 ? (
|
{isSelect && selectOptions.length > 0 && !isMultiSelect ? (
|
||||||
<select
|
<select
|
||||||
value={draftValue}
|
value={draftValue}
|
||||||
onChange={(event) => {
|
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"
|
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>
|
<option value="">Not set</option>
|
||||||
{options.map((option) => (
|
{selectOptions.map((option) => (
|
||||||
<option key={option} value={option}>
|
<option key={option} value={option}>
|
||||||
{option}
|
{option}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</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
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={draftValue}
|
value={draftValue}
|
||||||
@@ -1350,7 +1406,7 @@ export default function TicketDetailPage({
|
|||||||
autoFocus
|
autoFocus
|
||||||
className="h-8 flex-1 rounded-md border border-ring bg-card px-2 text-sm text-foreground outline-none"
|
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
|
<input
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
value={draftValue ? draftValue.slice(0, 16) : ""}
|
value={draftValue ? draftValue.slice(0, 16) : ""}
|
||||||
@@ -1363,6 +1419,58 @@ export default function TicketDetailPage({
|
|||||||
autoFocus
|
autoFocus
|
||||||
className="h-8 flex-1 rounded-md border border-ring bg-card px-2 text-sm text-foreground outline-none"
|
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
|
<input
|
||||||
value={draftValue}
|
value={draftValue}
|
||||||
|
|||||||
@@ -120,7 +120,10 @@ export function ScripWizard({ open, onClose, onCreate, queues, customFields, tem
|
|||||||
|
|
||||||
const stepLabels = ["Trigger", "Action", "Configure", "Review"];
|
const stepLabels = ["Trigger", "Action", "Configure", "Review"];
|
||||||
const selectedQueue = queueId ? queues.find((q) => q.id === queueId) : null;
|
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);
|
const emailTemplates = templates.filter((t) => !t.queue_id || t.queue_id === queueId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -340,6 +340,8 @@ export async function createCustomField(data: {
|
|||||||
values?: unknown | null;
|
values?: unknown | null;
|
||||||
max_values?: number;
|
max_values?: number;
|
||||||
pattern?: string | null;
|
pattern?: string | null;
|
||||||
|
validation_config?: Record<string, unknown> | null;
|
||||||
|
default_value?: string | null;
|
||||||
}): Promise<{ data: CustomField | null; error: string | null }> {
|
}): Promise<{ data: CustomField | null; error: string | null }> {
|
||||||
return request<CustomField>("/custom-fields", { method: "POST", body: JSON.stringify(data) });
|
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;
|
values?: unknown | null;
|
||||||
max_values?: number;
|
max_values?: number;
|
||||||
pattern?: string | null;
|
pattern?: string | null;
|
||||||
|
validation_config?: Record<string, unknown> | null;
|
||||||
|
default_value?: string | null;
|
||||||
}): Promise<{ data: CustomField | null; error: string | null }> {
|
}): Promise<{ data: CustomField | null; error: string | null }> {
|
||||||
return request<CustomField>(`/custom-fields/${id}`, { method: "PATCH", body: JSON.stringify(data) });
|
return request<CustomField>(`/custom-fields/${id}`, { method: "PATCH", body: JSON.stringify(data) });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,8 +100,22 @@ export interface CustomField {
|
|||||||
values: unknown | null;
|
values: unknown | null;
|
||||||
max_values: number;
|
max_values: number;
|
||||||
pattern: string | null;
|
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 {
|
export interface QueueCustomField {
|
||||||
id: string;
|
id: string;
|
||||||
queue_id: string;
|
queue_id: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user