From 38a82ad0d827c770a7e3204628019f051c00a7b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gjermund=20H=C3=B8s=C3=B8ien=20Wiggen?= Date: Tue, 9 Jun 2026 21:51:16 +0200 Subject: [PATCH] feat: persist columns to localStorage, custom fields as columns - Column config saves to localStorage on every change - Load from localStorage on mount (survive reloads without saved view) - Custom fields appear as column options in picker - Custom field values render in ticket rows - Backend now always includes custom_fields in GET /tickets response Co-Authored-By: Claude Opus 4.8 --- src/routes/tickets.ts | 39 +++++++++++++++++++++++ web/src/app/page.tsx | 74 ++++++++++++++++++++++++++++++++++++++----- 2 files changed, 105 insertions(+), 8 deletions(-) diff --git a/src/routes/tickets.ts b/src/routes/tickets.ts index 26baa56..eed6766 100644 --- a/src/routes/tickets.ts +++ b/src/routes/tickets.ts @@ -110,6 +110,45 @@ export function createTicketsRouter(db: Db): Hono { ); } + // Attach custom field values to all tickets + if (filtered.length > 0) { + const ticketIds = filtered.map((t) => t.id); + const allCfValues = await db.query.customFieldValues.findMany({ + where: (table, { inArray }) => inArray(table.ticket_id, ticketIds), + }); + const fieldIds = [...new Set(allCfValues.map((v) => v.custom_field_id))]; + const allFields = fieldIds.length > 0 + ? await db.query.customFields.findMany({ + where: (table, { inArray }) => inArray(table.id, fieldIds), + }) + : []; + const fieldMap = new Map(allFields.map((f) => [f.id, f])); + + const ticketsWithCf = filtered.map((ticket) => { + const cfs = allCfValues + .filter((v) => v.ticket_id === ticket.id) + .map((v) => ({ + id: v.id, + custom_field_id: v.custom_field_id, + ticket_id: v.ticket_id, + value: v.value, + created_at: v.created_at?.toISOString(), + custom_field: fieldMap.has(v.custom_field_id) ? { + id: v.custom_field_id, + key: fieldMap.get(v.custom_field_id)!.key, + name: fieldMap.get(v.custom_field_id)!.name, + field_type: fieldMap.get(v.custom_field_id)!.field_type, + values: fieldMap.get(v.custom_field_id)!.values, + max_values: fieldMap.get(v.custom_field_id)!.max_values, + pattern: fieldMap.get(v.custom_field_id)!.pattern, + } : undefined, + })); + return { ...ticket, custom_fields: cfs }; + }); + + return c.json(ticketsWithCf); + } + return c.json(filtered); }); diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index 6abb790..1259cd6 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -65,10 +65,24 @@ const ALL_COLUMNS: ColumnConfig[] = [ { key: "updated", label: "Updated", width: 130, visible: false }, ]; -function defaultColumns(): ColumnConfig[] { - return ALL_COLUMNS.map((c) => ({ ...c })); +function baseColumns(): ColumnConfig[] { + return [ + { key: "id", label: "ID", width: 100, visible: true }, + { key: "subject", label: "Subject", width: 320, visible: true }, + { key: "status", label: "Status", width: 120, visible: true }, + { key: "queue", label: "Queue", width: 140, visible: true }, + { key: "owner", label: "Owner", width: 130, visible: true }, + { key: "created", label: "Created", width: 130, visible: false }, + { key: "updated", label: "Updated", width: 130, visible: false }, + ]; } +function defaultColumns(): ColumnConfig[] { + return baseColumns().map((c) => ({ ...c })); +} + +const LS_KEY = "tessera_columns"; + interface Filter { id: string; field: string; // "status" | "queue" | "owner" | custom field key ("cf.") @@ -174,12 +188,47 @@ function TicketWorkbenchContent() { const [searchQuery, setSearchQuery] = useState(""); const [filters, setFilters] = useState([]); - const [columns, setColumns] = useState(defaultColumns); + const [columns, setColumns] = useState(() => { + if (typeof window === "undefined") return defaultColumns(); + try { + const stored = localStorage.getItem(LS_KEY); + if (stored) return JSON.parse(stored) as ColumnConfig[]; + } catch { /* ignore */ } + return defaultColumns(); + }); const [density, setDensity] = useState("comfortable"); const [sortKey, setSortKey] = useState("updated"); const [resizingCol, setResizingCol] = useState(null); const [colPickerOpen, setColPickerOpen] = useState(false); + // Persist columns to localStorage + useEffect(() => { + try { localStorage.setItem(LS_KEY, JSON.stringify(columns)); } catch { /* ignore */ } + }, [columns]); + + // Build available columns: base + custom fields + const availableColumns = useMemo(() => { + const base = baseColumns(); + const cfCols: ColumnConfig[] = customFields + .filter((cf) => cf.key) + .map((cf) => ({ + key: `cf.${cf.key}`, + label: cf.name, + width: 140, + visible: columns.find((c) => c.key === `cf.${cf.key}`)?.visible ?? false, + })); + // Merge with current visibility state + const merged = base.map((bc) => { + const current = columns.find((c) => c.key === bc.key); + return current ?? bc; + }); + for (const cf of cfCols) { + const current = columns.find((c) => c.key === cf.key); + merged.push(current ?? cf); + } + return merged; + }, [customFields, columns]); + // Saved views const [savedViewsList, setSavedViewsList] = useState([]); const [viewIdFromUrl, setViewIdFromUrl] = useState(null); @@ -691,7 +740,7 @@ function TicketWorkbenchContent() { <>
setColPickerOpen(false)} />
- {ALL_COLUMNS.map((col) => { + {availableColumns.map((col) => { const isVisible = columns.find((c) => c.key === col.key)?.visible ?? col.visible; return (