@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,183 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Admin Actions & Relations
|
|
3
|
+
description: Row, bulk, and page actions, related-record managers, and soft-delete handling.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Actions
|
|
7
|
+
|
|
8
|
+
Actions power the row, header, and bulk buttons. The defaults are
|
|
9
|
+
`viewAction()/editAction()/deleteAction()` (rows), `createAction()` (header), and
|
|
10
|
+
`bulkDeleteAction()` (bulk) — override the corresponding methods to customize.
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
import { action, editAction, deleteAction, textInput } from "@zerotal/admin";
|
|
14
|
+
|
|
15
|
+
static recordActions() {
|
|
16
|
+
return [
|
|
17
|
+
editAction(),
|
|
18
|
+
action("publish")
|
|
19
|
+
.label("Publish").icon("check-circle").color("success")
|
|
20
|
+
.requiresConfirmation("Publish this post?")
|
|
21
|
+
.authorize((rec) => this.can("update", rec))
|
|
22
|
+
.run(async (ctx) => { await ctx.record.publish(); }),
|
|
23
|
+
deleteAction(),
|
|
24
|
+
];
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
A row with more than three visible actions collapses the surplus into an overflow
|
|
29
|
+
menu. Actions can also open a **modal form**:
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
action("assign")
|
|
33
|
+
.label("Assign reviewer")
|
|
34
|
+
.form([ select("reviewer_id").options(reviewers).required() ])
|
|
35
|
+
.run(async (ctx) => { await ctx.record.assign(ctx.data.reviewer_id); }),
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`.run(ctx)` receives `{ resource, record?, ids?, data?, parentId?, listOptions? }`
|
|
39
|
+
(row / header / bulk).
|
|
40
|
+
|
|
41
|
+
## Action groups
|
|
42
|
+
|
|
43
|
+
A row with seven buttons is unreadable. `actionGroup` collapses several into one
|
|
44
|
+
labelled dropdown, so the row keeps the two people reach for and files the rest:
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
import { actionGroup, replicateAction, deleteAction } from "@zerotal/admin";
|
|
48
|
+
|
|
49
|
+
static recordActions() {
|
|
50
|
+
return [
|
|
51
|
+
viewAction(),
|
|
52
|
+
editAction(),
|
|
53
|
+
actionGroup([
|
|
54
|
+
replicateAction(),
|
|
55
|
+
action("archive").label("Archive").icon("folder").run(…),
|
|
56
|
+
deleteAction(),
|
|
57
|
+
]).label("More"),
|
|
58
|
+
];
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Members are gated individually — a group whose every member is hidden draws
|
|
63
|
+
nothing. Groups work in the header and the bulk toolbar too.
|
|
64
|
+
|
|
65
|
+
## Replicate
|
|
66
|
+
|
|
67
|
+
`replicateAction()` copies a record and opens the copy for editing. The primary key
|
|
68
|
+
and timestamps are always dropped; name anything else that must stay unique:
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
replicateAction()
|
|
72
|
+
.excludeAttributes(["sku", "slug"])
|
|
73
|
+
.beforeReplicaSaved((data) => ({ ...data, name: `${data.name} (copy)`, status: "draft" })),
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Import and export
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
import { exportAction, importAction, bulkExportAction } from "@zerotal/admin";
|
|
80
|
+
|
|
81
|
+
static headerActions() {
|
|
82
|
+
return [createAction(), exportAction(), importAction()];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
static bulkActions() {
|
|
86
|
+
return [bulkExportAction(), bulkDeleteAction()];
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
**Export** writes the current list as CSV — the same search, filters, tab and sort
|
|
91
|
+
the user is looking at, not the whole table. Someone who has narrowed a view to the
|
|
92
|
+
twelve rows they care about expects twelve rows in the file. `bulkExportAction()`
|
|
93
|
+
exports the selection instead.
|
|
94
|
+
|
|
95
|
+
Pass `"xlsx"` for a spreadsheet instead:
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
return [createAction(), exportAction(), exportAction("xlsx")];
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
CSV stays the better interchange format and the default. The workbook is for the case
|
|
102
|
+
CSV genuinely cannot serve: a recipient who opens the file, finds `007` turned into
|
|
103
|
+
`7` and a leading `=` treated as a formula, and reasonably calls the export broken.
|
|
104
|
+
Cells are written with real types, so dates sort as dates and numbers total, and the
|
|
105
|
+
header row is frozen with a filter over the used range.
|
|
106
|
+
|
|
107
|
+
Keep a column out of the file with `.exportable(false)`. Pay, internal notes and
|
|
108
|
+
anything else that should not travel in a spreadsheet belongs behind that flag:
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
text("salary").exportable(false),
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
**Import** takes a CSV through a modal in two steps on one screen: pick a file,
|
|
115
|
+
then confirm which field each of its columns feeds. The selects start on whatever
|
|
116
|
+
the headers match — case, spaces, underscores and hyphens are all treated the
|
|
117
|
+
same — so a file the panel exported needs no adjustment, and a file from
|
|
118
|
+
somewhere else needs only the columns that didn't line up. Mapping a column to
|
|
119
|
+
"skip" leaves it out.
|
|
120
|
+
|
|
121
|
+
Every row is validated through the resource's own fields, so an import cannot write
|
|
122
|
+
anything a person could not have typed into the create form. A row that fails is
|
|
123
|
+
reported by line number and skipped; one bad line out of five hundred does not
|
|
124
|
+
discard the other four hundred and ninety-nine.
|
|
125
|
+
|
|
126
|
+
### Large files
|
|
127
|
+
|
|
128
|
+
An import runs inline by default, capped at 2,000 rows — a synchronous import
|
|
129
|
+
holds a WebSocket round-trip open, and a bigger file looks like a hang. Hand it
|
|
130
|
+
to a queue instead and the cap lifts:
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
static headerActions() {
|
|
134
|
+
return [createAction(), exportAction(), importAction({ queue: true })];
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
The worker needs to be able to rebuild the job from its payload, so register the
|
|
139
|
+
class where it can see it:
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
import { JobRegistry } from "@zerotal/queue";
|
|
143
|
+
import { ImportRecordsJob } from "@zerotal/admin";
|
|
144
|
+
|
|
145
|
+
JobRegistry.register(ImportRecordsJob);
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
With no queue configured, a queued import falls back to running inline rather
|
|
149
|
+
than silently doing nothing. `@zerotal/queue` stays an optional peer: it is
|
|
150
|
+
imported only when an import is actually queued.
|
|
151
|
+
|
|
152
|
+
## Relations
|
|
153
|
+
|
|
154
|
+
Relation managers appear as tables on the View page.
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
import { hasMany, belongsToMany } from "@zerotal/admin";
|
|
158
|
+
|
|
159
|
+
static relations() {
|
|
160
|
+
return [
|
|
161
|
+
// Children referencing the parent — full CRUD links into their own resource.
|
|
162
|
+
hasMany(CommentResource, "post_id").title("Comments"),
|
|
163
|
+
|
|
164
|
+
// Many-to-many — attached rows with Detach + an Attach picker + pivot columns.
|
|
165
|
+
belongsToMany(TagResource, "tags")
|
|
166
|
+
.pivotColumns([{ key: "added_at", label: "Added" }]),
|
|
167
|
+
];
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
`belongsToMany(Resource, "tags")` drives the parent model's `tags().attach()` /
|
|
172
|
+
`tags().detach()` / `tags().get()`.
|
|
173
|
+
|
|
174
|
+
## Soft deletes
|
|
175
|
+
|
|
176
|
+
When the model uses the ORM `SoftDeletes` mixin, the List page gains an
|
|
177
|
+
**Active / All / Trashed** switch and the row + bulk actions gain Restore and
|
|
178
|
+
Force-delete automatically — no extra configuration.
|
|
179
|
+
|
|
180
|
+
## Next steps
|
|
181
|
+
|
|
182
|
+
- [Admin overview](/docs/admin) — the guide's front page and the rest of the sections.
|
|
183
|
+
- [Reference](/docs/admin/references) — the full API surface in one table.
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Admin Auth Pages & Theming
|
|
3
|
+
description: The built-in login and profile screens, and how to restyle the panel.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Auth pages
|
|
7
|
+
|
|
8
|
+
Opt in with `Panel.auth({...})` (or `Panel.configure({ auth: {...} })`). The pages
|
|
9
|
+
live behind the `@zerotal/admin/auth` subpath, so the `@zerotal/auth` dependency
|
|
10
|
+
stays optional unless you enable them.
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
import { Panel } from "@zerotal/admin";
|
|
14
|
+
|
|
15
|
+
Panel.auth({
|
|
16
|
+
enabled: true,
|
|
17
|
+
identifier: "email", // credential column
|
|
18
|
+
remember: true,
|
|
19
|
+
redirectTo: "/admin",
|
|
20
|
+
authenticateWhen: (u) => (u as { active?: boolean }).active === true,
|
|
21
|
+
|
|
22
|
+
// Optional — mounts the forgot/reset pages when provided:
|
|
23
|
+
passwordReset: {
|
|
24
|
+
sendResetLink: (email) => PasswordBroker.sendResetLink({ email }),
|
|
25
|
+
reset: (input) => PasswordBroker.reset(input),
|
|
26
|
+
},
|
|
27
|
+
// Optional — mounts the verify page + profile banner when provided:
|
|
28
|
+
emailVerification: {
|
|
29
|
+
isVerified: (u) => (u as { email_verified_at?: unknown }).email_verified_at != null,
|
|
30
|
+
resend: (u) =>
|
|
31
|
+
(
|
|
32
|
+
u as { sendEmailVerificationNotification(): Promise<void> }
|
|
33
|
+
).sendEmailVerificationNotification(),
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
| Page | Route | Uses |
|
|
39
|
+
| -------------------- | ------------------ | ----------------------------------------------- |
|
|
40
|
+
| `LoginPage` | `/login` | `Auth.attempt` (+ remember, `authenticateWhen`) |
|
|
41
|
+
| `ProfilePage` | `/profile` | update details · change password · sign out |
|
|
42
|
+
| `ForgotPasswordPage` | `/forgot-password` | `passwordReset.sendResetLink` |
|
|
43
|
+
| `ResetPasswordPage` | `/reset-password` | `passwordReset.reset` |
|
|
44
|
+
| `VerifyEmailPage` | `/verify-email` | `emailVerification.resend` |
|
|
45
|
+
|
|
46
|
+
Guest screens (login / forgot / reset) mount **outside** the panel guard so
|
|
47
|
+
unauthenticated users can reach them; profile / verify mount behind it
|
|
48
|
+
(override with `guestMiddleware` / `authMiddleware`). The 2FA **challenge** step is
|
|
49
|
+
left to your auth middleware — the rest of the flow ships here.
|
|
50
|
+
|
|
51
|
+
## Securing the panel
|
|
52
|
+
|
|
53
|
+
The panel is public until you set guard middleware. A typical setup:
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
Panel.configure({ middleware: [AuthMiddleware, RequireRoleMiddleware.with("admin")] });
|
|
57
|
+
Panel.auth({ enabled: true }); // login lives outside that guard automatically
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Theming
|
|
61
|
+
|
|
62
|
+
By default the panel themes everything with the **Tailwind Play CDN** plus shadcn-style
|
|
63
|
+
design tokens and a no-flash dark/light script — zero build step. The toggle in the
|
|
64
|
+
top bar flips `.dark` on `<html>` and persists the choice.
|
|
65
|
+
|
|
66
|
+
To ship a real build, point `theme.stylesheet` at your compiled CSS (the CDN is then
|
|
67
|
+
dropped) and reuse the exported config + tokens so your build matches the default look:
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
import { Panel, adminTailwindConfig, adminTokensCss } from "@zerotal/admin";
|
|
71
|
+
|
|
72
|
+
Panel.configure({
|
|
73
|
+
theme: {
|
|
74
|
+
stylesheet: "/assets/admin.css", // your prebuilt Tailwind output
|
|
75
|
+
// cdn: true, // keep both during migration
|
|
76
|
+
// tokensCss: ":root { --primary: 270 90% 60%; }", // override tokens
|
|
77
|
+
// noFonts: true, // self-host Inter
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`adminTailwindConfig()` returns the token→CSS-var Tailwind config and
|
|
83
|
+
`adminTokensCss()` the `:root`/`.dark` custom properties — feed both into your own
|
|
84
|
+
`tailwind.config` so a compiled stylesheet renders identically.
|
|
85
|
+
|
|
86
|
+
## Next steps
|
|
87
|
+
|
|
88
|
+
- [Admin overview](/docs/admin) — the guide's front page and the rest of the sections.
|
|
89
|
+
- [Reference](/docs/admin/references) — the full API surface in one table.
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Admin Dashboard & Navigation
|
|
3
|
+
description: Widgets, global search, the command palette, notifications, and the nav tree.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Dashboard widgets
|
|
7
|
+
|
|
8
|
+
Register widgets with `Panel.widgets(...)`; they render on the dashboard.
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import { Panel, statsWidget, stat, chartWidget, tableWidget } from "@zerotal/admin";
|
|
12
|
+
|
|
13
|
+
Panel.widgets(
|
|
14
|
+
statsWidget(async () => [
|
|
15
|
+
stat("Users", await User.count())
|
|
16
|
+
.description("+12% this week")
|
|
17
|
+
.color("success"),
|
|
18
|
+
stat("Revenue", "$48k"),
|
|
19
|
+
]),
|
|
20
|
+
chartWidget("Signups", async () => ({
|
|
21
|
+
type: "line",
|
|
22
|
+
labels: ["Mon", "Tue", "Wed"],
|
|
23
|
+
datasets: [{ label: "Signups", data: [4, 9, 7] }],
|
|
24
|
+
})),
|
|
25
|
+
tableWidget(
|
|
26
|
+
"Latest orders",
|
|
27
|
+
[
|
|
28
|
+
{ key: "id", label: "#" },
|
|
29
|
+
{ key: "total", label: "Total", align: "end" },
|
|
30
|
+
],
|
|
31
|
+
async () => await Order.latest().limit(5).get(),
|
|
32
|
+
),
|
|
33
|
+
);
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Charts use Chart.js via CDN (the dashboard renders once, so there's no morph re-init).
|
|
37
|
+
|
|
38
|
+
## Widgets on a resource
|
|
39
|
+
|
|
40
|
+
The dashboard answers "how is the business doing". A resource's own widgets
|
|
41
|
+
answer "what is going on in _this_ list" — a pending count above the orders
|
|
42
|
+
table, stock value above products:
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
export class OrderResource extends Resource {
|
|
46
|
+
static override widgets() {
|
|
47
|
+
return [
|
|
48
|
+
statsWidget(async () => [
|
|
49
|
+
stat("Awaiting payment", await Order.query().where("status", "pending").count()),
|
|
50
|
+
]).poll("30s"),
|
|
51
|
+
];
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
They render above the table and use the same builders as the dashboard, polling
|
|
57
|
+
included — and a polling widget there refreshes the table with it, which is what
|
|
58
|
+
someone watching a queue actually wants.
|
|
59
|
+
|
|
60
|
+
## Polling
|
|
61
|
+
|
|
62
|
+
A dashboard on a second screen is stale the moment it renders. `.poll()` gives a
|
|
63
|
+
widget an interval:
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
statsWidget(async () => [
|
|
67
|
+
stat("Awaiting payment", await Order.query().where("status", "pending").count()),
|
|
68
|
+
]).poll("30s"),
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The dashboard refreshes as one unit, at the shortest interval any of its widgets
|
|
72
|
+
asked for. That costs a query per tick per viewer, so it is worth it for the
|
|
73
|
+
numbers someone actually watches and wasteful on everything else — leave the rest
|
|
74
|
+
to render once per navigation.
|
|
75
|
+
|
|
76
|
+
## Global search & the command palette
|
|
77
|
+
|
|
78
|
+
Any resource with `.searchable()` columns and a `recordTitleAttribute` joins **global
|
|
79
|
+
search** — a top-bar box, a `/search` results page, and a client-side **⌘K / Ctrl-K
|
|
80
|
+
command palette** that filters resources and escalates a free query to the search
|
|
81
|
+
page.
|
|
82
|
+
|
|
83
|
+
## Notifications
|
|
84
|
+
|
|
85
|
+
The admin owns the bell + notifications page UI; your app supplies the data through a
|
|
86
|
+
provider (the same split as relations — admin UI, app data):
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
import { Panel } from "@zerotal/admin";
|
|
90
|
+
|
|
91
|
+
Panel.notifications({
|
|
92
|
+
async resolve() {
|
|
93
|
+
return (await Auth.user().notifications().latest().limit(20).get()).map((n) => ({
|
|
94
|
+
id: String(n.id),
|
|
95
|
+
title: n.data.title,
|
|
96
|
+
body: n.data.body,
|
|
97
|
+
href: n.data.url,
|
|
98
|
+
read: n.read_at != null,
|
|
99
|
+
time: n.created_at,
|
|
100
|
+
}));
|
|
101
|
+
},
|
|
102
|
+
async markRead(id) {
|
|
103
|
+
await Notification.find(id)?.markAsRead();
|
|
104
|
+
},
|
|
105
|
+
async markAllRead() {
|
|
106
|
+
await Auth.user().unreadNotifications().markAsRead();
|
|
107
|
+
},
|
|
108
|
+
async unreadCount() {
|
|
109
|
+
return Auth.user().unreadNotifications().count();
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
The header bell shows a **live unread badge** (refreshed each navigation). The
|
|
115
|
+
notifications page **polls** and listens for **broadcasts**: broadcast a notification
|
|
116
|
+
from your app on the `NOTIFICATION_CHANNEL` / `NOTIFICATION_EVENT`
|
|
117
|
+
(`admin-notifications` / `.notification.sent`) broadcast channel and the open page
|
|
118
|
+
updates live. When no provider is configured, the bell and page stay hidden.
|
|
119
|
+
|
|
120
|
+
### Database-backed notifications
|
|
121
|
+
|
|
122
|
+
`Panel.notifications()` takes a provider because "the current user's
|
|
123
|
+
notifications" depends on your auth and your schema. When both are the ordinary
|
|
124
|
+
ones — `@zerotal/auth` for the user, `@zerotal/notifications`' `DatabaseChannel`
|
|
125
|
+
for storage — there is a ready-made one:
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
import { databaseNotifications } from "@zerotal/admin";
|
|
129
|
+
|
|
130
|
+
Panel.notifications(databaseNotifications());
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Everything is adjustable: `notifiable` says whose notifications to show,
|
|
134
|
+
`present` turns a stored row into a title and a link, `table` names the table,
|
|
135
|
+
`limit` caps how many the bell holds.
|
|
136
|
+
|
|
137
|
+
It fails soft throughout. A missing table, an unconfigured database or a
|
|
138
|
+
signed-out user yields an empty bell rather than a broken panel — a notification
|
|
139
|
+
centre is never worth taking the page down for.
|
|
140
|
+
|
|
141
|
+
## Navigation
|
|
142
|
+
|
|
143
|
+
The sidebar is derived from each resource's `navigationGroup`, `navigationSort`,
|
|
144
|
+
`navigationIcon`, and `navigationParentItem`. Groups are collapsible (`<details>`),
|
|
145
|
+
parent items nest their children, and `navigationBadge()` renders a colored count
|
|
146
|
+
pill. The top-bar **user menu** comes from `Panel.configure({ userMenu })`.
|
|
147
|
+
|
|
148
|
+
Custom pages and anything contributed by a package sit in the same sidebar, sorted
|
|
149
|
+
and grouped by the same rules — a page's group heading is just a string, so a page
|
|
150
|
+
and a resource that name the same group land together.
|
|
151
|
+
|
|
152
|
+
## Next steps
|
|
153
|
+
|
|
154
|
+
- [Admin overview](/docs/admin) — the guide's front page and the rest of the sections.
|
|
155
|
+
- [Reference](/docs/admin/references) — the full API surface in one table.
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Extending the UI
|
|
3
|
+
description: Render your own cells, controls and entries, inject markup into the panel's chrome, and back a resource with something other than a model.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Extending the UI
|
|
7
|
+
|
|
8
|
+
The catalogue covers most screens. When it doesn't, four escape hatches let you
|
|
9
|
+
add what's missing without forking the panel.
|
|
10
|
+
|
|
11
|
+
| You want | Reach for |
|
|
12
|
+
| ---------------------------------------------- | ------------------------------------------- |
|
|
13
|
+
| A cell, control or entry the catalogue lacks | [Custom renderers](#custom-renderers) |
|
|
14
|
+
| Markup in the panel's chrome — banners, badges | [Render hooks](#render-hooks) |
|
|
15
|
+
| A resource backed by an API or a file | [Custom data sources](#custom-data-sources) |
|
|
16
|
+
| A different shape of table | [Table presentation](#table-presentation) |
|
|
17
|
+
|
|
18
|
+
## Custom renderers
|
|
19
|
+
|
|
20
|
+
Every layer has a `.render()` that takes over its own markup and leaves
|
|
21
|
+
everything else alone.
|
|
22
|
+
|
|
23
|
+
**Table cells.** The column still owns its label, sorting, search and export;
|
|
24
|
+
only the cell is yours:
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
text("health")
|
|
28
|
+
.label("Health")
|
|
29
|
+
.sortable()
|
|
30
|
+
.render((value) => <HealthBar value={Number(value)} />),
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
**Infolist entries.** The section's grid still places it:
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
textEntry("route").render((value) => <RouteMap path={String(value)} />),
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
**Form controls.** `customField` gives you a field that validates, binds and
|
|
40
|
+
saves like any other — you supply only the control. Bind your markup to
|
|
41
|
+
`form.<key>` for the value to round-trip:
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
customField("coordinates")
|
|
45
|
+
.label("Location")
|
|
46
|
+
.required()
|
|
47
|
+
.render((value, data) => <MapPicker value={value} country={data.country} />),
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The renderer receives the whole form's data as well as its own value, which is
|
|
51
|
+
what lets a control react to a sibling field.
|
|
52
|
+
|
|
53
|
+
## Render hooks
|
|
54
|
+
|
|
55
|
+
A contributed page can only add a page. A hook adds markup at a named position
|
|
56
|
+
in the chrome — a trial banner, a compliance notice, an environment badge:
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
Panel.renderHook("page.header.end", () => <TrialBanner />);
|
|
60
|
+
|
|
61
|
+
// Conditional placement: register once, decide per render.
|
|
62
|
+
Panel.renderHook("table.start", (ctx) =>
|
|
63
|
+
ctx.resource === "orders" ? <ShippingNotice /> : null,
|
|
64
|
+
);
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Returning `null` renders nothing. A hook that throws is logged and skipped — a
|
|
68
|
+
decoration must not be able to take down the page it decorates.
|
|
69
|
+
|
|
70
|
+
The positions:
|
|
71
|
+
|
|
72
|
+
| Group | Names |
|
|
73
|
+
| ------------ | -------------------------------------- |
|
|
74
|
+
| Shell | `body.start`, `body.end` |
|
|
75
|
+
| Sidebar | `sidebar.start`, `sidebar.end` |
|
|
76
|
+
| Top bar | `topbar.start`, `topbar.end` |
|
|
77
|
+
| Page heading | `page.header.start`, `page.header.end` |
|
|
78
|
+
| Table | `table.start`, `table.end` |
|
|
79
|
+
| Form | `form.start`, `form.end` |
|
|
80
|
+
| Record | `record.start`, `record.end` |
|
|
81
|
+
|
|
82
|
+
Each hook receives a context naming the resource, the kind of screen and the
|
|
83
|
+
record id where there is one, so one registration can serve every page and place
|
|
84
|
+
itself only where it belongs.
|
|
85
|
+
|
|
86
|
+
Packages get the same surface through the `admin.panel` binding — see
|
|
87
|
+
[Custom Pages & Plugins](/docs/admin/extending).
|
|
88
|
+
|
|
89
|
+
## Custom data sources
|
|
90
|
+
|
|
91
|
+
A resource does not have to be backed by a model. Return rows from `data()` and
|
|
92
|
+
the panel filters, sorts and paginates them in memory, so search, tabs,
|
|
93
|
+
summaries and the query builder all keep working:
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
export class RegionResource extends Resource {
|
|
97
|
+
static override async data() {
|
|
98
|
+
return await fetch("https://api.example.com/regions").then((r) => r.json());
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
static override columns() {
|
|
102
|
+
return [text("code").searchable(), text("name").searchable(), text("population")];
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Reads are the easy half. A read-only source needs no `form()` — an empty one
|
|
108
|
+
removes the create and edit pages. To make it writable, override `create`,
|
|
109
|
+
`update` and `destroy` to push the change back wherever it belongs.
|
|
110
|
+
|
|
111
|
+
In-memory means the whole set is loaded per request, so this suits hundreds of
|
|
112
|
+
rows and not millions. Past that, back it with a model or a view.
|
|
113
|
+
|
|
114
|
+
## Table presentation
|
|
115
|
+
|
|
116
|
+
Four statics change how a list renders:
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
export class ProductResource extends Resource {
|
|
120
|
+
static override tableLayout = "grid"; // "table" (default) | "grid" | "kanban" | "calendar"
|
|
121
|
+
static override striped = true;
|
|
122
|
+
static override stickyHeader = true;
|
|
123
|
+
static override density = "compact"; // "comfortable" (default) | "compact"
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
**Grid** trades columns for cards, which suits records you recognise by sight —
|
|
128
|
+
products, media, people. The layout is derived from the columns you already
|
|
129
|
+
declared: the first image column becomes the picture, the first text column the
|
|
130
|
+
title, and the next few render as label/value pairs. No second description.
|
|
131
|
+
|
|
132
|
+
**Kanban** turns a status column into lanes, for records that read as a pipeline:
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
static override tableLayout = "kanban";
|
|
136
|
+
static override kanbanColumn = "status";
|
|
137
|
+
static override kanbanLanes = { pending: "Pending", paid: "Paid", shipped: "Shipped" };
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Each card carries arrows to move it to the neighbouring lane, which runs the same
|
|
141
|
+
authorised update a row action would — a second way to do a thing the panel already
|
|
142
|
+
does, not a second source of truth. A value present in the data but missing from
|
|
143
|
+
`kanbanLanes` still gets a lane, so nothing is hidden by an incomplete declaration.
|
|
144
|
+
|
|
145
|
+
**Calendar** lays the page out as a month grid keyed on a date column:
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
static override tableLayout = "calendar";
|
|
149
|
+
static override calendarColumn = "startsOn";
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
The month shown is the one the listed rows fall in rather than the current month, so
|
|
153
|
+
paging back through older records does not land on an empty grid.
|
|
154
|
+
|
|
155
|
+
**Striped**, **sticky** and **compact** are what they sound like, and matter most
|
|
156
|
+
on wide or long tables.
|
|
157
|
+
|
|
158
|
+
### Trees
|
|
159
|
+
|
|
160
|
+
A resource whose records nest under each other renders as a tree:
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
export class CategoryResource extends Resource {
|
|
164
|
+
static override treeParentColumn = "parentId";
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Each page is arranged so children sit under their parent and the first column indents
|
|
169
|
+
by depth. The arranging happens over the rows on screen rather than in SQL — a
|
|
170
|
+
recursive query is the right answer for a deep tree but is not portable across the
|
|
171
|
+
drivers the panel supports, and a tree small enough to browse is small enough to
|
|
172
|
+
arrange in memory. A row whose parent is not on the page stays at the top level rather
|
|
173
|
+
than disappearing, so a filtered tree never hides a record.
|
|
174
|
+
|
|
175
|
+
### Translations
|
|
176
|
+
|
|
177
|
+
A resource whose text exists in several languages edits one at a time:
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
export class PostResource extends Resource {
|
|
181
|
+
static override translatable = ["title", "excerpt"];
|
|
182
|
+
static override locales = ["en", "fr"];
|
|
183
|
+
}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
The columns store `{ en: "…", fr: "…" }`. The list gains a locale switch and shows the
|
|
187
|
+
active one; the form gains locale tabs, and switching banks what you have typed rather
|
|
188
|
+
than discarding it. Saving one locale keeps the others, which is what stops an English
|
|
189
|
+
edit from wiping the French. A value that was never translated is shown as-is, so
|
|
190
|
+
turning this on for an existing column does not blank it.
|
|
191
|
+
|
|
192
|
+
### Header filters
|
|
193
|
+
|
|
194
|
+
A column can carry its own filter box in the table header:
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
text("sku").filterable();
|
|
198
|
+
selectColumn("status", STATUS).filterable();
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
The control follows the column's kind — a text box for text, a yes/no switch for a
|
|
202
|
+
toggle, the declared choices for a select — so a column usually needs nothing else.
|
|
203
|
+
Header filters write into the same `?filters=` parameter as declared filters, which
|
|
204
|
+
means they compose with tabs, search, sorting and pagination the same way, show up in
|
|
205
|
+
the active-filter chips, and land in a saved view.
|
|
206
|
+
|
|
207
|
+
### Filter placement
|
|
208
|
+
|
|
209
|
+
Filters sit above the table by default. Once there are more than a few, collapse
|
|
210
|
+
them:
|
|
211
|
+
|
|
212
|
+
```ts
|
|
213
|
+
static override filterLayout = "panel"; // "inline" (default) | "panel" | "drawer"
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Both `panel` and `drawer` hide the controls behind a Filters button carrying a
|
|
217
|
+
count of what's active; they differ in where the revealed controls sit.
|
|
218
|
+
|
|
219
|
+
Whatever the layout, anything currently narrowing the list shows as a chip above
|
|
220
|
+
the table — the search term, each filter, the trashed scope — and each chip is
|
|
221
|
+
its own undo. A table showing four of two hundred rows for no visible reason is
|
|
222
|
+
the most common way a panel misleads someone; the chips are the fix.
|
|
223
|
+
|
|
224
|
+
## Next steps
|
|
225
|
+
|
|
226
|
+
- [Tables](/docs/admin/tables) — columns, filters and the query builder.
|
|
227
|
+
- [Forms & Infolists](/docs/admin/forms) — the built-in field and entry catalogue.
|
|
228
|
+
- [Custom Pages & Plugins](/docs/admin/extending) — whole pages, and contributing from a package.
|
|
229
|
+
- [References](/docs/admin/references) — the full API surface in one table.
|