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 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
return c.json(filtered);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -65,10 +65,24 @@ const ALL_COLUMNS: ColumnConfig[] = [
|
|||||||
{ key: "updated", label: "Updated", width: 130, visible: false },
|
{ key: "updated", label: "Updated", width: 130, visible: false },
|
||||||
];
|
];
|
||||||
|
|
||||||
function defaultColumns(): ColumnConfig[] {
|
function baseColumns(): ColumnConfig[] {
|
||||||
return ALL_COLUMNS.map((c) => ({ ...c }));
|
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 {
|
interface Filter {
|
||||||
id: string;
|
id: string;
|
||||||
field: string; // "status" | "queue" | "owner" | custom field key ("cf.<key>")
|
field: string; // "status" | "queue" | "owner" | custom field key ("cf.<key>")
|
||||||
@@ -174,12 +188,47 @@ function TicketWorkbenchContent() {
|
|||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [filters, setFilters] = useState<Filter[]>([]);
|
const [filters, setFilters] = useState<Filter[]>([]);
|
||||||
const [columns, setColumns] = useState<ColumnConfig[]>(defaultColumns);
|
const [columns, setColumns] = useState<ColumnConfig[]>(() => {
|
||||||
|
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<Density>("comfortable");
|
const [density, setDensity] = useState<Density>("comfortable");
|
||||||
const [sortKey, setSortKey] = useState<SortKey>("updated");
|
const [sortKey, setSortKey] = useState<SortKey>("updated");
|
||||||
const [resizingCol, setResizingCol] = useState<string | null>(null);
|
const [resizingCol, setResizingCol] = useState<string | null>(null);
|
||||||
const [colPickerOpen, setColPickerOpen] = useState(false);
|
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
|
// Saved views
|
||||||
const [savedViewsList, setSavedViewsList] = useState<SavedView[]>([]);
|
const [savedViewsList, setSavedViewsList] = useState<SavedView[]>([]);
|
||||||
const [viewIdFromUrl, setViewIdFromUrl] = useState<string | null>(null);
|
const [viewIdFromUrl, setViewIdFromUrl] = useState<string | null>(null);
|
||||||
@@ -691,7 +740,7 @@ function TicketWorkbenchContent() {
|
|||||||
<>
|
<>
|
||||||
<div className="fixed inset-0 z-40" onClick={() => setColPickerOpen(false)} />
|
<div className="fixed inset-0 z-40" onClick={() => setColPickerOpen(false)} />
|
||||||
<div className="absolute right-0 top-full z-50 mt-1 w-48 rounded-md border border-border bg-card p-1 shadow-lg">
|
<div className="absolute right-0 top-full z-50 mt-1 w-48 rounded-md border border-border bg-card p-1 shadow-lg">
|
||||||
{ALL_COLUMNS.map((col) => {
|
{availableColumns.map((col) => {
|
||||||
const isVisible = columns.find((c) => c.key === col.key)?.visible ?? col.visible;
|
const isVisible = columns.find((c) => c.key === col.key)?.visible ?? col.visible;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -842,9 +891,9 @@ function TicketWorkbenchContent() {
|
|||||||
"sticky top-0 z-10 flex border-b border-border bg-muted/70",
|
"sticky top-0 z-10 flex border-b border-border bg-muted/70",
|
||||||
density === "compact" ? "min-h-8" : "min-h-9"
|
density === "compact" ? "min-h-8" : "min-h-9"
|
||||||
)}
|
)}
|
||||||
style={{ width: columns.filter((c) => c.visible).reduce((sum, c) => sum + c.width, 96), minWidth: "100%" }}
|
style={{ width: availableColumns.filter((c) => c.visible).reduce((sum, c) => sum + c.width, 96), minWidth: "100%" }}
|
||||||
>
|
>
|
||||||
{columns.filter((c) => c.visible).map((col) => (
|
{availableColumns.filter((c) => c.visible).map((col) => (
|
||||||
<div
|
<div
|
||||||
key={col.key}
|
key={col.key}
|
||||||
className="relative flex shrink-0 items-center gap-1 border-r border-border/60 px-3 last:border-r-0"
|
className="relative flex shrink-0 items-center gap-1 border-r border-border/60 px-3 last:border-r-0"
|
||||||
@@ -882,9 +931,18 @@ function TicketWorkbenchContent() {
|
|||||||
? "bg-accent/80 shadow-[inset_3px_0_0_var(--primary)]"
|
? "bg-accent/80 shadow-[inset_3px_0_0_var(--primary)]"
|
||||||
: "hover:bg-accent/45"
|
: "hover:bg-accent/45"
|
||||||
)}
|
)}
|
||||||
style={{ width: columns.filter((c) => c.visible).reduce((sum, c) => sum + c.width, 96), minWidth: "100%" }}
|
style={{ width: availableColumns.filter((c) => c.visible).reduce((sum, c) => sum + c.width, 96), minWidth: "100%" }}
|
||||||
>
|
>
|
||||||
{columns.filter((c) => c.visible).map((col) => {
|
{availableColumns.filter((c) => c.visible).map((col) => {
|
||||||
|
if (col.key.startsWith("cf.")) {
|
||||||
|
const cfKey = col.key.slice(3);
|
||||||
|
const cfValue = ticket.custom_fields?.find((v) => v.custom_field?.key === cfKey || v.custom_field?.name === cfKey)?.value;
|
||||||
|
return (
|
||||||
|
<span key={col.key} className="shrink-0 truncate px-3 text-sm text-foreground" style={{ width: col.width }}>
|
||||||
|
{cfValue ?? "—"}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
switch (col.key) {
|
switch (col.key) {
|
||||||
case "id":
|
case "id":
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user