@marktiderman/genesis-studio 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,51 +1,2402 @@
1
1
  "use client";
2
2
 
3
3
  // src/DataStudio.tsx
4
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
5
- import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
6
- import { Database, History, Lock, Table2 } from "lucide-react";
4
+ import { useCallback as useCallback2, useEffect as useEffect5, useMemo as useMemo8, useRef, useState as useState10 } from "react";
5
+ import { keepPreviousData as keepPreviousData2, useQuery as useQuery5, useQueryClient as useQueryClient2 } from "@tanstack/react-query";
6
+ import { ChevronLeft as ChevronLeft2, ChevronRight as ChevronRight2, Database, History as History2, Lock as Lock2, PencilLine, Plus as Plus3, Table2 as Table22, Trash2 as Trash22, Undo2, X as X4 } from "lucide-react";
7
7
  import { GenesisProvider } from "@marktiderman/genesis-core/provider";
8
+ import {
9
+ encodeRecordKey as encodeRecordKey5,
10
+ isDataWriteError as isDataWriteError5,
11
+ keyFields as keyFields3
12
+ } from "@marktiderman/genesis-core/schema";
13
+ import { useSavedViews } from "@marktiderman/genesis-ui/data";
14
+ import { Button as Button9 } from "@marktiderman/genesis-ui/button";
15
+ import { Dialog as Dialog3, DialogContent as DialogContent3, DialogDescription as DialogDescription3, DialogHeader as DialogHeader3, DialogTitle as DialogTitle3 } from "@marktiderman/genesis-ui/dialog";
16
+ import { Alert as Alert2, AlertDescription as AlertDescription2, AlertTitle as AlertTitle2 } from "@marktiderman/genesis-ui/alert";
17
+ import { cn as cn4 } from "@marktiderman/genesis-ui/utils";
18
+
19
+ // src/router.ts
20
+ import { useCallback, useEffect, useMemo, useState } from "react";
21
+ var VIEWS = ["sheet", "kanban", "calendar"];
22
+ var PARAM = {
23
+ resource: "table",
24
+ page: "page",
25
+ sort: "sort",
26
+ search: "q",
27
+ filters: "filter",
28
+ group: "group",
29
+ record: "record",
30
+ view: "view",
31
+ by: "by",
32
+ month: "month"
33
+ };
34
+ function locationFromSearchParams(params) {
35
+ const page = Number(params.get(PARAM.page));
36
+ return {
37
+ ...params.get(PARAM.resource) ? { resource: params.get(PARAM.resource) } : {},
38
+ ...Number.isInteger(page) && page > 1 ? { page } : {},
39
+ ...params.get(PARAM.sort) ? { sort: params.get(PARAM.sort) } : {},
40
+ ...params.get(PARAM.search) ? { search: params.get(PARAM.search) } : {},
41
+ ...params.get(PARAM.filters) ? { filters: params.get(PARAM.filters) } : {},
42
+ ...params.get(PARAM.group) ? { group: params.get(PARAM.group) } : {},
43
+ ...params.get(PARAM.record) ? { record: params.get(PARAM.record) } : {},
44
+ ...VIEWS.includes(params.get(PARAM.view) ?? "") ? { view: params.get(PARAM.view) } : {},
45
+ ...params.get(PARAM.by) ? { by: params.get(PARAM.by) } : {},
46
+ .../^\d{4}-\d{2}$/.test(params.get(PARAM.month) ?? "") ? { month: params.get(PARAM.month) } : {}
47
+ };
48
+ }
49
+ function searchParamsFromLocation(location, base) {
50
+ const params = new URLSearchParams(base);
51
+ for (const name of Object.values(PARAM)) params.delete(name);
52
+ if (location.resource) params.set(PARAM.resource, location.resource);
53
+ if (location.page && location.page > 1) params.set(PARAM.page, String(location.page));
54
+ if (location.sort) params.set(PARAM.sort, location.sort);
55
+ if (location.search) params.set(PARAM.search, location.search);
56
+ if (location.filters) params.set(PARAM.filters, location.filters);
57
+ if (location.group) params.set(PARAM.group, location.group);
58
+ if (location.record) params.set(PARAM.record, location.record);
59
+ if (location.view) params.set(PARAM.view, location.view);
60
+ if (location.by) params.set(PARAM.by, location.by);
61
+ if (location.month) params.set(PARAM.month, location.month);
62
+ return params;
63
+ }
64
+ function createSearchParamsRouter(params, setParams) {
65
+ return {
66
+ location: locationFromSearchParams(params),
67
+ navigate: (next, options) => setParams(searchParamsFromLocation(next, params), { replace: options?.replace ?? false })
68
+ };
69
+ }
70
+ function useHistoryRouter() {
71
+ const read = () => typeof window === "undefined" ? {} : locationFromSearchParams(new URLSearchParams(window.location.search));
72
+ const [location, setLocation] = useState(read);
73
+ useEffect(() => {
74
+ const onPop = () => setLocation(read());
75
+ window.addEventListener("popstate", onPop);
76
+ return () => window.removeEventListener("popstate", onPop);
77
+ }, []);
78
+ const navigate = useCallback((next, options) => {
79
+ const params = searchParamsFromLocation(next, new URLSearchParams(window.location.search));
80
+ const query = params.toString();
81
+ const url = `${window.location.pathname}${query ? `?${query}` : ""}${window.location.hash}`;
82
+ if (options?.replace) window.history.replaceState(window.history.state, "", url);
83
+ else window.history.pushState(window.history.state, "", url);
84
+ setLocation(next);
85
+ }, []);
86
+ return useMemo(() => ({ location, navigate }), [location, navigate]);
87
+ }
88
+ function useMemoryRouter(initial = {}) {
89
+ const [location, setLocation] = useState(initial);
90
+ const navigate = useCallback((next) => setLocation(next), []);
91
+ return useMemo(() => ({ location, navigate }), [location, navigate]);
92
+ }
93
+
94
+ // src/values.ts
95
+ function editText(value) {
96
+ if (value === null || value === void 0) return "";
97
+ if (typeof value === "object") return JSON.stringify(value);
98
+ return String(value);
99
+ }
100
+ function displayText(value) {
101
+ if (value === null || value === void 0) return null;
102
+ return editText(value);
103
+ }
104
+ function parseDraft(field, draft) {
105
+ if (draft === "" && field.nullable) return null;
106
+ return draft;
107
+ }
108
+ function choicesFor(field) {
109
+ if (field.enumValues) return [...field.enumValues];
110
+ if (field.type === "boolean") return ["true", "false"];
111
+ return void 0;
112
+ }
113
+ function fieldLabel(field) {
114
+ return field.label ?? field.name;
115
+ }
116
+
117
+ // src/lib/csv.ts
118
+ function csvField(value) {
119
+ if (value === null || value === void 0) return "";
120
+ const text = typeof value === "object" ? JSON.stringify(value) : String(value);
121
+ const plainNumber = /^-?\d+(\.\d+)?([eE][+-]?\d+)?$/.test(text);
122
+ const safe = !plainNumber && /^[=+\-@\t\r]/.test(text) ? `'${text}` : text;
123
+ return /[",\r\n]/.test(safe) ? `"${safe.replace(/"/g, '""')}"` : safe;
124
+ }
125
+ function toCsv(fields, rows) {
126
+ const lines = [fields.map((f) => csvField(f.name)).join(",")];
127
+ for (const row of rows) lines.push(fields.map((f) => csvField(row[f.name])).join(","));
128
+ return `${lines.join("\r\n")}\r
129
+ `;
130
+ }
131
+ async function exportCsv(options) {
132
+ const perPage = 200;
133
+ const max = options.maxRows ?? 1e4;
134
+ const rows = [];
135
+ let truncated = false;
136
+ for (let page = 1; ; page++) {
137
+ const result = await options.provider.getList(options.resource, {
138
+ pagination: { page, perPage },
139
+ sort: options.sorts[0],
140
+ sorts: options.sorts,
141
+ filters: options.filters,
142
+ ...options.search ? { search: { query: options.search, fields: [] } } : {}
143
+ });
144
+ rows.push(...result.data);
145
+ options.onProgress?.(rows.length);
146
+ if (rows.length >= max) {
147
+ truncated = rows.length > max || result.total > max;
148
+ rows.length = Math.min(rows.length, max);
149
+ break;
150
+ }
151
+ if (result.data.length < perPage || rows.length >= result.total) break;
152
+ }
153
+ return { csv: toCsv(options.fields, rows), rows: rows.length, truncated };
154
+ }
155
+ function parseCsv(text) {
156
+ const records = [];
157
+ let record = [];
158
+ let field = "";
159
+ let quoted = false;
160
+ const input = text.replace(/^\uFEFF/, "");
161
+ for (let i = 0; i < input.length; i++) {
162
+ const ch = input[i];
163
+ if (quoted) {
164
+ if (ch === '"' && input[i + 1] === '"') {
165
+ field += '"';
166
+ i++;
167
+ } else if (ch === '"') quoted = false;
168
+ else field += ch;
169
+ } else if (ch === '"' && field === "") quoted = true;
170
+ else if (ch === ",") {
171
+ record.push(field);
172
+ field = "";
173
+ } else if (ch === "\n" || ch === "\r") {
174
+ if (ch === "\r" && input[i + 1] === "\n") i++;
175
+ record.push(field);
176
+ records.push(record);
177
+ record = [];
178
+ field = "";
179
+ } else field += ch;
180
+ }
181
+ if (field !== "" || record.length) {
182
+ record.push(field);
183
+ records.push(record);
184
+ }
185
+ const nonEmpty = records.filter((r) => r.some((v) => v !== ""));
186
+ const [header = [], ...rows] = nonEmpty;
187
+ return { header: header.map((h) => h.trim()), rows };
188
+ }
189
+ function downloadText(filename, text, type = "text/csv;charset=utf-8") {
190
+ const url = URL.createObjectURL(new Blob([text], { type }));
191
+ const link = document.createElement("a");
192
+ link.href = url;
193
+ link.download = filename;
194
+ document.body.appendChild(link);
195
+ link.click();
196
+ link.remove();
197
+ setTimeout(() => URL.revokeObjectURL(url), 1e3);
198
+ }
199
+
200
+ // src/lib/query-state.ts
201
+ function parseSorts(sort) {
202
+ if (!sort) return [];
203
+ return sort.split(",").map((part) => {
204
+ const at = part.lastIndexOf(":");
205
+ const field = at > 0 ? part.slice(0, at) : part;
206
+ return { field, order: part.slice(at + 1) === "desc" ? "desc" : "asc" };
207
+ }).filter((s) => s.field);
208
+ }
209
+ function formatSorts(sorts) {
210
+ return sorts.length ? sorts.map((s) => `${s.field}:${s.order}`).join(",") : void 0;
211
+ }
212
+ function parseFilters(raw) {
213
+ if (!raw) return [];
214
+ try {
215
+ const list = JSON.parse(raw);
216
+ if (!Array.isArray(list)) return [];
217
+ return list.filter((f) => Array.isArray(f) && typeof f[0] === "string" && typeof f[1] === "string").map(
218
+ ([field, operator, value]) => operator === "is_null" || operator === "is_not_null" ? { field, operator } : { field, operator, value }
219
+ );
220
+ } catch {
221
+ return [];
222
+ }
223
+ }
224
+ function formatFilters(filters) {
225
+ if (!filters.length) return void 0;
226
+ return JSON.stringify(filters.map((f) => "value" in f ? [f.field, f.operator, f.value] : [f.field, f.operator]));
227
+ }
228
+ function operatorsFor(field) {
229
+ if (field.array) return ["is_null", "is_not_null"];
230
+ if (field.enumValues || field.type === "boolean") return ["eq", "neq", "is_null", "is_not_null"];
231
+ if (field.type === "number" || field.type === "datetime") {
232
+ return ["eq", "neq", "gt", "gte", "lt", "lte", "is_null", "is_not_null"];
233
+ }
234
+ if (field.type === "json") return ["contains", "is_null", "is_not_null"];
235
+ return ["contains", "eq", "neq", "is_null", "is_not_null"];
236
+ }
237
+ var OPERATOR_LABEL = {
238
+ eq: "is",
239
+ neq: "is not",
240
+ gt: "is after / more than",
241
+ gte: "is on or after / at least",
242
+ lt: "is before / less than",
243
+ lte: "is on or before / at most",
244
+ contains: "contains",
245
+ in: "is any of",
246
+ between: "is between",
247
+ is_null: "is empty",
248
+ is_not_null: "is not empty"
249
+ };
250
+ function needsValue(operator) {
251
+ return operator !== "is_null" && operator !== "is_not_null";
252
+ }
253
+
254
+ // src/lib/relations.ts
255
+ import { useQueries, useQuery } from "@tanstack/react-query";
256
+ import { keyFields } from "@marktiderman/genesis-core/schema";
257
+ function useSchema(provider, sessionKey, resource) {
258
+ return useQuery({
259
+ queryKey: ["genesis-studio", sessionKey, "describe", resource],
260
+ enabled: Boolean(resource && provider.describe),
261
+ retry: false,
262
+ queryFn: () => provider.describe(resource)
263
+ });
264
+ }
265
+ function singleKey(schema) {
266
+ if (!schema) return null;
267
+ const fields = keyFields(schema);
268
+ return fields.length === 1 ? fields[0] : null;
269
+ }
270
+ function usePageLinkLabels(provider, sessionKey, fields, rows) {
271
+ const links = fields.filter((f) => f.references);
272
+ const schemas = useQueries({
273
+ queries: links.map((f) => ({
274
+ queryKey: ["genesis-studio", sessionKey, "describe", f.references.resource],
275
+ enabled: Boolean(provider.describe),
276
+ retry: false,
277
+ queryFn: () => provider.describe(f.references.resource)
278
+ }))
279
+ });
280
+ const labels = useQueries({
281
+ queries: links.map((f, i) => {
282
+ const ids = [...new Set(rows.map((r) => r[f.name]).filter((v) => v !== null && v !== void 0).map(String))].sort();
283
+ const labelField = schemas[i]?.data?.labelField;
284
+ return {
285
+ queryKey: ["genesis-studio", sessionKey, "labels", f.references.resource, f.references.field, ids],
286
+ enabled: Boolean(schemas[i]?.data && ids.length),
287
+ staleTime: 6e4,
288
+ retry: false,
289
+ queryFn: async () => {
290
+ const result = await provider.getList(f.references.resource, {
291
+ pagination: { page: 1, perPage: Math.min(200, ids.length) },
292
+ filters: [{ field: f.references.field, operator: "in", value: ids }]
293
+ });
294
+ return new Map(
295
+ result.data.map((row) => [
296
+ String(row[f.references.field]),
297
+ displayText(row[labelField ?? f.references.field]) ?? String(row[f.references.field])
298
+ ])
299
+ );
300
+ }
301
+ };
302
+ })
303
+ });
304
+ const out = /* @__PURE__ */ new Map();
305
+ links.forEach((f, i) => out.set(f.name, labels[i]?.data ?? /* @__PURE__ */ new Map()));
306
+ return out;
307
+ }
308
+
309
+ // src/record/RecordForm.tsx
310
+ import { useEffect as useEffect2, useMemo as useMemo2, useState as useState3 } from "react";
311
+ import { Controller, useForm } from "react-hook-form";
312
+ import { zodResolver } from "@hookform/resolvers/zod";
313
+ import { Button as Button2 } from "@marktiderman/genesis-ui/button";
314
+ import { Checkbox } from "@marktiderman/genesis-ui/checkbox";
315
+ import { Input } from "@marktiderman/genesis-ui/input";
316
+ import { Label } from "@marktiderman/genesis-ui/label";
317
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@marktiderman/genesis-ui/select";
318
+ import { Textarea } from "@marktiderman/genesis-ui/textarea";
319
+
320
+ // src/lib/form-schema.ts
321
+ import { z } from "zod";
322
+ function isRequired(field, mode) {
323
+ if (field.nullable || field.generated) return false;
324
+ return mode === "edit" || !field.hasDefault;
325
+ }
326
+ var NUMBER = /^-?(\d+\.?\d*|\.\d+)(e[-+]?\d+)?$/i;
327
+ var DATE = /^\d{4}-\d{2}-\d{2}$/;
328
+ function textRule(field, required) {
329
+ const choices = choicesFor(field);
330
+ return z.string().superRefine((value, ctx) => {
331
+ if (value === "") {
332
+ if (required) ctx.addIssue({ code: "custom", message: "Required." });
333
+ return;
334
+ }
335
+ if (field.type === "number" && !NUMBER.test(value.trim())) ctx.addIssue({ code: "custom", message: "Enter a number." });
336
+ if (field.widget === "date" && !DATE.test(value)) ctx.addIssue({ code: "custom", message: "Enter a date (YYYY-MM-DD)." });
337
+ if (choices && field.type !== "boolean" && !choices.includes(value)) {
338
+ ctx.addIssue({ code: "custom", message: `Choose one of: ${choices.join(", ")}.` });
339
+ }
340
+ if (field.type === "json") {
341
+ try {
342
+ JSON.parse(value);
343
+ } catch {
344
+ ctx.addIssue({ code: "custom", message: "Enter valid JSON." });
345
+ }
346
+ }
347
+ });
348
+ }
349
+ function formSchema(fields, mode) {
350
+ const shape = {};
351
+ for (const field of fields) {
352
+ const required = isRequired(field, mode);
353
+ shape[field.name] = field.type === "boolean" && !field.nullable ? z.boolean() : textRule(field, required);
354
+ }
355
+ return z.object(shape);
356
+ }
357
+ function toFormValues(fields, row) {
358
+ const out = {};
359
+ for (const field of fields) {
360
+ const value = row?.[field.name];
361
+ out[field.name] = field.type === "boolean" && !field.nullable ? value === true || value === "true" : editText(value);
362
+ }
363
+ return out;
364
+ }
365
+ function fromFormValue(field, value) {
366
+ if (typeof value === "boolean") return value;
367
+ if (value === "") return field.nullable ? null : "";
368
+ return value;
369
+ }
370
+
371
+ // src/lib/form-config.ts
372
+ function sectionFields(fields, config) {
373
+ if (!config?.sections?.length) return [{ title: null, fields: [...fields] }];
374
+ const byName = new Map(fields.map((f) => [f.name, f]));
375
+ const used = /* @__PURE__ */ new Set();
376
+ const out = [];
377
+ for (const section of config.sections) {
378
+ const list = section.fields.map((n) => byName.get(n)).filter((f) => Boolean(f) && !used.has(f.name));
379
+ list.forEach((f) => used.add(f.name));
380
+ if (list.length) out.push({ title: section.title, fields: list });
381
+ }
382
+ const rest = fields.filter((f) => !used.has(f.name));
383
+ if (rest.length) out.push({ title: "Other", fields: rest });
384
+ return out;
385
+ }
386
+
387
+ // src/record/RelationPicker.tsx
388
+ import { useState as useState2 } from "react";
389
+ import { useQuery as useQuery2 } from "@tanstack/react-query";
390
+ import { ChevronsUpDown, X } from "lucide-react";
391
+ import { Button } from "@marktiderman/genesis-ui/button";
392
+ import { Popover, PopoverContent, PopoverTrigger } from "@marktiderman/genesis-ui/popover";
393
+ import { Command, CommandEmpty, CommandInput, CommandItem, CommandList } from "@marktiderman/genesis-ui/command";
394
+ import { jsx, jsxs } from "react/jsx-runtime";
395
+ function RelationPicker({ provider, sessionKey, field, value, onChange, disabled, id }) {
396
+ const [open, setOpen] = useState2(false);
397
+ const [search, setSearch] = useState2("");
398
+ const target = field.references.resource;
399
+ const targetField = field.references.field;
400
+ const schema = useSchema(provider, sessionKey, target);
401
+ const labelField = schema.data?.labelField ?? targetField;
402
+ const options = useQuery2({
403
+ queryKey: ["genesis-studio", sessionKey, "pick", target, search],
404
+ enabled: open && Boolean(schema.data),
405
+ retry: false,
406
+ queryFn: () => provider.getList(target, {
407
+ pagination: { page: 1, perPage: 20 },
408
+ ...search ? { search: { query: search, fields: [] } } : {}
409
+ })
410
+ });
411
+ const current = useQuery2({
412
+ queryKey: ["genesis-studio", sessionKey, "labels", target, targetField, [value]],
413
+ enabled: Boolean(value && schema.data),
414
+ retry: false,
415
+ queryFn: async () => {
416
+ const result = await provider.getList(target, {
417
+ pagination: { page: 1, perPage: 1 },
418
+ filters: [{ field: targetField, operator: "eq", value }]
419
+ });
420
+ const row = result.data[0];
421
+ return new Map(row ? [[value, displayText(row[labelField]) ?? value]] : []);
422
+ }
423
+ });
424
+ const label = value ? current.data?.get(value) ?? value : "\u2014";
425
+ return /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-1", children: [
426
+ /* @__PURE__ */ jsxs(Popover, { open, onOpenChange: setOpen, children: [
427
+ /* @__PURE__ */ jsx(PopoverTrigger, { asChild: true, children: /* @__PURE__ */ jsxs(
428
+ Button,
429
+ {
430
+ id,
431
+ type: "button",
432
+ variant: "outline",
433
+ role: "combobox",
434
+ "aria-expanded": open,
435
+ disabled,
436
+ className: "h-9 w-full justify-between font-normal",
437
+ testID: `studio-picker-${field.name}`,
438
+ children: [
439
+ /* @__PURE__ */ jsx("span", { className: "truncate", children: label }),
440
+ /* @__PURE__ */ jsx(ChevronsUpDown, { className: "ml-2 h-4 w-4 shrink-0 opacity-50", "aria-hidden": true })
441
+ ]
442
+ }
443
+ ) }),
444
+ /* @__PURE__ */ jsx(PopoverContent, { className: "w-72 p-0", align: "start", children: /* @__PURE__ */ jsxs(Command, { shouldFilter: false, children: [
445
+ /* @__PURE__ */ jsx(CommandInput, { placeholder: `Search ${target}\u2026`, value: search, onValueChange: setSearch }),
446
+ /* @__PURE__ */ jsxs(CommandList, { children: [
447
+ /* @__PURE__ */ jsx(CommandEmpty, { children: options.isLoading ? "Searching\u2026" : "No matches you can see." }),
448
+ options.data?.data?.map((row) => {
449
+ const key = String(row[targetField]);
450
+ return /* @__PURE__ */ jsxs(
451
+ CommandItem,
452
+ {
453
+ value: key,
454
+ onSelect: () => {
455
+ onChange(key);
456
+ setOpen(false);
457
+ },
458
+ children: [
459
+ /* @__PURE__ */ jsx("span", { className: "truncate", children: displayText(row[labelField]) ?? key }),
460
+ /* @__PURE__ */ jsx("span", { className: "ml-auto pl-2 font-mono text-[10px] text-muted-foreground", children: key })
461
+ ]
462
+ },
463
+ key
464
+ );
465
+ })
466
+ ] })
467
+ ] }) })
468
+ ] }),
469
+ value && field.nullable && !disabled && /* @__PURE__ */ jsx(Button, { type: "button", variant: "ghost", size: "icon", className: "h-9 w-9", "aria-label": `Clear ${field.name}`, onClick: () => onChange(""), children: /* @__PURE__ */ jsx(X, { className: "h-4 w-4", "aria-hidden": true }) })
470
+ ] });
471
+ }
472
+
473
+ // src/record/RecordForm.tsx
474
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
475
+ var NONE = "__genesis_none__";
476
+ function RecordForm({ provider, sessionKey, mode, fields, editable, row, onSubmit, submitLabel, onCancel, config }) {
477
+ const writable = useMemo2(() => fields.filter((f) => editable.has(f.name)), [fields, editable]);
478
+ const sections = useMemo2(() => sectionFields(fields, config), [fields, config]);
479
+ const resolver = useMemo2(
480
+ () => (values2, context, options) => {
481
+ const shown = writable.filter((f) => {
482
+ const rule = config?.visibleWhen?.[f.name];
483
+ return rule ? rule(values2) : true;
484
+ });
485
+ const validate = zodResolver(formSchema(shown, mode));
486
+ return validate(values2, context, options);
487
+ },
488
+ [writable, mode, config]
489
+ );
490
+ const defaults = useMemo2(() => toFormValues(fields, row), [fields, row]);
491
+ const form = useForm({ resolver, defaultValues: defaults });
492
+ const [error, setError] = useState3(null);
493
+ const values = form.watch();
494
+ const isVisible = (name) => {
495
+ const rule = config?.visibleWhen?.[name];
496
+ return rule ? rule(values) : true;
497
+ };
498
+ useEffect2(() => {
499
+ form.reset(defaults);
500
+ }, [defaults, form]);
501
+ const submit = form.handleSubmit(async (values2) => {
502
+ setError(null);
503
+ const out = {};
504
+ for (const field of writable) {
505
+ if (!isVisible(field.name)) continue;
506
+ const value = values2[field.name];
507
+ if (value === void 0) continue;
508
+ if (mode === "edit" && value === defaults[field.name]) continue;
509
+ if (mode === "create" && value === "" && !isRequired(field, mode)) continue;
510
+ out[field.name] = fromFormValue(field, value);
511
+ }
512
+ if (mode === "edit" && Object.keys(out).length === 0) return;
513
+ try {
514
+ await onSubmit(out);
515
+ } catch (e) {
516
+ setError(e instanceof Error ? e.message : String(e));
517
+ }
518
+ });
519
+ return /* @__PURE__ */ jsxs2("form", { onSubmit: submit, className: "space-y-4", noValidate: true, "data-testid": `studio-record-form-${mode}`, children: [
520
+ sections.map((section) => /* @__PURE__ */ jsxs2("fieldset", { className: "space-y-4", children: [
521
+ section.title && /* @__PURE__ */ jsx2("legend", { className: "mb-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground", children: section.title }),
522
+ section.fields.filter((field) => isVisible(field.name)).map((field) => {
523
+ const id = `studio-field-${field.name}`;
524
+ const canEdit = editable.has(field.name);
525
+ const fieldError = form.formState.errors[field.name]?.message;
526
+ const required = canEdit && isRequired(field, mode);
527
+ const choices = choicesFor(field);
528
+ return /* @__PURE__ */ jsxs2("div", { className: "space-y-1.5", children: [
529
+ /* @__PURE__ */ jsxs2(Label, { htmlFor: id, className: "flex items-baseline gap-1.5", children: [
530
+ /* @__PURE__ */ jsx2("span", { title: field.label ? field.name : void 0, children: fieldLabel(field) }),
531
+ required && /* @__PURE__ */ jsx2("span", { className: "text-destructive", "aria-hidden": true, children: "*" }),
532
+ /* @__PURE__ */ jsx2("span", { className: "ml-auto font-mono text-[10px] font-normal text-muted-foreground", children: field.dbType })
533
+ ] }),
534
+ /* @__PURE__ */ jsx2(
535
+ Controller,
536
+ {
537
+ control: form.control,
538
+ name: field.name,
539
+ render: ({ field: control }) => {
540
+ const disabled = !canEdit || form.formState.isSubmitting;
541
+ if (field.references) {
542
+ return /* @__PURE__ */ jsx2(
543
+ RelationPicker,
544
+ {
545
+ id,
546
+ provider,
547
+ sessionKey,
548
+ field,
549
+ value: String(control.value ?? ""),
550
+ onChange: control.onChange,
551
+ disabled
552
+ }
553
+ );
554
+ }
555
+ if (field.type === "boolean" && !field.nullable) {
556
+ return /* @__PURE__ */ jsx2("div", { className: "flex h-9 items-center", children: /* @__PURE__ */ jsx2(Checkbox, { id, checked: control.value === true, onCheckedChange: (v) => control.onChange(v === true), disabled }) });
557
+ }
558
+ if (choices) {
559
+ return /* @__PURE__ */ jsxs2(
560
+ Select,
561
+ {
562
+ value: control.value === "" ? NONE : String(control.value),
563
+ onValueChange: (v) => control.onChange(v === NONE ? "" : v),
564
+ disabled,
565
+ children: [
566
+ /* @__PURE__ */ jsx2(SelectTrigger, { id, "aria-invalid": fieldError ? true : void 0, children: /* @__PURE__ */ jsx2(SelectValue, {}) }),
567
+ /* @__PURE__ */ jsxs2(SelectContent, { children: [
568
+ (field.nullable || mode === "create") && /* @__PURE__ */ jsx2(SelectItem, { value: NONE, children: "\u2014" }),
569
+ choices.map((c) => /* @__PURE__ */ jsx2(SelectItem, { value: c, children: c }, c))
570
+ ] })
571
+ ]
572
+ }
573
+ );
574
+ }
575
+ if (field.widget === "textarea") {
576
+ return /* @__PURE__ */ jsx2(
577
+ Textarea,
578
+ {
579
+ id,
580
+ rows: 4,
581
+ className: "font-mono text-xs",
582
+ value: String(control.value ?? ""),
583
+ onChange: control.onChange,
584
+ onBlur: control.onBlur,
585
+ disabled,
586
+ "aria-invalid": fieldError ? true : void 0
587
+ }
588
+ );
589
+ }
590
+ return /* @__PURE__ */ jsx2(
591
+ Input,
592
+ {
593
+ id,
594
+ type: field.widget === "date" ? "date" : "text",
595
+ inputMode: field.type === "number" ? "decimal" : void 0,
596
+ value: String(control.value ?? ""),
597
+ onChange: control.onChange,
598
+ onBlur: control.onBlur,
599
+ disabled,
600
+ "aria-invalid": fieldError ? true : void 0
601
+ }
602
+ );
603
+ }
604
+ }
605
+ ),
606
+ fieldError ? /* @__PURE__ */ jsx2("p", { role: "alert", className: "text-xs text-destructive", children: String(fieldError) }) : !canEdit && field.permissions?.reason ? /* @__PURE__ */ jsx2("p", { className: "text-xs text-muted-foreground", children: field.permissions.reason }) : field.description ? /* @__PURE__ */ jsx2("p", { className: "text-xs text-muted-foreground", children: field.description }) : null
607
+ ] }, field.name);
608
+ })
609
+ ] }, section.title ?? "fields")),
610
+ error && /* @__PURE__ */ jsx2("p", { role: "alert", className: "rounded-md border border-destructive/40 bg-destructive/5 p-2 text-sm text-destructive", "data-testid": "studio-form-error", children: error }),
611
+ writable.length > 0 && /* @__PURE__ */ jsxs2("div", { className: "flex justify-end gap-2", children: [
612
+ onCancel && /* @__PURE__ */ jsx2(Button2, { type: "button", variant: "outline", onClick: onCancel, children: "Cancel" }),
613
+ /* @__PURE__ */ jsx2(Button2, { type: "submit", disabled: form.formState.isSubmitting, testID: `studio-${mode}-submit`, children: form.formState.isSubmitting ? "Saving\u2026" : submitLabel })
614
+ ] })
615
+ ] });
616
+ }
617
+
618
+ // src/record/RecordPanel.tsx
619
+ import { useMemo as useMemo3, useState as useState4 } from "react";
620
+ import { useQuery as useQuery3, useQueryClient } from "@tanstack/react-query";
621
+ import { ExternalLink, Link2, Play, Trash2 } from "lucide-react";
8
622
  import {
9
623
  encodeRecordKey,
10
- isDataWriteError,
11
- keyFields
624
+ isDataWriteError
12
625
  } from "@marktiderman/genesis-core/schema";
13
- import { ResourcePage } from "@marktiderman/genesis-ui/data";
626
+ import { Textarea as Textarea2 } from "@marktiderman/genesis-ui/textarea";
14
627
  import { Alert, AlertDescription, AlertTitle } from "@marktiderman/genesis-ui/alert";
628
+ import {
629
+ AlertDialog,
630
+ AlertDialogAction,
631
+ AlertDialogCancel,
632
+ AlertDialogContent,
633
+ AlertDialogDescription,
634
+ AlertDialogFooter,
635
+ AlertDialogHeader,
636
+ AlertDialogTitle,
637
+ AlertDialogTrigger
638
+ } from "@marktiderman/genesis-ui/alert-dialog";
639
+ import { Button as Button3 } from "@marktiderman/genesis-ui/button";
640
+ import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@marktiderman/genesis-ui/sheet";
641
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@marktiderman/genesis-ui/tabs";
642
+ import { Fragment, jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
643
+ function versionOf(schema, row) {
644
+ const field = schema.version?.field;
645
+ if (!field || !row || row[field] == null) return null;
646
+ return String(row[field]);
647
+ }
648
+ function RecordPanel(props) {
649
+ const { provider, sessionKey, schema, id } = props;
650
+ const queryClient = useQueryClient();
651
+ const recordKey = ["genesis-studio", sessionKey, "record", schema.name, id];
652
+ const record = useQuery3({
653
+ queryKey: recordKey,
654
+ retry: false,
655
+ queryFn: async () => (await provider.getOne(schema.name, { id })).data
656
+ });
657
+ const [conflict, setConflict] = useState4(null);
658
+ const [copied, setCopied] = useState4(false);
659
+ const row = record.data;
660
+ const title = row ? displayText(row[schema.labelField ?? ""]) ?? id : id;
661
+ const editable = useMemo3(() => new Set(schema.fields.filter((f) => f.permissions?.update).map((f) => f.name)), [schema]);
662
+ const relations = schema.relations ?? [];
663
+ async function save(values) {
664
+ setConflict(null);
665
+ try {
666
+ const result = await provider.update(schema.name, { id, data: values, expectedVersion: versionOf(schema, row) });
667
+ queryClient.setQueryData(recordKey, result.data);
668
+ props.onSaved(result.data, result.audit);
669
+ } catch (error) {
670
+ if (isDataWriteError(error) && error.outcome === "conflict") {
671
+ setConflict(error.message);
672
+ if (error.current) {
673
+ queryClient.setQueryData(recordKey, {
674
+ ...row,
675
+ ...error.current,
676
+ ...schema.version ? { [schema.version.field]: error.currentVersion ?? null } : {}
677
+ });
678
+ } else {
679
+ void record.refetch();
680
+ }
681
+ }
682
+ props.onFailed("update", error instanceof Error ? error.message : String(error));
683
+ throw error;
684
+ }
685
+ }
686
+ async function remove() {
687
+ try {
688
+ const result = await provider.deleteOne(schema.name, { id, expectedVersion: versionOf(schema, row) });
689
+ props.onDeleted(result.audit);
690
+ props.onClose();
691
+ } catch (error) {
692
+ const message = error instanceof Error ? error.message : String(error);
693
+ setConflict(message);
694
+ props.onFailed("delete", message);
695
+ void record.refetch();
696
+ }
697
+ }
698
+ const [running, setRunning] = useState4(null);
699
+ const [actionError, setActionError] = useState4(null);
700
+ const [confirming, setConfirming] = useState4(null);
701
+ async function runAction(action) {
702
+ setConflict(null);
703
+ setActionError(null);
704
+ setRunning(action.name);
705
+ try {
706
+ const result = await provider.runAction(schema.name, { id, action: action.name, expectedVersion: versionOf(schema, row) });
707
+ props.onActionRan?.(action, result.data, result.audit);
708
+ if (result.data) queryClient.setQueryData(recordKey, result.data);
709
+ else props.onClose();
710
+ void queryClient.invalidateQueries({ queryKey: ["genesis-studio", sessionKey, "history", schema.name, id] });
711
+ } catch (error) {
712
+ const message = error instanceof Error ? error.message : String(error);
713
+ setActionError({ label: action.label, message: message.replace(/^Not (saved|run): /, "") });
714
+ props.onFailed(`action:${action.name}`, message);
715
+ void record.refetch();
716
+ } finally {
717
+ setRunning(null);
718
+ }
719
+ }
720
+ const actions = provider.runAction ? schema.actions ?? [] : [];
721
+ async function copyLink() {
722
+ try {
723
+ await navigator.clipboard.writeText(window.location.href);
724
+ setCopied(true);
725
+ setTimeout(() => setCopied(false), 1500);
726
+ } catch {
727
+ setCopied(false);
728
+ }
729
+ }
730
+ return /* @__PURE__ */ jsx3(Sheet, { open: true, onOpenChange: (open) => !open && props.onClose(), children: /* @__PURE__ */ jsxs3(SheetContent, { className: "w-full overflow-y-auto sm:max-w-xl", "data-testid": "studio-record-panel", children: [
731
+ /* @__PURE__ */ jsxs3(SheetHeader, { children: [
732
+ /* @__PURE__ */ jsx3(SheetTitle, { className: "truncate pr-8", children: title }),
733
+ /* @__PURE__ */ jsxs3(SheetDescription, { children: [
734
+ schema.label ?? schema.name,
735
+ " \xB7 ",
736
+ /* @__PURE__ */ jsx3("span", { className: "font-mono text-xs", children: id })
737
+ ] })
738
+ ] }),
739
+ /* @__PURE__ */ jsxs3("div", { className: "mt-3 flex flex-wrap gap-2", children: [
740
+ /* @__PURE__ */ jsxs3(Button3, { type: "button", variant: "outline", size: "sm", onClick: copyLink, testID: "studio-copy-link", children: [
741
+ /* @__PURE__ */ jsx3(Link2, { className: "mr-1.5 h-3.5 w-3.5", "aria-hidden": true }),
742
+ copied ? "Link copied" : "Copy link"
743
+ ] }),
744
+ schema.access.delete ? /* @__PURE__ */ jsxs3(AlertDialog, { children: [
745
+ /* @__PURE__ */ jsx3(AlertDialogTrigger, { asChild: true, children: /* @__PURE__ */ jsxs3(Button3, { type: "button", variant: "outline", size: "sm", testID: "studio-delete", children: [
746
+ /* @__PURE__ */ jsx3(Trash2, { className: "mr-1.5 h-3.5 w-3.5", "aria-hidden": true }),
747
+ "Delete"
748
+ ] }) }),
749
+ /* @__PURE__ */ jsxs3(AlertDialogContent, { children: [
750
+ /* @__PURE__ */ jsxs3(AlertDialogHeader, { children: [
751
+ /* @__PURE__ */ jsxs3(AlertDialogTitle, { children: [
752
+ "Delete ",
753
+ title,
754
+ "?"
755
+ ] }),
756
+ /* @__PURE__ */ jsx3(AlertDialogDescription, { children: "The deletion is audited. It only goes through if nobody has changed the row since you opened it." })
757
+ ] }),
758
+ /* @__PURE__ */ jsxs3(AlertDialogFooter, { children: [
759
+ /* @__PURE__ */ jsx3(AlertDialogCancel, { children: "Keep it" }),
760
+ /* @__PURE__ */ jsx3(AlertDialogAction, { onClick: () => void remove(), "data-testid": "studio-delete-confirm", children: "Delete" })
761
+ ] })
762
+ ] })
763
+ ] }) : /* @__PURE__ */ jsxs3(Button3, { type: "button", variant: "outline", size: "sm", disabled: true, title: schema.access.reasons?.delete, children: [
764
+ /* @__PURE__ */ jsx3(Trash2, { className: "mr-1.5 h-3.5 w-3.5", "aria-hidden": true }),
765
+ "Delete"
766
+ ] }),
767
+ actions.map((action) => /* @__PURE__ */ jsxs3(
768
+ Button3,
769
+ {
770
+ type: "button",
771
+ variant: "outline",
772
+ size: "sm",
773
+ disabled: !row || running !== null,
774
+ title: action.description,
775
+ onClick: () => action.confirm ? setConfirming(action) : void runAction(action),
776
+ testID: `studio-action-${action.name}`,
777
+ children: [
778
+ /* @__PURE__ */ jsx3(Play, { className: "mr-1.5 h-3.5 w-3.5", "aria-hidden": true }),
779
+ running === action.name ? `${action.label}\u2026` : action.label
780
+ ]
781
+ },
782
+ action.name
783
+ ))
784
+ ] }),
785
+ /* @__PURE__ */ jsx3(AlertDialog, { open: confirming !== null, onOpenChange: (open) => !open && setConfirming(null), children: /* @__PURE__ */ jsxs3(AlertDialogContent, { children: [
786
+ /* @__PURE__ */ jsxs3(AlertDialogHeader, { children: [
787
+ /* @__PURE__ */ jsx3(AlertDialogTitle, { children: confirming?.confirm }),
788
+ /* @__PURE__ */ jsxs3(AlertDialogDescription, { children: [
789
+ confirming?.description ? `${confirming.description} ` : "",
790
+ "It runs on this row as you see it now, and is audited."
791
+ ] })
792
+ ] }),
793
+ /* @__PURE__ */ jsxs3(AlertDialogFooter, { children: [
794
+ /* @__PURE__ */ jsx3(AlertDialogCancel, { children: "Cancel" }),
795
+ /* @__PURE__ */ jsx3(
796
+ AlertDialogAction,
797
+ {
798
+ onClick: () => {
799
+ const action = confirming;
800
+ setConfirming(null);
801
+ if (action) void runAction(action);
802
+ },
803
+ "data-testid": "studio-action-confirm",
804
+ children: confirming?.label
805
+ }
806
+ )
807
+ ] })
808
+ ] }) }),
809
+ actionError && /* @__PURE__ */ jsxs3(Alert, { variant: "destructive", className: "mt-3", "data-testid": "studio-action-error", children: [
810
+ /* @__PURE__ */ jsxs3(AlertTitle, { children: [
811
+ actionError.label,
812
+ " didn't run"
813
+ ] }),
814
+ /* @__PURE__ */ jsxs3(AlertDescription, { children: [
815
+ actionError.message,
816
+ " Nothing was changed."
817
+ ] })
818
+ ] }),
819
+ conflict && /* @__PURE__ */ jsxs3(Alert, { variant: "destructive", className: "mt-3", "data-testid": "studio-record-conflict", children: [
820
+ /* @__PURE__ */ jsx3(AlertTitle, { children: "Not saved" }),
821
+ /* @__PURE__ */ jsxs3(AlertDescription, { children: [
822
+ conflict,
823
+ " The form now shows the row as it is."
824
+ ] })
825
+ ] }),
826
+ record.isError ? /* @__PURE__ */ jsxs3(Alert, { variant: "destructive", className: "mt-4", children: [
827
+ /* @__PURE__ */ jsx3(AlertTitle, { children: "Couldn't open this record" }),
828
+ /* @__PURE__ */ jsx3(AlertDescription, { children: record.error instanceof Error ? record.error.message : String(record.error) })
829
+ ] }) : /* @__PURE__ */ jsxs3(Tabs, { defaultValue: "details", className: "mt-4", children: [
830
+ /* @__PURE__ */ jsxs3(TabsList, { children: [
831
+ /* @__PURE__ */ jsx3(TabsTrigger, { value: "details", children: "Details" }),
832
+ relations.length > 0 && /* @__PURE__ */ jsxs3(TabsTrigger, { value: "related", children: [
833
+ "Related (",
834
+ relations.length,
835
+ ")"
836
+ ] }),
837
+ /* @__PURE__ */ jsx3(TabsTrigger, { value: "history", disabled: !schema.access.history, title: schema.access.reasons?.history, children: "History" }),
838
+ schema.access.comment !== void 0 && provider.comments && /* @__PURE__ */ jsx3(TabsTrigger, { value: "comments", disabled: !schema.access.comment, title: schema.access.reasons?.comment, children: "Comments" })
839
+ ] }),
840
+ /* @__PURE__ */ jsx3(TabsContent, { value: "details", className: "pt-2", children: row ? /* @__PURE__ */ jsx3(
841
+ RecordForm,
842
+ {
843
+ provider,
844
+ sessionKey,
845
+ mode: "edit",
846
+ fields: schema.fields,
847
+ editable,
848
+ row,
849
+ onSubmit: save,
850
+ submitLabel: "Save changes",
851
+ config: props.formConfig
852
+ }
853
+ ) : /* @__PURE__ */ jsx3("p", { className: "text-sm text-muted-foreground", children: "Loading\u2026" }) }),
854
+ relations.length > 0 && /* @__PURE__ */ jsx3(TabsContent, { value: "related", className: "space-y-5 pt-2", children: row && relations.map((relation) => /* @__PURE__ */ jsx3(
855
+ RelatedSection,
856
+ {
857
+ provider,
858
+ sessionKey,
859
+ schema,
860
+ row,
861
+ relation,
862
+ onOpenRecord: props.onOpenRecord
863
+ },
864
+ `${relation.kind}:${relation.resource}:${relation.field}`
865
+ )) }),
866
+ schema.access.history && /* @__PURE__ */ jsx3(TabsContent, { value: "history", className: "pt-2", children: /* @__PURE__ */ jsx3(
867
+ History,
868
+ {
869
+ provider,
870
+ sessionKey,
871
+ resource: schema.name,
872
+ id,
873
+ onUndone: (audit) => {
874
+ void record.refetch();
875
+ props.onUndone?.(audit);
876
+ }
877
+ }
878
+ ) }),
879
+ schema.access.comment && provider.comments && /* @__PURE__ */ jsx3(TabsContent, { value: "comments", className: "pt-2", children: /* @__PURE__ */ jsx3(Comments, { provider, sessionKey, resource: schema.name, id }) })
880
+ ] })
881
+ ] }) });
882
+ }
883
+ function RelatedSection({
884
+ provider,
885
+ sessionKey,
886
+ schema,
887
+ row,
888
+ relation,
889
+ onOpenRecord
890
+ }) {
891
+ const target = useSchema(provider, sessionKey, relation.resource);
892
+ const ownKey = singleKey(schema);
893
+ const query = useQuery3({
894
+ queryKey: ["genesis-studio", sessionKey, "related", schema.name, relation.kind, relation.resource, relation.field, row[ownKey ?? ""]],
895
+ enabled: Boolean(target.data),
896
+ retry: false,
897
+ queryFn: async () => {
898
+ const targetKey = singleKey(target.data);
899
+ if (relation.kind === "one") {
900
+ const value = row[relation.field];
901
+ if (value == null || !targetKey) return [];
902
+ const r2 = await provider.getList(relation.resource, {
903
+ pagination: { page: 1, perPage: 1 },
904
+ filters: [{ field: targetKey, operator: "eq", value: String(value) }]
905
+ });
906
+ return r2.data;
907
+ }
908
+ const self = row[ownKey ?? ""];
909
+ if (self == null) return [];
910
+ if (relation.kind === "many") {
911
+ const r2 = await provider.getList(relation.resource, {
912
+ pagination: { page: 1, perPage: 25 },
913
+ filters: [{ field: relation.field, operator: "eq", value: String(self) }]
914
+ });
915
+ return r2.data;
916
+ }
917
+ const links = await provider.getList(relation.through.resource, {
918
+ pagination: { page: 1, perPage: 100 },
919
+ filters: [{ field: relation.field, operator: "eq", value: String(self) }]
920
+ });
921
+ const ids = links.data.map((l) => String(l[relation.through.otherField]));
922
+ if (!ids.length || !targetKey) return [];
923
+ const r = await provider.getList(relation.resource, {
924
+ pagination: { page: 1, perPage: Math.min(100, ids.length) },
925
+ filters: [{ field: targetKey, operator: "in", value: ids }]
926
+ });
927
+ return r.data;
928
+ }
929
+ });
930
+ const heading = `${target.data?.label ?? relation.resource}${relation.kind === "one" ? ` (via ${relation.field})` : relation.kind === "through" ? ` (via ${relation.through.resource})` : ""}`;
931
+ const labelField = target.data?.labelField;
932
+ return /* @__PURE__ */ jsxs3("section", { "data-testid": `studio-related-${relation.resource}`, children: [
933
+ /* @__PURE__ */ jsx3("h3", { className: "mb-1.5 text-sm font-semibold", children: heading }),
934
+ query.isLoading ? /* @__PURE__ */ jsx3("p", { className: "text-xs text-muted-foreground", children: "Loading\u2026" }) : !query.data?.length ? /* @__PURE__ */ jsx3("p", { className: "text-xs text-muted-foreground", children: "None you can see." }) : /* @__PURE__ */ jsx3("ul", { className: "divide-y rounded-md border", children: query.data.map((related, index) => {
935
+ const relatedId = target.data ? String(encodeRecordKey(target.data, related)) : String(index);
936
+ return /* @__PURE__ */ jsx3("li", { children: /* @__PURE__ */ jsxs3(
937
+ "button",
938
+ {
939
+ type: "button",
940
+ className: "flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm hover:bg-muted",
941
+ onClick: () => onOpenRecord(relation.resource, relatedId),
942
+ children: [
943
+ /* @__PURE__ */ jsx3("span", { className: "truncate", children: displayText(related[labelField ?? ""]) ?? relatedId }),
944
+ /* @__PURE__ */ jsx3(ExternalLink, { className: "ml-auto h-3.5 w-3.5 shrink-0 text-muted-foreground", "aria-hidden": true })
945
+ ]
946
+ }
947
+ ) }, relatedId);
948
+ }) })
949
+ ] });
950
+ }
951
+ function History({
952
+ provider,
953
+ sessionKey,
954
+ resource,
955
+ id,
956
+ onUndone
957
+ }) {
958
+ const [undoError, setUndoError] = useState4(null);
959
+ const history = useQuery3({
960
+ queryKey: ["genesis-studio", sessionKey, "history", resource, id],
961
+ enabled: Boolean(provider.history),
962
+ retry: false,
963
+ queryFn: () => provider.history(resource, { id })
964
+ });
965
+ if (history.isLoading) return /* @__PURE__ */ jsx3("p", { className: "text-xs text-muted-foreground", children: "Loading\u2026" });
966
+ if (history.isError) {
967
+ return /* @__PURE__ */ jsx3("p", { role: "alert", className: "text-xs text-destructive", children: history.error instanceof Error ? history.error.message : String(history.error) });
968
+ }
969
+ if (!history.data?.length) return /* @__PURE__ */ jsx3("p", { className: "text-xs text-muted-foreground", children: "No audited changes yet." });
970
+ async function undo(entry) {
971
+ setUndoError(null);
972
+ try {
973
+ const result = await provider.undo(resource, { auditId: entry.id });
974
+ void history.refetch();
975
+ onUndone(result.audit);
976
+ } catch (error) {
977
+ setUndoError(error instanceof Error ? error.message : String(error));
978
+ }
979
+ }
980
+ return /* @__PURE__ */ jsxs3(Fragment, { children: [
981
+ undoError && /* @__PURE__ */ jsx3("p", { role: "alert", className: "mb-2 text-xs text-destructive", "data-testid": "studio-undo-error", children: undoError }),
982
+ /* @__PURE__ */ jsx3("ol", { className: "space-y-2", "data-testid": "studio-history", children: history.data.map((entry) => {
983
+ const fields = [.../* @__PURE__ */ new Set([...Object.keys(entry.before ?? {}), ...Object.keys(entry.after ?? {})])];
984
+ return /* @__PURE__ */ jsxs3("li", { className: "rounded-md border p-2 text-xs", children: [
985
+ /* @__PURE__ */ jsxs3("div", { className: "flex flex-wrap items-baseline gap-x-2", children: [
986
+ /* @__PURE__ */ jsx3("span", { className: "font-medium capitalize", children: entry.action === "run" ? `Ran ${entry.rowAction ?? "an action"}` : entry.action }),
987
+ /* @__PURE__ */ jsxs3("span", { className: "text-muted-foreground", children: [
988
+ "by ",
989
+ entry.actor ?? "unknown"
990
+ ] }),
991
+ /* @__PURE__ */ jsx3("time", { className: "ml-auto text-muted-foreground", children: entry.occurredAt }),
992
+ provider.undo && entry.action !== "run" && /* @__PURE__ */ jsx3(
993
+ "button",
994
+ {
995
+ type: "button",
996
+ className: "rounded px-1.5 py-0.5 text-[11px] text-muted-foreground hover:bg-muted hover:text-foreground",
997
+ onClick: () => void undo(entry),
998
+ title: "Put this change back. Refused if the row has changed since.",
999
+ "data-testid": "studio-history-undo",
1000
+ children: "Undo"
1001
+ }
1002
+ )
1003
+ ] }),
1004
+ /* @__PURE__ */ jsx3("div", { className: "mt-1 font-mono text-[11px]", children: fields.map((field) => /* @__PURE__ */ jsxs3("div", { children: [
1005
+ field,
1006
+ ": ",
1007
+ displayText(entry.before?.[field]) ?? "\u2014",
1008
+ " \u2192 ",
1009
+ displayText(entry.after?.[field]) ?? "\u2014"
1010
+ ] }, field)) })
1011
+ ] }, entry.id);
1012
+ }) })
1013
+ ] });
1014
+ }
1015
+ function Comments({
1016
+ provider,
1017
+ sessionKey,
1018
+ resource,
1019
+ id
1020
+ }) {
1021
+ const queryClient = useQueryClient();
1022
+ const key = ["genesis-studio", sessionKey, "comments", resource, id];
1023
+ const comments = useQuery3({ queryKey: key, retry: false, queryFn: () => provider.comments(resource, { id }) });
1024
+ const [draft, setDraft] = useState4("");
1025
+ const [busy, setBusy] = useState4(false);
1026
+ const [error, setError] = useState4(null);
1027
+ async function post() {
1028
+ if (!draft.trim() || !provider.addComment) return;
1029
+ setBusy(true);
1030
+ setError(null);
1031
+ try {
1032
+ await provider.addComment(resource, { id, body: draft });
1033
+ setDraft("");
1034
+ await queryClient.invalidateQueries({ queryKey: key });
1035
+ } catch (e) {
1036
+ setError(e instanceof Error ? e.message : String(e));
1037
+ } finally {
1038
+ setBusy(false);
1039
+ }
1040
+ }
1041
+ return /* @__PURE__ */ jsxs3("div", { className: "space-y-3", "data-testid": "studio-comments", children: [
1042
+ comments.isLoading ? /* @__PURE__ */ jsx3("p", { className: "text-xs text-muted-foreground", children: "Loading\u2026" }) : comments.isError ? /* @__PURE__ */ jsx3("p", { role: "alert", className: "text-xs text-destructive", children: comments.error instanceof Error ? comments.error.message : String(comments.error) }) : !comments.data?.length ? /* @__PURE__ */ jsx3("p", { className: "text-xs text-muted-foreground", children: "No comments yet." }) : /* @__PURE__ */ jsx3("ol", { className: "space-y-2", children: comments.data.map((c) => /* @__PURE__ */ jsxs3("li", { className: "rounded-md border p-2 text-sm", children: [
1043
+ /* @__PURE__ */ jsxs3("div", { className: "flex items-baseline gap-2 text-xs text-muted-foreground", children: [
1044
+ /* @__PURE__ */ jsx3("span", { className: "font-medium text-foreground", children: c.author }),
1045
+ /* @__PURE__ */ jsx3("time", { className: "ml-auto", children: c.createdAt })
1046
+ ] }),
1047
+ /* @__PURE__ */ jsx3("p", { className: "mt-1 whitespace-pre-wrap break-words", children: c.body })
1048
+ ] }, c.id)) }),
1049
+ provider.addComment && /* @__PURE__ */ jsxs3(
1050
+ "form",
1051
+ {
1052
+ className: "space-y-2",
1053
+ onSubmit: (e) => {
1054
+ e.preventDefault();
1055
+ void post();
1056
+ },
1057
+ children: [
1058
+ /* @__PURE__ */ jsx3(
1059
+ Textarea2,
1060
+ {
1061
+ "aria-label": "New comment",
1062
+ value: draft,
1063
+ maxLength: 1e4,
1064
+ onChange: (e) => setDraft(e.target.value),
1065
+ placeholder: "Add a comment\u2026",
1066
+ rows: 3
1067
+ }
1068
+ ),
1069
+ error && /* @__PURE__ */ jsx3("p", { role: "alert", className: "text-xs text-destructive", children: error }),
1070
+ /* @__PURE__ */ jsx3("div", { className: "flex justify-end", children: /* @__PURE__ */ jsx3(Button3, { type: "submit", size: "sm", disabled: busy || !draft.trim(), testID: "studio-comment-post", children: busy ? "Posting\u2026" : "Comment" }) })
1071
+ ]
1072
+ }
1073
+ )
1074
+ ] });
1075
+ }
1076
+
1077
+ // src/toolbar/Toolbar.tsx
1078
+ import { useState as useState5 } from "react";
1079
+ import { ArrowDownUp, Download, EyeOff, Filter, Group, Plus, Search, X as X2 } from "lucide-react";
1080
+ import { Badge } from "@marktiderman/genesis-ui/badge";
1081
+ import { Button as Button4 } from "@marktiderman/genesis-ui/button";
1082
+ import { Input as Input2 } from "@marktiderman/genesis-ui/input";
1083
+ import { Popover as Popover2, PopoverContent as PopoverContent2, PopoverTrigger as PopoverTrigger2 } from "@marktiderman/genesis-ui/popover";
1084
+ import { Select as Select2, SelectContent as SelectContent2, SelectItem as SelectItem2, SelectTrigger as SelectTrigger2, SelectValue as SelectValue2 } from "@marktiderman/genesis-ui/select";
1085
+ import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
1086
+ var NONE2 = "__genesis_none__";
1087
+ function toDraft(f) {
1088
+ return { field: f.field, operator: f.operator, value: "value" in f && f.value != null ? String(f.value) : "" };
1089
+ }
1090
+ function fromDraft(d) {
1091
+ return needsValue(d.operator) ? { field: d.field, operator: d.operator, value: d.value } : { field: d.field, operator: d.operator };
1092
+ }
1093
+ function FilterBuilder({
1094
+ fields,
1095
+ filters,
1096
+ onChange
1097
+ }) {
1098
+ const [open, setOpen] = useState5(false);
1099
+ const [drafts, setDrafts] = useState5(filters.map(toDraft));
1100
+ const filterable = fields.filter((f) => !f.array);
1101
+ const update = (i, patch) => setDrafts((prev) => prev.map((d, j) => j === i ? { ...d, ...patch } : d));
1102
+ return /* @__PURE__ */ jsxs4(
1103
+ Popover2,
1104
+ {
1105
+ open,
1106
+ onOpenChange: (next) => {
1107
+ setOpen(next);
1108
+ if (next) setDrafts(filters.map(toDraft));
1109
+ },
1110
+ children: [
1111
+ /* @__PURE__ */ jsx4(PopoverTrigger2, { asChild: true, children: /* @__PURE__ */ jsxs4(Button4, { type: "button", variant: "ghost", size: "sm", testID: "studio-filter", className: `px-2 ${filters.length ? "bg-success/15" : ""}`, children: [
1112
+ /* @__PURE__ */ jsx4(Filter, { className: "mr-1.5 h-3.5 w-3.5", "aria-hidden": true }),
1113
+ "Filter",
1114
+ filters.length > 0 && /* @__PURE__ */ jsx4(Badge, { className: "ml-1.5 h-4 px-1.5 text-[10px]", children: filters.length })
1115
+ ] }) }),
1116
+ /* @__PURE__ */ jsxs4(PopoverContent2, { align: "start", className: "w-[min(36rem,calc(100vw-2rem))] space-y-2", children: [
1117
+ drafts.length === 0 && /* @__PURE__ */ jsx4("p", { className: "text-xs text-muted-foreground", children: "No filters. Every row you can see is listed." }),
1118
+ drafts.map((d, i) => {
1119
+ const field = filterable.find((f) => f.name === d.field);
1120
+ const operators = field ? operatorsFor(field) : [];
1121
+ const choices = field ? choicesFor(field) : void 0;
1122
+ return /* @__PURE__ */ jsxs4("div", { className: "flex flex-wrap items-center gap-1.5", "data-testid": "studio-filter-row", children: [
1123
+ /* @__PURE__ */ jsxs4(Select2, { value: d.field, onValueChange: (v) => update(i, { field: v, operator: operatorsFor(filterable.find((f) => f.name === v))[0], value: "" }), children: [
1124
+ /* @__PURE__ */ jsx4(SelectTrigger2, { className: "h-8 w-36 text-xs", "aria-label": "Field", children: /* @__PURE__ */ jsx4(SelectValue2, {}) }),
1125
+ /* @__PURE__ */ jsx4(SelectContent2, { children: filterable.map((f) => /* @__PURE__ */ jsx4(SelectItem2, { value: f.name, children: fieldLabel(f) }, f.name)) })
1126
+ ] }),
1127
+ /* @__PURE__ */ jsxs4(Select2, { value: d.operator, onValueChange: (v) => update(i, { operator: v }), children: [
1128
+ /* @__PURE__ */ jsx4(SelectTrigger2, { className: "h-8 w-40 text-xs", "aria-label": "Operator", children: /* @__PURE__ */ jsx4(SelectValue2, {}) }),
1129
+ /* @__PURE__ */ jsx4(SelectContent2, { children: operators.map((op) => /* @__PURE__ */ jsx4(SelectItem2, { value: op, children: OPERATOR_LABEL[op] }, op)) })
1130
+ ] }),
1131
+ needsValue(d.operator) && (choices ? /* @__PURE__ */ jsxs4(Select2, { value: d.value || NONE2, onValueChange: (v) => update(i, { value: v === NONE2 ? "" : v }), children: [
1132
+ /* @__PURE__ */ jsx4(SelectTrigger2, { className: "h-8 w-36 text-xs", "aria-label": "Value", children: /* @__PURE__ */ jsx4(SelectValue2, {}) }),
1133
+ /* @__PURE__ */ jsxs4(SelectContent2, { children: [
1134
+ /* @__PURE__ */ jsx4(SelectItem2, { value: NONE2, children: "\u2014" }),
1135
+ choices.map((c) => /* @__PURE__ */ jsx4(SelectItem2, { value: c, children: c }, c))
1136
+ ] })
1137
+ ] }) : /* @__PURE__ */ jsx4(
1138
+ Input2,
1139
+ {
1140
+ className: "h-8 w-36 text-xs",
1141
+ "aria-label": "Value",
1142
+ type: field?.widget === "date" ? "date" : "text",
1143
+ value: d.value,
1144
+ onChange: (e) => update(i, { value: e.target.value })
1145
+ }
1146
+ )),
1147
+ /* @__PURE__ */ jsx4(Button4, { type: "button", variant: "ghost", size: "icon", className: "h-8 w-8", "aria-label": "Remove filter", onClick: () => setDrafts((p) => p.filter((_, j) => j !== i)), children: /* @__PURE__ */ jsx4(X2, { className: "h-3.5 w-3.5", "aria-hidden": true }) })
1148
+ ] }, i);
1149
+ }),
1150
+ /* @__PURE__ */ jsxs4("div", { className: "flex justify-between gap-2 pt-1", children: [
1151
+ /* @__PURE__ */ jsxs4(
1152
+ Button4,
1153
+ {
1154
+ type: "button",
1155
+ variant: "outline",
1156
+ size: "sm",
1157
+ disabled: filterable.length === 0,
1158
+ onClick: () => {
1159
+ const first = filterable[0];
1160
+ setDrafts((p) => [...p, { field: first.name, operator: operatorsFor(first)[0], value: "" }]);
1161
+ },
1162
+ children: [
1163
+ /* @__PURE__ */ jsx4(Plus, { className: "mr-1 h-3.5 w-3.5", "aria-hidden": true }),
1164
+ "Add filter"
1165
+ ]
1166
+ }
1167
+ ),
1168
+ /* @__PURE__ */ jsx4(
1169
+ Button4,
1170
+ {
1171
+ type: "button",
1172
+ size: "sm",
1173
+ testID: "studio-filter-apply",
1174
+ onClick: () => {
1175
+ onChange(drafts.filter((d) => !needsValue(d.operator) || d.value !== "").map(fromDraft));
1176
+ setOpen(false);
1177
+ },
1178
+ children: "Apply"
1179
+ }
1180
+ )
1181
+ ] })
1182
+ ] })
1183
+ ]
1184
+ }
1185
+ );
1186
+ }
1187
+ function SortBuilder({
1188
+ fields,
1189
+ sorts,
1190
+ onChange
1191
+ }) {
1192
+ const sortable = fields.filter((f) => !f.array);
1193
+ const unused = sortable.filter((f) => !sorts.some((s) => s.field === f.name));
1194
+ return /* @__PURE__ */ jsxs4(Popover2, { children: [
1195
+ /* @__PURE__ */ jsx4(PopoverTrigger2, { asChild: true, children: /* @__PURE__ */ jsxs4(Button4, { type: "button", variant: "ghost", size: "sm", testID: "studio-sort", className: `px-2 ${sorts.length ? "bg-warning/20" : ""}`, children: [
1196
+ /* @__PURE__ */ jsx4(ArrowDownUp, { className: "mr-1.5 h-3.5 w-3.5", "aria-hidden": true }),
1197
+ "Sort",
1198
+ sorts.length > 0 && /* @__PURE__ */ jsx4(Badge, { className: "ml-1.5 h-4 px-1.5 text-[10px]", children: sorts.length })
1199
+ ] }) }),
1200
+ /* @__PURE__ */ jsxs4(PopoverContent2, { align: "start", className: "w-80 space-y-2", children: [
1201
+ sorts.length === 0 && /* @__PURE__ */ jsx4("p", { className: "text-xs text-muted-foreground", children: "Rows are in key order." }),
1202
+ sorts.map((s, i) => /* @__PURE__ */ jsxs4("div", { className: "flex items-center gap-1.5", "data-testid": "studio-sort-row", children: [
1203
+ /* @__PURE__ */ jsx4("span", { className: "w-5 text-right text-xs text-muted-foreground", children: i + 1 }),
1204
+ /* @__PURE__ */ jsx4("span", { className: "flex-1 truncate text-sm", children: s.field }),
1205
+ /* @__PURE__ */ jsxs4(Select2, { value: s.order, onValueChange: (v) => onChange(sorts.map((x, j) => j === i ? { ...x, order: v } : x)), children: [
1206
+ /* @__PURE__ */ jsx4(SelectTrigger2, { className: "h-8 w-28 text-xs", "aria-label": `Direction for ${s.field}`, children: /* @__PURE__ */ jsx4(SelectValue2, {}) }),
1207
+ /* @__PURE__ */ jsxs4(SelectContent2, { children: [
1208
+ /* @__PURE__ */ jsx4(SelectItem2, { value: "asc", children: "Ascending" }),
1209
+ /* @__PURE__ */ jsx4(SelectItem2, { value: "desc", children: "Descending" })
1210
+ ] })
1211
+ ] }),
1212
+ /* @__PURE__ */ jsx4(Button4, { type: "button", variant: "ghost", size: "icon", className: "h-8 w-8", "aria-label": `Stop sorting by ${s.field}`, onClick: () => onChange(sorts.filter((_, j) => j !== i)), children: /* @__PURE__ */ jsx4(X2, { className: "h-3.5 w-3.5", "aria-hidden": true }) })
1213
+ ] }, s.field)),
1214
+ unused.length > 0 && sorts.length < 5 && /* @__PURE__ */ jsxs4(Select2, { value: NONE2, onValueChange: (v) => v !== NONE2 && onChange([...sorts, { field: v, order: "asc" }]), children: [
1215
+ /* @__PURE__ */ jsx4(SelectTrigger2, { className: "h-8 text-xs", "aria-label": "Add a sort field", children: /* @__PURE__ */ jsx4(SelectValue2, { placeholder: "Add a sort field" }) }),
1216
+ /* @__PURE__ */ jsxs4(SelectContent2, { children: [
1217
+ /* @__PURE__ */ jsx4(SelectItem2, { value: NONE2, disabled: true, children: "Add a sort field\u2026" }),
1218
+ unused.map((f) => /* @__PURE__ */ jsx4(SelectItem2, { value: f.name, children: fieldLabel(f) }, f.name))
1219
+ ] })
1220
+ ] })
1221
+ ] })
1222
+ ] });
1223
+ }
1224
+ function GroupPicker({
1225
+ fields,
1226
+ group,
1227
+ onChange
1228
+ }) {
1229
+ const groupable = fields.filter((f) => !f.array && f.type !== "json");
1230
+ return /* @__PURE__ */ jsxs4(Select2, { value: group ?? NONE2, onValueChange: (v) => onChange(v === NONE2 ? void 0 : v), children: [
1231
+ /* @__PURE__ */ jsxs4(SelectTrigger2, { className: `h-8 w-auto gap-1.5 border-0 text-sm shadow-none hover:bg-muted ${group ? "bg-info/15" : ""}`, "aria-label": "Group by", "data-testid": "studio-group", children: [
1232
+ /* @__PURE__ */ jsx4(Group, { className: "h-3.5 w-3.5", "aria-hidden": true }),
1233
+ /* @__PURE__ */ jsx4(SelectValue2, {})
1234
+ ] }),
1235
+ /* @__PURE__ */ jsxs4(SelectContent2, { children: [
1236
+ /* @__PURE__ */ jsx4(SelectItem2, { value: NONE2, children: "Group" }),
1237
+ groupable.map((f) => /* @__PURE__ */ jsxs4(SelectItem2, { value: f.name, children: [
1238
+ "Group by ",
1239
+ fieldLabel(f)
1240
+ ] }, f.name))
1241
+ ] })
1242
+ ] });
1243
+ }
1244
+ function ExportButton({ onExport, busy, progress }) {
1245
+ return /* @__PURE__ */ jsxs4(Button4, { type: "button", variant: "ghost", size: "sm", onClick: onExport, disabled: busy, testID: "studio-export", "aria-label": "Export CSV", title: "Export CSV", children: [
1246
+ /* @__PURE__ */ jsx4(Download, { className: "h-3.5 w-3.5", "aria-hidden": true }),
1247
+ busy && /* @__PURE__ */ jsx4("span", { className: "ml-1.5", children: progress })
1248
+ ] });
1249
+ }
1250
+ function NewRowButton({ allowed, reason, onClick }) {
1251
+ return /* @__PURE__ */ jsxs4(Button4, { type: "button", size: "sm", onClick, disabled: !allowed, title: allowed ? void 0 : reason, testID: "studio-new-row", children: [
1252
+ /* @__PURE__ */ jsx4(Plus, { className: "mr-1 h-3.5 w-3.5", "aria-hidden": true }),
1253
+ "Add record"
1254
+ ] });
1255
+ }
1256
+ function ViewFieldPicker({
1257
+ label,
1258
+ fields,
1259
+ value,
1260
+ onChange
1261
+ }) {
1262
+ return /* @__PURE__ */ jsxs4(Select2, { value, onValueChange: onChange, children: [
1263
+ /* @__PURE__ */ jsx4(SelectTrigger2, { className: "h-9 w-auto gap-1.5 text-sm", "aria-label": label, "data-testid": "studio-view-field", children: /* @__PURE__ */ jsx4(SelectValue2, {}) }),
1264
+ /* @__PURE__ */ jsx4(SelectContent2, { children: fields.map((f) => /* @__PURE__ */ jsxs4(SelectItem2, { value: f.name, children: [
1265
+ label,
1266
+ " ",
1267
+ fieldLabel(f)
1268
+ ] }, f.name)) })
1269
+ ] });
1270
+ }
1271
+ function HideFields({
1272
+ fields,
1273
+ hidden,
1274
+ onChange
1275
+ }) {
1276
+ return /* @__PURE__ */ jsxs4(Popover2, { children: [
1277
+ /* @__PURE__ */ jsx4(PopoverTrigger2, { asChild: true, children: /* @__PURE__ */ jsxs4(Button4, { type: "button", variant: "ghost", size: "sm", className: "px-2", testID: "studio-hide-fields", children: [
1278
+ /* @__PURE__ */ jsx4(EyeOff, { className: "mr-1.5 h-3.5 w-3.5", "aria-hidden": true }),
1279
+ hidden.size ? `${hidden.size} hidden field${hidden.size === 1 ? "" : "s"}` : "Hide fields"
1280
+ ] }) }),
1281
+ /* @__PURE__ */ jsxs4(PopoverContent2, { align: "start", className: "w-64 space-y-1 p-2", children: [
1282
+ fields.map((f) => /* @__PURE__ */ jsxs4("label", { className: "flex cursor-pointer items-center gap-2 rounded px-2 py-1 text-sm hover:bg-muted", children: [
1283
+ /* @__PURE__ */ jsx4(
1284
+ "input",
1285
+ {
1286
+ type: "checkbox",
1287
+ className: "h-3.5 w-3.5",
1288
+ checked: !hidden.has(f.name),
1289
+ onChange: () => {
1290
+ const next = new Set(hidden);
1291
+ if (next.has(f.name)) next.delete(f.name);
1292
+ else next.add(f.name);
1293
+ onChange(next);
1294
+ }
1295
+ }
1296
+ ),
1297
+ /* @__PURE__ */ jsx4("span", { className: "truncate", children: fieldLabel(f) })
1298
+ ] }, f.name)),
1299
+ hidden.size > 0 && /* @__PURE__ */ jsx4(Button4, { type: "button", variant: "ghost", size: "sm", className: "w-full", onClick: () => onChange(/* @__PURE__ */ new Set()), children: "Show all" })
1300
+ ] })
1301
+ ] });
1302
+ }
1303
+ function SearchBox({ value, onChange }) {
1304
+ return /* @__PURE__ */ jsxs4("div", { className: "relative", children: [
1305
+ /* @__PURE__ */ jsx4(Search, { className: "pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground", "aria-hidden": true }),
1306
+ /* @__PURE__ */ jsx4(
1307
+ Input2,
1308
+ {
1309
+ type: "search",
1310
+ "aria-label": "Search",
1311
+ placeholder: "Search",
1312
+ className: "h-8 w-36 pl-7 text-sm",
1313
+ value,
1314
+ onChange: (e) => onChange(e.target.value),
1315
+ "data-testid": "studio-search"
1316
+ }
1317
+ )
1318
+ ] });
1319
+ }
1320
+
1321
+ // src/workspace/ViewsPanel.tsx
1322
+ import { useState as useState6 } from "react";
1323
+ import { Popover as Popover3, PopoverContent as PopoverContent3, PopoverTrigger as PopoverTrigger3 } from "@marktiderman/genesis-ui/popover";
1324
+ import { Bookmark, CalendarDays, ChevronDown, Columns3, Lock, Plus as Plus2, Search as Search2, Table2, X as X3 } from "lucide-react";
15
1325
  import { cn } from "@marktiderman/genesis-ui/utils";
1326
+ import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
1327
+ var VIEWS2 = [
1328
+ { kind: "grid", label: "Grid", icon: Table2, unavailable: "" },
1329
+ { kind: "kanban", label: "Board", icon: Columns3, unavailable: "A board needs a field with a fixed set of values, like a status." },
1330
+ { kind: "calendar", label: "Calendar", icon: CalendarDays, unavailable: "A calendar needs a date field." }
1331
+ ];
1332
+ function ViewsPanel({
1333
+ view,
1334
+ available,
1335
+ onChange,
1336
+ saved,
1337
+ onLoad,
1338
+ onDelete,
1339
+ onSave
1340
+ }) {
1341
+ const [naming, setNaming] = useState6(false);
1342
+ const [name, setName] = useState6("");
1343
+ return /* @__PURE__ */ jsxs5(
1344
+ "nav",
1345
+ {
1346
+ "aria-label": "Views",
1347
+ className: "flex shrink-0 flex-row gap-1 overflow-x-auto border-b p-2 text-sm md:w-40 md:flex-col md:overflow-visible md:border-b-0 md:border-r",
1348
+ "data-testid": "studio-views",
1349
+ children: [
1350
+ /* @__PURE__ */ jsx5("p", { className: "hidden px-2 pb-1 text-xs text-muted-foreground md:block", children: "Views" }),
1351
+ VIEWS2.map(({ kind, label, icon: Icon, unavailable }) => /* @__PURE__ */ jsxs5(
1352
+ "button",
1353
+ {
1354
+ type: "button",
1355
+ "aria-pressed": view === kind,
1356
+ disabled: !available[kind],
1357
+ title: available[kind] ? void 0 : unavailable,
1358
+ onClick: () => onChange(kind),
1359
+ "data-testid": `studio-view-${kind}`,
1360
+ className: cn(
1361
+ "flex shrink-0 items-center gap-2 rounded-md px-2 py-1.5 text-left disabled:opacity-40",
1362
+ view === kind ? "bg-accent font-medium text-accent-foreground" : "text-muted-foreground hover:bg-muted hover:text-foreground"
1363
+ ),
1364
+ children: [
1365
+ /* @__PURE__ */ jsx5(Icon, { className: "h-4 w-4", "aria-hidden": true }),
1366
+ label
1367
+ ]
1368
+ },
1369
+ kind
1370
+ )),
1371
+ saved.length > 0 && /* @__PURE__ */ jsx5("p", { className: "hidden px-2 pb-1 pt-3 text-xs text-muted-foreground md:block", children: "Saved" }),
1372
+ saved.map((item) => /* @__PURE__ */ jsxs5("span", { className: "group flex shrink-0 items-center rounded-md hover:bg-muted", children: [
1373
+ /* @__PURE__ */ jsxs5(
1374
+ "button",
1375
+ {
1376
+ type: "button",
1377
+ className: "flex min-w-0 flex-1 items-center gap-2 px-2 py-1.5 text-left text-muted-foreground hover:text-foreground",
1378
+ onClick: () => onLoad(item),
1379
+ "data-testid": "studio-saved-view",
1380
+ children: [
1381
+ /* @__PURE__ */ jsx5(Bookmark, { className: "h-4 w-4 shrink-0", "aria-hidden": true }),
1382
+ /* @__PURE__ */ jsx5("span", { className: "truncate", children: item.name })
1383
+ ]
1384
+ }
1385
+ ),
1386
+ /* @__PURE__ */ jsx5(
1387
+ "button",
1388
+ {
1389
+ type: "button",
1390
+ "aria-label": `Delete view ${item.name}`,
1391
+ className: "mr-1 rounded p-0.5 text-muted-foreground opacity-0 hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100",
1392
+ onClick: () => onDelete(item.id),
1393
+ children: /* @__PURE__ */ jsx5(X3, { className: "h-3 w-3", "aria-hidden": true })
1394
+ }
1395
+ )
1396
+ ] }, item.id)),
1397
+ naming ? /* @__PURE__ */ jsx5(
1398
+ "form",
1399
+ {
1400
+ className: "flex shrink-0 items-center gap-1 px-1 pt-1",
1401
+ onSubmit: (e) => {
1402
+ e.preventDefault();
1403
+ if (!name.trim()) return;
1404
+ onSave(name.trim());
1405
+ setName("");
1406
+ setNaming(false);
1407
+ },
1408
+ children: /* @__PURE__ */ jsx5(
1409
+ "input",
1410
+ {
1411
+ autoFocus: true,
1412
+ "aria-label": "View name",
1413
+ placeholder: "View name",
1414
+ value: name,
1415
+ onChange: (e) => setName(e.target.value),
1416
+ onKeyDown: (e) => e.key === "Escape" && setNaming(false),
1417
+ className: "h-7 w-full min-w-24 rounded border bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
1418
+ }
1419
+ )
1420
+ }
1421
+ ) : /* @__PURE__ */ jsxs5(
1422
+ "button",
1423
+ {
1424
+ type: "button",
1425
+ className: "flex shrink-0 items-center gap-2 rounded-md px-2 py-1.5 text-left text-muted-foreground hover:bg-muted hover:text-foreground md:mt-2",
1426
+ onClick: () => setNaming(true),
1427
+ "data-testid": "studio-save-view",
1428
+ children: [
1429
+ /* @__PURE__ */ jsx5(Plus2, { className: "h-4 w-4", "aria-hidden": true }),
1430
+ "Save view"
1431
+ ]
1432
+ }
1433
+ )
1434
+ ]
1435
+ }
1436
+ );
1437
+ }
1438
+ function TableTabs({
1439
+ resources,
1440
+ selected,
1441
+ onSelect,
1442
+ variant = "card"
1443
+ }) {
1444
+ const picker = resources.length > PICKER_AFTER && /* @__PURE__ */ jsx5(AllTables, { resources, selected, onSelect });
1445
+ if (variant === "plain") {
1446
+ return /* @__PURE__ */ jsxs5("div", { className: "flex items-end border-b", children: [
1447
+ /* @__PURE__ */ jsx5("nav", { "aria-label": "Tables", className: "flex min-w-0 flex-1 gap-1 overflow-x-auto", "data-testid": "studio-sidebar", children: resources.map((r) => {
1448
+ const active = r.name === selected;
1449
+ return /* @__PURE__ */ jsxs5(
1450
+ "button",
1451
+ {
1452
+ type: "button",
1453
+ onClick: () => onSelect(r.name),
1454
+ "aria-current": active ? "page" : void 0,
1455
+ title: r.access.update ? r.name : `${r.name}: ${r.access.reason ?? "read-only"}`,
1456
+ "data-testid": `studio-table-${r.name}`,
1457
+ className: cn(
1458
+ "-mb-px flex shrink-0 items-center gap-1.5 border-b-2 px-3 py-2 text-sm",
1459
+ active ? "border-primary font-medium text-foreground" : "border-transparent text-muted-foreground hover:text-foreground"
1460
+ ),
1461
+ children: [
1462
+ r.label ?? r.name,
1463
+ !r.access.update && /* @__PURE__ */ jsx5(Lock, { className: "h-3 w-3 text-muted-foreground", "aria-label": "Read-only" })
1464
+ ]
1465
+ },
1466
+ r.name
1467
+ );
1468
+ }) }),
1469
+ picker
1470
+ ] });
1471
+ }
1472
+ return /* @__PURE__ */ jsxs5("div", { className: "flex items-end bg-primary/10 pr-2", children: [
1473
+ /* @__PURE__ */ jsx5("nav", { "aria-label": "Tables", className: "flex min-w-0 flex-1 gap-0.5 overflow-x-auto px-2 pt-1.5", "data-testid": "studio-sidebar", children: resources.map((r) => {
1474
+ const active = r.name === selected;
1475
+ return /* @__PURE__ */ jsxs5(
1476
+ "button",
1477
+ {
1478
+ type: "button",
1479
+ onClick: () => onSelect(r.name),
1480
+ "aria-current": active ? "page" : void 0,
1481
+ title: r.access.update ? r.name : `${r.name}: ${r.access.reason ?? "read-only"}`,
1482
+ "data-testid": `studio-table-${r.name}`,
1483
+ className: cn(
1484
+ "flex shrink-0 items-center gap-1.5 rounded-t-md px-3 py-1.5 text-sm",
1485
+ active ? "bg-background font-medium text-foreground" : "text-foreground/70 hover:bg-background/60 hover:text-foreground"
1486
+ ),
1487
+ children: [
1488
+ r.label ?? r.name,
1489
+ !r.access.update && /* @__PURE__ */ jsx5(Lock, { className: "h-3 w-3 text-muted-foreground", "aria-label": "Read-only" })
1490
+ ]
1491
+ },
1492
+ r.name
1493
+ );
1494
+ }) }),
1495
+ picker
1496
+ ] });
1497
+ }
1498
+ var PICKER_AFTER = 8;
1499
+ function AllTables({
1500
+ resources,
1501
+ selected,
1502
+ onSelect
1503
+ }) {
1504
+ const [open, setOpen] = useState6(false);
1505
+ const [query, setQuery] = useState6("");
1506
+ const q = query.trim().toLowerCase();
1507
+ const shown = resources.filter((r) => !q || (r.label ?? r.name).toLowerCase().includes(q) || r.name.toLowerCase().includes(q));
1508
+ return /* @__PURE__ */ jsxs5(Popover3, { open, onOpenChange: (next) => {
1509
+ setOpen(next);
1510
+ if (!next) setQuery("");
1511
+ }, children: [
1512
+ /* @__PURE__ */ jsx5(PopoverTrigger3, { asChild: true, children: /* @__PURE__ */ jsxs5(
1513
+ "button",
1514
+ {
1515
+ type: "button",
1516
+ className: "mb-1 ml-1 flex shrink-0 items-center gap-1 rounded-md px-2 py-1 text-sm text-muted-foreground hover:bg-muted hover:text-foreground",
1517
+ "data-testid": "studio-all-tables",
1518
+ children: [
1519
+ "All tables (",
1520
+ resources.length,
1521
+ ")",
1522
+ /* @__PURE__ */ jsx5(ChevronDown, { className: "h-3.5 w-3.5", "aria-hidden": true })
1523
+ ]
1524
+ }
1525
+ ) }),
1526
+ /* @__PURE__ */ jsxs5(PopoverContent3, { align: "end", className: "w-72 p-1", children: [
1527
+ /* @__PURE__ */ jsxs5("div", { className: "relative p-1", children: [
1528
+ /* @__PURE__ */ jsx5(Search2, { className: "pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground", "aria-hidden": true }),
1529
+ /* @__PURE__ */ jsx5(
1530
+ "input",
1531
+ {
1532
+ autoFocus: true,
1533
+ "aria-label": "Find a table",
1534
+ placeholder: "Find a table",
1535
+ value: query,
1536
+ onChange: (e) => setQuery(e.target.value),
1537
+ onKeyDown: (e) => {
1538
+ if (e.key === "Enter" && shown[0]) {
1539
+ onSelect(shown[0].name);
1540
+ setOpen(false);
1541
+ }
1542
+ },
1543
+ className: "h-8 w-full rounded border bg-background pl-7 pr-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
1544
+ }
1545
+ )
1546
+ ] }),
1547
+ /* @__PURE__ */ jsxs5("ul", { className: "max-h-72 overflow-y-auto", "aria-label": "Tables", children: [
1548
+ shown.map((r) => /* @__PURE__ */ jsx5("li", { children: /* @__PURE__ */ jsxs5(
1549
+ "button",
1550
+ {
1551
+ type: "button",
1552
+ "aria-current": r.name === selected ? "page" : void 0,
1553
+ className: cn(
1554
+ "flex w-full items-center justify-between gap-2 rounded px-2 py-1.5 text-left text-sm hover:bg-muted",
1555
+ r.name === selected && "bg-accent text-accent-foreground"
1556
+ ),
1557
+ onClick: () => {
1558
+ onSelect(r.name);
1559
+ setOpen(false);
1560
+ },
1561
+ children: [
1562
+ /* @__PURE__ */ jsx5("span", { className: "truncate", children: r.label ?? r.name }),
1563
+ !r.access.update && /* @__PURE__ */ jsx5(Lock, { className: "h-3 w-3 shrink-0 text-muted-foreground", "aria-label": "Read-only" })
1564
+ ]
1565
+ }
1566
+ ) }, r.name)),
1567
+ shown.length === 0 && /* @__PURE__ */ jsx5("li", { className: "px-2 py-1.5 text-sm text-muted-foreground", children: "No table matches." })
1568
+ ] })
1569
+ ] })
1570
+ ] });
1571
+ }
1572
+
1573
+ // src/grid/cells.tsx
1574
+ import {
1575
+ Braces,
1576
+ Calendar,
1577
+ Check,
1578
+ CircleChevronDown,
1579
+ Clock,
1580
+ Hash,
1581
+ KeyRound,
1582
+ Link2 as Link22,
1583
+ List,
1584
+ SquareCheck,
1585
+ Type
1586
+ } from "lucide-react";
1587
+ import { cn as cn2 } from "@marktiderman/genesis-ui/utils";
1588
+ import { Fragment as Fragment2, jsx as jsx6 } from "react/jsx-runtime";
1589
+ function FieldIcon({ field, isKey }) {
1590
+ const className = "h-3.5 w-3.5";
1591
+ if (isKey) return /* @__PURE__ */ jsx6(KeyRound, { className });
1592
+ if (field.array) return /* @__PURE__ */ jsx6(List, { className });
1593
+ if (field.references) return /* @__PURE__ */ jsx6(Link22, { className });
1594
+ if (field.enumValues) return /* @__PURE__ */ jsx6(CircleChevronDown, { className });
1595
+ switch (field.type) {
1596
+ case "number":
1597
+ return /* @__PURE__ */ jsx6(Hash, { className });
1598
+ case "boolean":
1599
+ return /* @__PURE__ */ jsx6(SquareCheck, { className });
1600
+ case "json":
1601
+ return /* @__PURE__ */ jsx6(Braces, { className });
1602
+ case "datetime":
1603
+ return field.dbType === "date" || field.widget === "date" ? /* @__PURE__ */ jsx6(Calendar, { className }) : /* @__PURE__ */ jsx6(Clock, { className });
1604
+ default:
1605
+ return /* @__PURE__ */ jsx6(Type, { className });
1606
+ }
1607
+ }
1608
+ var PILL_TINTS = [
1609
+ "bg-info/20",
1610
+ "bg-chart-2/25",
1611
+ "bg-chart-4/30",
1612
+ "bg-chart-1/20",
1613
+ "bg-chart-5/25",
1614
+ "bg-chart-3/20",
1615
+ "bg-primary/15"
1616
+ ];
1617
+ var POSITIVE = /^(active|paid|done|shipped|published|complete(d)?|approved|open|live|yes|true)$/i;
1618
+ var PENDING = /^(draft|pending|paused|review|in[ _-]?progress|waiting|scheduled|new)$/i;
1619
+ var NEGATIVE = /^(archived|cancel(l)?ed|closed|rejected|failed|deleted|inactive|no|false)$/i;
1620
+ function pillTint(value, choices = []) {
1621
+ if (POSITIVE.test(value)) return "bg-success/20";
1622
+ if (PENDING.test(value)) return "bg-warning/25";
1623
+ if (NEGATIVE.test(value)) return "bg-muted";
1624
+ const at = choices.indexOf(value);
1625
+ const index = at >= 0 ? at : [...value].reduce((h, ch) => h * 31 + ch.charCodeAt(0) >>> 0, 7);
1626
+ return PILL_TINTS[index % PILL_TINTS.length];
1627
+ }
1628
+ function Pill({ value, tint, className }) {
1629
+ return /* @__PURE__ */ jsx6("span", { className: cn2("inline-flex max-w-full items-center truncate rounded-full px-2 py-0.5 text-xs text-foreground", tint, className), children: value });
1630
+ }
1631
+ function shortTimestamp(text) {
1632
+ const m = /^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2})/.exec(text);
1633
+ return m ? `${m[1]} ${m[2]}` : text;
1634
+ }
1635
+ function CellValue({ field, value, linkLabel }) {
1636
+ const text = displayText(value);
1637
+ if (text === null || text === "") return null;
1638
+ if (field.type === "boolean" && !field.array) {
1639
+ return value === true || text === "true" ? /* @__PURE__ */ jsx6("span", { className: "inline-flex h-4 w-4 items-center justify-center rounded bg-success/20", "aria-label": "Yes", children: /* @__PURE__ */ jsx6(Check, { className: "h-3 w-3", "aria-hidden": true }) }) : /* @__PURE__ */ jsx6("span", { className: "inline-block h-4 w-4 rounded border", "aria-label": "No" });
1640
+ }
1641
+ if (field.enumValues && !field.array) return /* @__PURE__ */ jsx6(Pill, { value: text, tint: pillTint(text, field.enumValues) });
1642
+ if (field.references) {
1643
+ return /* @__PURE__ */ jsx6("span", { className: "inline-flex max-w-full items-center gap-1 truncate rounded bg-info/15 px-1.5 py-0.5 text-xs text-foreground", title: text, children: linkLabel ?? text });
1644
+ }
1645
+ if (field.array && Array.isArray(value)) {
1646
+ return /* @__PURE__ */ jsx6("span", { className: "flex gap-1 overflow-hidden", children: value.map((item, i) => /* @__PURE__ */ jsx6(Pill, { value: displayText(item) ?? "", tint: "bg-muted" }, i)) });
1647
+ }
1648
+ if (field.type === "datetime" && !(field.dbType === "date" || field.widget === "date")) {
1649
+ return /* @__PURE__ */ jsx6("span", { title: text, children: shortTimestamp(text) });
1650
+ }
1651
+ if (field.type === "json") return /* @__PURE__ */ jsx6("span", { className: "font-mono text-xs", children: text });
1652
+ return /* @__PURE__ */ jsx6(Fragment2, { children: text });
1653
+ }
1654
+
1655
+ // src/views/KanbanView.tsx
1656
+ import { useEffect as useEffect3, useMemo as useMemo4, useState as useState7 } from "react";
1657
+ import { useQueries as useQueries2 } from "@tanstack/react-query";
1658
+ import { encodeRecordKey as encodeRecordKey2, isDataWriteError as isDataWriteError2 } from "@marktiderman/genesis-core/schema";
1659
+ import { Button as Button5 } from "@marktiderman/genesis-ui/button";
1660
+ import { KanbanBoard } from "@marktiderman/genesis-ui/data";
1661
+ import { jsx as jsx7, jsxs as jsxs6 } from "react/jsx-runtime";
1662
+ var KANBAN_PAGE = 50;
1663
+ var NONE3 = "__genesis_none__";
1664
+ function versionOf2(schema, row) {
1665
+ const field = schema.version?.field;
1666
+ return field && row[field] != null ? String(row[field]) : null;
1667
+ }
1668
+ function KanbanView({
1669
+ provider,
1670
+ schema,
1671
+ sessionKey,
1672
+ field,
1673
+ filters,
1674
+ sorts,
1675
+ search,
1676
+ onOpenRecord,
1677
+ onMoved,
1678
+ onFailed,
1679
+ onSignedOut
1680
+ }) {
1681
+ const values = useMemo4(() => [...choicesFor(field) ?? [], ...field.nullable ? [NONE3] : []], [field]);
1682
+ const [pages, setPages] = useState7({});
1683
+ const [moving, setMoving] = useState7(null);
1684
+ const filterKey = JSON.stringify(filters);
1685
+ const sortKey = JSON.stringify(sorts);
1686
+ const specs = values.flatMap(
1687
+ (value) => Array.from({ length: pages[value] ?? 1 }, (_, i) => ({ value, page: i + 1 }))
1688
+ );
1689
+ const results = useQueries2({
1690
+ queries: specs.map(({ value, page }) => ({
1691
+ queryKey: ["genesis-studio", sessionKey, "list", schema.name, "kanban", field.name, value, page, filterKey, search, sortKey],
1692
+ retry: false,
1693
+ queryFn: () => provider.getList(schema.name, {
1694
+ pagination: { page, perPage: KANBAN_PAGE },
1695
+ sort: sorts[0],
1696
+ sorts,
1697
+ filters: [
1698
+ ...filters.filter((f) => f.field !== field.name),
1699
+ value === NONE3 ? { field: field.name, operator: "is_null" } : { field: field.name, operator: "eq", value }
1700
+ ],
1701
+ search: search ? { query: search, fields: [] } : void 0
1702
+ })
1703
+ }))
1704
+ });
1705
+ const failure = results.find((r) => r.error)?.error;
1706
+ const signedOut = Boolean(failure && isDataWriteError2(failure) && failure.outcome === "unauthenticated");
1707
+ useEffect3(() => {
1708
+ if (signedOut) onSignedOut();
1709
+ }, [signedOut, onSignedOut]);
1710
+ const ownFilter = filters.some((f) => f.field === field.name);
1711
+ const columns = values.map((value) => {
1712
+ const mine = specs.map((s, i) => ({ s, r: results[i] })).filter(({ s }) => s.value === value);
1713
+ return {
1714
+ key: value,
1715
+ label: value === NONE3 ? "No value" : value,
1716
+ items: mine.flatMap(({ r }) => r.data?.data ?? []),
1717
+ ...mine[0]?.r.data ? { total: mine[0].r.data.total } : {}
1718
+ };
1719
+ });
1720
+ const labelField = schema.labelField;
1721
+ const keyNames = new Set(typeof schema.key === "string" ? [schema.key] : schema.key);
1722
+ const extra = schema.fields.filter(
1723
+ (f) => f.name !== labelField && f.name !== field.name && f.name !== schema.version?.field && !keyNames.has(f.name) && !f.references && !f.array && f.type !== "json" && f.dbType !== "uuid"
1724
+ ).slice(0, 2);
1725
+ const canMove = Boolean(field.permissions?.update && schema.version);
1726
+ async function move(row, to) {
1727
+ const id = String(encodeRecordKey2(schema, row));
1728
+ setMoving(id);
1729
+ try {
1730
+ const result = await provider.update(schema.name, {
1731
+ id,
1732
+ data: { [field.name]: to === NONE3 ? null : to },
1733
+ expectedVersion: versionOf2(schema, row)
1734
+ });
1735
+ onMoved(id, result.audit);
1736
+ } catch (error) {
1737
+ if (isDataWriteError2(error) && error.outcome === "unauthenticated") onSignedOut();
1738
+ onFailed(id, error instanceof Error ? error.message : String(error));
1739
+ throw error;
1740
+ } finally {
1741
+ setMoving(null);
1742
+ }
1743
+ }
1744
+ return /* @__PURE__ */ jsxs6("div", { className: "space-y-2", "data-testid": "studio-kanban", children: [
1745
+ ownFilter && /* @__PURE__ */ jsxs6("p", { className: "text-xs text-muted-foreground", children: [
1746
+ "Your filter on ",
1747
+ field.name,
1748
+ " is set aside here: each column shows one of its values."
1749
+ ] }),
1750
+ failure && !signedOut && /* @__PURE__ */ jsx7("p", { role: "alert", className: "text-sm text-destructive", children: failure instanceof Error ? failure.message : String(failure) }),
1751
+ /* @__PURE__ */ jsx7("div", { children: /* @__PURE__ */ jsx7(
1752
+ KanbanBoard,
1753
+ {
1754
+ className: "grid-cols-[repeat(auto-fill,minmax(15rem,1fr))] md:grid-cols-[repeat(auto-fill,minmax(15rem,1fr))] lg:grid-cols-[repeat(auto-fill,minmax(15rem,1fr))] xl:grid-cols-[repeat(auto-fill,minmax(15rem,1fr))]",
1755
+ columns,
1756
+ getKey: (row) => String(encodeRecordKey2(schema, row)),
1757
+ getItemLabel: (row) => displayText(labelField ? row[labelField] : void 0) ?? String(encodeRecordKey2(schema, row)),
1758
+ onMoveItem: canMove ? move : void 0,
1759
+ emptyMessage: "No rows",
1760
+ renderCard: (row) => {
1761
+ const id = String(encodeRecordKey2(schema, row));
1762
+ return /* @__PURE__ */ jsxs6(
1763
+ "button",
1764
+ {
1765
+ type: "button",
1766
+ onClick: () => onOpenRecord(id),
1767
+ "aria-busy": moving === id || void 0,
1768
+ className: "w-full rounded-lg border bg-card p-3 pr-8 text-left text-sm shadow-sm hover:border-primary/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
1769
+ "data-testid": "studio-kanban-card",
1770
+ children: [
1771
+ /* @__PURE__ */ jsx7("span", { className: "block truncate font-medium", children: displayText(labelField ? row[labelField] : void 0) ?? id }),
1772
+ extra.map((f) => /* @__PURE__ */ jsxs6("span", { className: "block truncate text-xs text-muted-foreground", children: [
1773
+ fieldLabel(f),
1774
+ ": ",
1775
+ displayText(row[f.name]) ?? "\u2014"
1776
+ ] }, f.name))
1777
+ ]
1778
+ }
1779
+ );
1780
+ },
1781
+ renderColumnFooter: (column) => column.total !== void 0 && column.total > column.items.length ? /* @__PURE__ */ jsxs6(
1782
+ Button5,
1783
+ {
1784
+ type: "button",
1785
+ variant: "ghost",
1786
+ size: "sm",
1787
+ onClick: () => setPages((p) => ({ ...p, [column.key]: (p[column.key] ?? 1) + 1 })),
1788
+ children: [
1789
+ "Load more (",
1790
+ (column.total - column.items.length).toLocaleString(),
1791
+ " left)"
1792
+ ]
1793
+ }
1794
+ ) : null
1795
+ }
1796
+ ) }),
1797
+ !canMove && /* @__PURE__ */ jsx7("p", { className: "text-xs text-muted-foreground", children: field.permissions?.reason ?? `Cards can't be moved: ${field.name} can't be edited here.` })
1798
+ ] });
1799
+ }
1800
+
1801
+ // src/views/CalendarView.tsx
1802
+ import { useEffect as useEffect4, useMemo as useMemo5 } from "react";
1803
+ import { keepPreviousData, useQuery as useQuery4 } from "@tanstack/react-query";
1804
+ import { ChevronLeft, ChevronRight } from "lucide-react";
1805
+ import { encodeRecordKey as encodeRecordKey3, isDataWriteError as isDataWriteError3 } from "@marktiderman/genesis-core/schema";
1806
+ import { Button as Button6 } from "@marktiderman/genesis-ui/button";
1807
+ import { cn as cn3 } from "@marktiderman/genesis-ui/utils";
1808
+
1809
+ // src/views/fields.ts
1810
+ function boardFields(schema) {
1811
+ return schema.fields.filter((f) => !f.array && Boolean(choicesFor(f)));
1812
+ }
1813
+ function dateFields(schema) {
1814
+ return schema.fields.filter((f) => !f.array && f.type === "datetime" && !/^time( |$)|interval/.test(f.dbType ?? ""));
1815
+ }
1816
+ function isDateOnly(field) {
1817
+ return field.dbType === "date" || field.widget === "date";
1818
+ }
1819
+ var DB_DATE = /^(\d{4}-\d{2}-\d{2})(?:[ T](\d{2}:\d{2}(?::\d{2})?)(\.\d+)?)?\s*(Z|[+-]\d{2}(?::?\d{2})?)?$/;
1820
+ function localDay(value, dateOnly) {
1821
+ if (typeof value !== "string") return null;
1822
+ const m = DB_DATE.exec(value.trim());
1823
+ if (!m) return null;
1824
+ if (dateOnly || !m[2]) return m[1];
1825
+ let zone = m[4] ?? "";
1826
+ if (/^[+-]\d{2}$/.test(zone)) zone += ":00";
1827
+ else if (/^[+-]\d{4}$/.test(zone)) zone = `${zone.slice(0, 3)}:${zone.slice(3)}`;
1828
+ const time = m[2].length === 5 ? `${m[2]}:00` : m[2];
1829
+ const ms = m[3] ? m[3].slice(0, 4).padEnd(4, "0") : "";
1830
+ const date = /* @__PURE__ */ new Date(`${m[1]}T${time}${ms}${zone}`);
1831
+ if (Number.isNaN(date.getTime())) return null;
1832
+ return dayKey(date);
1833
+ }
1834
+ function dayKey(date) {
1835
+ const pad = (n) => String(n).padStart(2, "0");
1836
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
1837
+ }
1838
+ function isZoned(field) {
1839
+ return !isDateOnly(field) && (field.dbType === void 0 || /with time zone|timestamptz/.test(field.dbType));
1840
+ }
1841
+ function monthBounds(month, field) {
1842
+ const [y, m] = month.split("-").map(Number);
1843
+ const start = new Date(y, m - 1, 1);
1844
+ const next = new Date(y, m, 1);
1845
+ return isZoned(field) ? { from: start.toISOString(), to: next.toISOString() } : { from: dayKey(start), to: dayKey(next) };
1846
+ }
1847
+ function currentMonth(now = /* @__PURE__ */ new Date()) {
1848
+ return dayKey(now).slice(0, 7);
1849
+ }
1850
+ function shiftMonth(month, by) {
1851
+ const [y, m] = month.split("-").map(Number);
1852
+ return currentMonth(new Date(y, m - 1 + by, 1));
1853
+ }
16
1854
 
17
- // src/values.ts
18
- function editText(value) {
19
- if (value === null || value === void 0) return "";
20
- if (typeof value === "object") return JSON.stringify(value);
21
- return String(value);
1855
+ // src/views/CalendarView.tsx
1856
+ import { jsx as jsx8, jsxs as jsxs7 } from "react/jsx-runtime";
1857
+ var CALENDAR_CAP = 200;
1858
+ var WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
1859
+ function weeksOf(month) {
1860
+ const [y, m] = month.split("-").map(Number);
1861
+ const first = new Date(y, m - 1, 1);
1862
+ const days = new Date(y, m, 0).getDate();
1863
+ const lead = (first.getDay() + 6) % 7;
1864
+ const cells = [
1865
+ ...Array.from({ length: lead }, () => null),
1866
+ ...Array.from({ length: days }, (_, i) => dayKey(new Date(y, m - 1, i + 1)))
1867
+ ];
1868
+ while (cells.length % 7) cells.push(null);
1869
+ return Array.from({ length: cells.length / 7 }, (_, w) => cells.slice(w * 7, w * 7 + 7));
22
1870
  }
23
- function displayText(value) {
24
- if (value === null || value === void 0) return null;
25
- return editText(value);
1871
+ function CalendarView({
1872
+ provider,
1873
+ schema,
1874
+ sessionKey,
1875
+ field,
1876
+ month: monthProp,
1877
+ onMonthChange,
1878
+ filters,
1879
+ sorts,
1880
+ search,
1881
+ onOpenRecord,
1882
+ onSignedOut
1883
+ }) {
1884
+ const month = monthProp ?? currentMonth();
1885
+ const dateOnly = isDateOnly(field);
1886
+ const { from, to } = monthBounds(month, field);
1887
+ const query = useQuery4({
1888
+ queryKey: ["genesis-studio", sessionKey, "list", schema.name, "calendar", field.name, month, JSON.stringify(filters), search, JSON.stringify(sorts)],
1889
+ placeholderData: keepPreviousData,
1890
+ retry: false,
1891
+ queryFn: () => provider.getList(schema.name, {
1892
+ pagination: { page: 1, perPage: CALENDAR_CAP },
1893
+ sort: sorts[0] ?? { field: field.name, order: "asc" },
1894
+ sorts: sorts.length ? sorts : [{ field: field.name, order: "asc" }],
1895
+ filters: [
1896
+ ...filters,
1897
+ { field: field.name, operator: "gte", value: from },
1898
+ { field: field.name, operator: "lt", value: to }
1899
+ ],
1900
+ search: search ? { query: search, fields: [] } : void 0
1901
+ })
1902
+ });
1903
+ const signedOut = isDataWriteError3(query.error) && query.error.outcome === "unauthenticated";
1904
+ useEffect4(() => {
1905
+ if (signedOut) onSignedOut();
1906
+ }, [signedOut, onSignedOut]);
1907
+ const rows = query.data?.data ?? [];
1908
+ const byDay = useMemo5(() => {
1909
+ const map = /* @__PURE__ */ new Map();
1910
+ for (const row of rows) {
1911
+ const day = localDay(row[field.name], dateOnly);
1912
+ if (!day) continue;
1913
+ map.set(day, [...map.get(day) ?? [], row]);
1914
+ }
1915
+ return map;
1916
+ }, [rows, field.name, dateOnly]);
1917
+ const total = query.data?.total ?? 0;
1918
+ const today = dayKey(/* @__PURE__ */ new Date());
1919
+ const title = (/* @__PURE__ */ new Date(`${month}-01T00:00:00`)).toLocaleDateString(void 0, { month: "long", year: "numeric" });
1920
+ const labelField = schema.labelField;
1921
+ return /* @__PURE__ */ jsxs7("div", { className: "space-y-2", "data-testid": "studio-calendar", children: [
1922
+ /* @__PURE__ */ jsxs7("div", { className: "flex items-center gap-2", children: [
1923
+ /* @__PURE__ */ jsx8(Button6, { type: "button", variant: "outline", size: "icon", className: "h-8 w-8", "aria-label": "Previous month", onClick: () => onMonthChange(shiftMonth(month, -1)), children: /* @__PURE__ */ jsx8(ChevronLeft, { className: "h-4 w-4", "aria-hidden": true }) }),
1924
+ /* @__PURE__ */ jsx8(Button6, { type: "button", variant: "outline", size: "icon", className: "h-8 w-8", "aria-label": "Next month", onClick: () => onMonthChange(shiftMonth(month, 1)), children: /* @__PURE__ */ jsx8(ChevronRight, { className: "h-4 w-4", "aria-hidden": true }) }),
1925
+ /* @__PURE__ */ jsx8(Button6, { type: "button", variant: "ghost", size: "sm", onClick: () => onMonthChange(currentMonth()), children: "Today" }),
1926
+ /* @__PURE__ */ jsx8("h3", { className: "text-sm font-semibold", "aria-live": "polite", children: title }),
1927
+ /* @__PURE__ */ jsxs7("span", { className: "text-xs text-muted-foreground", children: [
1928
+ "by ",
1929
+ fieldLabel(field)
1930
+ ] })
1931
+ ] }),
1932
+ total > rows.length && /* @__PURE__ */ jsxs7("p", { role: "status", className: "text-xs text-muted-foreground", "data-testid": "studio-calendar-capped", children: [
1933
+ "Showing the first ",
1934
+ rows.length.toLocaleString(),
1935
+ " of ",
1936
+ total.toLocaleString(),
1937
+ " rows this month. Filter the table to see the rest."
1938
+ ] }),
1939
+ query.isError && !signedOut && /* @__PURE__ */ jsx8("p", { role: "alert", className: "text-sm text-destructive", children: query.error instanceof Error ? query.error.message : String(query.error) }),
1940
+ /* @__PURE__ */ jsx8("div", { className: "overflow-x-auto", children: /* @__PURE__ */ jsxs7("table", { className: "w-full min-w-[42rem] table-fixed border-collapse text-sm", children: [
1941
+ /* @__PURE__ */ jsxs7("caption", { className: "sr-only", children: [
1942
+ schema.label ?? schema.name,
1943
+ " by ",
1944
+ fieldLabel(field),
1945
+ ", ",
1946
+ title
1947
+ ] }),
1948
+ /* @__PURE__ */ jsx8("thead", { children: /* @__PURE__ */ jsx8("tr", { children: WEEKDAYS.map((d) => /* @__PURE__ */ jsx8("th", { scope: "col", className: "py-1 text-left text-xs font-medium text-muted-foreground", children: d }, d)) }) }),
1949
+ /* @__PURE__ */ jsx8("tbody", { children: weeksOf(month).map((week, w) => /* @__PURE__ */ jsx8("tr", { children: week.map((day, d) => /* @__PURE__ */ jsx8(
1950
+ "td",
1951
+ {
1952
+ className: cn3("h-28 border align-top", !day && "bg-muted/30"),
1953
+ "data-day": day ?? void 0,
1954
+ children: day && /* @__PURE__ */ jsxs7("div", { className: "flex h-full flex-col gap-0.5 p-1", children: [
1955
+ /* @__PURE__ */ jsx8("span", { className: cn3("text-xs", day === today ? "font-bold text-primary" : "text-muted-foreground"), children: Number(day.slice(8)) }),
1956
+ /* @__PURE__ */ jsx8("ul", { className: "max-h-24 space-y-0.5 overflow-y-auto", children: (byDay.get(day) ?? []).map((row) => {
1957
+ const id = String(encodeRecordKey3(schema, row));
1958
+ return /* @__PURE__ */ jsx8("li", { children: /* @__PURE__ */ jsx8(
1959
+ "button",
1960
+ {
1961
+ type: "button",
1962
+ className: "w-full truncate rounded bg-primary/10 px-1.5 py-0.5 text-left text-xs hover:bg-primary/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
1963
+ onClick: () => onOpenRecord(id),
1964
+ "data-testid": "studio-calendar-item",
1965
+ children: displayText(labelField ? row[labelField] : void 0) ?? id
1966
+ }
1967
+ ) }, id);
1968
+ }) })
1969
+ ] })
1970
+ },
1971
+ day ?? `pad-${w}-${d}`
1972
+ )) }, w)) })
1973
+ ] }) })
1974
+ ] });
26
1975
  }
27
- function parseDraft(field, draft) {
28
- if (draft === "" && field.nullable) return null;
29
- return draft;
1976
+
1977
+ // src/toolbar/ImportDialog.tsx
1978
+ import { useMemo as useMemo6, useState as useState8 } from "react";
1979
+ import { Upload } from "lucide-react";
1980
+ import { Button as Button7 } from "@marktiderman/genesis-ui/button";
1981
+ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@marktiderman/genesis-ui/dialog";
1982
+ import { Label as Label2 } from "@marktiderman/genesis-ui/label";
1983
+ import { Select as Select3, SelectContent as SelectContent3, SelectItem as SelectItem3, SelectTrigger as SelectTrigger3, SelectValue as SelectValue3 } from "@marktiderman/genesis-ui/select";
1984
+ import { Fragment as Fragment3, jsx as jsx9, jsxs as jsxs8 } from "react/jsx-runtime";
1985
+ var SKIP = "__genesis_skip__";
1986
+ function ImportDialog({ provider, schema, onImported }) {
1987
+ const [open, setOpen] = useState8(false);
1988
+ const [file, setFile] = useState8(null);
1989
+ const [mapping, setMapping] = useState8({});
1990
+ const [check, setCheck] = useState8(null);
1991
+ const [busy, setBusy] = useState8(false);
1992
+ const [error, setError] = useState8(null);
1993
+ const targets = useMemo6(() => schema.fields.filter((f) => schema.createFields?.includes(f.name)), [schema]);
1994
+ const allowed = Boolean(schema.access.create && provider.importRows);
1995
+ async function readFile(input) {
1996
+ const parsed = parseCsv(await input.text());
1997
+ setFile({ name: input.name, ...parsed });
1998
+ const auto = {};
1999
+ parsed.header.forEach((h, i) => {
2000
+ const field = targets.find((f) => f.name.toLowerCase() === h.toLowerCase());
2001
+ if (field) auto[i] = field.name;
2002
+ });
2003
+ setMapping(auto);
2004
+ setCheck(null);
2005
+ setError(null);
2006
+ }
2007
+ function rowsToSend() {
2008
+ if (!file) return [];
2009
+ return file.rows.map((cells) => {
2010
+ const row = {};
2011
+ for (const [index, field] of Object.entries(mapping)) {
2012
+ if (field === SKIP) continue;
2013
+ const value = cells[Number(index)] ?? "";
2014
+ if (value !== "") row[field] = value;
2015
+ }
2016
+ return row;
2017
+ });
2018
+ }
2019
+ async function run(dryRun) {
2020
+ setBusy(true);
2021
+ setError(null);
2022
+ try {
2023
+ const result = await provider.importRows(schema.name, { rows: rowsToSend(), dryRun });
2024
+ if (dryRun) setCheck(result);
2025
+ else if (result.ok) {
2026
+ onImported(result.count);
2027
+ setOpen(false);
2028
+ setFile(null);
2029
+ setCheck(null);
2030
+ } else setCheck(result);
2031
+ } catch (e) {
2032
+ setError(e instanceof Error ? e.message : String(e));
2033
+ } finally {
2034
+ setBusy(false);
2035
+ }
2036
+ }
2037
+ const mapped = Object.values(mapping).filter((f) => f && f !== SKIP).length;
2038
+ return /* @__PURE__ */ jsxs8(Fragment3, { children: [
2039
+ /* @__PURE__ */ jsx9(
2040
+ Button7,
2041
+ {
2042
+ type: "button",
2043
+ variant: "ghost",
2044
+ size: "sm",
2045
+ onClick: () => setOpen(true),
2046
+ disabled: !allowed,
2047
+ title: allowed ? "Import CSV" : schema.access.reasons?.create ?? "This data provider can't import.",
2048
+ testID: "studio-import",
2049
+ "aria-label": "Import CSV",
2050
+ children: /* @__PURE__ */ jsx9(Upload, { className: "h-3.5 w-3.5", "aria-hidden": true })
2051
+ }
2052
+ ),
2053
+ /* @__PURE__ */ jsx9(Dialog, { open, onOpenChange: setOpen, children: /* @__PURE__ */ jsxs8(DialogContent, { className: "max-h-[85vh] overflow-y-auto sm:max-w-2xl", "data-testid": "studio-import-dialog", children: [
2054
+ /* @__PURE__ */ jsxs8(DialogHeader, { children: [
2055
+ /* @__PURE__ */ jsxs8(DialogTitle, { children: [
2056
+ "Import into ",
2057
+ schema.label ?? schema.name
2058
+ ] }),
2059
+ /* @__PURE__ */ jsx9(DialogDescription, { children: "Every row is checked against the database first. The import then adds all of them in one transaction, or none." })
2060
+ ] }),
2061
+ /* @__PURE__ */ jsxs8("div", { className: "space-y-4", children: [
2062
+ /* @__PURE__ */ jsxs8("div", { className: "space-y-1.5", children: [
2063
+ /* @__PURE__ */ jsx9(Label2, { htmlFor: "studio-import-file", children: "CSV file, with a header row" }),
2064
+ /* @__PURE__ */ jsx9(
2065
+ "input",
2066
+ {
2067
+ id: "studio-import-file",
2068
+ type: "file",
2069
+ accept: ".csv,text/csv",
2070
+ className: "block w-full text-sm",
2071
+ onChange: (e) => e.target.files?.[0] && void readFile(e.target.files[0]),
2072
+ "data-testid": "studio-import-file"
2073
+ }
2074
+ )
2075
+ ] }),
2076
+ file && /* @__PURE__ */ jsxs8(Fragment3, { children: [
2077
+ /* @__PURE__ */ jsxs8("p", { className: "text-xs text-muted-foreground", children: [
2078
+ file.name,
2079
+ ": ",
2080
+ file.rows.length,
2081
+ " rows, ",
2082
+ file.header.length,
2083
+ " columns."
2084
+ ] }),
2085
+ /* @__PURE__ */ jsx9("div", { className: "grid gap-2 sm:grid-cols-2", children: file.header.map((column, i) => /* @__PURE__ */ jsxs8("div", { className: "flex items-center gap-2", children: [
2086
+ /* @__PURE__ */ jsx9("span", { className: "w-32 truncate font-mono text-xs", title: column, children: column || `(column ${i + 1})` }),
2087
+ /* @__PURE__ */ jsxs8(Select3, { value: mapping[i] ?? SKIP, onValueChange: (v) => {
2088
+ setMapping((m) => ({ ...m, [i]: v }));
2089
+ setCheck(null);
2090
+ }, children: [
2091
+ /* @__PURE__ */ jsx9(SelectTrigger3, { className: "h-8 flex-1 text-xs", "aria-label": `Field for ${column}`, children: /* @__PURE__ */ jsx9(SelectValue3, {}) }),
2092
+ /* @__PURE__ */ jsxs8(SelectContent3, { children: [
2093
+ /* @__PURE__ */ jsx9(SelectItem3, { value: SKIP, children: "Skip this column" }),
2094
+ targets.map((f) => /* @__PURE__ */ jsx9(SelectItem3, { value: f.name, children: fieldLabel(f) }, f.name))
2095
+ ] })
2096
+ ] })
2097
+ ] }, `${column}-${i}`)) }),
2098
+ check && /* @__PURE__ */ jsx9("div", { className: "rounded-md border p-3 text-sm", role: "status", "data-testid": "studio-import-check", children: check.ok ? /* @__PURE__ */ jsxs8("p", { children: [
2099
+ "All ",
2100
+ check.count,
2101
+ " rows are ready to import."
2102
+ ] }) : /* @__PURE__ */ jsxs8(Fragment3, { children: [
2103
+ /* @__PURE__ */ jsxs8("p", { className: "font-medium text-destructive", children: [
2104
+ (check.failures ?? []).length,
2105
+ " of ",
2106
+ file.rows.length,
2107
+ " rows won't import. Nothing was changed."
2108
+ ] }),
2109
+ /* @__PURE__ */ jsx9("ul", { className: "mt-2 max-h-48 space-y-1 overflow-y-auto font-mono text-xs", children: (check.failures ?? []).map((f) => /* @__PURE__ */ jsxs8("li", { children: [
2110
+ "Row ",
2111
+ f.index + 2,
2112
+ ": ",
2113
+ f.message
2114
+ ] }, f.index)) })
2115
+ ] }) }),
2116
+ error && /* @__PURE__ */ jsx9("p", { role: "alert", className: "text-sm text-destructive", children: error }),
2117
+ /* @__PURE__ */ jsxs8("div", { className: "flex justify-end gap-2", children: [
2118
+ /* @__PURE__ */ jsx9(Button7, { type: "button", variant: "outline", disabled: busy || mapped === 0, onClick: () => void run(true), testID: "studio-import-check-button", children: busy ? "Checking\u2026" : "Check rows" }),
2119
+ /* @__PURE__ */ jsxs8(Button7, { type: "button", disabled: busy || !check?.ok, onClick: () => void run(false), testID: "studio-import-commit", children: [
2120
+ "Import ",
2121
+ check?.ok ? check.count : "",
2122
+ " rows"
2123
+ ] })
2124
+ ] })
2125
+ ] })
2126
+ ] })
2127
+ ] }) })
2128
+ ] });
30
2129
  }
31
- function choicesFor(field) {
32
- if (field.enumValues) return [...field.enumValues];
33
- if (field.type === "boolean") return ["true", "false"];
34
- return void 0;
2130
+
2131
+ // src/toolbar/BulkEdit.tsx
2132
+ import { useState as useState9 } from "react";
2133
+ import { Button as Button8 } from "@marktiderman/genesis-ui/button";
2134
+ import { Dialog as Dialog2, DialogContent as DialogContent2, DialogDescription as DialogDescription2, DialogHeader as DialogHeader2, DialogTitle as DialogTitle2 } from "@marktiderman/genesis-ui/dialog";
2135
+ import { Input as Input3 } from "@marktiderman/genesis-ui/input";
2136
+ import { Label as Label3 } from "@marktiderman/genesis-ui/label";
2137
+ import { Select as Select4, SelectContent as SelectContent4, SelectItem as SelectItem4, SelectTrigger as SelectTrigger4, SelectValue as SelectValue4 } from "@marktiderman/genesis-ui/select";
2138
+ import { jsx as jsx10, jsxs as jsxs9 } from "react/jsx-runtime";
2139
+ function BulkEditDialog({ schema, count, open, onOpenChange, onApply }) {
2140
+ const fields = schema.fields.filter((f) => f.permissions?.update && !f.references);
2141
+ const [field, setField] = useState9(fields[0]?.name ?? "");
2142
+ const [value, setValue] = useState9("");
2143
+ const [result, setResult] = useState9(null);
2144
+ const [busy, setBusy] = useState9(false);
2145
+ const [error, setError] = useState9(null);
2146
+ const target = fields.find((f) => f.name === field);
2147
+ const choices = target ? choicesFor(target) : void 0;
2148
+ async function apply() {
2149
+ if (!target) return;
2150
+ setBusy(true);
2151
+ setError(null);
2152
+ try {
2153
+ const outcome = await onApply({ [target.name]: parseDraft(target, value) });
2154
+ if (outcome.ok) onOpenChange(false);
2155
+ else setResult(outcome);
2156
+ } catch (e) {
2157
+ setError(`Nothing was changed: ${e instanceof Error ? e.message : String(e)}`);
2158
+ } finally {
2159
+ setBusy(false);
2160
+ }
2161
+ }
2162
+ return /* @__PURE__ */ jsx10(
2163
+ Dialog2,
2164
+ {
2165
+ open,
2166
+ onOpenChange: (next) => {
2167
+ onOpenChange(next);
2168
+ if (!next) setResult(null);
2169
+ },
2170
+ children: /* @__PURE__ */ jsxs9(DialogContent2, { className: "sm:max-w-md", "data-testid": "studio-bulk-edit", children: [
2171
+ /* @__PURE__ */ jsxs9(DialogHeader2, { children: [
2172
+ /* @__PURE__ */ jsxs9(DialogTitle2, { children: [
2173
+ "Set a field on ",
2174
+ count,
2175
+ " row",
2176
+ count === 1 ? "" : "s"
2177
+ ] }),
2178
+ /* @__PURE__ */ jsx10(DialogDescription2, { children: "Each row is checked against the version you see. All of them change, or none do." })
2179
+ ] }),
2180
+ /* @__PURE__ */ jsxs9("div", { className: "space-y-3", children: [
2181
+ /* @__PURE__ */ jsxs9("div", { className: "space-y-1.5", children: [
2182
+ /* @__PURE__ */ jsx10(Label3, { htmlFor: "studio-bulk-field", children: "Field" }),
2183
+ /* @__PURE__ */ jsxs9(Select4, { value: field, onValueChange: (v) => {
2184
+ setField(v);
2185
+ setValue("");
2186
+ setError(null);
2187
+ }, children: [
2188
+ /* @__PURE__ */ jsx10(SelectTrigger4, { id: "studio-bulk-field", children: /* @__PURE__ */ jsx10(SelectValue4, {}) }),
2189
+ /* @__PURE__ */ jsx10(SelectContent4, { children: fields.map((f) => /* @__PURE__ */ jsx10(SelectItem4, { value: f.name, children: fieldLabel(f) }, f.name)) })
2190
+ ] })
2191
+ ] }),
2192
+ /* @__PURE__ */ jsxs9("div", { className: "space-y-1.5", children: [
2193
+ /* @__PURE__ */ jsx10(Label3, { htmlFor: "studio-bulk-value", children: "New value" }),
2194
+ choices ? /* @__PURE__ */ jsxs9(Select4, { value, onValueChange: (v) => {
2195
+ setValue(v);
2196
+ setError(null);
2197
+ }, children: [
2198
+ /* @__PURE__ */ jsx10(SelectTrigger4, { id: "studio-bulk-value", children: /* @__PURE__ */ jsx10(SelectValue4, { placeholder: "Choose\u2026" }) }),
2199
+ /* @__PURE__ */ jsx10(SelectContent4, { children: choices.map((c) => /* @__PURE__ */ jsx10(SelectItem4, { value: c, children: c }, c)) })
2200
+ ] }) : /* @__PURE__ */ jsx10(Input3, { id: "studio-bulk-value", value, onChange: (e) => {
2201
+ setValue(e.target.value);
2202
+ setError(null);
2203
+ } })
2204
+ ] }),
2205
+ error && /* @__PURE__ */ jsx10("p", { role: "alert", className: "rounded-md border border-destructive/40 p-2 text-sm text-destructive", "data-testid": "studio-bulk-error", children: error }),
2206
+ result && !result.ok && /* @__PURE__ */ jsxs9("div", { role: "alert", className: "rounded-md border border-destructive/40 p-2 text-sm text-destructive", children: [
2207
+ "Nothing was changed: ",
2208
+ (result.failures ?? []).length,
2209
+ " row(s) failed.",
2210
+ /* @__PURE__ */ jsx10("ul", { className: "mt-1 list-disc pl-5 text-xs", children: (result.failures ?? []).slice(0, 10).map((f) => /* @__PURE__ */ jsx10("li", { children: f.message }, f.index)) })
2211
+ ] }),
2212
+ /* @__PURE__ */ jsxs9("div", { className: "flex justify-end gap-2", children: [
2213
+ /* @__PURE__ */ jsx10(Button8, { type: "button", variant: "outline", onClick: () => onOpenChange(false), children: "Cancel" }),
2214
+ /* @__PURE__ */ jsx10(Button8, { type: "button", disabled: busy || !target, onClick: () => void apply(), testID: "studio-bulk-apply", children: busy ? "Saving\u2026" : `Set on ${count} row${count === 1 ? "" : "s"}` })
2215
+ ] })
2216
+ ] })
2217
+ ] })
2218
+ }
2219
+ );
2220
+ }
2221
+
2222
+ // src/sheet/SheetView.tsx
2223
+ import { useMemo as useMemo7 } from "react";
2224
+ import { Maximize2 } from "lucide-react";
2225
+ import { encodeRecordKey as encodeRecordKey4, isDataWriteError as isDataWriteError4, keyFields as keyFields2 } from "@marktiderman/genesis-core/schema";
2226
+ import { SheetCommitError, SheetGrid } from "@marktiderman/genesis-ui/sheet-grid";
2227
+ import { jsx as jsx11, jsxs as jsxs10 } from "react/jsx-runtime";
2228
+ function gridFields(schema) {
2229
+ const primary = schema.fields.find((f) => f.name === schema.labelField);
2230
+ return primary ? [primary, ...schema.fields.filter((f) => f !== primary)] : [...schema.fields];
2231
+ }
2232
+ function SheetView({
2233
+ provider,
2234
+ schema,
2235
+ rows,
2236
+ keys,
2237
+ linkLabels,
2238
+ onSaved,
2239
+ onFailed,
2240
+ onUndo,
2241
+ onNotice,
2242
+ hidden,
2243
+ selected,
2244
+ onToggleSelect,
2245
+ onToggleAll,
2246
+ onOpenRecord,
2247
+ firstRowNumber = 1,
2248
+ footer,
2249
+ height,
2250
+ label
2251
+ }) {
2252
+ const versionField = schema.version?.field;
2253
+ const keyNames = useMemo7(() => new Set(keyFields2(schema)), [schema]);
2254
+ const columns = useMemo7(
2255
+ () => gridFields(schema).filter((field) => !hidden?.has(field.name)).map((field, index) => {
2256
+ const labels = linkLabels.get(field.name);
2257
+ return {
2258
+ key: field.name,
2259
+ header: fieldLabel(field),
2260
+ icon: /* @__PURE__ */ jsx11(FieldIcon, { field, isKey: keyNames.has(field.name) }),
2261
+ // The first column names the row, so it gets more room.
2262
+ width: index === 0 && field.name === schema.labelField ? 220 : field.type === "json" ? 220 : field.type === "boolean" ? 110 : 170,
2263
+ ...field.type === "number" && !field.enumValues ? { align: "end" } : {},
2264
+ getText: (row) => editText(row[field.name]),
2265
+ render: (row) => /* @__PURE__ */ jsx11(CellValue, { field, value: row[field.name], linkLabel: labels?.get(String(row[field.name])) }),
2266
+ // Links are edited in the record form, with a picker.
2267
+ editable: Boolean(field.permissions?.update) && !field.references,
2268
+ readOnlyReason: field.references && field.permissions?.update ? "Edit links in the record's form." : field.permissions?.reason,
2269
+ ...choicesFor(field) ? { options: choicesFor(field) } : {}
2270
+ };
2271
+ }),
2272
+ [schema, linkLabels, hidden, keyNames]
2273
+ );
2274
+ const selectable = Boolean(selected && onToggleSelect);
2275
+ const allSelected = selectable && rows.length > 0 && rows.every((row) => selected.has(keys.get(row) ?? ""));
2276
+ async function commit(changes) {
2277
+ const byRow = /* @__PURE__ */ new Map();
2278
+ for (const change of changes) {
2279
+ const field = schema.fields.find((f) => f.name === change.column);
2280
+ const entry = byRow.get(change.rowKey) ?? { row: change.row, data: {} };
2281
+ entry.data[change.column] = parseDraft(field, change.value);
2282
+ byRow.set(change.rowKey, entry);
2283
+ }
2284
+ const items = [...byRow.entries()];
2285
+ const expected = (row) => versionField ? row[versionField] == null ? null : String(row[versionField]) : null;
2286
+ try {
2287
+ if (items.length === 1) {
2288
+ const [, { row, data }] = items[0];
2289
+ const result2 = await provider.update(schema.name, { id: encodeRecordKey4(schema, row), data, expectedVersion: expected(row) });
2290
+ onSaved([{ old: row, next: result2.data, ...result2.audit ? { audit: result2.audit } : {} }]);
2291
+ return;
2292
+ }
2293
+ if (!provider.updateMany) throw new Error("This data provider can't save several rows at once.");
2294
+ const result = await provider.updateMany(schema.name, {
2295
+ changes: items.map(([, { row, data }]) => ({ id: encodeRecordKey4(schema, row), data, expectedVersion: expected(row) }))
2296
+ });
2297
+ if (!result.ok) {
2298
+ const failures = result.failures ?? [];
2299
+ const cells = failures.flatMap((f) => {
2300
+ const [rowKey, { data }] = items[f.index];
2301
+ return Object.keys(data).map((column) => ({ rowKey, column, message: f.message }));
2302
+ });
2303
+ const message = `Nothing was saved: ${failures.length} of ${items.length} rows failed. Fix the marked cells and paste again.`;
2304
+ onFailed(message);
2305
+ throw new SheetCommitError(message, cells);
2306
+ }
2307
+ onSaved(
2308
+ items.map(([, { row, data }], i) => ({
2309
+ old: row,
2310
+ next: { ...row, ...data },
2311
+ ...result.audit?.[i] ? { audit: result.audit[i] } : {}
2312
+ }))
2313
+ );
2314
+ onNotice(`Saved ${result.count} rows in one transaction.`);
2315
+ } catch (error) {
2316
+ if (error instanceof SheetCommitError) throw error;
2317
+ const message = isDataWriteError4(error) || error instanceof Error ? error.message : String(error);
2318
+ onFailed(message);
2319
+ throw error;
2320
+ }
2321
+ }
2322
+ return /* @__PURE__ */ jsx11(
2323
+ SheetGrid,
2324
+ {
2325
+ label: label ?? `${schema.label ?? schema.name} sheet`,
2326
+ rows,
2327
+ columns,
2328
+ getKey: (row) => keys.get(row) ?? "",
2329
+ onCommit: commit,
2330
+ onUndo,
2331
+ onSkipped: (count) => onNotice(`${count} read-only cell${count === 1 ? " was" : "s were"} left as they were.`),
2332
+ height: height ?? Math.min(640, Math.max(160, (rows.length + 2) * 32 + 4)),
2333
+ className: "rounded-md",
2334
+ empty: "No records match.",
2335
+ footer,
2336
+ rowHeader: {
2337
+ width: selectable ? 84 : 64,
2338
+ header: selectable ? /* @__PURE__ */ jsx11(
2339
+ "input",
2340
+ {
2341
+ type: "checkbox",
2342
+ "aria-label": "Select every row on this page",
2343
+ className: "h-3.5 w-3.5",
2344
+ checked: allSelected,
2345
+ onChange: () => onToggleAll?.()
2346
+ }
2347
+ ) : null,
2348
+ render: (row, index) => {
2349
+ const id = keys.get(row) ?? "";
2350
+ return /* @__PURE__ */ jsxs10("span", { className: "flex w-full items-center gap-1.5", children: [
2351
+ selectable && /* @__PURE__ */ jsx11(
2352
+ "input",
2353
+ {
2354
+ type: "checkbox",
2355
+ "aria-label": `Select row ${firstRowNumber + index}`,
2356
+ className: "h-3.5 w-3.5",
2357
+ checked: selected.has(id),
2358
+ onChange: () => onToggleSelect(id),
2359
+ "data-testid": "studio-row-select"
2360
+ }
2361
+ ),
2362
+ /* @__PURE__ */ jsx11("span", { className: "min-w-[1.5rem] tabular-nums", children: firstRowNumber + index }),
2363
+ onOpenRecord && id && /* @__PURE__ */ jsx11(
2364
+ "button",
2365
+ {
2366
+ type: "button",
2367
+ className: "ml-auto rounded p-0.5 text-muted-foreground opacity-0 hover:bg-background hover:text-foreground focus-visible:opacity-100 group-hover/row:opacity-100",
2368
+ "aria-label": `Open row ${firstRowNumber + index}`,
2369
+ title: "Open record",
2370
+ onClick: () => onOpenRecord(id),
2371
+ "data-testid": "studio-open-record",
2372
+ children: /* @__PURE__ */ jsx11(Maximize2, { className: "h-3 w-3", "aria-hidden": true })
2373
+ }
2374
+ )
2375
+ ] });
2376
+ }
2377
+ }
2378
+ }
2379
+ );
35
2380
  }
36
2381
 
37
2382
  // src/DataStudio.tsx
38
- import { jsx, jsxs } from "react/jsx-runtime";
39
- var PER_PAGE = 25;
2383
+ import { Fragment as Fragment4, jsx as jsx12, jsxs as jsxs11 } from "react/jsx-runtime";
2384
+ var PER_PAGE = 50;
40
2385
  var SEARCH_DEBOUNCE_MS = 300;
41
2386
  function isUnauthenticated(error) {
42
- return isDataWriteError(error) && error.outcome === "unauthenticated";
2387
+ return isDataWriteError5(error) && error.outcome === "unauthenticated";
43
2388
  }
44
2389
  function errorText(error) {
45
2390
  return error instanceof Error ? error.message : String(error);
46
2391
  }
47
2392
  function DataStudio(props) {
48
- return /* @__PURE__ */ jsx(GenesisProvider, { provider: props.provider, children: /* @__PURE__ */ jsx(StudioWorkspace, { ...props }) });
2393
+ const historyRouter = useHistoryRouter();
2394
+ const router = props.router ?? historyRouter;
2395
+ const session = props.provider.session;
2396
+ const [sessionUser, setSessionUser] = useState10(null);
2397
+ useEffect5(() => session?.subscribe(setSessionUser), [session]);
2398
+ const sessionKey = props.sessionKey !== void 0 ? props.sessionKey : session ? sessionUser : null;
2399
+ return /* @__PURE__ */ jsx12(GenesisProvider, { provider: props.provider, children: /* @__PURE__ */ jsx12("div", { "data-genesis-studio": "", children: /* @__PURE__ */ jsx12(StudioWorkspace, { ...props, router, sessionKey }) }) });
49
2400
  }
50
2401
  function StudioWorkspace({
51
2402
  provider,
@@ -53,18 +2404,24 @@ function StudioWorkspace({
53
2404
  signIn,
54
2405
  sessionKey = null,
55
2406
  title = "Data",
2407
+ variant = "card",
56
2408
  perPage = PER_PAGE,
2409
+ forms,
57
2410
  className
58
2411
  }) {
59
- const queryClient = useQueryClient();
60
- const [activity, setActivity] = useState([]);
61
- const [signedOut, setSignedOut] = useState(false);
62
- useEffect(() => {
63
- setSignedOut(false);
2412
+ const queryClient = useQueryClient2();
2413
+ const [activity, setActivity] = useState10([]);
2414
+ const [refusedProvider, setRefusedProvider] = useState10(null);
2415
+ const signedOut = refusedProvider !== null && refusedProvider === provider;
2416
+ const providerRef = useRef(provider);
2417
+ providerRef.current = provider;
2418
+ const setSignedOut = useCallback2((value) => setRefusedProvider(value ? providerRef.current : null), []);
2419
+ useEffect5(() => {
2420
+ setRefusedProvider(null);
64
2421
  setActivity([]);
65
2422
  void queryClient.resetQueries({ queryKey: ["genesis-studio"] });
66
2423
  }, [sessionKey, queryClient]);
67
- const resources = useQuery({
2424
+ const resources = useQuery5({
68
2425
  queryKey: ["genesis-studio", sessionKey, "resources"],
69
2426
  queryFn: () => {
70
2427
  if (!provider.listResources) throw new Error("This data provider can't list its resources.");
@@ -72,34 +2429,123 @@ function StudioWorkspace({
72
2429
  },
73
2430
  retry: (count, error) => !isUnauthenticated(error) && count < 2
74
2431
  });
75
- useEffect(() => {
2432
+ useEffect5(() => {
76
2433
  if (isUnauthenticated(resources.error)) setSignedOut(true);
77
2434
  }, [resources.error]);
78
- const record = useCallback((entry) => {
2435
+ useEffect5(() => {
2436
+ if (refusedProvider && refusedProvider !== provider) {
2437
+ setRefusedProvider(null);
2438
+ void queryClient.resetQueries({ queryKey: ["genesis-studio", sessionKey] });
2439
+ }
2440
+ }, [provider, refusedProvider, queryClient, sessionKey]);
2441
+ const undoing = useRef(/* @__PURE__ */ new Set());
2442
+ const undoAll = useCallback2(
2443
+ async (entry, audits) => {
2444
+ const left = [];
2445
+ let firstError;
2446
+ for (const audit of audits) {
2447
+ try {
2448
+ await provider.undo(entry.resource, { auditId: audit.id });
2449
+ } catch (error) {
2450
+ left.push(audit);
2451
+ firstError ??= error;
2452
+ }
2453
+ }
2454
+ void queryClient.invalidateQueries({ queryKey: ["genesis-studio", sessionKey] });
2455
+ const done = audits.length - left.length;
2456
+ setActivity((prev) => {
2457
+ const updated = prev.map((e) => e.id === entry.id ? left.length ? { ...e, audits: left } : { ...e, undone: true } : e);
2458
+ const now = /* @__PURE__ */ new Date();
2459
+ const added = [];
2460
+ if (left.length) {
2461
+ added.push({ id: `${now.getTime()}-undo-failed`, at: now, resource: entry.resource, field: entry.field, record: `${left.length} rows`, outcome: "not_saved", message: `${left.length} of ${audits.length} rows weren't undone: ${errorText(firstError)}` });
2462
+ }
2463
+ if (done) {
2464
+ added.push({ id: `${now.getTime()}-undo`, at: now, resource: entry.resource, field: entry.field, record: `${done} rows`, outcome: "saved", action: "update", message: "Undone, each row with its own audit entry." });
2465
+ }
2466
+ return [...added, ...updated].slice(0, 50);
2467
+ });
2468
+ },
2469
+ [provider, queryClient, sessionKey]
2470
+ );
2471
+ const undoOne = useCallback2(
2472
+ async (entry) => {
2473
+ if (entry.audits?.length) return undoAll(entry, entry.audits);
2474
+ try {
2475
+ const result = await provider.undo(entry.resource, { auditId: entry.audit.id });
2476
+ setActivity((prev) => prev.map((e) => e.id === entry.id ? { ...e, undone: true } : e));
2477
+ void queryClient.invalidateQueries({ queryKey: ["genesis-studio", sessionKey] });
2478
+ setActivity(
2479
+ (prev) => [
2480
+ {
2481
+ id: `${Date.now()}-undo`,
2482
+ at: /* @__PURE__ */ new Date(),
2483
+ resource: entry.resource,
2484
+ field: entry.field,
2485
+ record: entry.record,
2486
+ outcome: "saved",
2487
+ action: "update",
2488
+ message: "Undone, with its own audit entry.",
2489
+ ...result.audit ? { audit: result.audit, undone: true } : {}
2490
+ },
2491
+ ...prev
2492
+ ].slice(0, 50)
2493
+ );
2494
+ } catch (error) {
2495
+ setActivity(
2496
+ (prev) => [
2497
+ {
2498
+ id: `${Date.now()}-undo-failed`,
2499
+ at: /* @__PURE__ */ new Date(),
2500
+ resource: entry.resource,
2501
+ field: entry.field,
2502
+ record: entry.record,
2503
+ outcome: "not_saved",
2504
+ message: errorText(error)
2505
+ },
2506
+ ...prev
2507
+ ].slice(0, 50)
2508
+ );
2509
+ }
2510
+ },
2511
+ [provider, queryClient, sessionKey, undoAll]
2512
+ );
2513
+ const undo = useCallback2(
2514
+ async (entry) => {
2515
+ if (!provider.undo || entry.undone || undoing.current.has(entry.id)) return;
2516
+ if (!entry.audit && !entry.audits?.length) return;
2517
+ undoing.current.add(entry.id);
2518
+ try {
2519
+ await undoOne(entry);
2520
+ } finally {
2521
+ undoing.current.delete(entry.id);
2522
+ }
2523
+ },
2524
+ [provider, undoOne]
2525
+ );
2526
+ const record = useCallback2((entry) => {
79
2527
  setActivity(
80
2528
  (prev) => [{ ...entry, id: `${Date.now()}-${Math.random().toString(36).slice(2)}`, at: /* @__PURE__ */ new Date() }, ...prev].slice(0, 50)
81
2529
  );
82
2530
  }, []);
83
2531
  if (signedOut) {
84
- return /* @__PURE__ */ jsx("div", { className: cn("flex min-h-[320px] items-center justify-center p-6", className), "data-testid": "studio-sign-in", children: signIn ?? /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: "Sign in to use the studio." }) });
2532
+ return /* @__PURE__ */ jsx12("div", { className: cn4("flex min-h-[320px] items-center justify-center p-6", className), "data-testid": "studio-sign-in", children: signIn ?? /* @__PURE__ */ jsx12("p", { className: "text-sm text-muted-foreground", children: "Sign in to use the studio." }) });
85
2533
  }
86
2534
  const selected = router.location.resource;
87
2535
  const list = resources.data ?? [];
88
- return /* @__PURE__ */ jsxs("div", { className: cn("flex min-h-[480px] w-full flex-col gap-4 lg:flex-row", className), "data-testid": "studio", children: [
89
- /* @__PURE__ */ jsx(
90
- TableSidebar,
91
- {
92
- title,
93
- resources: list,
94
- selected,
95
- loading: resources.isLoading,
96
- error: resources.isError ? errorText(resources.error) : null,
97
- onSelect: (resource) => router.navigate({ resource })
98
- }
99
- ),
100
- /* @__PURE__ */ jsxs("main", { className: "min-w-0 flex-1 space-y-4", "data-testid": "studio-main", children: [
101
- selected ? /* @__PURE__ */ jsx(
102
- Sheet,
2536
+ return /* @__PURE__ */ jsxs11("div", { className: cn4("w-full space-y-3", className), children: [
2537
+ /* @__PURE__ */ jsxs11("div", { className: variant === "card" ? "overflow-hidden rounded-lg border bg-background" : "min-w-0", "data-testid": "studio", "data-variant": variant, children: [
2538
+ (title !== null || resources.isLoading || resources.isError) && /* @__PURE__ */ jsxs11("div", { className: cn4("flex items-center gap-2", variant === "card" ? "border-b px-3 py-2" : "pb-3"), children: [
2539
+ title !== null && (variant === "card" ? /* @__PURE__ */ jsxs11(Fragment4, { children: [
2540
+ /* @__PURE__ */ jsx12(Database, { className: "h-4 w-4 text-muted-foreground", "aria-hidden": true }),
2541
+ /* @__PURE__ */ jsx12("span", { className: "text-sm font-semibold", children: title })
2542
+ ] }) : /* @__PURE__ */ jsx12("h1", { className: "text-2xl font-semibold tracking-tight", children: title })),
2543
+ resources.isLoading && /* @__PURE__ */ jsx12("span", { className: "text-xs text-muted-foreground", children: "Loading tables\u2026" }),
2544
+ resources.isError && /* @__PURE__ */ jsx12("span", { role: "alert", className: "text-xs text-destructive", children: errorText(resources.error) })
2545
+ ] }),
2546
+ /* @__PURE__ */ jsx12(TableTabs, { resources: list, selected, onSelect: (resource) => router.navigate({ resource }), variant }),
2547
+ /* @__PURE__ */ jsx12("main", { className: "min-w-0", "data-testid": "studio-main", children: selected ? /* @__PURE__ */ jsx12(
2548
+ Sheet2,
103
2549
  {
104
2550
  provider,
105
2551
  resource: selected,
@@ -107,84 +2553,77 @@ function StudioWorkspace({
107
2553
  router,
108
2554
  perPage,
109
2555
  onActivity: record,
110
- onSignedOut: () => setSignedOut(true)
2556
+ onSignedOut: () => setSignedOut(true),
2557
+ formConfig: forms?.[selected],
2558
+ onUndoLatest: () => {
2559
+ const latest = activity.find((e) => e.resource === selected && e.outcome === "saved" && (e.audit && e.audit.action !== "run" || e.audits?.length) && !e.undone);
2560
+ if (latest) void undo(latest);
2561
+ }
111
2562
  },
112
2563
  selected
113
- ) : /* @__PURE__ */ jsxs(
2564
+ ) : /* @__PURE__ */ jsxs11(
114
2565
  "div",
115
2566
  {
116
- className: "flex h-full min-h-[320px] flex-col items-center justify-center gap-2 rounded-lg border border-dashed p-8 text-center text-muted-foreground",
2567
+ className: "flex min-h-[320px] flex-col items-center justify-center gap-2 p-8 text-center text-muted-foreground",
117
2568
  "data-testid": "studio-empty",
118
2569
  children: [
119
- /* @__PURE__ */ jsx(Table2, { className: "h-6 w-6", "aria-hidden": true }),
120
- /* @__PURE__ */ jsx("p", { className: "text-sm", children: list.length ? "Pick a table to open it." : "No tables to show yet." })
2570
+ /* @__PURE__ */ jsx12(Table22, { className: "h-6 w-6", "aria-hidden": true }),
2571
+ /* @__PURE__ */ jsx12("p", { className: "text-sm", children: list.length ? "Pick a table to open it." : "No tables to show yet." })
121
2572
  ]
122
2573
  }
123
- ),
124
- /* @__PURE__ */ jsx(ActivityPanel, { entries: activity })
125
- ] })
126
- ] });
127
- }
128
- function TableSidebar({
129
- title,
130
- resources,
131
- selected,
132
- loading,
133
- error,
134
- onSelect
135
- }) {
136
- return /* @__PURE__ */ jsxs("nav", { "aria-label": "Tables", className: "w-full shrink-0 self-start rounded-lg border bg-card lg:w-56", "data-testid": "studio-sidebar", children: [
137
- /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 border-b px-3 py-2 text-sm font-semibold", children: [
138
- /* @__PURE__ */ jsx(Database, { className: "h-4 w-4 text-muted-foreground", "aria-hidden": true }),
139
- title
2574
+ ) })
140
2575
  ] }),
141
- loading && /* @__PURE__ */ jsx("p", { className: "px-3 py-2 text-xs text-muted-foreground", children: "Loading tables\u2026" }),
142
- error && /* @__PURE__ */ jsx("p", { role: "alert", className: "px-3 py-2 text-xs text-destructive", children: error }),
143
- /* @__PURE__ */ jsx("ul", { className: "p-1", children: resources.map((r) => {
144
- const active = r.name === selected;
145
- return /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsxs(
146
- "button",
147
- {
148
- type: "button",
149
- onClick: () => onSelect(r.name),
150
- "aria-current": active ? "page" : void 0,
151
- title: r.access.update ? r.name : `${r.name} \u2014 ${r.access.reason ?? "Read-only"}`,
152
- "data-testid": `studio-table-${r.name}`,
153
- className: cn(
154
- "flex w-full items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left text-sm",
155
- active ? "bg-accent text-accent-foreground" : "hover:bg-muted"
156
- ),
157
- children: [
158
- /* @__PURE__ */ jsx("span", { className: "truncate", children: r.label ?? r.name }),
159
- !r.access.update && /* @__PURE__ */ jsx(Lock, { className: "h-3.5 w-3.5 shrink-0 text-muted-foreground", "aria-label": "Read-only" })
160
- ]
161
- }
162
- ) }, r.name);
163
- }) })
2576
+ /* @__PURE__ */ jsx12(ActivityPanel, { entries: activity, onUndo: provider.undo ? (entry) => void undo(entry) : void 0 })
164
2577
  ] });
165
2578
  }
166
- function parseSort(sort) {
167
- if (!sort) return void 0;
168
- const at = sort.lastIndexOf(":");
169
- const field = at > 0 ? sort.slice(0, at) : sort;
170
- const order = sort.slice(at + 1) === "desc" ? "desc" : "asc";
171
- return field ? { field, order } : void 0;
172
- }
173
- function Sheet({
2579
+ function Sheet2({
174
2580
  provider,
175
2581
  resource,
176
2582
  sessionKey,
177
2583
  router,
178
2584
  perPage,
179
2585
  onActivity,
180
- onSignedOut
2586
+ onSignedOut,
2587
+ formConfig,
2588
+ onUndoLatest
181
2589
  }) {
182
- const queryClient = useQueryClient();
2590
+ const queryClient = useQueryClient2();
183
2591
  const { location } = router;
184
2592
  const page = location.page ?? 1;
185
- const sort = parseSort(location.sort);
2593
+ const sorts = useMemo8(() => parseSorts(location.sort), [location.sort]);
2594
+ const filters = useMemo8(() => parseFilters(location.filters), [location.filters]);
186
2595
  const search = location.search ?? "";
187
- const schemaQuery = useQuery({
2596
+ const group = location.group;
2597
+ const [creating, setCreating] = useState10(false);
2598
+ const [exporting, setExporting] = useState10(null);
2599
+ const [notice, setNotice] = useState10(null);
2600
+ const [viewEpoch, setViewEpoch] = useState10(0);
2601
+ const [bulkIds, setBulkIds] = useState10(null);
2602
+ const view = location.view ?? "table";
2603
+ const sheetView = view === "sheet";
2604
+ const savedViews = useSavedViews(`genesis-studio:views:${resource}`);
2605
+ const hiddenKey = `genesis-studio:hidden:${resource}`;
2606
+ const [hidden, setHiddenState] = useState10(() => {
2607
+ try {
2608
+ const stored = JSON.parse(localStorage.getItem(hiddenKey) ?? "[]");
2609
+ return new Set(Array.isArray(stored) ? stored.filter((x) => typeof x === "string") : []);
2610
+ } catch {
2611
+ return /* @__PURE__ */ new Set();
2612
+ }
2613
+ });
2614
+ const setHidden = useCallback2(
2615
+ (next) => {
2616
+ setHiddenState(next);
2617
+ try {
2618
+ localStorage.setItem(hiddenKey, JSON.stringify([...next]));
2619
+ } catch {
2620
+ }
2621
+ },
2622
+ [hiddenKey]
2623
+ );
2624
+ const [selected, setSelected] = useState10(/* @__PURE__ */ new Set());
2625
+ const [searchDraft, setSearchDraft] = useState10(location.search ?? "");
2626
+ const schemaQuery = useQuery5({
188
2627
  queryKey: ["genesis-studio", sessionKey, "describe", resource],
189
2628
  queryFn: () => {
190
2629
  if (!provider.describe) throw new Error("This data provider can't describe its resources.");
@@ -193,39 +2632,59 @@ function Sheet({
193
2632
  retry: false
194
2633
  });
195
2634
  const schema = schemaQuery.data;
196
- const listKey = useMemo(
197
- () => ["genesis-studio", sessionKey, "list", resource, page, perPage, location.sort ?? "", search],
198
- [sessionKey, resource, page, perPage, location.sort, search]
2635
+ const capabilities = useQuery5({
2636
+ queryKey: ["genesis-studio", sessionKey, "capabilities", resource],
2637
+ enabled: Boolean(provider.capabilities),
2638
+ retry: false,
2639
+ queryFn: () => provider.capabilities(resource)
2640
+ }).data;
2641
+ const effectiveSorts = useMemo8(
2642
+ () => group ? [{ field: group, order: "asc" }, ...sorts.filter((s) => s.field !== group)] : sorts,
2643
+ [group, sorts]
199
2644
  );
200
- const listQuery = useQuery({
2645
+ const listKey = useMemo8(
2646
+ () => ["genesis-studio", sessionKey, "list", resource, page, perPage, location.sort ?? "", search, location.filters ?? "", group ?? ""],
2647
+ [sessionKey, resource, page, perPage, location.sort, search, location.filters, group]
2648
+ );
2649
+ const keyed = Boolean(schema && keyFields3(schema).length > 0);
2650
+ const boardChoices = schema ? boardFields(schema) : [];
2651
+ const dateChoices = schema ? dateFields(schema) : [];
2652
+ const keyedView = (view === "kanban" || view === "calendar") && keyed;
2653
+ const viewChoices = view === "kanban" ? boardChoices : view === "calendar" ? dateChoices : [];
2654
+ const viewField = viewChoices.find((f) => f.name === location.by) ?? viewChoices[0];
2655
+ const altView = keyedView && viewField ? view : null;
2656
+ const listQuery = useQuery5({
201
2657
  queryKey: listKey,
202
- enabled: Boolean(schema),
203
- placeholderData: keepPreviousData,
2658
+ // A board or calendar makes its own requests; the grid's page isn't needed then.
2659
+ enabled: Boolean(schema) && !altView,
2660
+ placeholderData: keepPreviousData2,
204
2661
  retry: false,
205
2662
  queryFn: () => provider.getList(resource, {
206
2663
  pagination: { page, perPage },
207
- sort,
2664
+ sort: effectiveSorts[0],
2665
+ sorts: effectiveSorts,
2666
+ filters,
208
2667
  search: search ? { query: search, fields: [] } : void 0
209
2668
  })
210
2669
  });
211
2670
  const failure = schemaQuery.error ?? listQuery.error;
212
- useEffect(() => {
2671
+ useEffect5(() => {
213
2672
  if (isUnauthenticated(failure)) onSignedOut();
214
2673
  }, [failure, onSignedOut]);
215
2674
  const searchTimer = useRef(void 0);
216
- useEffect(() => () => clearTimeout(searchTimer.current), []);
217
- const navigate = useCallback(
2675
+ useEffect5(() => () => clearTimeout(searchTimer.current), []);
2676
+ const navigate = useCallback2(
218
2677
  (patch, replace = false) => router.navigate({ ...location, resource, ...patch }, { replace }),
219
2678
  [router, location, resource]
220
2679
  );
221
2680
  const rows = listQuery.data?.data ?? [];
222
- const keyed = Boolean(schema && keyFields(schema).length > 0);
223
- const keys = useMemo(() => {
2681
+ const keys = useMemo8(() => {
224
2682
  const map = /* @__PURE__ */ new Map();
225
- rows.forEach((row, index) => map.set(row, schema && keyed ? String(encodeRecordKey(schema, row)) : `row-${index}`));
2683
+ rows.forEach((row, index) => map.set(row, schema && keyed ? String(encodeRecordKey5(schema, row)) : `row-${index}`));
226
2684
  return map;
227
2685
  }, [rows, schema, keyed]);
228
- const replaceRow = useCallback(
2686
+ const linkLabels = usePageLinkLabels(provider, sessionKey, schema?.fields ?? [], rows);
2687
+ const replaceRow = useCallback2(
229
2688
  (oldRow, next) => {
230
2689
  queryClient.setQueryData(
231
2690
  listKey,
@@ -234,146 +2693,502 @@ function Sheet({
234
2693
  },
235
2694
  [queryClient, listKey]
236
2695
  );
237
- const save = useCallback(
238
- async (field, row, draft) => {
239
- if (!schema) return;
240
- if (draft === editText(row[field.name])) return;
241
- const recordLabel = keys.get(row) ?? "";
242
- const versionField = schema.version?.field;
243
- const expectedVersion = versionField ? row[versionField] == null ? null : String(row[versionField]) : null;
244
- try {
245
- const result = await provider.update(resource, {
246
- id: encodeRecordKey(schema, row),
247
- data: { [field.name]: parseDraft(field, draft) },
248
- expectedVersion
249
- });
250
- replaceRow(row, result.data);
251
- onActivity({
252
- resource,
253
- field: field.name,
254
- record: recordLabel,
255
- outcome: "saved",
256
- message: result.audit ? "Saved, with its audit entry." : "Saved.",
257
- ...result.audit ? { audit: result.audit } : {}
258
- });
259
- } catch (error) {
260
- if (isUnauthenticated(error)) onSignedOut();
261
- if (isDataWriteError(error) && error.outcome === "conflict" && error.current) {
262
- replaceRow(row, {
263
- ...row,
264
- ...error.current,
265
- ...versionField ? { [versionField]: error.currentVersion ?? null } : {}
266
- });
267
- }
268
- if (isDataWriteError(error) && error.outcome === "gone") {
269
- void queryClient.invalidateQueries({ queryKey: listKey });
270
- }
271
- onActivity({ resource, field: field.name, record: recordLabel, outcome: "not_saved", message: errorText(error) });
272
- throw error instanceof Error ? error : new Error(String(error));
273
- }
274
- },
275
- [schema, keys, provider, resource, replaceRow, onActivity, onSignedOut, queryClient, listKey]
2696
+ const refresh = useCallback2(() => {
2697
+ void queryClient.invalidateQueries({ queryKey: ["genesis-studio", sessionKey, "list", resource] });
2698
+ void queryClient.invalidateQueries({ queryKey: ["genesis-studio", sessionKey, "history", resource] });
2699
+ }, [queryClient, sessionKey, resource]);
2700
+ const openRecord = useCallback2(
2701
+ (target, id) => target === resource ? navigate({ record: id }) : router.navigate({ resource: target, record: id }),
2702
+ [navigate, router, resource]
276
2703
  );
277
- const columns = useMemo(() => {
278
- if (!schema) return [];
279
- return schema.fields.map((field) => {
280
- const options = choicesFor(field);
281
- return {
282
- key: field.name,
283
- header: field.name,
284
- sortable: true,
285
- accessor: (row) => editText(row[field.name]),
286
- render: (row) => {
287
- const text = displayText(row[field.name]);
288
- return text === null ? /* @__PURE__ */ jsx("span", { className: "text-muted-foreground", children: "\u2014" }) : /* @__PURE__ */ jsx("span", { className: "block max-w-[320px] truncate", title: text, children: text });
289
- },
290
- ...field.permissions?.update ? {
291
- editable: {
292
- type: options ? "select" : "text",
293
- ...options ? { options } : {},
294
- onSave: (row, draft) => save(field, row, draft)
295
- }
296
- } : {}
297
- };
298
- });
299
- }, [schema, save]);
2704
+ const createFields = useMemo8(() => new Set(schema?.createFields ?? []), [schema]);
2705
+ const fieldsForCreate = useMemo8(() => schema?.fields.filter((f) => createFields.has(f.name)) ?? [], [schema, createFields]);
2706
+ const listIdentity = JSON.stringify(listKey);
2707
+ useEffect5(() => {
2708
+ setSelected(/* @__PURE__ */ new Set());
2709
+ }, [listIdentity]);
2710
+ async function runExport() {
2711
+ if (!schema) return;
2712
+ setExporting(0);
2713
+ setNotice(null);
2714
+ try {
2715
+ const { csv, rows: count, truncated } = await exportCsv({
2716
+ provider,
2717
+ resource,
2718
+ fields: schema.fields,
2719
+ sorts: effectiveSorts,
2720
+ filters,
2721
+ search,
2722
+ onProgress: setExporting
2723
+ });
2724
+ downloadText(`${resource}.csv`, csv);
2725
+ setNotice(
2726
+ truncated ? `Exported the first ${count.toLocaleString()} matching rows. Narrow the list to export the rest.` : `Exported ${count.toLocaleString()} rows.`
2727
+ );
2728
+ } catch (error) {
2729
+ setNotice(`Export failed: ${errorText(error)}`);
2730
+ } finally {
2731
+ setExporting(null);
2732
+ }
2733
+ }
2734
+ async function create(values) {
2735
+ if (!schema) return;
2736
+ try {
2737
+ const result = await provider.create(resource, { data: values });
2738
+ setCreating(false);
2739
+ refresh();
2740
+ const id = String(encodeRecordKey5(schema, result.data));
2741
+ onActivity({ resource, field: "", record: id, outcome: "saved", action: "create", message: result.audit ? "Added, with its audit entry." : "Added.", ...result.audit ? { audit: result.audit } : {} });
2742
+ navigate({ record: id });
2743
+ } catch (error) {
2744
+ if (isUnauthenticated(error)) onSignedOut();
2745
+ onActivity({ resource, field: "", record: "new row", outcome: "not_saved", action: "create", message: errorText(error) });
2746
+ throw error;
2747
+ }
2748
+ }
300
2749
  if (schemaQuery.isError && !isUnauthenticated(schemaQuery.error)) {
301
- return /* @__PURE__ */ jsxs(Alert, { variant: "destructive", "data-testid": "studio-describe-error", children: [
302
- /* @__PURE__ */ jsxs(AlertTitle, { children: [
2750
+ return /* @__PURE__ */ jsxs11(Alert2, { variant: "destructive", "data-testid": "studio-describe-error", children: [
2751
+ /* @__PURE__ */ jsxs11(AlertTitle2, { children: [
303
2752
  "Couldn't open ",
304
2753
  resource
305
2754
  ] }),
306
- /* @__PURE__ */ jsx(AlertDescription, { children: errorText(schemaQuery.error) })
2755
+ /* @__PURE__ */ jsx12(AlertDescription2, { children: errorText(schemaQuery.error) })
307
2756
  ] });
308
2757
  }
309
2758
  const editable = schema?.fields.filter((f) => f.permissions?.update).length ?? 0;
310
- return /* @__PURE__ */ jsxs("div", { className: "space-y-3", "data-testid": "studio-sheet", children: [
311
- schema && !schema.access.update && /* @__PURE__ */ jsxs(Alert, { "data-testid": "studio-read-only", children: [
312
- /* @__PURE__ */ jsx(Lock, { className: "h-4 w-4", "aria-hidden": true }),
313
- /* @__PURE__ */ jsx(AlertTitle, { children: "Read-only" }),
314
- /* @__PURE__ */ jsx(AlertDescription, { children: schema.access.reason ?? "You can view this table but not edit it." })
2759
+ const gridView = !altView;
2760
+ const pageCount = Math.max(1, Math.ceil((listQuery.data?.total ?? 0) / perPage));
2761
+ const total = listQuery.data?.total ?? 0;
2762
+ const selectable = Boolean(schema && keyed && (schema.access.update && provider.updateMany || schema.access.delete && provider.deleteMany));
2763
+ const toggleSelect = (id) => setSelected((prev) => {
2764
+ const next = new Set(prev);
2765
+ if (next.has(id)) next.delete(id);
2766
+ else next.add(id);
2767
+ return next;
2768
+ });
2769
+ const pageIds = rows.map((r) => keys.get(r) ?? "");
2770
+ const toggleAll = () => setSelected((prev) => pageIds.every((id) => prev.has(id)) ? /* @__PURE__ */ new Set() : new Set(pageIds));
2771
+ const canAdd = Boolean(schema?.access.create);
2772
+ async function deleteSelected() {
2773
+ if (!schema) return;
2774
+ const ids = [...selected];
2775
+ const chosen = rows.filter((r) => ids.includes(keys.get(r) ?? ""));
2776
+ if (chosen.length !== ids.length) {
2777
+ setSelected(/* @__PURE__ */ new Set());
2778
+ setNotice("Some selected rows aren't on this page any more. Select the rows again.");
2779
+ return;
2780
+ }
2781
+ if (!window.confirm(`Delete ${ids.length} record${ids.length === 1 ? "" : "s"}? All of them go, or none do.`)) return;
2782
+ let result;
2783
+ try {
2784
+ result = await provider.deleteMany(resource, {
2785
+ items: chosen.map((r) => ({ id: encodeRecordKey5(schema, r), expectedVersion: versionOf3(schema, r) }))
2786
+ });
2787
+ } catch (error) {
2788
+ if (isUnauthenticated(error)) onSignedOut();
2789
+ refresh();
2790
+ const message = `Nothing was deleted: ${errorText(error)}`;
2791
+ setNotice(message);
2792
+ onActivity({ resource, field: "", record: `${ids.length} rows`, outcome: "not_saved", action: "delete", message });
2793
+ return;
2794
+ }
2795
+ refresh();
2796
+ setSelected(/* @__PURE__ */ new Set());
2797
+ if (result.ok) {
2798
+ setNotice(`Deleted ${result.count} records in one transaction.`);
2799
+ onActivity({
2800
+ resource,
2801
+ field: "",
2802
+ record: `${result.count} rows`,
2803
+ outcome: "saved",
2804
+ action: "delete",
2805
+ message: "Deleted, each row audited.",
2806
+ ...result.audit?.length ? { audits: result.audit } : {}
2807
+ });
2808
+ } else {
2809
+ const message = `Nothing was deleted: ${(result.failures ?? []).length} of ${ids.length} records couldn't be (${result.failures?.[0]?.message ?? ""}).`;
2810
+ setNotice(message);
2811
+ onActivity({ resource, field: "", record: `${ids.length} rows`, outcome: "not_saved", action: "delete", message });
2812
+ }
2813
+ }
2814
+ const gridFor = (items, firstRowNumber, label, withFooter = true) => schema && /* @__PURE__ */ jsx12(
2815
+ SheetView,
2816
+ {
2817
+ provider,
2818
+ schema,
2819
+ rows: items,
2820
+ keys,
2821
+ linkLabels,
2822
+ hidden,
2823
+ label,
2824
+ firstRowNumber,
2825
+ ...selectable ? { selected, onToggleSelect: toggleSelect, onToggleAll: toggleAll } : {},
2826
+ onOpenRecord: keyed ? (id) => navigate({ record: id }) : void 0,
2827
+ footer: withFooter && canAdd ? /* @__PURE__ */ jsxs11(
2828
+ "button",
2829
+ {
2830
+ type: "button",
2831
+ className: "flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm text-muted-foreground hover:bg-muted/40 hover:text-foreground",
2832
+ onClick: () => setCreating(true),
2833
+ "data-testid": "studio-add-record",
2834
+ children: [
2835
+ /* @__PURE__ */ jsx12(Plus3, { className: "h-3.5 w-3.5", "aria-hidden": true }),
2836
+ "Add a record"
2837
+ ]
2838
+ }
2839
+ ) : void 0,
2840
+ onSaved: (updates) => {
2841
+ if (updates.length === 1) replaceRow(updates[0].old, updates[0].next);
2842
+ else refresh();
2843
+ void queryClient.invalidateQueries({ queryKey: ["genesis-studio", sessionKey, "record", resource] });
2844
+ const fieldsOf = (audit) => Object.keys(audit?.after ?? {}).filter((f) => f !== schema.version?.field);
2845
+ if (updates.length === 1) {
2846
+ const u = updates[0];
2847
+ onActivity({
2848
+ resource,
2849
+ field: fieldsOf(u.audit).join(", "),
2850
+ record: keys.get(u.old) ?? "",
2851
+ outcome: "saved",
2852
+ message: u.audit ? "Saved, with its audit entry." : "Saved.",
2853
+ ...u.audit ? { audit: u.audit } : {}
2854
+ });
2855
+ } else {
2856
+ const audits = updates.flatMap((u) => u.audit ? [u.audit] : []);
2857
+ onActivity({
2858
+ resource,
2859
+ field: [...new Set(audits.flatMap(fieldsOf))].join(", "),
2860
+ record: `${updates.length} rows`,
2861
+ outcome: "saved",
2862
+ message: audits.length ? "Saved, each row audited." : "Saved.",
2863
+ ...audits.length ? { audits } : {}
2864
+ });
2865
+ }
2866
+ },
2867
+ onFailed: (message) => {
2868
+ refresh();
2869
+ onActivity({ resource, field: "", record: "", outcome: "not_saved", message });
2870
+ },
2871
+ onUndo: onUndoLatest,
2872
+ onNotice: setNotice
2873
+ }
2874
+ );
2875
+ return /* @__PURE__ */ jsxs11("div", { className: "flex min-h-[480px] flex-col md:flex-row", "data-testid": "studio-sheet", children: [
2876
+ schema && /* @__PURE__ */ jsx12(
2877
+ ViewsPanel,
2878
+ {
2879
+ view: altView ?? "grid",
2880
+ available: { grid: true, kanban: keyed && boardChoices.length > 0, calendar: keyed && dateChoices.length > 0 },
2881
+ onChange: (next) => navigate({ view: next === "grid" ? void 0 : next, by: void 0, month: void 0, record: void 0 }),
2882
+ saved: savedViews.savedViews,
2883
+ onDelete: savedViews.deleteView,
2884
+ onSave: (name) => savedViews.saveView(name, {
2885
+ sort: location.sort ?? null,
2886
+ search: location.search ?? null,
2887
+ filters: location.filters ?? null,
2888
+ group: location.group ?? null,
2889
+ view: location.view ?? null,
2890
+ by: location.by ?? null,
2891
+ hidden: [...hidden]
2892
+ }),
2893
+ onLoad: (item) => {
2894
+ const view2 = savedViews.loadViewFilters(item.id) ?? {};
2895
+ if (Array.isArray(view2.hidden)) setHidden(new Set(view2.hidden.filter((x) => typeof x === "string")));
2896
+ const str = (v) => typeof v === "string" ? v : void 0;
2897
+ const nextView = str(view2.view);
2898
+ setSearchDraft(str(view2.search) ?? "");
2899
+ router.navigate({
2900
+ resource,
2901
+ sort: str(view2.sort),
2902
+ search: str(view2.search),
2903
+ filters: str(view2.filters),
2904
+ group: str(view2.group),
2905
+ view: nextView === "kanban" || nextView === "calendar" ? nextView : void 0,
2906
+ by: str(view2.by)
2907
+ });
2908
+ }
2909
+ }
2910
+ ),
2911
+ /* @__PURE__ */ jsxs11("div", { className: "min-w-0 flex-1 space-y-2 p-2 md:p-3", children: [
2912
+ schema && !schema.access.update && /* @__PURE__ */ jsxs11("p", { className: "flex items-center gap-1.5 text-xs text-muted-foreground", "data-testid": "studio-read-only", children: [
2913
+ /* @__PURE__ */ jsx12(Lock2, { className: "h-3.5 w-3.5", "aria-hidden": true }),
2914
+ "Read-only: ",
2915
+ (schema.access.reason ?? "you can view this table but not edit it.").replace(/^Read-only: /, "")
2916
+ ] }),
2917
+ schema && /* @__PURE__ */ jsxs11("div", { className: "flex flex-wrap items-center gap-1", "data-testid": "studio-toolbar", children: [
2918
+ selected.size === 0 && gridView && /* @__PURE__ */ jsx12(HideFields, { fields: gridFields(schema), hidden, onChange: setHidden }),
2919
+ selected.size === 0 && (capabilities?.filterOperators?.length ?? 1) > 0 && /* @__PURE__ */ jsx12(FilterBuilder, { fields: schema.fields, filters, onChange: (next) => navigate({ filters: formatFilters(next), page: 1 }) }),
2920
+ selected.size === 0 && gridView && /* @__PURE__ */ jsx12(GroupPicker, { fields: schema.fields, group, onChange: (next) => navigate({ group: next, page: 1 }) }),
2921
+ selected.size === 0 && capabilities?.sort !== false && /* @__PURE__ */ jsx12(SortBuilder, { fields: schema.fields, sorts, onChange: (next) => navigate({ sort: formatSorts(next), page: 1 }) }),
2922
+ altView && viewField && /* @__PURE__ */ jsx12(
2923
+ ViewFieldPicker,
2924
+ {
2925
+ label: altView === "kanban" ? "Columns by" : "Dates by",
2926
+ fields: viewChoices,
2927
+ value: viewField.name,
2928
+ onChange: (by) => navigate({ by })
2929
+ }
2930
+ ),
2931
+ selected.size > 0 && /* @__PURE__ */ jsxs11("span", { className: "flex items-center gap-1 rounded-md bg-muted px-2 text-sm", "data-testid": "studio-selection", children: [
2932
+ selected.size,
2933
+ " selected",
2934
+ schema.access.update && provider.updateMany && /* @__PURE__ */ jsxs11(Button9, { type: "button", variant: "ghost", size: "sm", testID: "studio-bulk-set", onClick: () => setBulkIds([...selected]), children: [
2935
+ /* @__PURE__ */ jsx12(PencilLine, { className: "mr-1 h-3.5 w-3.5", "aria-hidden": true }),
2936
+ "Set a field\u2026"
2937
+ ] }),
2938
+ schema.access.delete && provider.deleteMany && /* @__PURE__ */ jsxs11(Button9, { type: "button", variant: "ghost", size: "sm", className: "text-destructive", testID: "studio-bulk-delete", onClick: () => void deleteSelected(), children: [
2939
+ /* @__PURE__ */ jsx12(Trash22, { className: "mr-1 h-3.5 w-3.5", "aria-hidden": true }),
2940
+ "Delete"
2941
+ ] }),
2942
+ /* @__PURE__ */ jsx12("button", { type: "button", "aria-label": "Clear the selection", className: "rounded p-0.5 hover:bg-background", onClick: () => setSelected(/* @__PURE__ */ new Set()), children: /* @__PURE__ */ jsx12(X4, { className: "h-3.5 w-3.5", "aria-hidden": true }) })
2943
+ ] }),
2944
+ /* @__PURE__ */ jsxs11("span", { className: "ml-auto flex items-center gap-1", children: [
2945
+ /* @__PURE__ */ jsx12(
2946
+ SearchBox,
2947
+ {
2948
+ value: searchDraft,
2949
+ onChange: (text) => {
2950
+ setSearchDraft(text);
2951
+ clearTimeout(searchTimer.current);
2952
+ searchTimer.current = setTimeout(() => navigate({ search: text || void 0, page: 1 }, true), SEARCH_DEBOUNCE_MS);
2953
+ }
2954
+ }
2955
+ ),
2956
+ /* @__PURE__ */ jsx12(ExportButton, { onExport: () => void runExport(), busy: exporting !== null, progress: exporting ?? 0 }),
2957
+ schema.access.create !== void 0 && /* @__PURE__ */ jsx12(
2958
+ ImportDialog,
2959
+ {
2960
+ provider,
2961
+ schema,
2962
+ onImported: (count) => {
2963
+ refresh();
2964
+ setNotice(`Imported ${count.toLocaleString()} rows in one transaction.`);
2965
+ onActivity({ resource, field: "", record: `${count} rows`, outcome: "saved", action: "create", message: "Imported, each row audited." });
2966
+ }
2967
+ }
2968
+ ),
2969
+ schema.access.create !== void 0 && /* @__PURE__ */ jsx12(NewRowButton, { allowed: canAdd, reason: schema.access.reasons?.create, onClick: () => setCreating(true) })
2970
+ ] })
2971
+ ] }),
2972
+ notice && /* @__PURE__ */ jsx12("p", { className: "text-xs text-muted-foreground", role: "status", "data-testid": "studio-notice", children: notice }),
2973
+ schema && altView === "kanban" && viewField && /* @__PURE__ */ jsx12(
2974
+ KanbanView,
2975
+ {
2976
+ provider,
2977
+ schema,
2978
+ sessionKey,
2979
+ field: viewField,
2980
+ filters,
2981
+ sorts,
2982
+ search,
2983
+ onOpenRecord: (id) => navigate({ record: id }),
2984
+ onMoved: (record, audit) => {
2985
+ refresh();
2986
+ onActivity({ resource, field: viewField.name, record, outcome: "saved", message: audit ? "Moved, with its audit entry." : "Moved.", ...audit ? { audit } : {} });
2987
+ },
2988
+ onFailed: (record, message) => {
2989
+ refresh();
2990
+ onActivity({ resource, field: viewField.name, record, outcome: "not_saved", message });
2991
+ },
2992
+ onSignedOut
2993
+ },
2994
+ `${viewField.name}|${location.filters ?? ""}|${search}|${location.sort ?? ""}`
2995
+ ),
2996
+ schema && altView === "calendar" && viewField && /* @__PURE__ */ jsx12(
2997
+ CalendarView,
2998
+ {
2999
+ provider,
3000
+ schema,
3001
+ sessionKey,
3002
+ field: viewField,
3003
+ month: location.month,
3004
+ onMonthChange: (month) => navigate({ month }),
3005
+ filters,
3006
+ sorts,
3007
+ search,
3008
+ onOpenRecord: (id) => navigate({ record: id }),
3009
+ onSignedOut
3010
+ }
3011
+ ),
3012
+ gridView && /* @__PURE__ */ jsxs11("div", { className: "space-y-3", "data-testid": "studio-grid", children: [
3013
+ (schemaQuery.isLoading || listQuery.isLoading) && /* @__PURE__ */ jsx12("p", { className: "px-1 py-6 text-sm text-muted-foreground", children: "Loading\u2026" }),
3014
+ listQuery.isError && !isUnauthenticated(listQuery.error) && /* @__PURE__ */ jsxs11(Alert2, { variant: "destructive", children: [
3015
+ /* @__PURE__ */ jsx12(AlertTitle2, { children: "Couldn't load the records" }),
3016
+ /* @__PURE__ */ jsxs11(AlertDescription2, { children: [
3017
+ errorText(listQuery.error),
3018
+ " ",
3019
+ /* @__PURE__ */ jsx12("button", { type: "button", className: "underline", onClick: () => void listQuery.refetch(), children: "Try again" })
3020
+ ] })
3021
+ ] }),
3022
+ schema && listQuery.data && !group && gridFor(rows, (page - 1) * perPage + 1),
3023
+ schema && listQuery.data && group && /* @__PURE__ */ jsx12("div", { className: "space-y-4", "data-testid": "studio-groups", children: groupRows(rows, group).map(([value, groupItems]) => {
3024
+ const field = schema.fields.find((f) => f.name === group);
3025
+ return /* @__PURE__ */ jsxs11("section", { "data-testid": "studio-group-section", className: "space-y-1", children: [
3026
+ /* @__PURE__ */ jsxs11("h3", { className: "flex items-center gap-2 text-sm", children: [
3027
+ /* @__PURE__ */ jsx12("span", { className: "text-muted-foreground", children: field ? fieldLabel(field) : group }),
3028
+ value === null ? /* @__PURE__ */ jsx12("span", { className: "text-muted-foreground", children: "Empty" }) : field ? /* @__PURE__ */ jsx12(CellValue, { field, value }) : value,
3029
+ /* @__PURE__ */ jsx12("span", { className: "text-xs text-muted-foreground", children: groupItems.length })
3030
+ ] }),
3031
+ gridFor(groupItems, rows.indexOf(groupItems[0]) + (page - 1) * perPage + 1, `${schema.label ?? resource}: ${value ?? "empty"}`, false)
3032
+ ] }, value ?? "__none");
3033
+ }) }),
3034
+ listQuery.data && /* @__PURE__ */ jsxs11("div", { className: "flex items-center gap-2 px-1 text-xs text-muted-foreground", "data-testid": "studio-footer", children: [
3035
+ /* @__PURE__ */ jsxs11("span", { children: [
3036
+ total.toLocaleString(),
3037
+ " record",
3038
+ total === 1 ? "" : "s"
3039
+ ] }),
3040
+ pageCount > 1 && /* @__PURE__ */ jsxs11("span", { className: "ml-auto flex items-center gap-1", children: [
3041
+ /* @__PURE__ */ jsx12(Button9, { type: "button", variant: "ghost", size: "sm", disabled: page <= 1, onClick: () => navigate({ page: page - 1 }), "aria-label": "Previous page", children: /* @__PURE__ */ jsx12(ChevronLeft2, { className: "h-3.5 w-3.5", "aria-hidden": true }) }),
3042
+ "Page ",
3043
+ page,
3044
+ " of ",
3045
+ pageCount,
3046
+ /* @__PURE__ */ jsx12(Button9, { type: "button", variant: "ghost", size: "sm", disabled: page >= pageCount, onClick: () => navigate({ page: page + 1 }), "aria-label": "Next page", children: /* @__PURE__ */ jsx12(ChevronRight2, { className: "h-3.5 w-3.5", "aria-hidden": true }) })
3047
+ ] })
3048
+ ] })
3049
+ ] })
315
3050
  ] }),
316
- /* @__PURE__ */ jsx(
317
- ResourcePage,
3051
+ schema && location.record && /* @__PURE__ */ jsx12(
3052
+ RecordPanel,
318
3053
  {
319
- title: schema?.label ?? resource,
320
- subtitle: schemaSubtitle(schema, editable),
321
- data: rows,
322
- total: listQuery.data?.total ?? 0,
323
- columns,
324
- getKey: (row) => keys.get(row) ?? "",
325
- detail: "none",
326
- page,
327
- perPage,
328
- onPageChange: (next) => navigate({ page: next }),
329
- onSortChange: (next) => navigate({ sort: next ? `${next.field}:${next.order}` : void 0, page: 1 }),
330
- onSearchChange: (text) => {
331
- clearTimeout(searchTimer.current);
332
- searchTimer.current = setTimeout(() => navigate({ search: text || void 0, page: 1 }, true), SEARCH_DEBOUNCE_MS);
3054
+ provider,
3055
+ sessionKey,
3056
+ schema,
3057
+ id: location.record,
3058
+ onClose: () => navigate({ record: void 0 }),
3059
+ onOpenRecord: openRecord,
3060
+ formConfig,
3061
+ onUndone: (audit) => {
3062
+ refresh();
3063
+ onActivity({ resource, field: "", record: location.record, outcome: "saved", message: "Undone, with its own audit entry.", ...audit ? { audit, undone: true } : {} });
333
3064
  },
334
- initialFilters: [
335
- ...search ? [{ field: "__search__", operator: "contains", value: search }] : [],
336
- ...location.sort ? [{ field: "__sort__", operator: "eq", value: location.sort }] : []
337
- ],
338
- loading: schemaQuery.isLoading || listQuery.isLoading,
339
- error: listQuery.isError && !isUnauthenticated(listQuery.error),
340
- errorMessage: listQuery.error ? errorText(listQuery.error) : void 0,
341
- onRetry: () => void listQuery.refetch(),
342
- columnVisibilityKey: `genesis-studio:${resource}`,
343
- resizeKey: `genesis-studio:${resource}`,
344
- viewSettingsKey: `genesis-studio:${resource}`,
345
- stickyFirstColumn: true
3065
+ onSaved: (row, audit) => {
3066
+ refresh();
3067
+ onActivity({ resource, field: Object.keys(audit?.after ?? {}).join(", "), record: location.record, outcome: "saved", message: audit ? "Saved, with its audit entry." : "Saved.", ...audit ? { audit } : {} });
3068
+ void row;
3069
+ },
3070
+ onActionRan: (action, row, audit) => {
3071
+ refresh();
3072
+ onActivity({
3073
+ resource,
3074
+ field: "",
3075
+ record: location.record,
3076
+ outcome: "saved",
3077
+ message: `${action.label}: done${row ? "" : ", and the row is gone"}${audit ? ", with its audit entry" : ""}.`,
3078
+ ...audit ? { audit } : {}
3079
+ });
3080
+ },
3081
+ onDeleted: (audit) => {
3082
+ refresh();
3083
+ onActivity({ resource, field: "", record: location.record, outcome: "saved", action: "delete", message: audit ? "Deleted, with its audit entry." : "Deleted.", ...audit ? { audit } : {} });
3084
+ },
3085
+ onFailed: (action, message) => onActivity({ resource, field: "", record: location.record, outcome: "not_saved", action: action === "delete" ? "delete" : "update", message })
346
3086
  }
347
- )
3087
+ ),
3088
+ schema && bulkIds && /* @__PURE__ */ jsx12(
3089
+ BulkEditDialog,
3090
+ {
3091
+ schema,
3092
+ count: bulkIds.length,
3093
+ open: true,
3094
+ onOpenChange: (open) => !open && setBulkIds(null),
3095
+ onApply: async (data) => {
3096
+ const chosen = rows.filter((r) => bulkIds.includes(keys.get(r) ?? ""));
3097
+ if (chosen.length !== bulkIds.length) {
3098
+ throw new Error("Some selected rows aren't on this page any more. Select the rows again.");
3099
+ }
3100
+ const result = await provider.updateMany(resource, {
3101
+ changes: chosen.map((r) => ({ id: encodeRecordKey5(schema, r), data, expectedVersion: versionOf3(schema, r) }))
3102
+ });
3103
+ refresh();
3104
+ if (result.ok) {
3105
+ setNotice(`Saved ${result.count} rows in one transaction.`);
3106
+ onActivity({
3107
+ resource,
3108
+ field: Object.keys(data).join(", "),
3109
+ record: `${result.count} rows`,
3110
+ outcome: "saved",
3111
+ message: "Saved, each row audited.",
3112
+ ...result.audit?.length ? { audits: result.audit } : {}
3113
+ });
3114
+ }
3115
+ return result;
3116
+ }
3117
+ }
3118
+ ),
3119
+ schema && /* @__PURE__ */ jsx12(Dialog3, { open: creating, onOpenChange: setCreating, children: /* @__PURE__ */ jsxs11(DialogContent3, { className: "max-h-[85vh] overflow-y-auto sm:max-w-lg", "data-testid": "studio-create-dialog", children: [
3120
+ /* @__PURE__ */ jsxs11(DialogHeader3, { children: [
3121
+ /* @__PURE__ */ jsxs11(DialogTitle3, { children: [
3122
+ "New ",
3123
+ schema.label ?? resource,
3124
+ " row"
3125
+ ] }),
3126
+ /* @__PURE__ */ jsx12(DialogDescription3, { children: "Only fields you may set are shown. The new row is audited." })
3127
+ ] }),
3128
+ /* @__PURE__ */ jsx12(
3129
+ RecordForm,
3130
+ {
3131
+ provider,
3132
+ sessionKey,
3133
+ mode: "create",
3134
+ fields: fieldsForCreate,
3135
+ editable: createFields,
3136
+ onSubmit: create,
3137
+ submitLabel: "Add row",
3138
+ onCancel: () => setCreating(false),
3139
+ config: formConfig
3140
+ }
3141
+ )
3142
+ ] }) })
348
3143
  ] });
349
3144
  }
350
- function schemaSubtitle(schema, editable) {
351
- if (!schema) return void 0;
352
- const parts = [schema.name];
353
- if (schema.access.update) parts.push(`${editable} of ${schema.fields.length} fields editable`);
354
- if (schema.version) parts.push(`edits checked against ${schema.version.field}`);
355
- return parts.join(" \xB7 ");
356
- }
357
- function ActivityPanel({ entries }) {
358
- return /* @__PURE__ */ jsxs("section", { "aria-label": "This session's edits", className: "rounded-lg border bg-card", "data-testid": "studio-activity", children: [
359
- /* @__PURE__ */ jsxs("h2", { className: "flex items-center gap-2 border-b px-3 py-2 text-sm font-semibold", children: [
360
- /* @__PURE__ */ jsx(History, { className: "h-4 w-4 text-muted-foreground", "aria-hidden": true }),
361
- "This session's edits"
3145
+ function versionOf3(schema, row) {
3146
+ const field = schema.version?.field;
3147
+ return field && row[field] != null ? String(row[field]) : null;
3148
+ }
3149
+ function groupRows(items, field) {
3150
+ const groups = /* @__PURE__ */ new Map();
3151
+ for (const item of items) {
3152
+ const value = displayText(item[field]);
3153
+ if (!groups.has(value)) groups.set(value, []);
3154
+ groups.get(value).push(item);
3155
+ }
3156
+ return [...groups.entries()];
3157
+ }
3158
+ var OUTCOME_LABEL = {
3159
+ update: { saved: "Saved", not_saved: "Not saved" },
3160
+ create: { saved: "Added", not_saved: "Not added" },
3161
+ delete: { saved: "Deleted", not_saved: "Not deleted" }
3162
+ };
3163
+ function ActivityPanel({ entries, onUndo }) {
3164
+ return /* @__PURE__ */ jsxs11("details", { "aria-label": "This session's edits", className: "group rounded-lg border bg-card", "data-testid": "studio-activity", children: [
3165
+ /* @__PURE__ */ jsxs11("summary", { className: "flex cursor-pointer list-none items-center gap-2 px-3 py-2 text-sm font-medium group-open:border-b", children: [
3166
+ /* @__PURE__ */ jsx12(History2, { className: "h-4 w-4 text-muted-foreground", "aria-hidden": true }),
3167
+ "This session's edits",
3168
+ entries.length > 0 && /* @__PURE__ */ jsx12("span", { className: "text-xs font-normal text-muted-foreground", children: entries.length }),
3169
+ entries[0] && /* @__PURE__ */ jsx12("span", { className: cn4("ml-2 truncate text-xs font-normal", entries[0].outcome === "saved" ? "text-muted-foreground" : "text-destructive"), children: entries[0].message })
362
3170
  ] }),
363
- entries.length === 0 ? /* @__PURE__ */ jsx("p", { className: "px-3 py-3 text-xs text-muted-foreground", children: "Click a cell to edit it. Each save is checked against the latest version of the row, and audited." }) : /* @__PURE__ */ jsx("ol", { className: "divide-y", "aria-live": "polite", children: entries.map((entry) => /* @__PURE__ */ jsxs("li", { className: "px-3 py-2 text-xs", "data-testid": `studio-activity-${entry.outcome}`, children: [
364
- /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-baseline gap-x-2", children: [
365
- /* @__PURE__ */ jsx("span", { className: entry.outcome === "saved" ? "font-medium text-foreground" : "font-medium text-destructive", children: entry.outcome === "saved" ? "Saved" : "Not saved" }),
366
- /* @__PURE__ */ jsxs("span", { className: "text-muted-foreground", children: [
367
- entry.resource,
368
- " \xB7 ",
369
- entry.field,
370
- " \xB7 ",
371
- entry.record
372
- ] }),
373
- /* @__PURE__ */ jsx("time", { className: "ml-auto text-muted-foreground", dateTime: entry.at.toISOString(), children: entry.at.toLocaleTimeString() })
3171
+ entries.length === 0 ? /* @__PURE__ */ jsx12("p", { className: "px-3 py-3 text-xs text-muted-foreground", children: "Click a cell to edit it. Each save is checked against the latest version of the row, and audited." }) : /* @__PURE__ */ jsx12("ol", { className: "divide-y", "aria-live": "polite", children: entries.map((entry) => /* @__PURE__ */ jsxs11("li", { className: "px-3 py-2 text-xs", "data-testid": `studio-activity-${entry.outcome}`, children: [
3172
+ /* @__PURE__ */ jsxs11("div", { className: "flex flex-wrap items-baseline gap-x-2", children: [
3173
+ /* @__PURE__ */ jsx12("span", { className: entry.outcome === "saved" ? "font-medium text-foreground" : "font-medium text-destructive", children: OUTCOME_LABEL[entry.action ?? "update"][entry.outcome] }),
3174
+ /* @__PURE__ */ jsx12("span", { className: "text-muted-foreground", children: [entry.resource, entry.field, entry.record].filter(Boolean).join(" \xB7 ") }),
3175
+ /* @__PURE__ */ jsx12("time", { className: "ml-auto text-muted-foreground", dateTime: entry.at.toISOString(), children: entry.at.toLocaleTimeString() }),
3176
+ onUndo && entry.outcome === "saved" && (entry.audit && entry.audit.action !== "run" || entry.audits?.length) && !entry.undone && /* @__PURE__ */ jsxs11(
3177
+ "button",
3178
+ {
3179
+ type: "button",
3180
+ className: "inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px] text-muted-foreground hover:bg-muted hover:text-foreground",
3181
+ onClick: () => onUndo(entry),
3182
+ "data-testid": "studio-activity-undo",
3183
+ children: [
3184
+ /* @__PURE__ */ jsx12(Undo2, { className: "h-3 w-3", "aria-hidden": true }),
3185
+ entry.audits?.length ? `Undo all ${entry.audits.length}` : "Undo"
3186
+ ]
3187
+ }
3188
+ )
374
3189
  ] }),
375
- /* @__PURE__ */ jsx("p", { className: "mt-0.5 text-muted-foreground", children: entry.message }),
376
- entry.audit && /* @__PURE__ */ jsx(AuditLine, { audit: entry.audit })
3190
+ /* @__PURE__ */ jsx12("p", { className: "mt-0.5 text-muted-foreground", children: entry.message }),
3191
+ entry.audit && /* @__PURE__ */ jsx12(AuditLine, { audit: entry.audit })
377
3192
  ] }, entry.id)) })
378
3193
  ] });
379
3194
  }
@@ -383,8 +3198,8 @@ function AuditLine({ audit }) {
383
3198
  before: displayText(audit.before?.[field]) ?? "\u2014",
384
3199
  after: displayText(audit.after?.[field]) ?? "\u2014"
385
3200
  }));
386
- return /* @__PURE__ */ jsxs("div", { className: "mt-1 rounded bg-muted/50 px-2 py-1 font-mono text-[11px]", "data-testid": "studio-audit-entry", children: [
387
- /* @__PURE__ */ jsxs("div", { className: "text-muted-foreground", children: [
3201
+ return /* @__PURE__ */ jsxs11("div", { className: "mt-1 rounded bg-muted/50 px-2 py-1 font-mono text-[11px]", "data-testid": "studio-audit-entry", children: [
3202
+ /* @__PURE__ */ jsxs11("div", { className: "text-muted-foreground", children: [
388
3203
  "audit ",
389
3204
  audit.id.slice(0, 8),
390
3205
  " \xB7 ",
@@ -392,7 +3207,7 @@ function AuditLine({ audit }) {
392
3207
  " \xB7 by ",
393
3208
  audit.actor ?? "unknown"
394
3209
  ] }),
395
- changes.map((c) => /* @__PURE__ */ jsxs("div", { children: [
3210
+ changes.map((c) => /* @__PURE__ */ jsxs11("div", { children: [
396
3211
  c.field,
397
3212
  ": ",
398
3213
  c.before,
@@ -401,57 +3216,6 @@ function AuditLine({ audit }) {
401
3216
  ] }, c.field))
402
3217
  ] });
403
3218
  }
404
-
405
- // src/router.ts
406
- import { useCallback as useCallback2, useEffect as useEffect2, useMemo as useMemo2, useState as useState2 } from "react";
407
- var PARAM = { resource: "table", page: "page", sort: "sort", search: "q" };
408
- function locationFromSearchParams(params) {
409
- const page = Number(params.get(PARAM.page));
410
- return {
411
- ...params.get(PARAM.resource) ? { resource: params.get(PARAM.resource) } : {},
412
- ...Number.isInteger(page) && page > 1 ? { page } : {},
413
- ...params.get(PARAM.sort) ? { sort: params.get(PARAM.sort) } : {},
414
- ...params.get(PARAM.search) ? { search: params.get(PARAM.search) } : {}
415
- };
416
- }
417
- function searchParamsFromLocation(location, base) {
418
- const params = new URLSearchParams(base);
419
- for (const name of Object.values(PARAM)) params.delete(name);
420
- if (location.resource) params.set(PARAM.resource, location.resource);
421
- if (location.page && location.page > 1) params.set(PARAM.page, String(location.page));
422
- if (location.sort) params.set(PARAM.sort, location.sort);
423
- if (location.search) params.set(PARAM.search, location.search);
424
- return params;
425
- }
426
- function createSearchParamsRouter(params, setParams) {
427
- return {
428
- location: locationFromSearchParams(params),
429
- navigate: (next, options) => setParams(searchParamsFromLocation(next, params), { replace: options?.replace ?? false })
430
- };
431
- }
432
- function useHistoryRouter() {
433
- const read = () => typeof window === "undefined" ? {} : locationFromSearchParams(new URLSearchParams(window.location.search));
434
- const [location, setLocation] = useState2(read);
435
- useEffect2(() => {
436
- const onPop = () => setLocation(read());
437
- window.addEventListener("popstate", onPop);
438
- return () => window.removeEventListener("popstate", onPop);
439
- }, []);
440
- const navigate = useCallback2((next, options) => {
441
- const params = searchParamsFromLocation(next, new URLSearchParams(window.location.search));
442
- const query = params.toString();
443
- const url = `${window.location.pathname}${query ? `?${query}` : ""}${window.location.hash}`;
444
- if (options?.replace) window.history.replaceState(window.history.state, "", url);
445
- else window.history.pushState(window.history.state, "", url);
446
- setLocation(next);
447
- }, []);
448
- return useMemo2(() => ({ location, navigate }), [location, navigate]);
449
- }
450
- function useMemoryRouter(initial = {}) {
451
- const [location, setLocation] = useState2(initial);
452
- const navigate = useCallback2((next) => setLocation(next), []);
453
- return useMemo2(() => ({ location, navigate }), [location, navigate]);
454
- }
455
3219
  export {
456
3220
  DataStudio,
457
3221
  createSearchParamsRouter,