@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,158 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Panel Structure
|
|
3
|
+
description: Group resources into clusters, nest one resource under another's records, back a single row, and run more than one panel from a single app.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Panel Structure
|
|
7
|
+
|
|
8
|
+
A panel with six resources needs no structure at all: register them and the sidebar
|
|
9
|
+
reads fine. Past that it starts to matter, and four tools cover almost every shape a
|
|
10
|
+
real back office takes.
|
|
11
|
+
|
|
12
|
+
| You have | Reach for |
|
|
13
|
+
| ----------------------------------------------------- | ----------------------------------------- |
|
|
14
|
+
| Too many resources for one sidebar list | [Clusters](#clusters) |
|
|
15
|
+
| Records that only make sense inside a parent | [Nested resources](#nested-resources) |
|
|
16
|
+
| Exactly one row — settings, a company profile | [Singular resources](#singular-resources) |
|
|
17
|
+
| Two audiences who should not see each other's screens | [Multiple panels](#multiple-panels) |
|
|
18
|
+
|
|
19
|
+
## Clusters
|
|
20
|
+
|
|
21
|
+
A cluster gives a group of resources a shared URL segment and a single sidebar
|
|
22
|
+
entry. Declare one, then point resources at it:
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import { Cluster } from "@zerotal/admin";
|
|
26
|
+
|
|
27
|
+
export class ShopCluster extends Cluster {
|
|
28
|
+
static override slug = "shop";
|
|
29
|
+
static override title = "Shop";
|
|
30
|
+
static override navigationIcon = "collection";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class ProductResource extends Resource {
|
|
34
|
+
static override model = Product;
|
|
35
|
+
static override cluster = ShopCluster; // → /admin/shop/products
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Members collapse under one expandable entry rather than sitting loose in the
|
|
40
|
+
sidebar, and every URL the panel builds — links, redirects, breadcrumbs — picks up
|
|
41
|
+
the cluster segment on its own. Nothing in a resource needs to know its own path.
|
|
42
|
+
|
|
43
|
+
A cluster's `ability` gates the whole section:
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
export class FinanceCluster extends Cluster {
|
|
47
|
+
static override slug = "finance";
|
|
48
|
+
static override title = "Finance";
|
|
49
|
+
static override ability = "finance.view";
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Every member route enforces it, so no resource has to restate the check. Custom
|
|
54
|
+
pages join a cluster the same way, with `static cluster = FinanceCluster`.
|
|
55
|
+
|
|
56
|
+
## Nested resources
|
|
57
|
+
|
|
58
|
+
Some records have no meaning apart from their parent. Comments belong to a post;
|
|
59
|
+
there is no useful screen listing every comment in the database. Declare the
|
|
60
|
+
parent and the resource moves inside it:
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
export class CommentResource extends Resource {
|
|
64
|
+
static override model = Comment;
|
|
65
|
+
static override parent = { resource: () => PostResource, foreignKey: "post_id" };
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
That gives you `/admin/posts/7/comments`, and three things follow automatically:
|
|
70
|
+
|
|
71
|
+
- **Every list is scoped to the parent.** The scope is applied before any tab or
|
|
72
|
+
filter, so nothing a user selects can widen the query past their parent record.
|
|
73
|
+
- **New records inherit the foreign key** from the URL rather than from a form
|
|
74
|
+
field, so it cannot be tampered with on the way to the server.
|
|
75
|
+
- **The resource leaves the sidebar,** because there is no parent-free URL to
|
|
76
|
+
link to. It is reached through the parent's own pages.
|
|
77
|
+
|
|
78
|
+
The parent is named by a **function**, not a direct reference. The two resources
|
|
79
|
+
almost always point at each other — the parent lists the child as a relation, the
|
|
80
|
+
child names the parent here — and a direct reference would resolve to `undefined`
|
|
81
|
+
on whichever side of the import cycle evaluated first.
|
|
82
|
+
|
|
83
|
+
When the parent declares the child with `hasMany`, its view page links into the
|
|
84
|
+
nested pages instead of rendering an inline table:
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
export class PostResource extends Resource {
|
|
88
|
+
static override relations() {
|
|
89
|
+
return [hasMany(CommentResource, "post_id").title("Comments")];
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Singular resources
|
|
95
|
+
|
|
96
|
+
Site settings are one row. A list of one, with a view page and an edit page behind
|
|
97
|
+
it, is three screens too many:
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
export class SettingsResource extends Resource {
|
|
101
|
+
static override model = Setting;
|
|
102
|
+
static override singular = true;
|
|
103
|
+
static override slug = "settings";
|
|
104
|
+
|
|
105
|
+
static override form() {
|
|
106
|
+
return [
|
|
107
|
+
formSection("Identity").schema([
|
|
108
|
+
textInput("siteName").required(),
|
|
109
|
+
toggle("ordersOpen").label("Accepting orders"),
|
|
110
|
+
]),
|
|
111
|
+
];
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`/admin/settings` opens the form directly. There is no list, no create page and no
|
|
117
|
+
id in the URL. The row is resolved on first visit and created from the form's
|
|
118
|
+
defaults if it does not exist yet, so a fresh install has something to edit rather
|
|
119
|
+
than an error.
|
|
120
|
+
|
|
121
|
+
## Multiple panels
|
|
122
|
+
|
|
123
|
+
Most applications have one panel, and `Panel.configure(...)` / `Panel.register(...)`
|
|
124
|
+
write to it. When a second audience needs a second set of screens, make another:
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
// The back office — everything.
|
|
128
|
+
Panel.configure({ brand: "Acme", path: "/admin", middleware: [AuthMiddleware] });
|
|
129
|
+
Panel.register(ProductResource, OrderResource, UserResource);
|
|
130
|
+
|
|
131
|
+
// A read-only console for the wider team.
|
|
132
|
+
const console = Panel.make("console", {
|
|
133
|
+
brand: "Acme Console",
|
|
134
|
+
path: "/app",
|
|
135
|
+
middleware: [AuthMiddleware],
|
|
136
|
+
});
|
|
137
|
+
console.register(TeamPostResource);
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Each panel owns its resources, pages, widgets, guard, branding and URL prefix.
|
|
141
|
+
Neither can see the other's registrations, so the same model can appear in both
|
|
142
|
+
under different resources — a full CRUD resource in one, a read-only one in the
|
|
143
|
+
other. That is a better answer than one resource with half its buttons hidden,
|
|
144
|
+
because the second panel simply never mounts the routes it should not have.
|
|
145
|
+
|
|
146
|
+
Give each panel a distinct path; two panels sharing a prefix cannot be told apart
|
|
147
|
+
from a URL. Where they nest, the longest match wins, so `/admin/billing` can be its
|
|
148
|
+
own panel inside `/admin`.
|
|
149
|
+
|
|
150
|
+
Sharing a sign-in is the common case — one identity, two sets of screens — so
|
|
151
|
+
point both at the same guard.
|
|
152
|
+
|
|
153
|
+
## Next steps
|
|
154
|
+
|
|
155
|
+
- [Resources](/docs/admin/resources) — what a resource declares.
|
|
156
|
+
- [Tables](/docs/admin/tables) — columns, filters and the query builder.
|
|
157
|
+
- [Custom Pages & Plugins](/docs/admin/extending) — pages that aren't a model.
|
|
158
|
+
- [References](/docs/admin/references) — the full API surface in one table.
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Admin Tables
|
|
3
|
+
description: Columns, filters, sorting, search, and bulk actions on the list screen.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Tables
|
|
7
|
+
|
|
8
|
+
`columns()` returns `Column`s built with `text(key)` plus chainable modifiers.
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
static columns() {
|
|
12
|
+
return [
|
|
13
|
+
text("title").label("Title").sortable().searchable(),
|
|
14
|
+
text("status").badge((v) => (v === "published" ? "success" : "muted")),
|
|
15
|
+
text("views").align("end").sum("Total views"),
|
|
16
|
+
text("created_at").label("Created").since().sortable(),
|
|
17
|
+
];
|
|
18
|
+
}
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Column kinds & modifiers
|
|
22
|
+
|
|
23
|
+
| Modifier / factory | Effect |
|
|
24
|
+
| --------------------------------------- | ------------------------------------------------------- |
|
|
25
|
+
| `.label(text)` | Header label (defaults to a title-cased key). |
|
|
26
|
+
| `.sortable()` | Clickable, URL-driven sort header. |
|
|
27
|
+
| `.searchable()` | Include in the table's search box. |
|
|
28
|
+
| `.align("start" \| "center" \| "end")` | Cell alignment. |
|
|
29
|
+
| `.format((v, row) => string)` | Custom value formatter. |
|
|
30
|
+
| `.badge((v, row) => tone \| null)` | Render as a colored pill. |
|
|
31
|
+
| `.copyable()` | Copy-to-clipboard affordance. |
|
|
32
|
+
| `toggleColumn(key)` / `.toggle()` | Inline boolean toggle — writes on click. |
|
|
33
|
+
| `selectColumn(key, options)` | Inline select — saves the chosen value on change. |
|
|
34
|
+
| `textInputColumn(key)` / `.editText()` | Inline text input — saves on change/blur. |
|
|
35
|
+
| `imageColumn(key)` / `.circular()` | Image / avatar cell. |
|
|
36
|
+
| `colorColumn(key)`, `iconColumn(key)` | Color swatch, boolean check/cross icon. |
|
|
37
|
+
| `.sum() / .avg() / .count() / .range()` | Column **summaries** (see below). Or `.summarize([…])`. |
|
|
38
|
+
|
|
39
|
+
## Summaries
|
|
40
|
+
|
|
41
|
+
`.sum()`, `.avg()`, `.count()`, `.range()` (each takes an optional label and number
|
|
42
|
+
formatter) render a `<tfoot>` total computed over the **full filtered dataset**, plus
|
|
43
|
+
per-group subtotals when grouping is active.
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
text("amount").align("end").sum("Revenue", (n) => `$${n.toFixed(2)}`),
|
|
47
|
+
text("id").count("Orders"),
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Filters
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
import { selectFilter, ternaryFilter } from "@zerotal/admin";
|
|
54
|
+
|
|
55
|
+
static filters() {
|
|
56
|
+
return [
|
|
57
|
+
selectFilter("status").options({ draft: "Draft", published: "Published" }),
|
|
58
|
+
ternaryFilter("featured").labels("Featured", "Standard"),
|
|
59
|
+
// custom query:
|
|
60
|
+
selectFilter("author").options(authorMap).query((q, v) => q.where("author_id", v)),
|
|
61
|
+
];
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Filters are URL-driven and compose with tabs, search, sort, and pagination.
|
|
66
|
+
|
|
67
|
+
## The query builder
|
|
68
|
+
|
|
69
|
+
Fixed filters work when you can name the useful questions in advance. A catalogue
|
|
70
|
+
or a ledger is queried in too many ways for that, so `queryBuilder` lets the user
|
|
71
|
+
stack their own comparisons and nest AND/OR groups:
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
import {
|
|
75
|
+
queryBuilder, textConstraint, numberConstraint,
|
|
76
|
+
selectConstraint, booleanConstraint, dateConstraint,
|
|
77
|
+
} from "@zerotal/admin";
|
|
78
|
+
|
|
79
|
+
static filters() {
|
|
80
|
+
return [
|
|
81
|
+
queryBuilder("q").label("Advanced filter").constraints([
|
|
82
|
+
textConstraint("name"),
|
|
83
|
+
numberConstraint("price").label("Price (cents)"),
|
|
84
|
+
dateConstraint("created_at").label("Created"),
|
|
85
|
+
selectConstraint("status").options({ draft: "Draft", active: "Active" }),
|
|
86
|
+
booleanConstraint("featured"),
|
|
87
|
+
]),
|
|
88
|
+
];
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
You declare **what may be compared**; the panel supplies the operators each kind
|
|
93
|
+
deserves — `contains` / `starts with` / `is empty` for text, `is at least` / `is
|
|
94
|
+
before` for numbers and dates — and turns the result into predicates.
|
|
95
|
+
|
|
96
|
+
Two things are worth knowing about how it applies:
|
|
97
|
+
|
|
98
|
+
- **The whole tree is wrapped in one group.** An `OR` inside a rule can never
|
|
99
|
+
break out and widen a scope the page already applied: a tab, a parent record, a
|
|
100
|
+
soft-delete filter. The tree narrows; it cannot escape.
|
|
101
|
+
- **A rule naming a constraint you never declared is dropped.** The active tree
|
|
102
|
+
travels in the URL, so it is user input; only the columns you listed are
|
|
103
|
+
reachable, and a hand-edited URL cannot filter on a password hash.
|
|
104
|
+
|
|
105
|
+
Groups nest as deep as the question needs — a group inside a group inside a
|
|
106
|
+
group — and the whole tree lives in the same `?filters=` parameter as everything
|
|
107
|
+
else, so it composes with tabs, search, sort and pagination. A narrowed view is a
|
|
108
|
+
link someone can send to a colleague.
|
|
109
|
+
|
|
110
|
+
## Tabs
|
|
111
|
+
|
|
112
|
+
`tab(key)` adds quick-filter tabs above the table, each scoping the query and
|
|
113
|
+
optionally showing a count badge.
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
import { tab } from "@zerotal/admin";
|
|
117
|
+
|
|
118
|
+
static tabs() {
|
|
119
|
+
return [
|
|
120
|
+
tab("all").label("All"),
|
|
121
|
+
tab("published").modifyQuery((q) => q.where("status", "published")).badge(),
|
|
122
|
+
tab("draft").modifyQuery((q) => q.where("status", "draft")).badgeColor("muted"),
|
|
123
|
+
];
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Grouping
|
|
128
|
+
|
|
129
|
+
`group(column)` adds a "Group by" menu; the page renders header rows per bucket with
|
|
130
|
+
counts (and per-group summary subtotals).
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
import { group } from "@zerotal/admin";
|
|
134
|
+
|
|
135
|
+
static groups() {
|
|
136
|
+
return [group("status"), group("author").label("Author")];
|
|
137
|
+
}
|
|
138
|
+
static defaultGroup = "status"; // optional
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## Reordering
|
|
142
|
+
|
|
143
|
+
Set `reorderable` to an integer position column to show up/down handles that persist
|
|
144
|
+
order:
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
static reorderable = "sort";
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Built-in table affordances
|
|
151
|
+
|
|
152
|
+
Per-page selector, a **Columns** visibility manager (`?cols=`), full-text search
|
|
153
|
+
across `.searchable()` columns, and bulk selection with a toolbar all ship
|
|
154
|
+
automatically and are URL-driven.
|
|
155
|
+
|
|
156
|
+
## Empty states
|
|
157
|
+
|
|
158
|
+
A blank table teaches nobody anything. Override `emptyState()` to say why the list
|
|
159
|
+
is empty and what will fill it:
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
static emptyState() {
|
|
163
|
+
return {
|
|
164
|
+
heading: "No orders yet",
|
|
165
|
+
description: "Orders appear here as soon as a customer checks out.",
|
|
166
|
+
icon: "inbox",
|
|
167
|
+
actions: [createAction()],
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
A search or filter that matches nothing gets a different, automatic message — this
|
|
173
|
+
is for a genuinely empty resource, which is a different problem and wants a
|
|
174
|
+
different answer.
|
|
175
|
+
|
|
176
|
+
## Layout and placement
|
|
177
|
+
|
|
178
|
+
How the table renders — grid instead of rows, striping, sticky headers, density —
|
|
179
|
+
and where the filters sit are covered in
|
|
180
|
+
[Extending the UI](/docs/admin/extending-ui#table-presentation).
|
|
181
|
+
|
|
182
|
+
## Next steps
|
|
183
|
+
|
|
184
|
+
- [Admin overview](/docs/admin) — the guide's front page and the rest of the sections.
|
|
185
|
+
- [Reference](/docs/admin/references) — the full API surface in one table.
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Testing the Admin Panel
|
|
3
|
+
description: Drive panel screens in tests and assert on what they render.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Testing
|
|
7
|
+
|
|
8
|
+
`@zerotal/admin/testing` mounts a resource's pages on Flow's in-process test
|
|
9
|
+
harness and adds assertions phrased in admin terms — columns, fields, actions and
|
|
10
|
+
records — so a test reads like a description of the screen rather than a walk
|
|
11
|
+
through its markup.
|
|
12
|
+
|
|
13
|
+
Nothing is served over the network. Mounting a page instantiates the component,
|
|
14
|
+
runs its real lifecycle, and renders once, so there is no server to boot and no
|
|
15
|
+
WebSocket to connect. Tests stay fast enough to cover every resource you register.
|
|
16
|
+
|
|
17
|
+
## Mounting a page
|
|
18
|
+
|
|
19
|
+
Three helpers cover the three screens a resource renders. Each returns a
|
|
20
|
+
`FlowTest`, so every assertion in [Flow's testing guide](/docs/flow/testing)
|
|
21
|
+
works alongside the admin-specific ones below.
|
|
22
|
+
|
|
23
|
+
| Helper | Screen | Reach for it to check |
|
|
24
|
+
| ---------------------------------- | ------------------ | ------------------------------------------------ |
|
|
25
|
+
| `AdminTest.list(Resource, props?)` | List page | Columns, search, sorting, filters, bulk actions |
|
|
26
|
+
| `AdminTest.view(Resource, id)` | View page | Infolist entries and header actions for a record |
|
|
27
|
+
| `AdminTest.form(Resource, mode?)` | Create / Edit form | Fields, validation and saving |
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
// tests/admin/users.test.ts
|
|
31
|
+
import { AdminTest, assertHasColumn, assertHasAction } from "@zerotal/admin/testing";
|
|
32
|
+
import { UserResource } from "../../app/admin/UserResource.ts";
|
|
33
|
+
|
|
34
|
+
const list = await AdminTest.list(UserResource);
|
|
35
|
+
assertHasColumn(list, UserResource, "email");
|
|
36
|
+
assertHasAction(list, "Create");
|
|
37
|
+
list.assertSee("ada@example.com");
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`AdminTest.form()` takes the mode as its second argument — `"create"` (the
|
|
41
|
+
default) or `"edit"`. Drive the form through its `form` property, which holds
|
|
42
|
+
every field's value:
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
const form = await AdminTest.form(UserResource, "create");
|
|
46
|
+
assertHasField(form, UserResource, "name");
|
|
47
|
+
await form.set("form", { name: "" });
|
|
48
|
+
await form.call("save");
|
|
49
|
+
form.assertHasErrors("name");
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Seeding list state
|
|
53
|
+
|
|
54
|
+
The List page keeps search, sorting, pagination and filters in `@url` state. Pass
|
|
55
|
+
those values as the second argument to `AdminTest.list()` and the page mounts as
|
|
56
|
+
though the reader had arrived on that URL — which is how you assert on a filtered
|
|
57
|
+
or sorted table without first driving the clicks that would produce it.
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
const list = await AdminTest.list(UserResource, {
|
|
61
|
+
search: "ada",
|
|
62
|
+
sortBy: "createdAt",
|
|
63
|
+
sortDir: "desc",
|
|
64
|
+
page: "2",
|
|
65
|
+
});
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
| Prop | Seeds |
|
|
69
|
+
| -------------------- | -------------------------------- |
|
|
70
|
+
| `search` | The search box |
|
|
71
|
+
| `sortBy` / `sortDir` | Sort column and direction |
|
|
72
|
+
| `page` / `perPage` | Pagination |
|
|
73
|
+
| `filters` | Active filter values |
|
|
74
|
+
| `tab` | The selected tab |
|
|
75
|
+
| `trashed` | The soft-delete scope |
|
|
76
|
+
| `group` | Active grouping |
|
|
77
|
+
| `cols` | Column visibility toggles |
|
|
78
|
+
| `locale` | The locale translated values use |
|
|
79
|
+
|
|
80
|
+
Every one of these is a string, because they round-trip through the query string.
|
|
81
|
+
|
|
82
|
+
## Admin assertions
|
|
83
|
+
|
|
84
|
+
These four take a key from the resource definition and assert against the
|
|
85
|
+
_resolved_ label. A test written this way keeps passing when a column is
|
|
86
|
+
relabelled through `.label()`, and fails when the column is removed — usually the
|
|
87
|
+
change you wanted the test to catch.
|
|
88
|
+
|
|
89
|
+
| Assertion | Passes when |
|
|
90
|
+
| --------------------------------------- | ---------------------------------------- |
|
|
91
|
+
| `assertHasColumn(t, Resource, key)` | That column's header is rendered |
|
|
92
|
+
| `assertHasField(t, Resource, key)` | That form field's label is rendered |
|
|
93
|
+
| `assertHasAction(t, label)` | An action with that label is on the page |
|
|
94
|
+
| `assertSeesRecord(t, Resource, record)` | The record's title appears in a row |
|
|
95
|
+
|
|
96
|
+
`assertSeesRecord` composes the title through the resource's
|
|
97
|
+
`recordTitleAttribute`, so it finds the row however that title is assembled.
|
|
98
|
+
`assertHasField` searches through nested layout components, so a field inside a
|
|
99
|
+
tab or section is found without naming its container.
|
|
100
|
+
|
|
101
|
+
## Driving and inspecting the page
|
|
102
|
+
|
|
103
|
+
The harness inherits Flow's actions and assertions. Two are worth calling out,
|
|
104
|
+
because reaching for the wrong one hides bugs:
|
|
105
|
+
|
|
106
|
+
- **`set(prop, value)`** assigns a property directly and skips the
|
|
107
|
+
`updating`/`updated` hooks. Use it to arrange state before the behaviour you are
|
|
108
|
+
actually testing.
|
|
109
|
+
- **`update(prop, value)`** takes the same path a change from the browser does and
|
|
110
|
+
fires those hooks. Use it when the hooks _are_ the behaviour under test — a
|
|
111
|
+
filter that refetches rows, or a field that derives another field.
|
|
112
|
+
|
|
113
|
+
`call(method, ...args)` invokes an action the way a button would. For assertions,
|
|
114
|
+
`assertSee` / `assertDontSee` cover rendered text, `assertHasErrors(field)` and
|
|
115
|
+
`assertNoErrors()` cover validation, `assertRedirectedTo(url)` covers a save that
|
|
116
|
+
navigates away, and `assertFlashed(level?, message?)` covers the notification a
|
|
117
|
+
successful action leaves behind.
|
|
118
|
+
|
|
119
|
+
When an assertion fails and you need to see why, `html()` returns the rendered
|
|
120
|
+
markup, `errors()` the validation bag, and `page()` the live component instance.
|
|
121
|
+
|
|
122
|
+
## Testing a non-default panel
|
|
123
|
+
|
|
124
|
+
Every helper accepts a panel as its last argument, defaulting to
|
|
125
|
+
`Panel.default()`. Apps that register more than one panel pass the one under test,
|
|
126
|
+
so the page resolves that panel's own configuration and navigation:
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
const shop = Panel.get("shop");
|
|
130
|
+
|
|
131
|
+
const list = await AdminTest.list(OrderResource, {}, shop);
|
|
132
|
+
const form = await AdminTest.form(OrderResource, "edit", { recordId: "1" }, shop);
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Next steps
|
|
136
|
+
|
|
137
|
+
- [Admin overview](/docs/admin) — the guide's front page and the rest of the sections.
|
|
138
|
+
- [Flow testing](/docs/flow/testing) — the harness these helpers are built on.
|