lowlander 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +43 -67
  2. package/build/client/client.d.ts +8 -1
  3. package/build/client/client.js +39 -22
  4. package/build/client/client.js.map +1 -1
  5. package/build/dashboard/client/crud.d.ts +16 -0
  6. package/build/dashboard/client/crud.js +525 -0
  7. package/build/dashboard/client/crud.js.map +1 -0
  8. package/build/dashboard/client/main.js +238 -246
  9. package/build/dashboard/client/main.js.map +1 -1
  10. package/build/dashboard/dashboard.html +8 -8
  11. package/build/dashboard/server.d.ts +20 -9
  12. package/build/dashboard/server.d.ts.map +1 -1
  13. package/build/dashboard/server.js +139 -3
  14. package/build/dashboard/server.js.map +1 -1
  15. package/build/examples/helloworld/.edinburgh/commit_worker.log +1765 -0
  16. package/build/examples/helloworld/.edinburgh/data.mdb +0 -0
  17. package/build/examples/helloworld/.edinburgh/lock.mdb +0 -0
  18. package/build/examples/helloworld/client/assets/style.css +0 -45
  19. package/build/examples/helloworld/client/index.html +2 -13
  20. package/build/examples/helloworld/client/js/base.d.ts +1 -4
  21. package/build/examples/helloworld/client/js/base.js +8 -217
  22. package/build/examples/helloworld/client/js/base.js.map +1 -1
  23. package/build/examples/helloworld/server/api.d.ts +4 -0
  24. package/build/examples/helloworld/server/api.d.ts.map +1 -1
  25. package/build/examples/helloworld/server/api.js +10 -0
  26. package/build/examples/helloworld/server/api.js.map +1 -1
  27. package/build/server/server.d.ts +3 -3
  28. package/build/server/server.d.ts.map +1 -1
  29. package/build/server/server.js +44 -5
  30. package/build/server/server.js.map +1 -1
  31. package/build/tsconfig.client.tsbuildinfo +1 -1
  32. package/build/tsconfig.server.tsbuildinfo +1 -1
  33. package/client/client.ts +41 -23
  34. package/dashboard/build-bundle.ts +8 -2
  35. package/dashboard/client/crud.ts +634 -0
  36. package/dashboard/client/main.ts +234 -246
  37. package/dashboard/server.ts +149 -5
  38. package/package.json +9 -5
  39. package/server/server.ts +43 -8
  40. package/skill/SKILL.md +30 -47
  41. package/skill/Connection_pruneCommitIds.md +0 -8
@@ -0,0 +1,634 @@
1
+ /**
2
+ * CRUD row editor for the Lowlander dashboard.
3
+ *
4
+ * Provides openEditModal, openCreateModal, and openDeleteConfirm, each of
5
+ * which opens a Staffa modal to let the user create, edit or delete a record.
6
+ * Field editors are chosen recursively based on Edinburgh TypeInfo descriptors.
7
+ */
8
+ import A from 'aberdeen';
9
+ import S from 'staffa';
10
+ import type { ClientProxyObject } from 'lowlander/client';
11
+ import type { TypeInfo, DashboardAPI } from '../server.js';
12
+
13
+ // ─── Types ───────────────────────────────────────────────────────────────────
14
+
15
+ type ServerProxy = { serverProxy: ClientProxyObject<DashboardAPI> };
16
+
17
+ export interface FieldInfo {
18
+ name: string;
19
+ type: TypeInfo;
20
+ description?: string;
21
+ hasDefault: boolean;
22
+ isPk: boolean;
23
+ }
24
+
25
+ // ─── Value conversion helpers ─────────────────────────────────────────────
26
+
27
+ /**
28
+ * Convert a display-serialised value (from serializeValue on the server) into
29
+ * an editor-friendly form. The editor sends these back to the server, which
30
+ * runs parseValueFromJson to reconstruct the Edinburgh value.
31
+ */
32
+ function toEditValue(type: TypeInfo, value: any): any {
33
+ if (value === null || value === undefined) return null;
34
+ switch (type.kind) {
35
+ case 'dateTime':
36
+ // ISO → datetime-local (slice to "YYYY-MM-DDTHH:MM")
37
+ if (typeof value === 'string') return value.slice(0, 16);
38
+ return value;
39
+ case 'link':
40
+ // { __ref, pk } → just pk (server knows the type)
41
+ return value?.__ref != null ? value.pk : value;
42
+ case 'array':
43
+ case 'set':
44
+ if (!Array.isArray(value)) return [];
45
+ return value.map((v: any) => toEditValue(type.inner!, v));
46
+ case 'record':
47
+ if (!value || typeof value !== 'object') return {};
48
+ const out: Record<string, any> = {};
49
+ for (const [k, v] of Object.entries(value)) out[k] = toEditValue(type.innerValue!, v);
50
+ return out;
51
+ case 'or':
52
+ if (type.isOptional && type.inner) {
53
+ return value === null ? null : toEditValue(type.inner, value);
54
+ }
55
+ return value;
56
+ default:
57
+ return value;
58
+ }
59
+ }
60
+
61
+ /** Default edit value for a type when creating a new record. */
62
+ function defaultEditValue(type: TypeInfo): any {
63
+ if (type.isOptional) return null;
64
+ switch (type.kind) {
65
+ case 'string': return '';
66
+ case 'number': return 0;
67
+ case 'boolean': return false;
68
+ case 'dateTime': return new Date().toISOString().slice(0, 16);
69
+ case 'array':
70
+ case 'set': return [];
71
+ case 'record': return {};
72
+ case 'link': return null;
73
+ case 'or':
74
+ case 'literal': return null;
75
+ default: return '';
76
+ }
77
+ }
78
+
79
+ // ─── Inline field editors ─────────────────────────────────────────────────
80
+
81
+ /**
82
+ * Draw the standard Staffa field chrome (a `.s-field` wrapper with a label)
83
+ * around a caller-supplied control. Composite editors (link/collection/record/
84
+ * optional/enum) use this so their labels match Staffa's own form fields.
85
+ *
86
+ * Staffa doesn't export its internal `drawField`, but the `.s-field` CSS class
87
+ * (and its `> label` rule) is registered globally, so reusing the class gives us
88
+ * the exact same styling.
89
+ */
90
+ function drawFieldChrome(label: string | undefined, drawControl: () => void) {
91
+ A('div.s-field', () => {
92
+ if (label != null) A('label', () => A('#', label));
93
+ drawControl();
94
+ });
95
+ }
96
+
97
+ /**
98
+ * Render an appropriate input widget for the given TypeInfo.
99
+ * $bind.value holds/receives the edit-format value.
100
+ */
101
+ function renderFieldEditor(
102
+ type: TypeInfo,
103
+ $bind: { value: any },
104
+ proxy: ServerProxy,
105
+ label?: string,
106
+ readOnly = false,
107
+ ) {
108
+ // Unwrap opt(T) → checkbox + inner editor
109
+ if (type.isOptional && type.inner) {
110
+ renderOptionalEditor(type.inner, $bind, proxy, label, readOnly);
111
+ return;
112
+ }
113
+
114
+ switch (type.kind) {
115
+ case 'string':
116
+ S.textline({ label, disabled: readOnly, bind: $bind });
117
+ return;
118
+
119
+ case 'id':
120
+ S.textline({ label, disabled: true, bind: $bind,
121
+ help: readOnly ? 'auto-generated' : undefined });
122
+ return;
123
+
124
+ case 'number':
125
+ S.textline({ label, type: 'number', disabled: readOnly, bind: $bind });
126
+ return;
127
+
128
+ case 'boolean':
129
+ S.checkbox({ label: label ?? 'Yes', disabled: readOnly, bind: $bind });
130
+ return;
131
+
132
+ case 'dateTime':
133
+ S.textline({ label, type: 'datetime-local', disabled: readOnly, bind: $bind });
134
+ return;
135
+
136
+ case 'link':
137
+ renderLinkEditor(type, $bind, proxy, label, readOnly);
138
+ return;
139
+
140
+ case 'array':
141
+ case 'set':
142
+ renderCollectionEditor(type, $bind, proxy, label, readOnly);
143
+ return;
144
+
145
+ case 'record':
146
+ renderRecordEditor(type, $bind, proxy, label, readOnly);
147
+ return;
148
+
149
+ case 'or': {
150
+ // A union of literals is an enum → render as a segmented chooser.
151
+ const choices = type.choices ?? [];
152
+ if (choices.length && choices.every(c => c.kind === 'literal')) {
153
+ renderEnumEditor(choices, $bind, label, readOnly);
154
+ return;
155
+ }
156
+ // General union: fall back to raw JSON textarea
157
+ renderJsonEditor($bind, label, readOnly);
158
+ return;
159
+ }
160
+
161
+ case 'literal':
162
+ // Read-only — show value as disabled text
163
+ S.textline({ label, disabled: true, value: type.literalValue ?? '' });
164
+ return;
165
+
166
+ default:
167
+ renderJsonEditor($bind, label, readOnly);
168
+ }
169
+ }
170
+
171
+ function renderOptionalEditor(
172
+ innerType: TypeInfo,
173
+ $bind: { value: any },
174
+ proxy: ServerProxy,
175
+ label?: string,
176
+ readOnly = false,
177
+ ) {
178
+ // $mode ('set' | 'undefined') tracks enabled state independently from $bind so
179
+ // the inner editor scope doesn't re-run (and lose focus) on every value change.
180
+ // Peek the initial value so the enclosing scope doesn't subscribe to it.
181
+ const initiallySet = A.peek(() => $bind.value !== null && $bind.value !== undefined);
182
+ const $mode = A.proxy({ v: initiallySet ? 'set' : 'undefined' });
183
+
184
+ // One-way sync: $mode.v → $bind.value.
185
+ // Uses A.peek to read $bind.value without subscribing (avoids circular loop).
186
+ A(() => {
187
+ if ($mode.v === 'set') {
188
+ if (A.peek(() => $bind.value) === null || A.peek(() => $bind.value) === undefined) {
189
+ $bind.value = defaultEditValue(innerType);
190
+ }
191
+ } else {
192
+ $bind.value = null;
193
+ }
194
+ });
195
+
196
+ drawFieldChrome(label, () => {
197
+ S.buttonChooser({
198
+ attrs: readOnly ? 'pointer-events:none opacity:0.6' : undefined,
199
+ options: { set: 'set', undefined: 'undefined' },
200
+ bind: A.ref($mode, 'v'),
201
+ });
202
+ // Inner editor: depends on $mode.v, NOT on $bind.value — so typing
203
+ // in a text field inside the optional editor won't recreate the DOM.
204
+ A(() => {
205
+ if ($mode.v !== 'set') return;
206
+ renderFieldEditor(innerType, $bind, proxy, undefined, readOnly);
207
+ });
208
+ });
209
+ }
210
+
211
+ /**
212
+ * Render a union of literal values (an enum) as a segmented buttonChooser.
213
+ * Each literal's JSON-serialised form (TypeInfo.literalValue) is used as the
214
+ * button id; the `undefined` literal becomes a "(none)" choice.
215
+ */
216
+ function renderEnumEditor(
217
+ choices: TypeInfo[],
218
+ $bind: { value: any },
219
+ label?: string,
220
+ readOnly = false,
221
+ ) {
222
+ const idForValue = (v: any): string | null =>
223
+ v === null || v === undefined ? 'undefined' : JSON.stringify(v);
224
+ const valueForId = (id: string | null): any =>
225
+ id === null || id === 'undefined' ? null : JSON.parse(id);
226
+
227
+ const options: Record<string, string> = {};
228
+ let hasNone = false;
229
+ for (const c of choices) {
230
+ const id = c.literalValue ?? 'undefined';
231
+ if (id === 'undefined') { hasNone = true; options[id] = '(none)'; }
232
+ else options[id] = String(valueForId(id));
233
+ }
234
+
235
+ const $sel = A.proxy({ v: idForValue(A.peek(() => $bind.value)) });
236
+
237
+ // One-way: $sel.v → $bind.value (reads $sel only, so no circular loop).
238
+ A(() => { $bind.value = valueForId($sel.v); });
239
+
240
+ drawFieldChrome(label, () => {
241
+ S.buttonChooser({
242
+ attrs: readOnly ? 'pointer-events:none opacity:0.6' : undefined,
243
+ options,
244
+ bind: A.ref($sel, 'v'),
245
+ // Without an explicit "(none)" choice, allow clearing back to null.
246
+ allowDeselect: !hasNone,
247
+ });
248
+ });
249
+ }
250
+
251
+ function renderLinkEditor(
252
+ type: TypeInfo,
253
+ $bind: { value: any },
254
+ proxy: ServerProxy,
255
+ label?: string,
256
+ readOnly = false,
257
+ ) {
258
+ const linkedModel = type.linkedModel;
259
+ if (!linkedModel) {
260
+ renderJsonEditor($bind, label, readOnly);
261
+ return;
262
+ }
263
+
264
+ // The autocomplete needs a real proxy Bindable holding a string. We keep a
265
+ // display proxy ($acStr) and one-way sync it back to $bind (string → pk).
266
+ const initial = A.peek(() => $bind.value);
267
+ const $acStr = A.proxy({ v: initial === null || initial === undefined ? '' : String(initial) });
268
+
269
+ // One-way: $acStr.v → $bind.value. Reads $acStr only (writes $bind), no loop.
270
+ A(() => {
271
+ const s = $acStr.v;
272
+ if (s === '') {
273
+ $bind.value = null;
274
+ } else {
275
+ const asNum = Number(s);
276
+ $bind.value = !isNaN(asNum) && !/[^0-9.\-]/.test(s) ? asNum : s;
277
+ }
278
+ });
279
+
280
+ // Load candidate records ONCE into a stable proxy. The outer scope has no
281
+ // reactive dependencies, so findRecords runs a single time; the inner scope
282
+ // copies the rows over once the RPC resolves. (Calling findRecords directly
283
+ // inside the autocomplete's reactive options() refetched on every settle and
284
+ // never produced a stable result — hence the perpetual "No matches".)
285
+ const $opts = A.proxy({ rows: [] as any[] });
286
+ A(() => {
287
+ const result = proxy.serverProxy.findRecords(linkedModel, '(primary)', { limit: 100 });
288
+ A(() => { if (result.value) $opts.rows = result.value.rows; });
289
+ });
290
+
291
+ S.autocomplete({
292
+ label,
293
+ disabled: readOnly,
294
+ allowCustom: false,
295
+ placeholder: `Search ${linkedModel}…`,
296
+ bind: A.ref($acStr, 'v'),
297
+ // The autocomplete filters these client-side by label as the user types.
298
+ options: () => ($opts.rows as any[]).map((row: any) => ({
299
+ value: jsonStringify(row.pk),
300
+ label: jsonStringify(row.pk),
301
+ })),
302
+ });
303
+ }
304
+
305
+ function renderCollectionEditor(
306
+ type: TypeInfo,
307
+ $bind: { value: any },
308
+ proxy: ServerProxy,
309
+ label?: string,
310
+ readOnly = false,
311
+ ) {
312
+ const innerType = type.inner!;
313
+
314
+ // Build the local state WITHOUT subscribing the enclosing scope. Every read
315
+ // here (the initial value, its elements, the new proxy's length) must happen
316
+ // inside A.peek — otherwise, when this editor renders inside a reactive scope
317
+ // such as S.form's content, reading $items.length would subscribe that scope
318
+ // and pushing/removing items would re-run it, wiping this local state.
319
+ const [$items, $len] = A.peek(() => {
320
+ const initVal = $bind.value;
321
+ const initArr: any[] = Array.isArray(initVal) ? initVal : [];
322
+ // Stable per-item proxies: the item editors write to $items[i].v, which
323
+ // syncs back to $bind.value via a one-way reactive block.
324
+ const items = A.proxy(initArr.map((v: any) => ({ v })));
325
+ // Count proxy: drives add/remove re-renders without coupling to item values.
326
+ const len = A.proxy({ v: (items as any[]).length });
327
+ return [items, len] as const;
328
+ });
329
+
330
+ // One-way sync: item proxies → $bind.value (never reads $bind.value so no loop)
331
+ A(() => { $bind.value = ($items as any[]).map((item: any) => item.v); });
332
+
333
+ drawFieldChrome(label, () => {
334
+ A('div.s-s.raised', 'display:flex flex-direction:column gap:$2 p:$2 r:$s-radius-lg border: 1px solid $s-border;', () => {
335
+ A(() => {
336
+ const len: number = $len.v;
337
+ if (len === 0) {
338
+ A('span', 'fg:$s-fg-faint font-size:0.9em', '#(empty)');
339
+ }
340
+ for (let i = 0; i < len; i++) {
341
+ const idx = i;
342
+ A('div', 'display:flex gap:$2 align-items:flex-start', () => {
343
+ A('div', 'flex:1', () => {
344
+ // A.ref gives Aberdeen's bind= an actual proxy Bindable
345
+ const $item = A.ref(($items as any)[idx], 'v');
346
+ renderFieldEditor(innerType, $item, proxy, undefined, readOnly);
347
+ });
348
+ if (!readOnly) {
349
+ S.button({
350
+ text: '×',
351
+ attrs: '.outlined.danger .small flex-shrink:0 mt:$3',
352
+ click: () => {
353
+ ($items as any[]).splice(idx, 1);
354
+ $len.v = ($items as any[]).length;
355
+ },
356
+ });
357
+ }
358
+ });
359
+ }
360
+ });
361
+
362
+ if (!readOnly) {
363
+ S.button({
364
+ text: `+ Add ${innerType.display}`,
365
+ attrs: '.tonal .small',
366
+ click: () => {
367
+ ($items as any[]).push({ v: defaultEditValue(innerType) });
368
+ $len.v = ($items as any[]).length;
369
+ },
370
+ });
371
+ }
372
+ });
373
+ });
374
+ }
375
+
376
+ function renderRecordEditor(
377
+ type: TypeInfo,
378
+ $bind: { value: any },
379
+ proxy: ServerProxy,
380
+ label?: string,
381
+ readOnly = false,
382
+ ) {
383
+ const valueType = type.innerValue!;
384
+
385
+ // Build local state without subscribing the enclosing scope (see
386
+ // renderCollectionEditor for why every read must be inside A.peek).
387
+ const [$entries, $eLen] = A.peek(() => {
388
+ const initVal = $bind.value;
389
+ const initObj: Record<string, any> =
390
+ initVal && typeof initVal === 'object' && !Array.isArray(initVal) ? initVal : {};
391
+ // Stable per-entry proxies (same pattern as renderCollectionEditor).
392
+ const entries = A.proxy(Object.entries(initObj).map(([k, v]) => ({ k, v })));
393
+ const len = A.proxy({ v: (entries as any[]).length });
394
+ return [entries, len] as const;
395
+ });
396
+
397
+ // Entries with a blank key are skipped; on duplicate keys the last one wins.
398
+ A(() => {
399
+ const obj: Record<string, any> = {};
400
+ for (const e of ($entries as any[])) {
401
+ if (e.k === '') continue;
402
+ obj[e.k] = e.v;
403
+ }
404
+ $bind.value = obj;
405
+ });
406
+
407
+ drawFieldChrome(label, () => {
408
+ A('div.s-s.raised', 'display:flex flex-direction:column gap:$4 p:$2 r:$s-radius-lg border: 1px solid $s-border;', () => {
409
+ A(() => {
410
+ const len: number = $eLen.v;
411
+ if (len === 0) A('span', 'fg:$s-fg-faint font-size:0.9em', '#(empty)');
412
+ for (let i = 0; i < len; i++) {
413
+ const idx = i;
414
+ A('div', 'display:flex flex-direction:column gap:$1', () => {
415
+ // Key row: key field + delete button
416
+ A('div', 'display:flex gap:$2 align-items:center', () => {
417
+ if (readOnly) {
418
+ A('code', 'flex:1 font-size:0.85em fg:$s-fg-muted', '#', A.peek(() => ($entries as any)[idx].k));
419
+ } else {
420
+ A('div', 'flex:1', () => {
421
+ S.textline({ placeholder: 'key', bind: A.ref(($entries as any)[idx], 'k') });
422
+ });
423
+ }
424
+ if (!readOnly) {
425
+ S.button({
426
+ text: '×', attrs: '.outlined.danger .small flex-shrink:0', click: () => {
427
+ ($entries as any[]).splice(idx, 1);
428
+ $eLen.v = ($entries as any[]).length;
429
+ }});
430
+ }
431
+ });
432
+ // Value row
433
+ A('div', () => {
434
+ const $item = A.ref(($entries as any)[idx], 'v');
435
+ renderFieldEditor(valueType, $item, proxy, undefined, readOnly);
436
+ });
437
+ });
438
+ }
439
+ });
440
+
441
+ if (!readOnly) {
442
+ S.button({
443
+ text: '+ Add entry',
444
+ attrs: '.tonal .small',
445
+ click: () => {
446
+ ($entries as any[]).push({ k: '', v: defaultEditValue(valueType) });
447
+ $eLen.v = ($entries as any[]).length;
448
+ },
449
+ });
450
+ }
451
+ });
452
+ });
453
+ }
454
+
455
+ function renderJsonEditor($bind: { value: any }, label?: string, readOnly = false) {
456
+ const initial = A.peek(() => $bind.value);
457
+ const $str = A.proxy({ v: initial === undefined ? '' : jsonStringify(initial) });
458
+ const $error = A.proxy({ v: '' });
459
+
460
+ // One-way: $str.v → $bind.value. Writes $bind only, so no circular loop.
461
+ A(() => {
462
+ const s = $str.v;
463
+ if (s.trim() === '') { $bind.value = undefined; $error.v = ''; return; }
464
+ try {
465
+ $bind.value = JSON.parse(s);
466
+ $error.v = '';
467
+ } catch {
468
+ $error.v = 'Invalid JSON';
469
+ }
470
+ });
471
+
472
+ // Textarea binds to a real proxy ref; error shown in its own scope so the
473
+ // textarea is never recreated (which would lose focus on each keystroke).
474
+ S.textarea({ label, disabled: readOnly, rows: 3, bind: A.ref($str, 'v') });
475
+ A(() => {
476
+ if ($error.v) A('div', 'fg:$s-danger font-size:0.82em mt:$1', '#', $error.v);
477
+ });
478
+ }
479
+
480
+ // ─── Public API ─────────────────────────────────────────────────────────────
481
+
482
+ function openFormDialog(
483
+ proxy: ServerProxy,
484
+ header: string,
485
+ fields: FieldInfo[],
486
+ initValue: (f: FieldInfo) => any,
487
+ isReadOnly: (f: FieldInfo) => boolean,
488
+ submitText: string,
489
+ onSubmit: (boxes: Record<string, { v: any }>, close: () => void) => Promise<void>,
490
+ ) {
491
+ const boxes: Record<string, { v: any }> = {};
492
+ for (const f of fields) {
493
+ boxes[f.name] = A.proxy({ v: initValue(f) });
494
+ }
495
+
496
+ const $status = A.proxy({ saving: false });
497
+
498
+ S.dialog({
499
+ header,
500
+ attrs: "width:800px",
501
+ content: (close) => {
502
+ S.form({
503
+ content: () => {
504
+ for (const f of fields) {
505
+ renderFieldEditor(f.type, A.ref(boxes[f.name]!, 'v'), proxy, f.name, isReadOnly(f));
506
+ }
507
+ },
508
+ layout: "grid",
509
+ actions: () => {
510
+ S.button({ text: 'Cancel', attrs: '.outlined.neutral', click: close });
511
+ A(() => {
512
+ S.button({
513
+ text: submitText,
514
+ type: 'button',
515
+ disabled: $status.saving,
516
+ click: async () => {
517
+ $status.saving = true;
518
+ try {
519
+ await onSubmit(boxes, close);
520
+ } catch (err: any) {
521
+ S.toast({ message: err?.message ?? String(err), type: 'danger' });
522
+ } finally {
523
+ $status.saving = false;
524
+ }
525
+ },
526
+ });
527
+ });
528
+ },
529
+ });
530
+ },
531
+ });
532
+ }
533
+
534
+ export function openCreateModal(
535
+ proxy: ServerProxy,
536
+ modelName: string,
537
+ fields: FieldInfo[],
538
+ onCreated?: (pk: any) => void,
539
+ ) {
540
+ const editableFields = fields.filter(f => f.type.kind !== 'id');
541
+ openFormDialog(
542
+ proxy,
543
+ `New ${modelName}`,
544
+ editableFields,
545
+ (f) => defaultEditValue(f.type),
546
+ () => false,
547
+ 'Create',
548
+ async (boxes, close) => {
549
+ const payload: Record<string, any> = {};
550
+ for (const f of editableFields) payload[f.name] = boxes[f.name]!.v;
551
+ const pk = await proxy.serverProxy.createRecord(modelName, payload).promise;
552
+ close();
553
+ onCreated?.(pk);
554
+ },
555
+ );
556
+ }
557
+
558
+ export function openEditModal(
559
+ proxy: ServerProxy,
560
+ modelName: string,
561
+ fields: FieldInfo[],
562
+ pk: any,
563
+ currentValues: Record<string, any>,
564
+ onSaved?: () => void,
565
+ ) {
566
+ openFormDialog(
567
+ proxy,
568
+ `Edit ${modelName}`,
569
+ fields,
570
+ (f) => toEditValue(f.type, currentValues[f.name]),
571
+ (f) => f.isPk,
572
+ 'Save',
573
+ async (boxes, close) => {
574
+ const payload: Record<string, any> = {};
575
+ for (const f of fields) {
576
+ if (!f.isPk) payload[f.name] = boxes[f.name]!.v;
577
+ }
578
+ await proxy.serverProxy.updateRecord(modelName, pk, payload).promise;
579
+ close();
580
+ onSaved?.();
581
+ },
582
+ );
583
+ }
584
+
585
+ export function openDeleteConfirm(
586
+ proxy: ServerProxy,
587
+ modelName: string,
588
+ pk: any,
589
+ displayLabel: string,
590
+ onDeleted?: () => void,
591
+ ) {
592
+ const $status = A.proxy({ deleting: false, error: '' });
593
+
594
+ S.dialog({
595
+ header: `Delete ${modelName}`,
596
+ content: (close) => {
597
+ A('p', 'fg:$s-fg m:0', () => {
598
+ A('#Delete ');
599
+ A('strong', '#', displayLabel);
600
+ A('#? This cannot be undone.');
601
+ });
602
+ A(() => {
603
+ if ($status.error) A('p', 'fg:$s-danger m:0', '#', $status.error);
604
+ });
605
+ A('div', 'display:flex gap:$2 mt:$3', () => {
606
+ S.button({
607
+ text: 'Delete',
608
+ attrs: '.danger',
609
+ disabled: A.peek(() => $status.deleting),
610
+ click: async () => {
611
+ $status.deleting = true;
612
+ $status.error = '';
613
+ try {
614
+ await proxy.serverProxy.deleteRecord(modelName, pk).promise;
615
+ close();
616
+ onDeleted?.();
617
+ } catch (err: any) {
618
+ $status.error = err?.message ?? String(err);
619
+ $status.deleting = false;
620
+ }
621
+ },
622
+ });
623
+ S.button({ text: 'Cancel', attrs: '.outlined.neutral', click: close });
624
+ });
625
+ },
626
+ });
627
+ }
628
+
629
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
630
+
631
+ function jsonStringify(v: any): string {
632
+ if (typeof v === 'string') return v;
633
+ try { return JSON.stringify(v); } catch { return String(v); }
634
+ }