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/README.md +61 -3
- package/dist/Dashboard-2raba6xf.js +212 -0
- package/dist/Panel-5wx7qpqt.js +1159 -0
- package/dist/Panel-9k5vambv.js +1160 -0
- package/dist/index-9k12apt9.js +157 -0
- package/dist/index-vq4gh509.js +40 -0
- package/dist/panel-app.js +322 -83
- package/dist/panel.css +1 -1
- package/dist/rack/index.js +10 -6
- package/dist/rack/page.d.ts +2 -0
- package/dist/rack/query.d.ts +14 -12
- package/dist/rack/types/actions.d.ts +27 -0
- package/dist/rack/types/fields.d.ts +48 -0
- package/dist/rack/types/index.d.ts +15 -0
- package/dist/rack/types/metadata.d.ts +11 -0
- package/dist/react/index.js +28 -9
- package/dist/react/page/Panel.d.ts +6 -23
- package/dist/react/page/components/DataTable.d.ts +9 -2
- package/dist/react/page/components/Toolbar.d.ts +5 -1
- package/dist/react/page/components/panelFields.d.ts +39 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -57,8 +57,22 @@ const app = new Elysia().use(
|
|
|
57
57
|
label: "Product",
|
|
58
58
|
pluralLabel: "Products",
|
|
59
59
|
group: "Catalog",
|
|
60
|
+
icon: "package",
|
|
61
|
+
fields: {
|
|
62
|
+
name: { label: "Product name" },
|
|
63
|
+
price: { format: "currency", currency: "USD" },
|
|
64
|
+
categoryId: {
|
|
65
|
+
label: "Category",
|
|
66
|
+
relation: { load: () => loadCategoryOptions() },
|
|
67
|
+
},
|
|
68
|
+
},
|
|
60
69
|
},
|
|
61
70
|
|
|
71
|
+
actions: [
|
|
72
|
+
{ label: "Duplicate", href: "/catalog/products/:id/duplicate", method: "POST" },
|
|
73
|
+
{ label: "Export CSV", href: "/catalog/products/export", scope: "toolbar" },
|
|
74
|
+
],
|
|
75
|
+
|
|
62
76
|
openapi: { tags: ["Products"] },
|
|
63
77
|
settings: { primaryKey: "id", returning: true },
|
|
64
78
|
}),
|
|
@@ -98,11 +112,41 @@ The panel ships create / edit / delete UI driven by the same API:
|
|
|
98
112
|
- Row `Delete` confirms natively, then `DELETE /:id`.
|
|
99
113
|
- Checkbox selection enables the bulk bar: `Delete selected` loops
|
|
100
114
|
`DELETE /:id`, and the field applier loops `PATCH /:id` with the
|
|
101
|
-
chosen field/value. `Clear` resets the selection.
|
|
115
|
+
chosen field/value (coerced to the field kind). `Clear` resets the selection.
|
|
116
|
+
- Row `View` loads the record through `QUERY /data/:id` into a read-only dialog.
|
|
117
|
+
- With `settings.softDelete`, the toolbar adds `Show deleted`. Deleted rows
|
|
118
|
+
show a `Restore` button that clears `deletedAt` via `PATCH /:id`.
|
|
119
|
+
|
|
120
|
+
Mutations report success and failures with toast messages and disable the
|
|
121
|
+
submit button while a request is in flight.
|
|
102
122
|
|
|
103
123
|
Action UI follows `operations`: disable `create`/`update`/`delete` and
|
|
104
124
|
the corresponding buttons, dialogs and bulk controls disappear.
|
|
105
125
|
|
|
126
|
+
### Custom actions
|
|
127
|
+
|
|
128
|
+
Add extra buttons with the `actions` option. `:id` in `href` is replaced
|
|
129
|
+
with the row primary key. Row actions (default) sit next to
|
|
130
|
+
Edit/Delete; `scope: "toolbar"` actions sit in the page header.
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
rack("/catalog/products", {
|
|
134
|
+
model,
|
|
135
|
+
actions: [
|
|
136
|
+
{ label: "Duplicate", href: "/catalog/products/:id/duplicate", method: "POST" },
|
|
137
|
+
{ label: "Export CSV", href: "/catalog/products/export", scope: "toolbar", icon: "download" }
|
|
138
|
+
]
|
|
139
|
+
});
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Without `method`, the button is a plain link. With `method`, the panel
|
|
143
|
+
runs it via `fetch` and refreshes the table; `confirm` shows a native
|
|
144
|
+
confirmation first.
|
|
145
|
+
|
|
146
|
+
Custom action endpoints are yours to protect. The panel does not apply the
|
|
147
|
+
rack's `authorization` rules to them, so call `authorize()` (or your own
|
|
148
|
+
guard) inside the route that handles the action URL.
|
|
149
|
+
|
|
106
150
|
## Idempotency (POST)
|
|
107
151
|
|
|
108
152
|
`POST /` requires an idempotency key by default. Send a client-generated
|
|
@@ -233,11 +277,23 @@ rack("/catalog/products", {
|
|
|
233
277
|
order: 1, // smaller = higher
|
|
234
278
|
icon: "package", // Lucide icon name (kebab-case or PascalCase)
|
|
235
279
|
parent: "catalog-root", // nest under another id
|
|
236
|
-
hidden: false
|
|
280
|
+
hidden: false,
|
|
281
|
+
hiddenFields: ["cost"], // fields hidden from table columns and forms
|
|
282
|
+
fields: {
|
|
283
|
+
name: { label: "Product name", order: 1 },
|
|
284
|
+
price: { format: "currency", currency: "USD" },
|
|
285
|
+
cost: { hideInTable: true }, // editable in forms, not listed
|
|
286
|
+
categoryId: { // relation select in forms
|
|
287
|
+
label: "Category",
|
|
288
|
+
relation: { load: async () => loadCategories() }
|
|
289
|
+
}
|
|
290
|
+
}
|
|
237
291
|
}
|
|
238
292
|
});
|
|
239
293
|
```
|
|
240
294
|
|
|
295
|
+
`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.
|
|
296
|
+
|
|
241
297
|
**Decide what users can do.** Turn operations on/off. If you disable `create`, the “New record” button disappears automatically.
|
|
242
298
|
|
|
243
299
|
```ts
|
|
@@ -254,7 +310,7 @@ rack("/catalog/products", {
|
|
|
254
310
|
model,
|
|
255
311
|
query: {
|
|
256
312
|
searchable: ["name", "sku"],
|
|
257
|
-
filterable: ["status"],
|
|
313
|
+
filterable: ["status", "categoryId"],
|
|
258
314
|
sortable: ["name", "price", "createdAt"],
|
|
259
315
|
defaultSort: { field: "createdAt", direction: "desc" },
|
|
260
316
|
pagination: { default: 20, max: 100 }
|
|
@@ -262,6 +318,8 @@ rack("/catalog/products", {
|
|
|
262
318
|
});
|
|
263
319
|
```
|
|
264
320
|
|
|
321
|
+
Filter controls adapt to the field kind: enum and boolean become selects, dates get a date picker, numbers get numeric inputs, and relation fields become selects. `metadata.hiddenFields` / `hideInTable` keep a field out of the toolbar too.
|
|
322
|
+
|
|
265
323
|
**Add safety.** Validation and authorization are per-operation. A string like `"products.delete"` checks the `x-permissions` header; a function gives you the full `Request`.
|
|
266
324
|
|
|
267
325
|
```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
|
+
};
|