@marktiderman/genesis-studio 0.0.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 ADDED
@@ -0,0 +1,3227 @@
1
+ "use client";
2
+
3
+ // src/DataStudio.tsx
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
+ 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";
622
+ import {
623
+ encodeRecordKey,
624
+ isDataWriteError
625
+ } from "@marktiderman/genesis-core/schema";
626
+ import { Textarea as Textarea2 } from "@marktiderman/genesis-ui/textarea";
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";
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
+ }
1854
+
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));
1870
+ }
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
+ ] });
1975
+ }
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
+ ] });
2129
+ }
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
+ );
2380
+ }
2381
+
2382
+ // src/DataStudio.tsx
2383
+ import { Fragment as Fragment4, jsx as jsx12, jsxs as jsxs11 } from "react/jsx-runtime";
2384
+ var PER_PAGE = 50;
2385
+ var SEARCH_DEBOUNCE_MS = 300;
2386
+ function isUnauthenticated(error) {
2387
+ return isDataWriteError5(error) && error.outcome === "unauthenticated";
2388
+ }
2389
+ function errorText(error) {
2390
+ return error instanceof Error ? error.message : String(error);
2391
+ }
2392
+ function DataStudio(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 }) }) });
2400
+ }
2401
+ function StudioWorkspace({
2402
+ provider,
2403
+ router,
2404
+ signIn,
2405
+ sessionKey = null,
2406
+ title = "Data",
2407
+ variant = "card",
2408
+ perPage = PER_PAGE,
2409
+ forms,
2410
+ className
2411
+ }) {
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);
2421
+ setActivity([]);
2422
+ void queryClient.resetQueries({ queryKey: ["genesis-studio"] });
2423
+ }, [sessionKey, queryClient]);
2424
+ const resources = useQuery5({
2425
+ queryKey: ["genesis-studio", sessionKey, "resources"],
2426
+ queryFn: () => {
2427
+ if (!provider.listResources) throw new Error("This data provider can't list its resources.");
2428
+ return provider.listResources();
2429
+ },
2430
+ retry: (count, error) => !isUnauthenticated(error) && count < 2
2431
+ });
2432
+ useEffect5(() => {
2433
+ if (isUnauthenticated(resources.error)) setSignedOut(true);
2434
+ }, [resources.error]);
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) => {
2527
+ setActivity(
2528
+ (prev) => [{ ...entry, id: `${Date.now()}-${Math.random().toString(36).slice(2)}`, at: /* @__PURE__ */ new Date() }, ...prev].slice(0, 50)
2529
+ );
2530
+ }, []);
2531
+ if (signedOut) {
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." }) });
2533
+ }
2534
+ const selected = router.location.resource;
2535
+ const list = resources.data ?? [];
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,
2549
+ {
2550
+ provider,
2551
+ resource: selected,
2552
+ sessionKey,
2553
+ router,
2554
+ perPage,
2555
+ onActivity: record,
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
+ }
2562
+ },
2563
+ selected
2564
+ ) : /* @__PURE__ */ jsxs11(
2565
+ "div",
2566
+ {
2567
+ className: "flex min-h-[320px] flex-col items-center justify-center gap-2 p-8 text-center text-muted-foreground",
2568
+ "data-testid": "studio-empty",
2569
+ children: [
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." })
2572
+ ]
2573
+ }
2574
+ ) })
2575
+ ] }),
2576
+ /* @__PURE__ */ jsx12(ActivityPanel, { entries: activity, onUndo: provider.undo ? (entry) => void undo(entry) : void 0 })
2577
+ ] });
2578
+ }
2579
+ function Sheet2({
2580
+ provider,
2581
+ resource,
2582
+ sessionKey,
2583
+ router,
2584
+ perPage,
2585
+ onActivity,
2586
+ onSignedOut,
2587
+ formConfig,
2588
+ onUndoLatest
2589
+ }) {
2590
+ const queryClient = useQueryClient2();
2591
+ const { location } = router;
2592
+ const page = location.page ?? 1;
2593
+ const sorts = useMemo8(() => parseSorts(location.sort), [location.sort]);
2594
+ const filters = useMemo8(() => parseFilters(location.filters), [location.filters]);
2595
+ const search = location.search ?? "";
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({
2627
+ queryKey: ["genesis-studio", sessionKey, "describe", resource],
2628
+ queryFn: () => {
2629
+ if (!provider.describe) throw new Error("This data provider can't describe its resources.");
2630
+ return provider.describe(resource);
2631
+ },
2632
+ retry: false
2633
+ });
2634
+ const schema = schemaQuery.data;
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]
2644
+ );
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({
2657
+ queryKey: listKey,
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,
2661
+ retry: false,
2662
+ queryFn: () => provider.getList(resource, {
2663
+ pagination: { page, perPage },
2664
+ sort: effectiveSorts[0],
2665
+ sorts: effectiveSorts,
2666
+ filters,
2667
+ search: search ? { query: search, fields: [] } : void 0
2668
+ })
2669
+ });
2670
+ const failure = schemaQuery.error ?? listQuery.error;
2671
+ useEffect5(() => {
2672
+ if (isUnauthenticated(failure)) onSignedOut();
2673
+ }, [failure, onSignedOut]);
2674
+ const searchTimer = useRef(void 0);
2675
+ useEffect5(() => () => clearTimeout(searchTimer.current), []);
2676
+ const navigate = useCallback2(
2677
+ (patch, replace = false) => router.navigate({ ...location, resource, ...patch }, { replace }),
2678
+ [router, location, resource]
2679
+ );
2680
+ const rows = listQuery.data?.data ?? [];
2681
+ const keys = useMemo8(() => {
2682
+ const map = /* @__PURE__ */ new Map();
2683
+ rows.forEach((row, index) => map.set(row, schema && keyed ? String(encodeRecordKey5(schema, row)) : `row-${index}`));
2684
+ return map;
2685
+ }, [rows, schema, keyed]);
2686
+ const linkLabels = usePageLinkLabels(provider, sessionKey, schema?.fields ?? [], rows);
2687
+ const replaceRow = useCallback2(
2688
+ (oldRow, next) => {
2689
+ queryClient.setQueryData(
2690
+ listKey,
2691
+ (prev) => prev ? { ...prev, data: prev.data.map((r) => r === oldRow ? next : r) } : prev
2692
+ );
2693
+ },
2694
+ [queryClient, listKey]
2695
+ );
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]
2703
+ );
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
+ }
2749
+ if (schemaQuery.isError && !isUnauthenticated(schemaQuery.error)) {
2750
+ return /* @__PURE__ */ jsxs11(Alert2, { variant: "destructive", "data-testid": "studio-describe-error", children: [
2751
+ /* @__PURE__ */ jsxs11(AlertTitle2, { children: [
2752
+ "Couldn't open ",
2753
+ resource
2754
+ ] }),
2755
+ /* @__PURE__ */ jsx12(AlertDescription2, { children: errorText(schemaQuery.error) })
2756
+ ] });
2757
+ }
2758
+ const editable = schema?.fields.filter((f) => f.permissions?.update).length ?? 0;
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
+ ] })
3050
+ ] }),
3051
+ schema && location.record && /* @__PURE__ */ jsx12(
3052
+ RecordPanel,
3053
+ {
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 } : {} });
3064
+ },
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 })
3086
+ }
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
+ ] }) })
3143
+ ] });
3144
+ }
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 })
3170
+ ] }),
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
+ )
3189
+ ] }),
3190
+ /* @__PURE__ */ jsx12("p", { className: "mt-0.5 text-muted-foreground", children: entry.message }),
3191
+ entry.audit && /* @__PURE__ */ jsx12(AuditLine, { audit: entry.audit })
3192
+ ] }, entry.id)) })
3193
+ ] });
3194
+ }
3195
+ function AuditLine({ audit }) {
3196
+ const changes = Object.keys(audit.after ?? {}).map((field) => ({
3197
+ field,
3198
+ before: displayText(audit.before?.[field]) ?? "\u2014",
3199
+ after: displayText(audit.after?.[field]) ?? "\u2014"
3200
+ }));
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: [
3203
+ "audit ",
3204
+ audit.id.slice(0, 8),
3205
+ " \xB7 ",
3206
+ audit.occurredAt,
3207
+ " \xB7 by ",
3208
+ audit.actor ?? "unknown"
3209
+ ] }),
3210
+ changes.map((c) => /* @__PURE__ */ jsxs11("div", { children: [
3211
+ c.field,
3212
+ ": ",
3213
+ c.before,
3214
+ " \u2192 ",
3215
+ c.after
3216
+ ] }, c.field))
3217
+ ] });
3218
+ }
3219
+ export {
3220
+ DataStudio,
3221
+ createSearchParamsRouter,
3222
+ locationFromSearchParams,
3223
+ searchParamsFromLocation,
3224
+ useHistoryRouter,
3225
+ useMemoryRouter
3226
+ };
3227
+ //# sourceMappingURL=index.js.map