elysia-rack 0.4.0 → 0.5.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/README.md CHANGED
@@ -98,11 +98,41 @@ The panel ships create / edit / delete UI driven by the same API:
98
98
  - Row `Delete` confirms natively, then `DELETE /:id`.
99
99
  - Checkbox selection enables the bulk bar: `Delete selected` loops
100
100
  `DELETE /:id`, and the field applier loops `PATCH /:id` with the
101
- chosen field/value. `Clear` resets the selection.
101
+ chosen field/value (coerced to the field kind). `Clear` resets the selection.
102
+ - Row `View` loads the record through `QUERY /data/:id` into a read-only dialog.
103
+ - With `settings.softDelete`, the toolbar adds `Show deleted`. Deleted rows
104
+ show a `Restore` button that clears `deletedAt` via `PATCH /:id`.
105
+
106
+ Mutations report success and failures with toast messages and disable the
107
+ submit button while a request is in flight.
102
108
 
103
109
  Action UI follows `operations`: disable `create`/`update`/`delete` and
104
110
  the corresponding buttons, dialogs and bulk controls disappear.
105
111
 
112
+ ### Custom actions
113
+
114
+ Add extra buttons with the `actions` option. `:id` in `href` is replaced
115
+ with the row primary key. Row actions (default) sit next to
116
+ Edit/Delete; `scope: "toolbar"` actions sit in the page header.
117
+
118
+ ```ts
119
+ rack("/catalog/products", {
120
+ model,
121
+ actions: [
122
+ { label: "Duplicate", href: "/catalog/products/:id/duplicate", method: "POST" },
123
+ { label: "Export CSV", href: "/catalog/products/export", scope: "toolbar", icon: "download" }
124
+ ]
125
+ });
126
+ ```
127
+
128
+ Without `method`, the button is a plain link. With `method`, the panel
129
+ runs it via `fetch` and refreshes the table; `confirm` shows a native
130
+ confirmation first.
131
+
132
+ Custom action endpoints are yours to protect. The panel does not apply the
133
+ rack's `authorization` rules to them, so call `authorize()` (or your own
134
+ guard) inside the route that handles the action URL.
135
+
106
136
  ## Idempotency (POST)
107
137
 
108
138
  `POST /` requires an idempotency key by default. Send a client-generated
@@ -233,11 +263,23 @@ rack("/catalog/products", {
233
263
  order: 1, // smaller = higher
234
264
  icon: "package", // Lucide icon name (kebab-case or PascalCase)
235
265
  parent: "catalog-root", // nest under another id
236
- hidden: false
266
+ hidden: false,
267
+ hiddenFields: ["cost"], // fields hidden from table columns and forms
268
+ fields: {
269
+ name: { label: "Product name", order: 1 },
270
+ price: { format: "currency", currency: "USD" },
271
+ cost: { hideInTable: true }, // editable in forms, not listed
272
+ categoryId: { // relation select in forms
273
+ label: "Category",
274
+ relation: { load: async () => loadCategories() }
275
+ }
276
+ }
237
277
  }
238
278
  });
239
279
  ```
240
280
 
281
+ `metadata.icon` also becomes the page favicon (an inline SVG in `<head>`). Per-field config supports `label`, `format` (`text`, `number`, `currency`, `date`, `datetime`, `boolean`, `badge`, `json`), `readOnly`, `hideInTable`, `hideInForm`, `order`, and `relation`. A relation renders a `<select>`; provide static `options` or a `load` function, and the label is used for table/detail display.
282
+
241
283
  **Decide what users can do.** Turn operations on/off. If you disable `create`, the “New record” button disappears automatically.
242
284
 
243
285
  ```ts
@@ -0,0 +1,212 @@
1
+ import {
2
+ Masthead,
3
+ Sidebar
4
+ } from "./index-9k12apt9.js";
5
+ import {
6
+ getRackTree
7
+ } from "./index-4wxvmd8a.js";
8
+ import {
9
+ RackIcon,
10
+ resolveDonateProp
11
+ } from "./index-vq4gh509.js";
12
+ import"./index-37x76zdn.js";
13
+
14
+ // src/react/page/Dashboard.tsx
15
+ import { Fragment } from "react";
16
+ import { jsxDEV, Fragment as Fragment2 } from "react/jsx-dev-runtime";
17
+ function itemLabel(r) {
18
+ return r.metadata.pluralLabel ?? r.metadata.label ?? r.metadata.id;
19
+ }
20
+ function toSidebarItem(node, activeId) {
21
+ return {
22
+ id: node.metadata.id,
23
+ label: itemLabel(node),
24
+ icon: node.metadata.icon,
25
+ href: node.path,
26
+ active: node.metadata.id === activeId,
27
+ children: node.children.filter((c) => !c.metadata.hidden).map((c) => toSidebarItem(c, activeId))
28
+ };
29
+ }
30
+ function buildGroupsFromTree(tree, activeId) {
31
+ const byGroup = new Map;
32
+ for (const root of tree) {
33
+ if (root.metadata.hidden)
34
+ continue;
35
+ const name = root.metadata.group ?? "General";
36
+ let group = byGroup.get(name);
37
+ if (!group) {
38
+ group = { name, items: [] };
39
+ byGroup.set(name, group);
40
+ }
41
+ group.items.push(toSidebarItem(root, activeId));
42
+ }
43
+ return [...byGroup.values()].sort((a, b) => a.name.localeCompare(b.name));
44
+ }
45
+ function countNodes(nodes) {
46
+ return nodes.reduce((sum, n) => sum + 1 + countNodes(n.children), 0);
47
+ }
48
+ function operationsLabel(node) {
49
+ const op = node.operations;
50
+ const write = [op.create, op.update, op.replace, op.delete];
51
+ if (!write.some(Boolean))
52
+ return "Read only";
53
+ if (write.every(Boolean))
54
+ return "Full";
55
+ const labels = [];
56
+ if (op.create)
57
+ labels.push("Create");
58
+ if (op.update)
59
+ labels.push("Update");
60
+ if (op.replace)
61
+ labels.push("Replace");
62
+ if (op.delete)
63
+ labels.push("Delete");
64
+ return labels.join(", ");
65
+ }
66
+ function plural(count, word) {
67
+ return `${count} ${word}${count === 1 ? "" : "s"}`;
68
+ }
69
+ function ResourceRow({ node, depth = 0 }) {
70
+ return /* @__PURE__ */ jsxDEV(Fragment2, {
71
+ children: [
72
+ /* @__PURE__ */ jsxDEV("tr", {
73
+ className: "hover:bg-table-row-hover",
74
+ children: [
75
+ /* @__PURE__ */ jsxDEV("td", {
76
+ className: "border-table-border border-t px-3 py-2.5",
77
+ children: /* @__PURE__ */ jsxDEV("a", {
78
+ href: node.path,
79
+ className: "text-link hover:text-link-hover font-medium hover:underline",
80
+ style: depth > 0 ? { paddingLeft: depth * 16 } : undefined,
81
+ children: [
82
+ node.metadata.icon ? /* @__PURE__ */ jsxDEV(RackIcon, {
83
+ name: node.metadata.icon,
84
+ size: 16,
85
+ className: "mr-1.5 inline size-4 align-text-bottom"
86
+ }, undefined, false, undefined, this) : null,
87
+ node.metadata.label ?? node.metadata.id
88
+ ]
89
+ }, undefined, true, undefined, this)
90
+ }, undefined, false, undefined, this),
91
+ /* @__PURE__ */ jsxDEV("td", {
92
+ className: "border-table-border text-text-muted border-t px-3 py-2.5 font-mono text-xs",
93
+ children: node.path
94
+ }, undefined, false, undefined, this),
95
+ /* @__PURE__ */ jsxDEV("td", {
96
+ className: "border-table-border text-text-muted border-t px-3 py-2.5 text-xs",
97
+ children: operationsLabel(node)
98
+ }, undefined, false, undefined, this)
99
+ ]
100
+ }, undefined, true, undefined, this),
101
+ node.children.filter((c) => !c.metadata.hidden).map((child) => /* @__PURE__ */ jsxDEV(ResourceRow, {
102
+ node: child,
103
+ depth: depth + 1
104
+ }, child.metadata.id, false, undefined, this))
105
+ ]
106
+ }, undefined, true, undefined, this);
107
+ }
108
+ async function Dashboard(props) {
109
+ const tree = getRackTree().filter((r) => !r.metadata.hidden);
110
+ const groups = buildGroupsFromTree(tree, props.resource);
111
+ const totalResources = countNodes(tree);
112
+ const totalGroups = groups.length;
113
+ const donate = resolveDonateProp(props.donate);
114
+ return /* @__PURE__ */ jsxDEV("div", {
115
+ className: "text-foreground flex min-h-screen flex-col lg:flex-row",
116
+ children: [
117
+ /* @__PURE__ */ jsxDEV(Sidebar, {
118
+ title: props.name ?? "Panel",
119
+ groups
120
+ }, undefined, false, undefined, this),
121
+ /* @__PURE__ */ jsxDEV("main", {
122
+ className: "min-w-0 flex-1",
123
+ children: /* @__PURE__ */ jsxDEV("div", {
124
+ className: "mx-auto w-full max-w-5xl px-4 py-4 sm:px-6",
125
+ children: [
126
+ /* @__PURE__ */ jsxDEV(Masthead, {
127
+ title: props.name ?? "Panel",
128
+ detail: `${plural(totalResources, "resource")} in ${plural(totalGroups, "group")}`
129
+ }, undefined, false, undefined, this),
130
+ /* @__PURE__ */ jsxDEV("div", {
131
+ className: "bg-table border-border overflow-x-auto rounded-lg border",
132
+ children: /* @__PURE__ */ jsxDEV("table", {
133
+ className: "w-full border-collapse text-left text-sm",
134
+ children: [
135
+ /* @__PURE__ */ jsxDEV("thead", {
136
+ children: /* @__PURE__ */ jsxDEV("tr", {
137
+ children: [
138
+ /* @__PURE__ */ jsxDEV("th", {
139
+ className: "border-border text-text-muted border-b px-3 py-2 text-xs font-medium",
140
+ children: "Resource"
141
+ }, undefined, false, undefined, this),
142
+ /* @__PURE__ */ jsxDEV("th", {
143
+ className: "border-border text-text-muted border-b px-3 py-2 text-xs font-medium",
144
+ children: "Path"
145
+ }, undefined, false, undefined, this),
146
+ /* @__PURE__ */ jsxDEV("th", {
147
+ className: "border-border text-text-muted border-b px-3 py-2 text-xs font-medium",
148
+ children: "Operations"
149
+ }, undefined, false, undefined, this)
150
+ ]
151
+ }, undefined, true, undefined, this)
152
+ }, undefined, false, undefined, this),
153
+ /* @__PURE__ */ jsxDEV("tbody", {
154
+ children: groups.length === 0 ? /* @__PURE__ */ jsxDEV("tr", {
155
+ children: /* @__PURE__ */ jsxDEV("td", {
156
+ colSpan: 3,
157
+ className: "text-text-muted px-4 py-10 text-center",
158
+ children: "No resources registered. Call rack() before rendering this page."
159
+ }, undefined, false, undefined, this)
160
+ }, undefined, false, undefined, this) : groups.map((group) => {
161
+ const nodes = tree.filter((r) => (r.metadata.group ?? "General") === group.name);
162
+ return /* @__PURE__ */ jsxDEV(Fragment, {
163
+ children: [
164
+ /* @__PURE__ */ jsxDEV("tr", {
165
+ className: "bg-surface-muted",
166
+ children: /* @__PURE__ */ jsxDEV("th", {
167
+ colSpan: 3,
168
+ className: "border-border text-text-muted border-b px-3 py-1.5 text-left text-xs font-medium",
169
+ children: [
170
+ group.name,
171
+ " ",
172
+ /* @__PURE__ */ jsxDEV("span", {
173
+ className: "text-text-subtle font-normal",
174
+ children: plural(countNodes(nodes), "resource")
175
+ }, undefined, false, undefined, this)
176
+ ]
177
+ }, undefined, true, undefined, this)
178
+ }, undefined, false, undefined, this),
179
+ nodes.map((node) => /* @__PURE__ */ jsxDEV(ResourceRow, {
180
+ node
181
+ }, node.metadata.id, false, undefined, this))
182
+ ]
183
+ }, group.name, true, undefined, this);
184
+ })
185
+ }, undefined, false, undefined, this)
186
+ ]
187
+ }, undefined, true, undefined, this)
188
+ }, undefined, false, undefined, this),
189
+ /* @__PURE__ */ jsxDEV("footer", {
190
+ className: "border-border text-text-muted mt-6 flex flex-wrap items-center justify-between gap-2 border-t pt-3 text-xs",
191
+ children: [
192
+ /* @__PURE__ */ jsxDEV("span", {
193
+ children: "elysia-rack"
194
+ }, undefined, false, undefined, this),
195
+ donate.enabled ? /* @__PURE__ */ jsxDEV("a", {
196
+ href: donate.url,
197
+ target: "_blank",
198
+ rel: "noopener noreferrer",
199
+ className: "hover:text-foreground underline-offset-4 hover:underline",
200
+ children: donate.label
201
+ }, undefined, false, undefined, this) : null
202
+ ]
203
+ }, undefined, true, undefined, this)
204
+ ]
205
+ }, undefined, true, undefined, this)
206
+ }, undefined, false, undefined, this)
207
+ ]
208
+ }, undefined, true, undefined, this);
209
+ }
210
+ export {
211
+ Dashboard as default
212
+ };