elysia-rack 0.4.0 → 0.5.2

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/panel-app.js CHANGED
@@ -71,6 +71,72 @@ function fillForm(form, row) {
71
71
  }
72
72
  }
73
73
 
74
+ // src/react/page/components/panelFields.ts
75
+ function isNumericField(field) {
76
+ return field.format === "number" || field.format === "currency" || field.kind === "integer" || field.kind === "number";
77
+ }
78
+ function formatDate(value, withTime) {
79
+ const date = new Date(String(value));
80
+ if (Number.isNaN(date.getTime()))
81
+ return String(value);
82
+ return withTime ? date.toLocaleString("en-US") : date.toLocaleDateString("en-US");
83
+ }
84
+ function displayValue(field, value) {
85
+ if (value === null || value === undefined)
86
+ return "";
87
+ if (field?.relation) {
88
+ const match = field.relation.find((o) => String(o.value) === String(value));
89
+ if (match)
90
+ return match.label;
91
+ }
92
+ switch (field?.format) {
93
+ case "boolean":
94
+ return value === true || value === "true" || value === 1 ? "Yes" : "No";
95
+ case "date":
96
+ return formatDate(value, false);
97
+ case "datetime":
98
+ return formatDate(value, true);
99
+ case "currency": {
100
+ const amount = Number(value);
101
+ if (Number.isNaN(amount))
102
+ return String(value);
103
+ return new Intl.NumberFormat("en-US", {
104
+ style: "currency",
105
+ currency: field.currency ?? "USD"
106
+ }).format(amount);
107
+ }
108
+ case "number":
109
+ return Number.isNaN(Number(value)) ? String(value) : Number(value).toLocaleString("en-US");
110
+ case "json":
111
+ return typeof value === "object" ? JSON.stringify(value) : String(value);
112
+ default:
113
+ return typeof value === "object" ? JSON.stringify(value) : String(value);
114
+ }
115
+ }
116
+ function coerceFieldValue(field, raw) {
117
+ if (!field)
118
+ return { value: raw };
119
+ switch (field.kind) {
120
+ case "integer":
121
+ case "number": {
122
+ const n = Number(raw);
123
+ if (raw.trim() === "" || Number.isNaN(n))
124
+ return { error: `${field.label} must be a number` };
125
+ return { value: n };
126
+ }
127
+ case "boolean":
128
+ return { value: raw === "true" || raw === "1" };
129
+ case "json":
130
+ try {
131
+ return { value: JSON.parse(raw) };
132
+ } catch {
133
+ return { error: `${field.label} is not valid JSON` };
134
+ }
135
+ default:
136
+ return { value: raw };
137
+ }
138
+ }
139
+
74
140
  // src/react/page/client/panel.ts
75
141
  function debounce(fn, wait) {
76
142
  let id;
@@ -82,23 +148,6 @@ function debounce(fn, wait) {
82
148
  function esc(value) {
83
149
  return String(value ?? "").split("&").join("&amp;").split("<").join("&lt;").split(">").join("&gt;").split('"').join("&quot;");
84
150
  }
85
- function cellText(value) {
86
- if (value === null || value === undefined)
87
- return "";
88
- if (typeof value === "object")
89
- return JSON.stringify(value);
90
- return String(value);
91
- }
92
- function badgeClass(col, value) {
93
- if (col !== "status" || typeof value !== "string")
94
- return null;
95
- const text = value.toLowerCase();
96
- if (text === "active")
97
- return "bg-success-muted text-success-muted-foreground";
98
- if (text === "archived")
99
- return "bg-warning-muted text-warning-muted-foreground";
100
- return "bg-badge-background text-badge-foreground";
101
- }
102
151
  function toParams(sp) {
103
152
  const out = {};
104
153
  sp.forEach((v, k) => {
@@ -122,11 +171,87 @@ function setErr(form, msg) {
122
171
  if (el)
123
172
  el.textContent = msg;
124
173
  }
174
+ function setBusy(el, busy) {
175
+ if (!el)
176
+ return;
177
+ el.disabled = busy;
178
+ if (busy)
179
+ el.setAttribute("aria-busy", "true");
180
+ else
181
+ el.removeAttribute("aria-busy");
182
+ }
183
+ function toast(root, message, error = false) {
184
+ const box = root.querySelector("[data-rack-toasts]");
185
+ if (!box)
186
+ return;
187
+ const el = document.createElement("div");
188
+ el.className = error ? "bg-destructive text-destructive-foreground pointer-events-auto rounded-md px-3.5 py-2 text-sm shadow-lg" : "bg-card text-card-foreground border-border pointer-events-auto rounded-md border px-3.5 py-2 text-sm shadow-lg";
189
+ el.textContent = message;
190
+ box.appendChild(el);
191
+ window.setTimeout(() => el.remove(), 4000);
192
+ }
125
193
  function refresh(root) {
126
194
  run(root, new URLSearchParams(window.location.search));
127
195
  }
128
- function actionCell(id) {
129
- return `<td class="border-table-border border-t px-3 py-2.5 whitespace-nowrap text-right">` + `<button type="button" data-edit-id="${esc(id)}" class="text-link hover:text-link-hover px-1 text-sm font-medium hover:underline">Edit</button>` + `<button type="button" data-delete-id="${esc(id)}" class="text-destructive ml-3 px-1 text-sm font-medium hover:underline">Delete</button>` + `</td>`;
196
+ function parse(raw, fallback) {
197
+ try {
198
+ return raw ? JSON.parse(raw) : fallback;
199
+ } catch {
200
+ return fallback;
201
+ }
202
+ }
203
+ function tableOf(root) {
204
+ return root.querySelector("table");
205
+ }
206
+ function tableConfig(root) {
207
+ const tbl = tableOf(root);
208
+ const fields = parse(tbl?.getAttribute("data-rack-fields"), []);
209
+ return {
210
+ fields,
211
+ byName: new Map(fields.map((f) => [f.name, f])),
212
+ actions: parse(tbl?.getAttribute("data-rack-actions"), []),
213
+ pk: tbl?.getAttribute("data-pk") || "id",
214
+ selectable: !!tbl?.hasAttribute("data-selectable"),
215
+ editable: !!tbl?.hasAttribute("data-editable"),
216
+ viewable: !!tbl?.hasAttribute("data-viewable"),
217
+ softDelete: !!tbl?.hasAttribute("data-soft-delete"),
218
+ restorable: !!tbl?.hasAttribute("data-restorable"),
219
+ deletedAt: tbl?.getAttribute("data-deleted-at") || "deletedAt"
220
+ };
221
+ }
222
+ function badgeClass(value) {
223
+ if (typeof value !== "string")
224
+ return "bg-badge-background text-badge-foreground";
225
+ const text = value.toLowerCase();
226
+ if (text === "active")
227
+ return "bg-success-muted text-success-muted-foreground";
228
+ if (text === "archived")
229
+ return "bg-warning-muted text-warning-muted-foreground";
230
+ return "bg-badge-background text-badge-foreground";
231
+ }
232
+ function actionButton(action, id) {
233
+ const target = esc(action.href.replaceAll(":id", encodeURIComponent(id)));
234
+ const cls = "text-link hover:text-link-hover ml-3 px-1 text-sm font-medium hover:underline";
235
+ if (action.method) {
236
+ const confirm = action.confirm ? ` data-rack-confirm="${esc(action.confirm)}"` : "";
237
+ return `<button type="button" data-rack-href="${target}" data-rack-method="${esc(action.method)}"${confirm} class="${cls}">${esc(action.label)}</button>`;
238
+ }
239
+ return `<a href="${target}" class="${cls}">${esc(action.label)}</a>`;
240
+ }
241
+ function actionCell(id, cfg, deleted) {
242
+ let inner = "";
243
+ if (deleted) {
244
+ if (cfg.restorable)
245
+ inner = `<button type="button" data-restore-id="${esc(id)}" class="text-link hover:text-link-hover px-1 text-sm font-medium hover:underline">Restore</button>`;
246
+ } else {
247
+ if (cfg.viewable)
248
+ inner += `<button type="button" data-view-id="${esc(id)}" class="text-link hover:text-link-hover px-1 text-sm font-medium hover:underline">View</button>`;
249
+ if (cfg.editable)
250
+ inner += `<button type="button" data-edit-id="${esc(id)}" class="text-link hover:text-link-hover ${cfg.viewable ? "ml-3 " : ""}px-1 text-sm font-medium hover:underline">Edit</button>` + `<button type="button" data-delete-id="${esc(id)}" class="text-destructive ml-3 px-1 text-sm font-medium hover:underline">Delete</button>`;
251
+ for (const action of cfg.actions)
252
+ inner += actionButton(action, id);
253
+ }
254
+ return `<td class="border-table-border border-t px-3 py-2.5 whitespace-nowrap text-right">${inner}</td>`;
130
255
  }
131
256
  function numericCols(rows, cols) {
132
257
  const out = new Set;
@@ -138,26 +263,35 @@ function numericCols(rows, cols) {
138
263
  }
139
264
  return out;
140
265
  }
141
- function paintRows(tbody, cols, rows, pk, selectable, editable) {
142
- const span = cols.length + (selectable ? 1 : 0) + (editable ? 1 : 0);
266
+ function cellHtml(field, value) {
267
+ if (value === null || value === undefined)
268
+ return "";
269
+ const text = esc(displayValue(field, value));
270
+ if (field?.format === "badge" || field?.name === "status")
271
+ return `<span class="rounded-sm px-1.5 py-0.5 text-xs font-medium ${badgeClass(value)}">${text}</span>`;
272
+ return text;
273
+ }
274
+ function paintRows(tbody, cols, rows, cfg) {
275
+ const showActions = cfg.editable || cfg.viewable || cfg.softDelete || cfg.actions.length > 0;
276
+ const span = cols.length + (cfg.selectable ? 1 : 0) + (showActions ? 1 : 0);
143
277
  if (!rows.length) {
144
278
  tbody.innerHTML = `<tr><td colspan="${span || 1}" class="text-text-muted px-4 py-10 text-center">No records found.</td></tr>`;
145
279
  return;
146
280
  }
147
281
  const numeric = numericCols(rows, cols);
148
282
  tbody.innerHTML = rows.map((row) => {
149
- const id = row[pk] == null ? "" : String(row[pk]);
283
+ const id = row[cfg.pk] == null ? "" : String(row[cfg.pk]);
284
+ const deleted = cfg.softDelete && row[cfg.deletedAt] != null;
150
285
  let html = `<tr class="hover:bg-table-row-hover">`;
151
- if (selectable)
286
+ if (cfg.selectable)
152
287
  html += `<td class="border-table-border border-t px-3 py-2.5"><input type="checkbox" data-select-row value="${esc(id)}" aria-label="Select row ${esc(id)}" class="accent-primary size-4"></td>`;
153
288
  html += cols.map((col) => {
154
- const cls = badgeClass(col, row[col]);
155
- const inner = cls ? `<span class="rounded-sm px-1.5 py-0.5 text-xs font-medium ${cls}">${esc(cellText(row[col]))}</span>` : esc(cellText(row[col]));
156
- const align = numeric.has(col) ? " text-right tabular-nums" : "";
157
- return `<td class="border-table-border border-t px-3 py-2.5${align}">${inner}</td>`;
289
+ const field = cfg.byName.get(col);
290
+ const align = numeric.has(col) || field && isNumericField(field) ? " text-right tabular-nums" : "";
291
+ return `<td class="border-table-border border-t px-3 py-2.5${align}">${cellHtml(field, row[col])}</td>`;
158
292
  }).join("");
159
- if (editable)
160
- html += actionCell(id);
293
+ if (showActions)
294
+ html += actionCell(id, cfg, Boolean(deleted));
161
295
  return html + "</tr>";
162
296
  }).join("");
163
297
  }
@@ -187,13 +321,10 @@ function paint(root, json, sp) {
187
321
  const limit = q.limit ?? 20;
188
322
  const total = json.total ?? 0;
189
323
  const totalPages = Math.max(1, Math.ceil(total / limit));
190
- const tbl = root.querySelector("table");
191
- const selectable = !!tbl?.hasAttribute("data-selectable");
192
- const editable = !!tbl?.hasAttribute("data-editable");
193
- const pk = tbl?.getAttribute("data-pk") || "id";
324
+ const cfg = tableConfig(root);
194
325
  const tbody = root.querySelector("[data-panel-rows]");
195
326
  if (tbody)
196
- paintRows(tbody, cols, json.data ?? [], pk, selectable, editable);
327
+ paintRows(tbody, cols, json.data ?? [], cfg);
197
328
  const count = root.querySelector("[data-panel-count]");
198
329
  if (count) {
199
330
  const from = total === 0 ? 0 : (page - 1) * limit + 1;
@@ -253,14 +384,25 @@ function syncBulk(root) {
253
384
  }
254
385
  async function createRecord(root, form) {
255
386
  setErr(form, "");
256
- const raw = form.querySelector("[data-create-json]");
257
- if (raw) {
387
+ const submit = form.querySelector('[type="submit"]');
388
+ setBusy(submit, true);
389
+ try {
390
+ const raw = form.querySelector("[data-create-json]");
258
391
  let body;
259
- try {
260
- body = JSON.parse(raw.value);
261
- } catch {
262
- setErr(form, "Invalid JSON body");
263
- return;
392
+ if (raw) {
393
+ try {
394
+ body = JSON.parse(raw.value);
395
+ } catch {
396
+ setErr(form, "Invalid JSON body");
397
+ return;
398
+ }
399
+ } else {
400
+ const collected = collectFormBody(form);
401
+ if (collected.error) {
402
+ setErr(form, collected.error);
403
+ return;
404
+ }
405
+ body = collected.body;
264
406
  }
265
407
  const res = await fetch(baseUrl(root), {
266
408
  method: "POST",
@@ -268,64 +410,65 @@ async function createRecord(root, form) {
268
410
  body: JSON.stringify(body)
269
411
  });
270
412
  if (!res.ok) {
271
- setErr(form, `Error ${res.status}: ${(await res.text()).slice(0, 200)}`);
413
+ const message = `Error ${res.status}: ${(await res.text()).slice(0, 200)}`;
414
+ setErr(form, message);
415
+ toast(root, message, true);
272
416
  return;
273
417
  }
274
- } else {
418
+ const dlg = form.closest("dialog");
419
+ form.reset();
420
+ if (dlg)
421
+ dlg.close();
422
+ toast(root, "Record created");
423
+ refresh(root);
424
+ } finally {
425
+ setBusy(submit, false);
426
+ }
427
+ }
428
+ async function updateRecord(root, form) {
429
+ setErr(form, "");
430
+ const id = form.dataset?.id || "";
431
+ if (!id) {
432
+ setErr(form, "Missing record id");
433
+ return;
434
+ }
435
+ const submit = form.querySelector('[type="submit"]');
436
+ setBusy(submit, true);
437
+ try {
275
438
  const collected = collectFormBody(form);
276
439
  if (collected.error) {
277
440
  setErr(form, collected.error);
278
441
  return;
279
442
  }
280
- const res = await fetch(baseUrl(root), {
281
- method: "POST",
282
- headers: { "Content-Type": "application/json", "Idempotency-Key": uid() },
443
+ const res = await fetch(`${baseUrl(root)}/${encodeURIComponent(id)}`, {
444
+ method: "PATCH",
445
+ headers: { "Content-Type": "application/json" },
283
446
  body: JSON.stringify(collected.body)
284
447
  });
285
448
  if (!res.ok) {
286
- setErr(form, `Error ${res.status}: ${(await res.text()).slice(0, 200)}`);
449
+ const message = `Error ${res.status}: ${(await res.text()).slice(0, 200)}`;
450
+ setErr(form, message);
451
+ toast(root, message, true);
287
452
  return;
288
453
  }
454
+ const dlg = form.closest("dialog");
455
+ if (dlg)
456
+ dlg.close();
457
+ toast(root, "Record saved");
458
+ refresh(root);
459
+ } finally {
460
+ setBusy(submit, false);
289
461
  }
290
- const dlg = form.closest("dialog");
291
- form.reset();
292
- if (dlg)
293
- dlg.close();
294
- refresh(root);
295
- }
296
- async function updateRecord(root, form) {
297
- setErr(form, "");
298
- const id = form.dataset?.id || "";
299
- if (!id) {
300
- setErr(form, "Missing record id");
301
- return;
302
- }
303
- const collected = collectFormBody(form);
304
- if (collected.error) {
305
- setErr(form, collected.error);
306
- return;
307
- }
308
- const res = await fetch(`${baseUrl(root)}/${encodeURIComponent(id)}`, {
309
- method: "PATCH",
310
- headers: { "Content-Type": "application/json" },
311
- body: JSON.stringify(collected.body)
312
- });
313
- if (!res.ok) {
314
- setErr(form, `Error ${res.status}: ${(await res.text()).slice(0, 200)}`);
315
- return;
316
- }
317
- const dlg = form.closest("dialog");
318
- if (dlg)
319
- dlg.close();
320
- refresh(root);
321
462
  }
322
463
  async function openEdit(root, id) {
323
464
  const res = await fetch(`${baseUrl(root)}/data/${encodeURIComponent(id)}`, {
324
465
  method: "QUERY",
325
466
  headers: { Accept: "application/json" }
326
467
  });
327
- if (!res.ok)
468
+ if (!res.ok) {
469
+ toast(root, `Could not load record ${id}`, true);
328
470
  return;
471
+ }
329
472
  const row = (await res.json()).data ?? {};
330
473
  const dlg = document.getElementById("rack-edit");
331
474
  if (!dlg)
@@ -341,14 +484,58 @@ async function openEdit(root, id) {
341
484
  setErr(form, "");
342
485
  dlg.showModal?.();
343
486
  }
487
+ async function openDetail(root, id) {
488
+ const res = await fetch(`${baseUrl(root)}/data/${encodeURIComponent(id)}`, {
489
+ method: "QUERY",
490
+ headers: { Accept: "application/json" }
491
+ });
492
+ if (!res.ok) {
493
+ toast(root, `Could not load record ${id}`, true);
494
+ return;
495
+ }
496
+ const row = (await res.json()).data ?? {};
497
+ const dlg = document.getElementById("rack-detail");
498
+ if (!dlg)
499
+ return;
500
+ const label = dlg.querySelector("[data-detail-id-label]");
501
+ if (label)
502
+ label.textContent = `#${id}`;
503
+ const body = dlg.querySelector("[data-detail-body]");
504
+ if (body)
505
+ body.innerHTML = renderDetail(root, row);
506
+ dlg.showModal?.();
507
+ }
508
+ function renderDetail(root, row) {
509
+ const cfg = tableConfig(root);
510
+ const items = cfg.fields.filter((field) => row[field.name] !== undefined && !field.hideInTable).map((field) => `<div class="flex flex-col gap-0.5"><dt class="text-text-muted text-xs font-medium">${esc(field.label)}</dt><dd class="text-foreground text-sm">${esc(displayValue(field, row[field.name])) || "—"}</dd></div>`);
511
+ return items.join("") || `<p class="text-text-muted text-sm">No fields</p>`;
512
+ }
344
513
  async function deleteOne(root, id) {
345
514
  if (!window.confirm(`Delete record ${id}?`))
346
515
  return;
347
516
  const res = await fetch(`${baseUrl(root)}/${encodeURIComponent(id)}`, {
348
517
  method: "DELETE"
349
518
  });
350
- if (res.ok)
519
+ if (res.ok) {
520
+ toast(root, "Record deleted");
351
521
  refresh(root);
522
+ return;
523
+ }
524
+ toast(root, `Error ${res.status}: ${(await res.text()).slice(0, 200)}`, true);
525
+ }
526
+ async function restoreOne(root, id) {
527
+ const cfg = tableConfig(root);
528
+ const res = await fetch(`${baseUrl(root)}/${encodeURIComponent(id)}`, {
529
+ method: "PATCH",
530
+ headers: { "Content-Type": "application/json" },
531
+ body: JSON.stringify({ [cfg.deletedAt]: null })
532
+ });
533
+ if (res.ok) {
534
+ toast(root, "Record restored");
535
+ refresh(root);
536
+ return;
537
+ }
538
+ toast(root, `Error ${res.status}: ${(await res.text()).slice(0, 200)}`, true);
352
539
  }
353
540
  async function bulkDelete(root) {
354
541
  const ids = selectedIds(root);
@@ -357,9 +544,16 @@ async function bulkDelete(root) {
357
544
  if (!window.confirm(`Delete ${ids.length} records?`))
358
545
  return;
359
546
  const base = baseUrl(root);
547
+ let failed = 0;
360
548
  for (const id of ids) {
361
- await fetch(`${base}/${encodeURIComponent(id)}`, { method: "DELETE" });
549
+ const res = await fetch(`${base}/${encodeURIComponent(id)}`, { method: "DELETE" });
550
+ if (!res.ok)
551
+ failed++;
362
552
  }
553
+ if (failed > 0)
554
+ toast(root, `${failed} of ${ids.length} deletes failed`, true);
555
+ else
556
+ toast(root, `${ids.length} record${ids.length === 1 ? "" : "s"} deleted`);
363
557
  refresh(root);
364
558
  }
365
559
  async function bulkApply(root) {
@@ -368,18 +562,47 @@ async function bulkApply(root) {
368
562
  const ids = selectedIds(root);
369
563
  if (!field?.value || !input || !ids.length)
370
564
  return;
565
+ const cfg = tableConfig(root);
566
+ const meta = cfg.byName.get(field.value);
567
+ const coerced = coerceFieldValue(meta, input.value);
568
+ if (coerced.error) {
569
+ toast(root, coerced.error, true);
570
+ return;
571
+ }
371
572
  const base = baseUrl(root);
372
- const payload = JSON.stringify({ [field.value]: input.value });
573
+ const payload = JSON.stringify({ [field.value]: coerced.value });
574
+ let failed = 0;
373
575
  for (const id of ids) {
374
- await fetch(`${base}/${encodeURIComponent(id)}`, {
576
+ const res = await fetch(`${base}/${encodeURIComponent(id)}`, {
375
577
  method: "PATCH",
376
578
  headers: { "Content-Type": "application/json" },
377
579
  body: payload
378
580
  });
581
+ if (!res.ok)
582
+ failed++;
379
583
  }
380
584
  input.value = "";
585
+ if (failed > 0)
586
+ toast(root, `${failed} of ${ids.length} updates failed`, true);
587
+ else
588
+ toast(root, `${ids.length} record${ids.length === 1 ? "" : "s"} updated`);
381
589
  refresh(root);
382
590
  }
591
+ async function runCustomAction(root, el) {
592
+ const target = el.getAttribute("data-rack-href");
593
+ if (!target)
594
+ return;
595
+ const confirmMsg = el.getAttribute("data-rack-confirm");
596
+ if (confirmMsg && !window.confirm(confirmMsg))
597
+ return;
598
+ const res = await fetch(target, {
599
+ method: el.getAttribute("data-rack-method") || "GET"
600
+ });
601
+ if (res.ok)
602
+ refresh(root);
603
+ else
604
+ toast(root, `Error ${res.status}: ${(await res.text()).slice(0, 200)}`, true);
605
+ }
383
606
  document.addEventListener("click", (e) => {
384
607
  const t = e.target;
385
608
  if (!t?.closest)
@@ -392,6 +615,12 @@ document.addEventListener("click", (e) => {
392
615
  closer.closest("dialog")?.close();
393
616
  return;
394
617
  }
618
+ const custom = t.closest("[data-rack-href]");
619
+ if (custom) {
620
+ e.preventDefault();
621
+ runCustomAction(root, custom);
622
+ return;
623
+ }
395
624
  if (t.closest("[data-open-create]")) {
396
625
  const dlg = document.getElementById("rack-create");
397
626
  if (dlg) {
@@ -404,6 +633,16 @@ document.addEventListener("click", (e) => {
404
633
  }
405
634
  return;
406
635
  }
636
+ const view = t.closest("[data-view-id]");
637
+ if (view) {
638
+ openDetail(root, view.getAttribute("data-view-id"));
639
+ return;
640
+ }
641
+ const restore = t.closest("[data-restore-id]");
642
+ if (restore) {
643
+ restoreOne(root, restore.getAttribute("data-restore-id"));
644
+ return;
645
+ }
407
646
  const eb = t.closest("[data-edit-id]");
408
647
  if (eb) {
409
648
  openEdit(root, eb.getAttribute("data-edit-id"));