@zerotal/arch 1.7.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 +31 -0
- package/LICENSE +21 -0
- package/README.md +67 -0
- package/api-surface.md +283 -0
- package/docs/about.md +676 -0
- package/docs/admin/actions.md +183 -0
- package/docs/admin/auth.md +89 -0
- package/docs/admin/dashboard.md +155 -0
- package/docs/admin/extending-ui.md +229 -0
- package/docs/admin/extending.md +205 -0
- package/docs/admin/forms.md +155 -0
- package/docs/admin/index.md +144 -0
- package/docs/admin/operations.md +224 -0
- package/docs/admin/references.md +174 -0
- package/docs/admin/resources.md +132 -0
- package/docs/admin/structure.md +158 -0
- package/docs/admin/tables.md +185 -0
- package/docs/admin/testing.md +138 -0
- package/docs/ai.md +373 -0
- package/docs/application.md +435 -0
- package/docs/arch.md +263 -0
- package/docs/assets.md +755 -0
- package/docs/audit.md +367 -0
- package/docs/authentication.md +1000 -0
- package/docs/authorization.md +529 -0
- package/docs/broadcasting/channels.md +170 -0
- package/docs/broadcasting/client.md +108 -0
- package/docs/broadcasting/events.md +190 -0
- package/docs/broadcasting/index.md +163 -0
- package/docs/broadcasting/references.md +84 -0
- package/docs/broadcasting/testing.md +132 -0
- package/docs/cache.md +387 -0
- package/docs/carbon.md +830 -0
- package/docs/changelog.md +465 -0
- package/docs/client/auth.md +113 -0
- package/docs/client/errors.md +139 -0
- package/docs/client/files.md +118 -0
- package/docs/client/index.md +128 -0
- package/docs/client/references.md +58 -0
- package/docs/client/requests.md +131 -0
- package/docs/client/resilience.md +141 -0
- package/docs/client/testing.md +146 -0
- package/docs/commands.md +450 -0
- package/docs/components.md +2427 -0
- package/docs/config-system.md +258 -0
- package/docs/container.md +596 -0
- package/docs/context.md +579 -0
- package/docs/contributing.md +164 -0
- package/docs/controllers.md +354 -0
- package/docs/conventions.md +466 -0
- package/docs/cookies.md +101 -0
- package/docs/csrf.md +206 -0
- package/docs/database.md +476 -0
- package/docs/deployment.md +434 -0
- package/docs/devtools.md +1013 -0
- package/docs/email-verification.md +320 -0
- package/docs/encryption.md +380 -0
- package/docs/errors.md +317 -0
- package/docs/events.md +420 -0
- package/docs/flow/components.md +846 -0
- package/docs/flow/decorators.md +458 -0
- package/docs/flow/events.md +420 -0
- package/docs/flow/forms.md +551 -0
- package/docs/flow/index.md +568 -0
- package/docs/flow/layouts.md +793 -0
- package/docs/flow/lifecycle.md +336 -0
- package/docs/flow/pagination.md +402 -0
- package/docs/flow/performance.md +262 -0
- package/docs/flow/references.md +317 -0
- package/docs/flow/routing.md +550 -0
- package/docs/flow/testing.md +447 -0
- package/docs/getting-started.md +286 -0
- package/docs/health.md +269 -0
- package/docs/helpers.md +423 -0
- package/docs/i18n.md +414 -0
- package/docs/index.md +65 -0
- package/docs/inertia/build.md +95 -0
- package/docs/inertia/devtools.md +197 -0
- package/docs/inertia/index.md +267 -0
- package/docs/inertia/middleware.md +81 -0
- package/docs/inertia/props.md +507 -0
- package/docs/inertia/references.md +144 -0
- package/docs/inertia/rendering.md +189 -0
- package/docs/inertia/ssr.md +102 -0
- package/docs/inspirations.md +257 -0
- package/docs/lifecycle.md +331 -0
- package/docs/lock.md +454 -0
- package/docs/logger.md +517 -0
- package/docs/media.md +582 -0
- package/docs/middleware.md +509 -0
- package/docs/migrations.md +529 -0
- package/docs/monitor.md +377 -0
- package/docs/notifications.md +848 -0
- package/docs/orm/casts.md +365 -0
- package/docs/orm/factories.md +231 -0
- package/docs/orm/index.md +663 -0
- package/docs/orm/lifecycle.md +421 -0
- package/docs/orm/queries.md +692 -0
- package/docs/orm/relationships.md +440 -0
- package/docs/orm/serialization.md +251 -0
- package/docs/package-development.md +536 -0
- package/docs/pagination.md +340 -0
- package/docs/password-reset.md +335 -0
- package/docs/providers.md +447 -0
- package/docs/query-builder.md +465 -0
- package/docs/queue.md +549 -0
- package/docs/rate-limiting.md +253 -0
- package/docs/responses.md +336 -0
- package/docs/roles-and-2fa.md +418 -0
- package/docs/routing.md +1008 -0
- package/docs/scaffolding.md +335 -0
- package/docs/scheduler.md +533 -0
- package/docs/seeding.md +229 -0
- package/docs/session.md +451 -0
- package/docs/social.md +524 -0
- package/docs/storage.md +622 -0
- package/docs/structure.md +230 -0
- package/docs/support-policy.md +114 -0
- package/docs/telemetry.md +505 -0
- package/docs/tenancy.md +608 -0
- package/docs/testing/browser.md +153 -0
- package/docs/testing/console.md +130 -0
- package/docs/testing/database.md +238 -0
- package/docs/testing/flow-browser.md +216 -0
- package/docs/testing/http.md +441 -0
- package/docs/testing/index.md +240 -0
- package/docs/testing/mocking.md +410 -0
- package/docs/upgrade.md +192 -0
- package/docs/validator.md +549 -0
- package/docs/view.md +453 -0
- package/package.json +60 -0
- package/src/bin/mcp.ts +73 -0
- package/src/config.ts +63 -0
- package/src/errors.ts +27 -0
- package/src/index.ts +56 -0
- package/src/install/ArchInstallCommand.ts +189 -0
- package/src/install/detect.ts +98 -0
- package/src/install/guidelines.ts +290 -0
- package/src/install/markers.ts +77 -0
- package/src/install/mcpConfig.ts +81 -0
- package/src/mcp/index.ts +27 -0
- package/src/mcp/jsonrpc.ts +133 -0
- package/src/mcp/server.ts +307 -0
- package/src/mcp/stdio.ts +111 -0
- package/src/mcp/types.ts +146 -0
- package/src/probe/ArchProbeCommand.ts +48 -0
- package/src/probe/sentinel.ts +13 -0
- package/src/probe/topics.ts +291 -0
- package/src/provider/ArchProvider.ts +101 -0
- package/src/tools/_probe.ts +164 -0
- package/src/tools/apiSurface.ts +241 -0
- package/src/tools/appInfo.ts +88 -0
- package/src/tools/baselines.ts +210 -0
- package/src/tools/context.ts +37 -0
- package/src/tools/doctor.ts +93 -0
- package/src/tools/index.ts +50 -0
- package/src/tools/logs.ts +265 -0
- package/src/tools/routes.ts +133 -0
- package/src/tools/schema.ts +119 -0
- package/src/tools/searchDocs.ts +345 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Operations
|
|
3
|
+
description: The features a panel grows once real people use it daily — record history, impersonation, saved views, the media library, roles and permissions, and a dashboard each person arranges.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Operations
|
|
7
|
+
|
|
8
|
+
Everything in the rest of this guide is about getting a panel to show your data. This
|
|
9
|
+
page is about the things people start asking for once they use it every day: who
|
|
10
|
+
changed that, why can't this person see the thing they say they see, why do I have to
|
|
11
|
+
set up the same filters every morning.
|
|
12
|
+
|
|
13
|
+
| Somebody asks | Reach for |
|
|
14
|
+
| -------------------------------------------------- | ------------------------------------------------------- |
|
|
15
|
+
| "Who changed this price, and can we put it back?" | [Record history](#record-history) |
|
|
16
|
+
| "The customer says the button is missing" | [Impersonation](#impersonation) |
|
|
17
|
+
| "I set these filters up every single morning" | [Saved views](#saved-views) |
|
|
18
|
+
| "Where did that logo go? I uploaded it last week" | [The media library](#the-media-library) |
|
|
19
|
+
| "Can support see orders but not delete them?" | [Roles and permissions](#roles-and-permissions) |
|
|
20
|
+
| "I don't care about revenue, I care about tickets" | [A dashboard per person](#a-dashboard-per-person) |
|
|
21
|
+
| "I edited it and my changes vanished" | [Record locking](#record-locking) |
|
|
22
|
+
| "Am I on production right now?" | [The environment indicator](#the-environment-indicator) |
|
|
23
|
+
|
|
24
|
+
Most of these need somewhere to keep something, and where that is depends on your
|
|
25
|
+
app rather than on the panel. So each takes a small provider you supply, and none of
|
|
26
|
+
them appears in the UI until you do — a panel with nothing configured looks exactly
|
|
27
|
+
as it does today.
|
|
28
|
+
|
|
29
|
+
## Record history
|
|
30
|
+
|
|
31
|
+
Turn it on per resource:
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
export class ProductResource extends Resource {
|
|
35
|
+
static override history = true;
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The view page grows a History card listing what changed, when, and by whom, with a
|
|
40
|
+
Revert button on each entry that can be undone. It reads from `@zerotal/audit`, so
|
|
41
|
+
you need that package recording the model — the panel does not keep a second trail of
|
|
42
|
+
its own, which would only be a second thing to disagree with the first.
|
|
43
|
+
|
|
44
|
+
Reverting restores the previous values of the fields that entry changed, not the whole
|
|
45
|
+
record as it was. That distinction matters: reverting a price change from three weeks
|
|
46
|
+
ago should not also undo the description somebody fixed yesterday.
|
|
47
|
+
|
|
48
|
+
## Impersonation
|
|
49
|
+
|
|
50
|
+
The support request nobody can reproduce is usually solved by seeing what the person
|
|
51
|
+
actually sees.
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
export class UserResource extends Resource {
|
|
55
|
+
static override impersonatable = true;
|
|
56
|
+
|
|
57
|
+
static override can(ability: string, record?: AdminRecord): boolean {
|
|
58
|
+
if (ability !== "impersonate") return true;
|
|
59
|
+
// Nobody impersonates an administrator — otherwise this is a way to
|
|
60
|
+
// acquire more access than you have.
|
|
61
|
+
return !((record?.roles as string[]) ?? []).includes("admin");
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Add `impersonateAction()` to the resource's row actions and the panel handles the
|
|
67
|
+
rest: it remembers who started the impersonation in the session, shows an amber banner
|
|
68
|
+
across the top while it is going on, and offers one link back. The original user is
|
|
69
|
+
remembered in the session rather than derived from the impersonated account, so
|
|
70
|
+
returning always works even if the account you switched into is broken.
|
|
71
|
+
|
|
72
|
+
Two rules are enforced rather than suggested. Impersonation never nests — a second one
|
|
73
|
+
on top of a first makes "stop" ambiguous, so it is refused. And `can("impersonate")`
|
|
74
|
+
decides per record, defaulting to refusing.
|
|
75
|
+
|
|
76
|
+
## Saved views
|
|
77
|
+
|
|
78
|
+
Every bit of list state already lives in the URL: search, filters, tab, sort, column
|
|
79
|
+
visibility, grouping, page size. So a saved view is a saved query string, and
|
|
80
|
+
restoring one is a link.
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
Panel.savedViews({
|
|
84
|
+
async list(resource) {
|
|
85
|
+
/* … */
|
|
86
|
+
},
|
|
87
|
+
async save(view) {
|
|
88
|
+
/* … */
|
|
89
|
+
},
|
|
90
|
+
async remove(id) {
|
|
91
|
+
/* … */
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
A Views control then appears above every list, with a name box for saving the current
|
|
97
|
+
one. Page number is deliberately excluded from what a view stores: a view should
|
|
98
|
+
restore how a list was _shaped_, not which page of it somebody happened to be on.
|
|
99
|
+
|
|
100
|
+
## The media library
|
|
101
|
+
|
|
102
|
+
A file upload field puts a file somewhere and stores a path. That works until the same
|
|
103
|
+
logo is needed on twenty products, or somebody wants to know what is still pointing at
|
|
104
|
+
a file before deleting it.
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
import { databaseMedia, mediaPicker } from "@zerotal/admin";
|
|
108
|
+
|
|
109
|
+
Panel.media(databaseMedia());
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`databaseMedia()` expects a table with `path`, `name`, `mime`, `size`, `alt`, `folder`
|
|
113
|
+
and `uploaded_at`; the column names are adjustable, so an existing table usually needs
|
|
114
|
+
no migration. Supply your own `{ list, save, remove }` if the catalogue belongs
|
|
115
|
+
somewhere else.
|
|
116
|
+
|
|
117
|
+
Configured, the panel gains a Media page — a grid with upload, search, folder
|
|
118
|
+
filtering, alt-text editing and deletion — and forms can use the picker:
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
mediaPicker("imageUrl").label("Image");
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
which offers the library in a modal, with upload-and-select in the same dialog.
|
|
125
|
+
|
|
126
|
+
The split between the two halves is deliberate. `zerotal/storage` holds the bytes;
|
|
127
|
+
the catalogue holds the record of them. Listing a bucket is not a substitute, because
|
|
128
|
+
it cannot tell you alt text, who uploaded something, or what it is for — and on a
|
|
129
|
+
large disk it is slow besides.
|
|
130
|
+
|
|
131
|
+
## Roles and permissions
|
|
132
|
+
|
|
133
|
+
Authorization already works without a UI: a resource's `can()` answers every question
|
|
134
|
+
the panel asks. What is missing is the other direction — seeing who can do what, and
|
|
135
|
+
changing it, without editing code.
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
import { authRoles } from "@zerotal/admin";
|
|
139
|
+
|
|
140
|
+
Panel.roles(authRoles({ superusers: ["admin"] }));
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`authRoles()` drives the role-based access control in `@zerotal/auth`, so the matrix
|
|
144
|
+
edits the same roles and permissions the app already checks against — ticking a box
|
|
145
|
+
makes a real check start passing. Supply your own `{ list, permissionsFor,
|
|
146
|
+
setPermissions }` if roles live elsewhere.
|
|
147
|
+
|
|
148
|
+
The permission catalogue is derived, not declared. The panel walks its registered
|
|
149
|
+
resources, pages and actions and reports every ability it actually checks:
|
|
150
|
+
`products.viewAny`, `products.delete`, the soft-delete abilities where the model has
|
|
151
|
+
them, and every custom action's key. A hand-maintained list drifts the moment somebody
|
|
152
|
+
adds a resource, and a matrix missing a row is worse than no matrix, because it
|
|
153
|
+
quietly implies the permission does not exist.
|
|
154
|
+
|
|
155
|
+
A role named as a superuser is shown holding everything and cannot be edited or
|
|
156
|
+
deleted from the panel. That is worth modelling explicitly rather than by ticking
|
|
157
|
+
every box, so an administrator does not silently lose access to a resource added next
|
|
158
|
+
week.
|
|
159
|
+
|
|
160
|
+
## A dashboard per person
|
|
161
|
+
|
|
162
|
+
What belongs at the top of a dashboard differs by role, and neither the finance lead
|
|
163
|
+
nor support wants to scroll past the other's widget every morning.
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
Panel.dashboardLayout({
|
|
167
|
+
async load() {
|
|
168
|
+
return Auth.user()?.dashboard ?? null;
|
|
169
|
+
},
|
|
170
|
+
async save(layout) {
|
|
171
|
+
await Auth.user()?.update({ dashboard: layout });
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
An Arrange control appears on the dashboard, with move-up, move-down and hide per
|
|
177
|
+
widget, and a Reset that puts it back the way the app declares it. Name your widgets
|
|
178
|
+
with `.key("revenue")` if their titles might change — a layout keyed by title silently
|
|
179
|
+
resets the moment somebody rewords a heading.
|
|
180
|
+
|
|
181
|
+
This is order and visibility, not a drag-around canvas. Dragging boxes on a grid is a
|
|
182
|
+
lot of machinery to maintain and mostly produces layouts that break at the next screen
|
|
183
|
+
width; reordering and hiding covers what people actually ask for and stays responsive
|
|
184
|
+
by construction.
|
|
185
|
+
|
|
186
|
+
The declaration stays the source of truth for _which_ widgets exist. A widget added
|
|
187
|
+
since somebody last arranged their dashboard appears at the end, where it is
|
|
188
|
+
noticeable rather than lost, and a widget since removed does not come back because a
|
|
189
|
+
stale key mentions it.
|
|
190
|
+
|
|
191
|
+
## Record locking
|
|
192
|
+
|
|
193
|
+
Two people editing the same record is normal; one of them losing their work silently
|
|
194
|
+
is not.
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
export class ProductResource extends Resource {
|
|
198
|
+
static override optimisticLock = "version";
|
|
199
|
+
}
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
The form remembers the version it loaded, and a save against a stale one is refused
|
|
203
|
+
with a field error rather than going through. Refusing is the only safe answer here:
|
|
204
|
+
overwriting loses somebody's work, and merging blind is worse.
|
|
205
|
+
|
|
206
|
+
## The environment indicator
|
|
207
|
+
|
|
208
|
+
The expensive mistake is editing production believing it is staging.
|
|
209
|
+
|
|
210
|
+
```ts
|
|
211
|
+
import { environmentIndicator } from "@zerotal/admin";
|
|
212
|
+
|
|
213
|
+
Panel.renderHook("body.start", environmentIndicator());
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
A coloured strip across the top names the environment — red for production, amber for
|
|
217
|
+
staging. It returns nothing in local development, so registering it unconditionally is
|
|
218
|
+
the intended usage; there is nothing to switch off per environment.
|
|
219
|
+
|
|
220
|
+
## Next steps
|
|
221
|
+
|
|
222
|
+
- [Panel Structure](/docs/admin/structure) — clusters, nested resources, multiple panels.
|
|
223
|
+
- [Extending the UI](/docs/admin/extending-ui) — render hooks, custom cells, table layouts.
|
|
224
|
+
- [Reference](/docs/admin/references) — the full API surface in one table.
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Admin References
|
|
3
|
+
description: Every resource, table, form, and action API in one table.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# References
|
|
7
|
+
|
|
8
|
+
## `Panel`
|
|
9
|
+
|
|
10
|
+
| Method | Description |
|
|
11
|
+
| ---------------------------------------- | ------------------------------------------------- |
|
|
12
|
+
| `Panel.configure(config)` | Merge panel configuration. |
|
|
13
|
+
| `Panel.register(...resources)` | Register one or more `Resource` classes. |
|
|
14
|
+
| `Panel.pages(...pages)` | Register `AdminPage` subclasses. |
|
|
15
|
+
| `Panel.plugin(...plugins)` | Install app-authored plugins. |
|
|
16
|
+
| `Panel.widgets(...widgets)` | Register dashboard widgets. |
|
|
17
|
+
| `Panel.notifications(provider)` | Wire the notification center. |
|
|
18
|
+
| `Panel.auth(config)` | Enable + configure the auth pages. |
|
|
19
|
+
| `Panel.can(ability)` | Resolve an ability the way the panel does. |
|
|
20
|
+
| `Panel.host()` | The contribution surface, bound as `admin.panel`. |
|
|
21
|
+
| `Panel.resources()` / `find(slug)` | Inspect the resource registry. |
|
|
22
|
+
| `Panel.registeredPages()` / `findPage()` | Inspect the page registry. |
|
|
23
|
+
| `Panel.navigation()` | The full sidebar map, unfiltered. |
|
|
24
|
+
| `Panel.visibleNavigation()` | The sidebar as the current user may see it. |
|
|
25
|
+
| `Panel.make(id, config)` | Create an additional panel on its own path. |
|
|
26
|
+
| `Panel.get(id)` / `Panel.all()` | Inspect the panel registry. |
|
|
27
|
+
| `Panel.default()` | The panel every app starts with. |
|
|
28
|
+
| `Panel.current()` | The panel owning the request being served. |
|
|
29
|
+
| `Panel.renderHook(name, fn)` | Render into a named position in the chrome. |
|
|
30
|
+
| `Panel.renderHooks(name)` | The hooks registered at a position. |
|
|
31
|
+
|
|
32
|
+
## `Resource` statics
|
|
33
|
+
|
|
34
|
+
`model`, `slug`, `label`, `pluralLabel`, `primaryKey`, `perPage`, `defaultSort`,
|
|
35
|
+
`eager`, `recordTitleAttribute`, `navigationIcon`, `navigationGroup`,
|
|
36
|
+
`navigationSort`, `navigationParentItem`, `navigationBadgeColor`, `reorderable`,
|
|
37
|
+
`defaultGroup`, `cluster`, `parent`, `singular`, `tableLayout`, `striped`,
|
|
38
|
+
`stickyHeader`, `density`, `filterLayout` — plus the methods `columns()`,
|
|
39
|
+
`form()`, `infolist()`, `filters()`, `tabs()`, `groups()`, `relations()`,
|
|
40
|
+
`recordActions()`, `headerActions()`, `bulkActions()`, `navigationBadge()`,
|
|
41
|
+
`emptyState()`, `widgets()`, `data()`, `can()`, and the lifecycle hooks above.
|
|
42
|
+
|
|
43
|
+
Each resource also builds its own URLs — `indexUrl(base)`, `recordUrl(base, id)`,
|
|
44
|
+
`createUrl(base)`, `editUrl(base, id)` and `routePath()`. Link through these rather
|
|
45
|
+
than assembling paths by hand, and a resource can move into a cluster or under a
|
|
46
|
+
parent without anything else changing.
|
|
47
|
+
|
|
48
|
+
## Generator
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
bun zt make:admin-resource Product
|
|
52
|
+
bun zt make:admin-resource Comment --parent=Post --foreign-key=post_id
|
|
53
|
+
bun zt make:admin-resource Setting --singular
|
|
54
|
+
bun zt make:admin-resource Order --cluster=ShopCluster
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Writes `app/admin/<Name>Resource.ts`. Named `make:admin-resource` because
|
|
58
|
+
`make:resource` already belongs to the API transformer generator.
|
|
59
|
+
|
|
60
|
+
## Configuration and middleware
|
|
61
|
+
|
|
62
|
+
| Export | Signature | Description |
|
|
63
|
+
| ------------------------ | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
|
|
64
|
+
| `AdminConfig` | `(options?: Partial<AdminConfigShape>) => AdminConfigShape` | Build `config/admin.ts` with defaults filled in, the same shape `Panel.configure` takes. |
|
|
65
|
+
| `AdminGuardMiddleware` | middleware class | Gates every panel route behind the configured auth check. |
|
|
66
|
+
| `AdminAbilityMiddleware` | middleware class | Enforces the resolved ability for the route being served. |
|
|
67
|
+
| `adminHead` | `(title?, theme?) => string` | The panel shell's `<head>` markup, for use as `Layout.head` in a custom layout. |
|
|
68
|
+
|
|
69
|
+
## Row and bulk actions
|
|
70
|
+
|
|
71
|
+
The [Actions guide](/docs/admin/actions) covers `createAction`, `editAction`,
|
|
72
|
+
`viewAction`, `deleteAction`, `replicateAction`, `importAction`, `exportAction` and
|
|
73
|
+
`impersonateAction`. The rest of the built-ins:
|
|
74
|
+
|
|
75
|
+
| Export | Signature | Description |
|
|
76
|
+
| ----------------------- | ------------------------- | --------------------------------------------------------------------------- |
|
|
77
|
+
| `restoreAction` | `() => Action` | Restore a soft-deleted record. Pair with `Model.using(SoftDeletes)`. |
|
|
78
|
+
| `forceDeleteAction` | `() => Action` | Permanently delete a soft-deleted record, bypassing the trash. |
|
|
79
|
+
| `bulkEditAction` | `(fields?: string[])` | Edit the named fields across every selected record in one form. |
|
|
80
|
+
| `bulkRestoreAction` | `() => Action` | Restore every selected soft-deleted record. |
|
|
81
|
+
| `bulkForceDeleteAction` | `() => Action` | Permanently delete every selected record. |
|
|
82
|
+
| `textFilter` | `(key: string) => Filter` | A free-text filter on one column, alongside `selectFilter`/`ternaryFilter`. |
|
|
83
|
+
|
|
84
|
+
`RelationManager` is the base class a resource's `relations()` returns; see
|
|
85
|
+
[Actions & Relations](/docs/admin/actions).
|
|
86
|
+
|
|
87
|
+
## Impersonation
|
|
88
|
+
|
|
89
|
+
Backing the `impersonateAction` button, for wiring it into your own UI. Each returns
|
|
90
|
+
a `[true]` / `[false, reason]` pair rather than throwing, so a refusal is a value you
|
|
91
|
+
can render.
|
|
92
|
+
|
|
93
|
+
| Export | Signature | Description |
|
|
94
|
+
| -------------------- | ------------------------------------------------ | --------------------------------------------------- |
|
|
95
|
+
| `startImpersonating` | `(userId) => Promise<[true] \| [false, string]>` | Become another user, remembering the original. |
|
|
96
|
+
| `stopImpersonating` | `() => Promise<[true] \| [false, string]>` | Return to the original user. |
|
|
97
|
+
| `isImpersonating` | `() => Promise<boolean>` | Whether the session is currently impersonating. |
|
|
98
|
+
| `impersonatedName` | `() => Promise<string \| null>` | The impersonated user's display name, for a banner. |
|
|
99
|
+
|
|
100
|
+
## Media
|
|
101
|
+
|
|
102
|
+
The pieces behind `mediaPicker` and the media library, for driving uploads yourself.
|
|
103
|
+
|
|
104
|
+
| Export | Signature | Description |
|
|
105
|
+
| ----------------- | ------------------------------------------------------------------- | ---------------------------------------------------------- |
|
|
106
|
+
| `storeMedia` | `(file, options) => Promise<[true, MediaItem] \| [false, string]>` | Store an upload and record it against the provider. |
|
|
107
|
+
| `deleteMedia` | `(item, { provider, disk? }) => Promise<[true] \| [false, string]>` | Remove a stored item and its record. |
|
|
108
|
+
| `mediaUrl` | `(item, disk?) => Promise<string \| null>` | A browser-fetchable URL, signed when the disk requires it. |
|
|
109
|
+
| `mediaPath` | `(name, folder?) => string` | The storage path a given file name resolves to. |
|
|
110
|
+
| `resolveMediaSrc` | `(value, disk?) => string \| null` | Turn a stored field value into an `src`, or `null`. |
|
|
111
|
+
| `formatSize` | `(bytes: number) => string` | Human file size — `1.4 MB`. |
|
|
112
|
+
| `databaseMedia` | provider | The built-in provider storing media rows in your database. |
|
|
113
|
+
|
|
114
|
+
## Import and export
|
|
115
|
+
|
|
116
|
+
| Export | Signature | Description |
|
|
117
|
+
| ------------------ | ---------------------------------------------------------------- | --------------------------------------------------------------------------------- |
|
|
118
|
+
| `importCsv` | `(resource, csv, mapping?, { limit? }) => Promise<ImportResult>` | Import rows into a resource, honouring the column mapping. |
|
|
119
|
+
| `parseCsv` | `(text) => string[][]` | Parse CSV text into rows of cells. |
|
|
120
|
+
| `toCsv` | `(rows, columns) => string` | Render rows as CSV using a resource's columns. |
|
|
121
|
+
| `toXlsx` | `(rows, columns, { sheet? }) => Uint8Array` | Render rows as a spreadsheet. |
|
|
122
|
+
| `dispatchImport` | `(payload) => Promise<boolean>` | Queue an import; `false` when no queue is bound, so the caller can run it inline. |
|
|
123
|
+
| `runQueuedImport` | `(payload) => Promise<ImportResult>` | Run a queued import — exported so an app can drive it from its own job class. |
|
|
124
|
+
| `ImportRecordsJob` | job class | The built-in job `dispatchImport` enqueues. |
|
|
125
|
+
|
|
126
|
+
## Builder classes
|
|
127
|
+
|
|
128
|
+
You build these through their factories rather than constructing them; the class
|
|
129
|
+
names matter only when you want to annotate a variable or a return type.
|
|
130
|
+
|
|
131
|
+
| Class | Built by |
|
|
132
|
+
| --------------- | --------------------------------------------------------------------------------------------------------- |
|
|
133
|
+
| `Constraint` | `textConstraint()`, `numberConstraint()`, `dateConstraint()`, `selectConstraint()`, `booleanConstraint()` |
|
|
134
|
+
| `FormSplit` | `split(sections)` — side-by-side form sections |
|
|
135
|
+
| `Prime` | `prime(text)`, `primeHtml(html)`, `primeImage(src)` — static display blocks in a schema |
|
|
136
|
+
| `PanelInstance` | `Panel.make(id, config)`, `Panel.get(id)`, `Panel.current()` |
|
|
137
|
+
|
|
138
|
+
The same holds for `Section`, `Tab`, `FormSection`, `FormTab`, `FormTabs`, `Wizard`,
|
|
139
|
+
`WizardStep`, `Stat`, `Callout`, `ActionGroup`, `BuilderBlock`, and the dashboard
|
|
140
|
+
widgets `StatsWidget`, `ChartWidget` and `TableWidget` — each is the return type of
|
|
141
|
+
the like-named factory documented in its own guide.
|
|
142
|
+
|
|
143
|
+
## History and permissions
|
|
144
|
+
|
|
145
|
+
| Export | Signature | Description |
|
|
146
|
+
| ------------------ | ---------------------------------------- | -------------------------------------------------------------------------------------------------- |
|
|
147
|
+
| `recordHistory` | `(options) => Promise<HistoryEntry[]>` | A record's audit history, newest first; empty rather than throwing when the audit table is absent. |
|
|
148
|
+
| `panelPermissions` | `(panel: PanelInstance) => Permission[]` | Every permission a panel checks, derived from what it has registered — use it to seed roles. |
|
|
149
|
+
| `roleHas` | `(role, held: string) => boolean` | Whether a role holds a permission; a superuser holds everything by definition. |
|
|
150
|
+
| `authRoles` | roles helper | The role set the auth pages recognise. |
|
|
151
|
+
|
|
152
|
+
## Subpaths
|
|
153
|
+
|
|
154
|
+
| Import | Contents |
|
|
155
|
+
| ------------------------ | ------------------------------------------------------ |
|
|
156
|
+
| `@zerotal/admin` | Resources, columns, fields, actions, widgets, `Panel`. |
|
|
157
|
+
| `@zerotal/admin/auth` | The opt-in auth page classes + `registerAuthRoutes`. |
|
|
158
|
+
| `@zerotal/admin/testing` | `AdminTest` + the assertion helpers. |
|
|
159
|
+
|
|
160
|
+
## Deliberately deferred
|
|
161
|
+
|
|
162
|
+
Multi-tenancy and the 2FA challenge step are intentionally out of scope for now.
|
|
163
|
+
|
|
164
|
+
Everything else once listed here has landed: [clusters, nested and singular
|
|
165
|
+
resources, and multiple panels](/docs/admin/structure); [CSV and spreadsheet import
|
|
166
|
+
and export](/docs/admin/actions); the [visual query-builder
|
|
167
|
+
filter](/docs/admin/tables); [kanban, calendar and tree layouts, header filters and
|
|
168
|
+
translations](/docs/admin/extending-ui); and [record history, impersonation, saved
|
|
169
|
+
views, the media library, roles and per-user
|
|
170
|
+
dashboards](/docs/admin/operations).
|
|
171
|
+
|
|
172
|
+
## Next steps
|
|
173
|
+
|
|
174
|
+
- [Admin overview](/docs/admin) — the guide's front page and the rest of the sections.
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Admin Resources
|
|
3
|
+
description: Declare a resource and get list, create, edit, and view screens for a model.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Resources
|
|
7
|
+
|
|
8
|
+
A `Resource` is a `static`-only class describing one model. Override the statics and
|
|
9
|
+
methods you need; everything has a sensible default.
|
|
10
|
+
|
|
11
|
+
Nothing instantiates a resource — the class itself is the configuration, read by the
|
|
12
|
+
panel when it builds a screen. That is why every member is `static`, and why a
|
|
13
|
+
resource can be imported and inspected anywhere without constructing anything.
|
|
14
|
+
|
|
15
|
+
Scaffold one with the generator, which writes `app/admin/<Name>Resource.ts`:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
bun zt make:admin-resource Post
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
class PostResource extends Resource {
|
|
23
|
+
static model = Post;
|
|
24
|
+
|
|
25
|
+
// Navigation
|
|
26
|
+
static navigationIcon = "document";
|
|
27
|
+
static navigationGroup = "Content";
|
|
28
|
+
static navigationSort = 10;
|
|
29
|
+
static navigationParentItem = "Blog"; // nest under another item's label
|
|
30
|
+
static navigationBadgeColor = "primary";
|
|
31
|
+
|
|
32
|
+
// Identity & data
|
|
33
|
+
static slug = "posts"; // default: kebab-cased plural
|
|
34
|
+
static label = "Post"; // default: from model name
|
|
35
|
+
static pluralLabel = "Posts";
|
|
36
|
+
static primaryKey = "id";
|
|
37
|
+
static perPage = 15;
|
|
38
|
+
static defaultSort = { column: "created_at", direction: "desc" };
|
|
39
|
+
static eager = ["author"]; // relations to eager-load
|
|
40
|
+
static recordTitleAttribute = "title"; // used by search / breadcrumbs
|
|
41
|
+
|
|
42
|
+
// A live count badge in the sidebar
|
|
43
|
+
static async navigationBadge() {
|
|
44
|
+
return this.count((q) => q.where("status", "draft"));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
static columns() {
|
|
48
|
+
/* … */ return [];
|
|
49
|
+
}
|
|
50
|
+
static form() {
|
|
51
|
+
/* … */ return [];
|
|
52
|
+
}
|
|
53
|
+
static infolist() {
|
|
54
|
+
/* … */ return [];
|
|
55
|
+
}
|
|
56
|
+
static filters() {
|
|
57
|
+
/* … */ return [];
|
|
58
|
+
}
|
|
59
|
+
static tabs() {
|
|
60
|
+
/* … */ return [];
|
|
61
|
+
}
|
|
62
|
+
static groups() {
|
|
63
|
+
/* … */ return [];
|
|
64
|
+
}
|
|
65
|
+
static relations() {
|
|
66
|
+
/* … */ return [];
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
A resource does nothing until the panel knows about it. Register it where the panel
|
|
72
|
+
is configured:
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
Panel.register(PostResource);
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Three of the statics are load-bearing in ways worth calling out. `slug` becomes the
|
|
79
|
+
URL segment, so changing it changes every link to the resource — set it once and
|
|
80
|
+
leave it alone. `eager` is the cure for a list page issuing one query per row: name
|
|
81
|
+
the relations your columns read and they are loaded up front.
|
|
82
|
+
`recordTitleAttribute` decides how a record names itself in breadcrumbs, global
|
|
83
|
+
search results, and relation pickers, so a resource without a meaningful one stays
|
|
84
|
+
hard to navigate even when every screen renders correctly.
|
|
85
|
+
|
|
86
|
+
Rather than assembling URLs by hand, ask the resource: `indexUrl(base)`,
|
|
87
|
+
`recordUrl(base, id)`, `createUrl(base)`, `editUrl(base, id)` and `routePath()`
|
|
88
|
+
build them for you, so a resource can move under a parent or into a cluster without
|
|
89
|
+
any of its links changing.
|
|
90
|
+
|
|
91
|
+
## Authorization
|
|
92
|
+
|
|
93
|
+
Override `can(ability, record?)` to gate the built-in actions. Abilities used by the
|
|
94
|
+
presets: `create`, `update`, `delete`, `restore`, `forceDelete`.
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
static can(ability: string, record?: AdminRecord) {
|
|
98
|
+
return Gate.allows(ability, record ?? this.model);
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Delegating to `Gate` is usually the whole implementation, and it is the reason to
|
|
103
|
+
write policies once rather than per screen: the same rule then governs the list
|
|
104
|
+
page's row actions, the form's save button, and the bulk actions without being
|
|
105
|
+
restated in each. A denied ability hides the control rather than merely rejecting
|
|
106
|
+
the request, so the panel never offers a button that cannot work.
|
|
107
|
+
|
|
108
|
+
## Lifecycle hooks
|
|
109
|
+
|
|
110
|
+
| Hook | When |
|
|
111
|
+
| -------------------------------- | -------------------------------------------------------- |
|
|
112
|
+
| `mutateFormDataBeforeFill(data)` | Transform a record into form state before Edit fills. |
|
|
113
|
+
| `mutateBeforeSave(data, mode)` | Transform validated form data just before create/update. |
|
|
114
|
+
| `afterSave(record, mode)` | After a successful create/update (e.g. sync relations). |
|
|
115
|
+
|
|
116
|
+
The first two are mirror images, and implementing them as a pair is what keeps a
|
|
117
|
+
field stored differently from how it is edited working in both directions —
|
|
118
|
+
splitting a stored `fullName` into two inputs on fill, rejoining them on save.
|
|
119
|
+
|
|
120
|
+
`mutateBeforeSave` runs _after_ validation, so its input is already valid and its
|
|
121
|
+
job is to shape data rather than sanitise it. `afterSave` receives the persisted
|
|
122
|
+
record, which is the moment to sync related rows: the record has an id by then,
|
|
123
|
+
including on create.
|
|
124
|
+
|
|
125
|
+
Both save hooks receive the `mode`, so one implementation can branch on `"create"`
|
|
126
|
+
versus `"edit"` instead of duplicating the resource.
|
|
127
|
+
|
|
128
|
+
## Next steps
|
|
129
|
+
|
|
130
|
+
- [Admin overview](/docs/admin) — the guide's front page and the rest of the sections.
|
|
131
|
+
- [Tables](/docs/admin/tables) — the columns, filters, and tabs declared above.
|
|
132
|
+
- [Forms & Infolists](/docs/admin/forms) — the fields the save hooks operate on.
|