@zerotal/admin 1.0.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.
- package/CHANGELOG.md +69 -0
- package/LICENSE +21 -0
- package/README.md +344 -0
- package/package.json +78 -0
- package/src/Cluster.ts +50 -0
- package/src/Panel.ts +288 -0
- package/src/PanelInstance.ts +644 -0
- package/src/Resource.ts +918 -0
- package/src/actions/Action.ts +607 -0
- package/src/actions/ImportRecordsJob.ts +108 -0
- package/src/actions/csv.ts +123 -0
- package/src/actions/index.ts +39 -0
- package/src/actions/render.tsx +181 -0
- package/src/actions/transfer.ts +307 -0
- package/src/actions/xlsx.ts +304 -0
- package/src/auth/AuthLayout.tsx +34 -0
- package/src/auth/index.ts +13 -0
- package/src/auth/pages/ForgotPasswordPage.tsx +87 -0
- package/src/auth/pages/LoginPage.tsx +121 -0
- package/src/auth/pages/ProfilePage.tsx +216 -0
- package/src/auth/pages/ResetPasswordPage.tsx +103 -0
- package/src/auth/pages/VerifyEmailPage.tsx +68 -0
- package/src/auth/register.ts +44 -0
- package/src/authRoles.ts +141 -0
- package/src/commands/MakeAdminResourceCommand.ts +181 -0
- package/src/config.ts +128 -0
- package/src/dashboardLayout.ts +101 -0
- package/src/databaseMedia.ts +148 -0
- package/src/databaseNotifications.ts +169 -0
- package/src/form/Field.ts +928 -0
- package/src/form/ResourceForm.ts +48 -0
- package/src/form/Section.ts +364 -0
- package/src/form/editors.ts +43 -0
- package/src/form/index.ts +59 -0
- package/src/history.ts +151 -0
- package/src/impersonation.ts +126 -0
- package/src/index.ts +380 -0
- package/src/infolist/Entry.ts +537 -0
- package/src/infolist/Section.ts +99 -0
- package/src/infolist/index.ts +38 -0
- package/src/media.ts +297 -0
- package/src/notifications.ts +65 -0
- package/src/pages/AdminPage.ts +100 -0
- package/src/pages/ConsolePage.tsx +324 -0
- package/src/pages/DashboardPage.tsx +264 -0
- package/src/pages/MediaPage.tsx +346 -0
- package/src/pages/NotificationsPage.tsx +155 -0
- package/src/pages/RecordViewPage.tsx +951 -0
- package/src/pages/ResourceFormPage.tsx +1856 -0
- package/src/pages/ResourceListPage.tsx +2552 -0
- package/src/pages/RolesPage.tsx +325 -0
- package/src/pages/SearchPage.tsx +169 -0
- package/src/plugin.ts +283 -0
- package/src/provider/AdminAbilityMiddleware.ts +25 -0
- package/src/provider/AdminGuardMiddleware.ts +29 -0
- package/src/provider/AdminProvider.ts +334 -0
- package/src/relations/RelationManager.ts +114 -0
- package/src/renderHooks.ts +86 -0
- package/src/roles.ts +175 -0
- package/src/savedViews.ts +79 -0
- package/src/support/ability.ts +73 -0
- package/src/support/authorize.ts +105 -0
- package/src/support/countCache.ts +37 -0
- package/src/support/hostPage.ts +30 -0
- package/src/table/Column.ts +353 -0
- package/src/table/Constraint.ts +238 -0
- package/src/table/Filter.ts +275 -0
- package/src/table/Group.ts +73 -0
- package/src/table/Tab.ts +77 -0
- package/src/testing.ts +121 -0
- package/src/theme.ts +70 -0
- package/src/ui/AdminLayout.tsx +355 -0
- package/src/ui/Breadcrumbs.tsx +84 -0
- package/src/ui/environmentIndicator.tsx +63 -0
- package/src/ui/icons.tsx +124 -0
- package/src/widgets/Widget.ts +251 -0
- package/src/widgets/render.tsx +154 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# Changelog â @zerotal/admin
|
|
2
|
+
|
|
3
|
+
All notable changes to this package are documented here. The format is
|
|
4
|
+
based on [Keep a Changelog](https://keepachangelog.com/); this package
|
|
5
|
+
follows the Zerotal monorepo's unified versioning.
|
|
6
|
+
|
|
7
|
+
**Maturity: `experimental`**
|
|
8
|
+
|
|
9
|
+
## [Unreleased]
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
#### Panel structure
|
|
14
|
+
|
|
15
|
+
- `Cluster` — a shared URL segment and one sidebar entry for a group of resources. Members opt in with `static cluster = ShopCluster`, and a cluster's `ability` gates every route inside it.
|
|
16
|
+
- Nested resources — `static parent = { resource: () => PostResource, foreignKey: "post_id" }` moves a resource under its parent's records (`/admin/posts/7/comments`). Every list is scoped to the parent before any tab or filter, and new records inherit the foreign key from the URL rather than from a form field.
|
|
17
|
+
- Singular resources — `static singular = true` collapses list, view and edit into one route for a one-row resource. The row is resolved on first visit and created from the form's defaults if absent.
|
|
18
|
+
- Multiple panels — `Panel.make(id, config)` creates an additional panel with its own resources, pages, widgets, guard, branding and URL prefix. `Panel` remains a facade over the panel owning the current request, so single-panel apps are unaffected.
|
|
19
|
+
- Resources build their own URLs: `routePath()`, `indexUrl()`, `recordUrl()`, `createUrl()`, `editUrl()`. Linking through these lets a resource move into a cluster or under a parent without any caller changing.
|
|
20
|
+
|
|
21
|
+
#### Tables
|
|
22
|
+
|
|
23
|
+
- `queryBuilder(key)` — a build-your-own filter with nested AND/OR rule groups, backed by `textConstraint` / `numberConstraint` / `dateConstraint` / `booleanConstraint` / `selectConstraint`. The whole tree is wrapped in one group so an inner `OR` cannot widen a tab, parent or soft-delete scope, and a rule naming an undeclared constraint is dropped.
|
|
24
|
+
- Active-filter indicators — a chip per filter narrowing the list, each its own undo.
|
|
25
|
+
- `filterLayout` — `"inline"` (default), `"panel"` or `"drawer"`.
|
|
26
|
+
- `tableLayout: "grid"`, `striped`, `stickyHeader`, `density`. The grid derives its cards from the columns already declared.
|
|
27
|
+
- `Resource.emptyState()` — a heading, description, icon and actions in place of a blank table. A narrowed view that matches nothing gets a different, automatic message.
|
|
28
|
+
- `Column.exportable(false)` keeps a column out of CSV exports.
|
|
29
|
+
|
|
30
|
+
#### Actions
|
|
31
|
+
|
|
32
|
+
- `exportAction()` / `bulkExportAction()` — CSV of the current list, matching its search, filters, tab and sort exactly.
|
|
33
|
+
- `importAction()` — CSV import through a two-step modal: pick a file, then map each column to a field. Mapping is seeded by matching headers to field names and labels. Rows are validated through the resource's own fields; failures are reported by line number and skipped.
|
|
34
|
+
- `importAction({ queue: true })` dispatches `ImportRecordsJob`, lifting the inline row cap. `@zerotal/queue` is resolved lazily and stays optional; with no queue configured the import runs inline.
|
|
35
|
+
- `actionGroup([...])` collapses several actions into one dropdown.
|
|
36
|
+
- `replicateAction()` copies a record and opens the copy, with `.excludeAttributes()` and `.beforeReplicaSaved()`.
|
|
37
|
+
- `Action.formUsing(fn)` builds a modal's fields from what it currently holds.
|
|
38
|
+
|
|
39
|
+
#### Infolists and forms
|
|
40
|
+
|
|
41
|
+
- `imageEntry`, `colorEntry`, `codeEntry`, `keyValueEntry`, `repeatableEntry` — the last renders a nested schema once per array item, the read side of `repeater`.
|
|
42
|
+
- `customField(key).render(fn)`, plus `.render()` on `Column` and `Entry`, for controls and cells the catalogue lacks. The renderer owns only the markup; label, validation, sorting and binding still come from the declaration.
|
|
43
|
+
|
|
44
|
+
#### Extensibility
|
|
45
|
+
|
|
46
|
+
- Render hooks — 14 named positions in the panel's chrome (`table.start`, `page.header.end`, `sidebar.end`, …). A hook returning `null` renders nothing; one that throws is logged and skipped. Available to packages through the `admin.panel` binding.
|
|
47
|
+
- `Resource.data()` — back a resource with an API, a file or a computation instead of a model. The panel filters, sorts and paginates in memory, so search, tabs and summaries keep working.
|
|
48
|
+
- `Resource.widgets()` — widgets above a resource's table, using the same builders as the dashboard.
|
|
49
|
+
- `databaseNotifications()` — a ready-made notification provider over `@zerotal/notifications`' `DatabaseChannel`, failing soft to an empty bell at every step.
|
|
50
|
+
|
|
51
|
+
#### Other
|
|
52
|
+
|
|
53
|
+
- Breadcrumbs derived from panel → cluster → parent record → resource → record.
|
|
54
|
+
- `.poll(interval)` on every widget kind; the dashboard refreshes at the shortest interval any of its widgets asked for.
|
|
55
|
+
- `bun zt make:admin-resource <Model>` with `--cluster`, `--parent`, `--foreign-key` and `--singular`.
|
|
56
|
+
- An `admin` template for `bun create zerotal`.
|
|
57
|
+
|
|
58
|
+
- `AdminProvider` also loads `app/admin/index.ts`, so a panel large enough to need a directory can have one. `app/admin.ts` still works and is tried first.
|
|
59
|
+
- `AdminProvider` now boots in the `console` environment as well, solely to register `make:admin-resource`; it mounts no routes there.
|
|
60
|
+
- `recordActions()`, `headerActions()` and `bulkActions()` return `ActionItem[]` — an action or an `ActionGroup`.
|
|
61
|
+
- `registerResourceForm()` is keyed by panel and slug, so two panels can each register the same resource slug.
|
|
62
|
+
|
|
63
|
+
## [1.0.0] — 2026-08-05
|
|
64
|
+
|
|
65
|
+
_First public release._
|
|
66
|
+
|
|
67
|
+
### Notes
|
|
68
|
+
|
|
69
|
+
- Conforms to the Zerotal package conventions (provider in `src/provider/`, PascalCase config factory, `ZerotalError`-based errors, test coverage).
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Zerotal
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
# @zerotal/admin
|
|
2
|
+
|
|
3
|
+
> A Filament-style, server-driven admin panel for Zerotal — built on
|
|
4
|
+
> [`@zerotal/flow`](../flow) (reactivity) and [`@zerotal/flow-ui`](../flow-ui) (components).
|
|
5
|
+
|
|
6
|
+
You declare a **Resource** per model; the panel renders the navigation, the data
|
|
7
|
+
table, search, sorting, and pagination for you. Pages are Flow components, so
|
|
8
|
+
everything runs on the server and streams over WebSocket — no API layer, no
|
|
9
|
+
client store.
|
|
10
|
+
|
|
11
|
+
The default UI ships with **light + dark mode out of the box**. Styling currently
|
|
12
|
+
loads Tailwind via the Play CDN (configured in `theme.ts`); swapping to a real
|
|
13
|
+
Tailwind build later only touches that one file.
|
|
14
|
+
|
|
15
|
+
> **Status:** experimental. You get navigation, list pages (search / sort /
|
|
16
|
+
> paginate), a read-only **View** page (infolists), reactive **Create / Edit**
|
|
17
|
+
> forms with validation, a **Delete** row action, and a configurable auth
|
|
18
|
+
> **guard**. The previous config-driven admin has been retired; it remains
|
|
19
|
+
> available in pre-1.1 releases (and on the `admin-legacy` git tag).
|
|
20
|
+
|
|
21
|
+
## Setup
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
// bootstrap/providers.ts
|
|
25
|
+
import { FlowProvider } from "@zerotal/flow";
|
|
26
|
+
import { AdminProvider } from "@zerotal/admin"; // after FlowProvider
|
|
27
|
+
|
|
28
|
+
export default [FlowProvider, AdminProvider];
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
// app/admin.ts (auto-loaded on boot)
|
|
33
|
+
import { Panel, Resource, text } from "@zerotal/admin";
|
|
34
|
+
import { User } from "./models/User.ts";
|
|
35
|
+
|
|
36
|
+
Panel.configure({ brand: "Acme", path: "/admin" });
|
|
37
|
+
|
|
38
|
+
class UserResource extends Resource {
|
|
39
|
+
static model = User;
|
|
40
|
+
static navigationIcon = "users";
|
|
41
|
+
static navigationGroup = "Access";
|
|
42
|
+
|
|
43
|
+
static columns() {
|
|
44
|
+
return [
|
|
45
|
+
text("id").sortable(),
|
|
46
|
+
text("name").searchable().sortable(),
|
|
47
|
+
text("email").searchable(),
|
|
48
|
+
text("role").badge((v) => (v === "admin" ? "primary" : "muted")),
|
|
49
|
+
text("created_at").label("Joined").sortable(),
|
|
50
|
+
];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
Panel.register(UserResource);
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Visit `/admin`. Each resource gets a list page (`/admin/users`), a read-only
|
|
58
|
+
detail page (`/admin/users/:id`), and a Delete action on every row.
|
|
59
|
+
|
|
60
|
+
### Guarding the panel
|
|
61
|
+
|
|
62
|
+
The panel is public by default — fine locally, not in production. Pass
|
|
63
|
+
`middleware` to gate every route (it runs on the list, view, and dashboard):
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
import { AuthMiddleware } from "@zerotal/auth";
|
|
67
|
+
|
|
68
|
+
Panel.configure({
|
|
69
|
+
path: "/admin",
|
|
70
|
+
middleware: [AuthMiddleware.with({ mustVerifyEmail: true })],
|
|
71
|
+
});
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### View page (infolists)
|
|
75
|
+
|
|
76
|
+
The detail page renders a Filament-style **infolist** — read-only entries grouped
|
|
77
|
+
into sections. Define `infolist()` to customize it; omit it and the page falls
|
|
78
|
+
back to one section derived from `columns()`.
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
import { Resource, section, textEntry, iconEntry } from "@zerotal/admin";
|
|
82
|
+
|
|
83
|
+
class UserResource extends Resource {
|
|
84
|
+
static infolist() {
|
|
85
|
+
return [
|
|
86
|
+
section("Profile")
|
|
87
|
+
.description("Identity and sign-in details")
|
|
88
|
+
.icon("users")
|
|
89
|
+
.columns(2)
|
|
90
|
+
.schema([
|
|
91
|
+
textEntry("name").weight("semibold").size("lg"),
|
|
92
|
+
textEntry("email").icon("mail").copyable(),
|
|
93
|
+
iconEntry("email_verified_at").label("Email verified"), // ✓ / ✗ boolean
|
|
94
|
+
textEntry("role")
|
|
95
|
+
.badge()
|
|
96
|
+
.color((v) => (v === "admin" ? "primary" : "muted")),
|
|
97
|
+
]),
|
|
98
|
+
section("Activity")
|
|
99
|
+
.columns(2)
|
|
100
|
+
.schema([
|
|
101
|
+
textEntry("created_at").label("Joined").dateTime(),
|
|
102
|
+
textEntry("created_at").label("Member for").since(),
|
|
103
|
+
]),
|
|
104
|
+
];
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Entry API: `.label()`, `.state(fn)`, `.default()`, `.placeholder()`, `.format(fn)`,
|
|
110
|
+
`.badge()`, `.color()`, `.icon()`, `.copyable()`, `.url(fn)`, `.weight()`, `.size()`,
|
|
111
|
+
`.tooltip()`, `.columnSpan()`, `.limit()`, `.date()` / `.dateTime()` / `.since()`,
|
|
112
|
+
`.money()`. `iconEntry(...)` renders a boolean check/cross. Section API:
|
|
113
|
+
`.heading()`, `.description()`, `.icon()`, `.columns()`, `.collapsible()`.
|
|
114
|
+
|
|
115
|
+
### Create / Edit forms
|
|
116
|
+
|
|
117
|
+
Declare `form()` to get reactive Create (`/admin/users/create`) and Edit
|
|
118
|
+
(`/admin/users/:id/edit`) pages. Fields bind two-way over Flow's WebSocket
|
|
119
|
+
runtime and validate on save — no API layer, no client store.
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
import { Resource, textInput, textarea, select } from "@zerotal/admin";
|
|
123
|
+
import { Hash } from "@zerotal/auth";
|
|
124
|
+
|
|
125
|
+
class UserResource extends Resource {
|
|
126
|
+
static form() {
|
|
127
|
+
return [
|
|
128
|
+
textInput("name").required().minLength(2).maxLength(120),
|
|
129
|
+
textInput("email").email().required(),
|
|
130
|
+
select("role").options({ admin: "Admin", member: "Member" }).required(),
|
|
131
|
+
// Hash on save; only ask for it when creating.
|
|
132
|
+
textInput("password")
|
|
133
|
+
.password()
|
|
134
|
+
.required()
|
|
135
|
+
.minLength(8)
|
|
136
|
+
.visibleOn("create")
|
|
137
|
+
.mutate((v) => Hash.make(String(v))),
|
|
138
|
+
];
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Field API: `textInput` / `textarea` / `select` / `checkbox`; type modifiers
|
|
144
|
+
`.email()`, `.password()`, `.numeric()`, `.url()`, `.tel()`; `.label()`,
|
|
145
|
+
`.placeholder()`, `.helperText()`, `.default()`, `.required()`, `.minLength()` /
|
|
146
|
+
`.maxLength()` / `.min()` / `.max()`, `.confirmed()`, `.options()`, `.rows()`,
|
|
147
|
+
`.columnSpan()`, `.disabled()`, `.rule(fn)`, `.mutate(fn)` (transform before
|
|
148
|
+
save, e.g. hashing), and `.visibleOn()` / `.hiddenOn()` to vary a field by page.
|
|
149
|
+
|
|
150
|
+
## Column API (Phase 1)
|
|
151
|
+
|
|
152
|
+
```ts
|
|
153
|
+
text("name")
|
|
154
|
+
.label("Full name") // header text (defaults to a title-cased key)
|
|
155
|
+
.sortable() // clickable, URL-driven sort header
|
|
156
|
+
.searchable() // included in the list search box
|
|
157
|
+
.align("end") // "start" | "center" | "end"
|
|
158
|
+
.format((v) => …) // custom value formatter
|
|
159
|
+
.badge((v) => "success" | "primary" | "muted" | "destructive" | null)
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## Theming / dark mode
|
|
163
|
+
|
|
164
|
+
`theme.ts` defines the shadcn-style design tokens (the same ones flow-ui
|
|
165
|
+
components consume) for both `:root` and `.dark`, plus a no-flash init script.
|
|
166
|
+
The toggle in the top bar persists the choice to `localStorage`. To rebrand,
|
|
167
|
+
edit the `--primary` (and friends) HSL values in `theme.ts`.
|
|
168
|
+
|
|
169
|
+
## Listing: tabs + default sort
|
|
170
|
+
|
|
171
|
+
Add filter **tabs** (Filament's `getTabs()`) above the table, each scoping the
|
|
172
|
+
query with an optional count badge, and set a **default sort**:
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
import { Resource, tab } from "@zerotal/admin";
|
|
176
|
+
|
|
177
|
+
class UserResource extends Resource {
|
|
178
|
+
static defaultSort = { column: "created_at", direction: "desc" as const };
|
|
179
|
+
|
|
180
|
+
static tabs() {
|
|
181
|
+
return [
|
|
182
|
+
tab("all").label("All").badge(),
|
|
183
|
+
tab("verified")
|
|
184
|
+
.label("Verified")
|
|
185
|
+
.badge()
|
|
186
|
+
.badgeColor("success")
|
|
187
|
+
.modifyQuery((q) => q.whereNotNull!("email_verified_at")),
|
|
188
|
+
tab("unverified")
|
|
189
|
+
.label("Unverified")
|
|
190
|
+
.badge()
|
|
191
|
+
.modifyQuery((q) => q.whereNull!("email_verified_at")),
|
|
192
|
+
];
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
`tab(key)` API: `.label()`, `.icon()`, `.badge(value?)` (no arg = live count),
|
|
198
|
+
`.badgeColor(tone)`, `.modifyQuery(q => q)`. The active tab lives in the URL
|
|
199
|
+
(`?tab=…`) and composes with search, sort, and pagination.
|
|
200
|
+
|
|
201
|
+
Badge **counts are cached** per resource and invalidated automatically whenever
|
|
202
|
+
one of its records is created / updated / deleted (via the ORM's `ModelChanged`
|
|
203
|
+
event), so the `COUNT(*)` per tab runs only after a write — not on every list
|
|
204
|
+
view. Caching is best-effort: with no cache driver bound, counts are computed
|
|
205
|
+
each render.
|
|
206
|
+
|
|
207
|
+
## Relationships
|
|
208
|
+
|
|
209
|
+
Three pieces, mirroring Filament's relationship support:
|
|
210
|
+
|
|
211
|
+
```ts
|
|
212
|
+
class PostResource extends Resource {
|
|
213
|
+
static model = Post;
|
|
214
|
+
static eager = ["author"]; // eager-load relations for the table/view
|
|
215
|
+
|
|
216
|
+
static columns() {
|
|
217
|
+
return [
|
|
218
|
+
text("title").searchable().sortable(),
|
|
219
|
+
// BelongsTo column — read the loaded relation
|
|
220
|
+
text("author")
|
|
221
|
+
.label("Author")
|
|
222
|
+
.format((author) => author?.name ?? "—"),
|
|
223
|
+
];
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
static form() {
|
|
227
|
+
return [
|
|
228
|
+
textInput("title").required(),
|
|
229
|
+
// BelongsTo <select> — options loaded from the related model
|
|
230
|
+
select("userId")
|
|
231
|
+
.label("Author")
|
|
232
|
+
.required()
|
|
233
|
+
.optionsUsing(async () =>
|
|
234
|
+
(await User.all()).map((u) => ({ value: String(u.id), label: u.name })),
|
|
235
|
+
),
|
|
236
|
+
];
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
class UserResource extends Resource {
|
|
241
|
+
static model = User;
|
|
242
|
+
// HasMany relation manager — the user's posts, as a linked table on the View page
|
|
243
|
+
static relations() {
|
|
244
|
+
return [hasMany(PostResource, "user_id").title("Posts")];
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
- `static eager: string[]` — relations to eager-load for list + view (so a column
|
|
250
|
+
or entry can read `row.author.name`).
|
|
251
|
+
- `select(key).optionsUsing(async () => …)` — async option source for a BelongsTo
|
|
252
|
+
picker. FK columns should be cast (`@column("integer")`) so they match the
|
|
253
|
+
parent key type when the ORM resolves the relation.
|
|
254
|
+
- `relations()` + `hasMany(RelatedResource, foreignKey)` — renders the children as
|
|
255
|
+
a table on the parent's View page, linking into their own resource for full CRUD.
|
|
256
|
+
|
|
257
|
+
## Custom pages
|
|
258
|
+
|
|
259
|
+
Anything that isn't a model to edit — a settings screen, a report, an ops console
|
|
260
|
+
— extends `AdminPage`. It's a Flow component with statics describing where it
|
|
261
|
+
belongs, so the panel mounts the route and adds the sidebar entry for you.
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
import { AdminPage, Panel } from "@zerotal/admin";
|
|
265
|
+
|
|
266
|
+
class ReportsPage extends AdminPage {
|
|
267
|
+
static override slug = "reports";
|
|
268
|
+
static override title = "Reports";
|
|
269
|
+
static override navigationGroup = "Insights";
|
|
270
|
+
static override ability = "reports.view";
|
|
271
|
+
|
|
272
|
+
override async render() {
|
|
273
|
+
return <div>…</div>;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
Panel.pages(ReportsPage);
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
`ability` is checked twice: once to draw the sidebar entry, once in the route
|
|
281
|
+
guard. Both run the same resolver, so a link the user can't see is a URL they
|
|
282
|
+
can't open. Abilities resolve through `authorize` in `config/admin.ts`, then the
|
|
283
|
+
`gate` binding when `@zerotal/auth` is installed — and with neither configured
|
|
284
|
+
they're denied outside a development environment, so an unwired panel stays closed
|
|
285
|
+
in production.
|
|
286
|
+
|
|
287
|
+
## Extending the panel from a package
|
|
288
|
+
|
|
289
|
+
The panel is a host. It publishes a contribution surface as the `admin.panel`
|
|
290
|
+
container binding and names no contributor, so another package can add pages,
|
|
291
|
+
widgets, navigation and search results without depending on this one:
|
|
292
|
+
|
|
293
|
+
```ts
|
|
294
|
+
// In a contributing provider's onBooting()
|
|
295
|
+
const panel = app.container.tryMake("admin.panel") as AdminHost | undefined;
|
|
296
|
+
if (!panel?.enabled("queue")) return;
|
|
297
|
+
panel.page({ slug: "jobs", page: JobsPage, title: "Jobs", ability: "queue.view" });
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
Contributors declare the host's shape locally rather than importing it, so nothing
|
|
301
|
+
links the two packages at build time. Contributions are automatic — installing
|
|
302
|
+
both providers is enough — and `plugins: { queue: false }` in `config/admin.ts`
|
|
303
|
+
switches one off without uninstalling it.
|
|
304
|
+
|
|
305
|
+
See [the admin guide](../../docs/admin/extending.md) for the full contribution surface.
|
|
306
|
+
|
|
307
|
+
## Structuring a larger panel
|
|
308
|
+
|
|
309
|
+
Six resources need no structure. Past that:
|
|
310
|
+
|
|
311
|
+
- **Clusters** give a group of resources a shared URL segment and one sidebar
|
|
312
|
+
entry — `static cluster = ShopCluster` puts a resource at `/admin/shop/products`.
|
|
313
|
+
- **Nested resources** move a resource inside its parent's records:
|
|
314
|
+
`static parent = { resource: () => PostResource, foreignKey: "post_id" }` gives
|
|
315
|
+
`/admin/posts/7/comments`, scoped to that post.
|
|
316
|
+
- **Singular resources** back a single row — `static singular = true` collapses
|
|
317
|
+
list, view and edit into one route.
|
|
318
|
+
- **Multiple panels** serve a second audience: `Panel.make("console", { path: "/app" })`
|
|
319
|
+
gets its own registry, guard and branding.
|
|
320
|
+
|
|
321
|
+
See [Panel Structure](../../docs/admin/structure.md) for the details.
|
|
322
|
+
|
|
323
|
+
## Scaffolding
|
|
324
|
+
|
|
325
|
+
```bash
|
|
326
|
+
bun zt make:admin-resource Product
|
|
327
|
+
bun zt make:admin-resource Comment --parent=Post --foreign-key=post_id
|
|
328
|
+
bun zt make:admin-resource Setting --singular
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
Or start a whole project from the panel: `bun create zerotal my-admin` and choose
|
|
332
|
+
the **Admin** template.
|
|
333
|
+
|
|
334
|
+
## Not yet covered
|
|
335
|
+
|
|
336
|
+
Multi-tenancy and the 2FA challenge step.
|
|
337
|
+
|
|
338
|
+
> **Design note:** the List and View pages are generated as one subclass per
|
|
339
|
+
> resource and are **URL-driven** — search, sort, pagination, and tabs live in
|
|
340
|
+
> `@url` props that re-seed from the query string on every navigation, so they
|
|
341
|
+
> work for any number of resources. The Create/Edit page is different: it binds a
|
|
342
|
+
> client-reactive `@expose form` object over the WebSocket, which Flow's
|
|
343
|
+
> field-decorator registration ties to a single prototype, so it's a single
|
|
344
|
+
> shared class that resolves the resource from the route slug.
|
package/package.json
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zerotal/admin",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"maturity": "experimental",
|
|
6
|
+
"private": false,
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "./src/index.ts",
|
|
9
|
+
"types": "./src/index.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./src/index.ts",
|
|
12
|
+
"./auth": "./src/auth/index.ts",
|
|
13
|
+
"./testing": "./src/testing.ts"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"CHANGELOG.md",
|
|
17
|
+
"src",
|
|
18
|
+
"!src/**/*.test.ts",
|
|
19
|
+
"!src/**/*.test.tsx",
|
|
20
|
+
"!src/**/*.spec.ts",
|
|
21
|
+
"!src/**/__fixtures__/**"
|
|
22
|
+
],
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"engines": {
|
|
27
|
+
"bun": ">=1.3.14"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"test": "bun test",
|
|
31
|
+
"typecheck": "tsc --noEmit"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@zerotal/cache": "1.0.0",
|
|
35
|
+
"@zerotal/core": "1.0.0",
|
|
36
|
+
"@zerotal/flow": "1.0.0",
|
|
37
|
+
"@zerotal/flow-ui": "1.0.0",
|
|
38
|
+
"@zerotal/validator": "1.0.0"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@zerotal/orm": "1.0.0",
|
|
42
|
+
"@zerotal/auth": "1.0.0",
|
|
43
|
+
"@zerotal/queue": "1.0.0"
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"@zerotal/orm": "^1.0.0",
|
|
47
|
+
"@zerotal/auth": "^1.0.0",
|
|
48
|
+
"@zerotal/queue": "^1.0.0"
|
|
49
|
+
},
|
|
50
|
+
"peerDependenciesMeta": {
|
|
51
|
+
"@zerotal/orm": {
|
|
52
|
+
"optional": true
|
|
53
|
+
},
|
|
54
|
+
"@zerotal/auth": {
|
|
55
|
+
"optional": true
|
|
56
|
+
},
|
|
57
|
+
"@zerotal/queue": {
|
|
58
|
+
"optional": true
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
"description": "Auto-generated admin panel (resources, tables, forms, widgets) for Zerotal apps.",
|
|
62
|
+
"keywords": [
|
|
63
|
+
"zerotal",
|
|
64
|
+
"bun",
|
|
65
|
+
"typescript",
|
|
66
|
+
"framework",
|
|
67
|
+
"admin",
|
|
68
|
+
"crud",
|
|
69
|
+
"panel"
|
|
70
|
+
],
|
|
71
|
+
"repository": {
|
|
72
|
+
"type": "git",
|
|
73
|
+
"url": "git+https://github.com/zerotaldev/zerotal.git",
|
|
74
|
+
"directory": "packages/admin"
|
|
75
|
+
},
|
|
76
|
+
"homepage": "https://github.com/zerotaldev/zerotal/tree/main/packages/admin#readme",
|
|
77
|
+
"bugs": "https://github.com/zerotaldev/zerotal/issues"
|
|
78
|
+
}
|
package/src/Cluster.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cluster — a named section of the panel that owns a group of resources and
|
|
3
|
+
* pages, giving them a shared URL segment and a single sidebar entry.
|
|
4
|
+
*
|
|
5
|
+
* A panel with thirty resources spread across three business areas is easier to
|
|
6
|
+
* navigate as three clusters than as one long list. Members declare which
|
|
7
|
+
* cluster they belong to; the panel does the rest:
|
|
8
|
+
*
|
|
9
|
+
* export class ShopCluster extends Cluster {
|
|
10
|
+
* static override slug = "shop";
|
|
11
|
+
* static override title = "Shop";
|
|
12
|
+
* static override navigationIcon = "collection";
|
|
13
|
+
* }
|
|
14
|
+
*
|
|
15
|
+
* export class ProductResource extends Resource {
|
|
16
|
+
* static override cluster = ShopCluster; // → /admin/shop/products
|
|
17
|
+
* }
|
|
18
|
+
*
|
|
19
|
+
* The cluster's own `ability` gates the whole section: deny it and every member
|
|
20
|
+
* disappears from the sidebar and refuses its route, without each member having
|
|
21
|
+
* to repeat the check.
|
|
22
|
+
*/
|
|
23
|
+
export abstract class Cluster {
|
|
24
|
+
/** URL segment the cluster's members live under. */
|
|
25
|
+
static slug: string;
|
|
26
|
+
/** Heading shown for the cluster in navigation and breadcrumbs. */
|
|
27
|
+
static title: string;
|
|
28
|
+
/** Sidebar label, when it should differ from the title. */
|
|
29
|
+
static navigationLabel?: string;
|
|
30
|
+
/** Navigation icon key (see `ui/icons.ts`). */
|
|
31
|
+
static navigationIcon = "layout-grid";
|
|
32
|
+
/** Optional sidebar group the cluster itself sits in. */
|
|
33
|
+
static navigationGroup?: string;
|
|
34
|
+
/** Sort order within the sidebar (lower = higher). */
|
|
35
|
+
static navigationSort = 0;
|
|
36
|
+
/** Ability gating the whole cluster, checked for the entry and every route. */
|
|
37
|
+
static ability?: string;
|
|
38
|
+
|
|
39
|
+
static getNavigationLabel(): string {
|
|
40
|
+
return this.navigationLabel ?? this.title;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The cluster's URL prefix under a panel base. */
|
|
44
|
+
static url(base: string): string {
|
|
45
|
+
return `${base}/${this.slug}`;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A Cluster subclass (used by its static surface — never instantiated). */
|
|
50
|
+
export type ClusterClass = typeof Cluster;
|