@marktiderman/genesis-studio 0.1.0 → 0.2.1

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