elysia-rack 0.3.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 +61 -9
- 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 +15 -11
- 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 +65 -21
- 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/dist/react/render.d.ts +1 -1
- package/dist/react/types.d.ts +18 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,8 +4,6 @@
|
|
|
4
4
|
|
|
5
5
|
Model-driven CRUD rack for [Elysia](https://elysiajs.com): declare a resource once, get REST + `QUERY` routes, validation, authorization, query engine, OpenAPI metadata, and a React panel — running on [Bun](https://bun.com) or Node.js 20+.
|
|
6
6
|
|
|
7
|
-
Full usage guide: [docs/USAGE.md](docs/USAGE.md).
|
|
8
|
-
|
|
9
7
|
## Documentation
|
|
10
8
|
|
|
11
9
|
End-user documentation covers installation, configuration, daily usage, and troubleshooting: [docs/index.md](docs/index.md).
|
|
@@ -100,11 +98,41 @@ The panel ships create / edit / delete UI driven by the same API:
|
|
|
100
98
|
- Row `Delete` confirms natively, then `DELETE /:id`.
|
|
101
99
|
- Checkbox selection enables the bulk bar: `Delete selected` loops
|
|
102
100
|
`DELETE /:id`, and the field applier loops `PATCH /:id` with the
|
|
103
|
-
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.
|
|
104
108
|
|
|
105
109
|
Action UI follows `operations`: disable `create`/`update`/`delete` and
|
|
106
110
|
the corresponding buttons, dialogs and bulk controls disappear.
|
|
107
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
|
+
|
|
108
136
|
## Idempotency (POST)
|
|
109
137
|
|
|
110
138
|
`POST /` requires an idempotency key by default. Send a client-generated
|
|
@@ -235,11 +263,23 @@ rack("/catalog/products", {
|
|
|
235
263
|
order: 1, // smaller = higher
|
|
236
264
|
icon: "package", // Lucide icon name (kebab-case or PascalCase)
|
|
237
265
|
parent: "catalog-root", // nest under another id
|
|
238
|
-
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
|
+
}
|
|
239
277
|
}
|
|
240
278
|
});
|
|
241
279
|
```
|
|
242
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
|
+
|
|
243
283
|
**Decide what users can do.** Turn operations on/off. If you disable `create`, the “New record” button disappears automatically.
|
|
244
284
|
|
|
245
285
|
```ts
|
|
@@ -335,17 +375,28 @@ Helpers to inspect the tree yourself: `listRacks()`, `getRack(id)`, `getRackTree
|
|
|
335
375
|
`reactPlugin` is the bridge between Elysia and React. `pages` is now optional and mergeable — defaults already include `"/dashboard"` and `"/panel"`, and any custom pages you pass are merged in. You can pass a single registry or an array.
|
|
336
376
|
|
|
337
377
|
```ts
|
|
338
|
-
import { pages, reactPlugin, page } from "elysia-rack/react";
|
|
378
|
+
import { lazyPage, pages, reactPlugin, page } from "elysia-rack/react";
|
|
339
379
|
|
|
340
380
|
// Option A: just use defaults
|
|
341
381
|
new Elysia().use(reactPlugin());
|
|
342
382
|
|
|
343
383
|
// Option B: extend defaults with your own page
|
|
344
|
-
const myPages = { "/hello": () => import("./Hello") };
|
|
384
|
+
const myPages = { "/hello": lazyPage(() => import("./Hello")) };
|
|
345
385
|
new Elysia().use(reactPlugin({ pages: myPages })); // merged with defaults
|
|
346
386
|
|
|
347
|
-
// Option C:
|
|
348
|
-
|
|
387
|
+
// Option C: pass a component directly (no import wrapper)
|
|
388
|
+
import Hello from "./Hello";
|
|
389
|
+
new Elysia().use(reactPlugin({ pages: { "/hello": Hello } }));
|
|
390
|
+
|
|
391
|
+
// Option D: auto-mount a view — no page() call needed
|
|
392
|
+
new Elysia().use(
|
|
393
|
+
reactPlugin({
|
|
394
|
+
views: [{ path: "/hello", component: Hello, props: { name: "Ada" } }],
|
|
395
|
+
}),
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
// Option E: merge multiple registries — array will be merged
|
|
399
|
+
const extra = { "/admin": lazyPage(() => import("./Admin")) };
|
|
349
400
|
new Elysia().use(reactPlugin({ pages: [pages, myPages, extra] }));
|
|
350
401
|
|
|
351
402
|
// With a style override
|
|
@@ -360,7 +411,8 @@ new Elysia().use(reactPlugin({
|
|
|
360
411
|
|
|
361
412
|
What each option does:
|
|
362
413
|
|
|
363
|
-
- `pages` *(optional, `PageRegistry | PageRegistry[]`)* — registry of
|
|
414
|
+
- `pages` *(optional, `PageRegistry | PageRegistry[]`)* — registry of page components or loaders wrapped with `lazyPage(() => import(...))`. If omitted, defaults are used. If you pass one or an array, they are merged with defaults so `"/dashboard"` + `"/panel"` stay available. Later entries win on key collision.
|
|
415
|
+
- `views` *(optional, `ReactView[]`)* — components auto-mounted at their `path` with fixed `props`. Equivalent to registering a page and returning `page(path, props)` yourself.
|
|
364
416
|
- `css` / `panelCss` — replace `dist/panel.css` served at `/__rack/panel.css`. If the string points to an existing file it’s used, otherwise it’s treated as raw CSS. Useful for quick theming without forking.
|
|
365
417
|
|
|
366
418
|
Under the hood `reactPlugin` also serves `/__rack/panel-theme.js` and `/__rack/panel-app.js` and turns any `page()` return value into streamed HTML using `src/react/app.html` (`{{title}}` + `<!--app-->`).
|
|
@@ -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
|
+
};
|