@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.
Files changed (160) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/LICENSE +21 -0
  3. package/README.md +67 -0
  4. package/api-surface.md +283 -0
  5. package/docs/about.md +676 -0
  6. package/docs/admin/actions.md +183 -0
  7. package/docs/admin/auth.md +89 -0
  8. package/docs/admin/dashboard.md +155 -0
  9. package/docs/admin/extending-ui.md +229 -0
  10. package/docs/admin/extending.md +205 -0
  11. package/docs/admin/forms.md +155 -0
  12. package/docs/admin/index.md +144 -0
  13. package/docs/admin/operations.md +224 -0
  14. package/docs/admin/references.md +174 -0
  15. package/docs/admin/resources.md +132 -0
  16. package/docs/admin/structure.md +158 -0
  17. package/docs/admin/tables.md +185 -0
  18. package/docs/admin/testing.md +138 -0
  19. package/docs/ai.md +373 -0
  20. package/docs/application.md +435 -0
  21. package/docs/arch.md +263 -0
  22. package/docs/assets.md +755 -0
  23. package/docs/audit.md +367 -0
  24. package/docs/authentication.md +1000 -0
  25. package/docs/authorization.md +529 -0
  26. package/docs/broadcasting/channels.md +170 -0
  27. package/docs/broadcasting/client.md +108 -0
  28. package/docs/broadcasting/events.md +190 -0
  29. package/docs/broadcasting/index.md +163 -0
  30. package/docs/broadcasting/references.md +84 -0
  31. package/docs/broadcasting/testing.md +132 -0
  32. package/docs/cache.md +387 -0
  33. package/docs/carbon.md +830 -0
  34. package/docs/changelog.md +465 -0
  35. package/docs/client/auth.md +113 -0
  36. package/docs/client/errors.md +139 -0
  37. package/docs/client/files.md +118 -0
  38. package/docs/client/index.md +128 -0
  39. package/docs/client/references.md +58 -0
  40. package/docs/client/requests.md +131 -0
  41. package/docs/client/resilience.md +141 -0
  42. package/docs/client/testing.md +146 -0
  43. package/docs/commands.md +450 -0
  44. package/docs/components.md +2427 -0
  45. package/docs/config-system.md +258 -0
  46. package/docs/container.md +596 -0
  47. package/docs/context.md +579 -0
  48. package/docs/contributing.md +164 -0
  49. package/docs/controllers.md +354 -0
  50. package/docs/conventions.md +466 -0
  51. package/docs/cookies.md +101 -0
  52. package/docs/csrf.md +206 -0
  53. package/docs/database.md +476 -0
  54. package/docs/deployment.md +434 -0
  55. package/docs/devtools.md +1013 -0
  56. package/docs/email-verification.md +320 -0
  57. package/docs/encryption.md +380 -0
  58. package/docs/errors.md +317 -0
  59. package/docs/events.md +420 -0
  60. package/docs/flow/components.md +846 -0
  61. package/docs/flow/decorators.md +458 -0
  62. package/docs/flow/events.md +420 -0
  63. package/docs/flow/forms.md +551 -0
  64. package/docs/flow/index.md +568 -0
  65. package/docs/flow/layouts.md +793 -0
  66. package/docs/flow/lifecycle.md +336 -0
  67. package/docs/flow/pagination.md +402 -0
  68. package/docs/flow/performance.md +262 -0
  69. package/docs/flow/references.md +317 -0
  70. package/docs/flow/routing.md +550 -0
  71. package/docs/flow/testing.md +447 -0
  72. package/docs/getting-started.md +286 -0
  73. package/docs/health.md +269 -0
  74. package/docs/helpers.md +423 -0
  75. package/docs/i18n.md +414 -0
  76. package/docs/index.md +65 -0
  77. package/docs/inertia/build.md +95 -0
  78. package/docs/inertia/devtools.md +197 -0
  79. package/docs/inertia/index.md +267 -0
  80. package/docs/inertia/middleware.md +81 -0
  81. package/docs/inertia/props.md +507 -0
  82. package/docs/inertia/references.md +144 -0
  83. package/docs/inertia/rendering.md +189 -0
  84. package/docs/inertia/ssr.md +102 -0
  85. package/docs/inspirations.md +257 -0
  86. package/docs/lifecycle.md +331 -0
  87. package/docs/lock.md +454 -0
  88. package/docs/logger.md +517 -0
  89. package/docs/media.md +582 -0
  90. package/docs/middleware.md +509 -0
  91. package/docs/migrations.md +529 -0
  92. package/docs/monitor.md +377 -0
  93. package/docs/notifications.md +848 -0
  94. package/docs/orm/casts.md +365 -0
  95. package/docs/orm/factories.md +231 -0
  96. package/docs/orm/index.md +663 -0
  97. package/docs/orm/lifecycle.md +421 -0
  98. package/docs/orm/queries.md +692 -0
  99. package/docs/orm/relationships.md +440 -0
  100. package/docs/orm/serialization.md +251 -0
  101. package/docs/package-development.md +536 -0
  102. package/docs/pagination.md +340 -0
  103. package/docs/password-reset.md +335 -0
  104. package/docs/providers.md +447 -0
  105. package/docs/query-builder.md +465 -0
  106. package/docs/queue.md +549 -0
  107. package/docs/rate-limiting.md +253 -0
  108. package/docs/responses.md +336 -0
  109. package/docs/roles-and-2fa.md +418 -0
  110. package/docs/routing.md +1008 -0
  111. package/docs/scaffolding.md +335 -0
  112. package/docs/scheduler.md +533 -0
  113. package/docs/seeding.md +229 -0
  114. package/docs/session.md +451 -0
  115. package/docs/social.md +524 -0
  116. package/docs/storage.md +622 -0
  117. package/docs/structure.md +230 -0
  118. package/docs/support-policy.md +114 -0
  119. package/docs/telemetry.md +505 -0
  120. package/docs/tenancy.md +608 -0
  121. package/docs/testing/browser.md +153 -0
  122. package/docs/testing/console.md +130 -0
  123. package/docs/testing/database.md +238 -0
  124. package/docs/testing/flow-browser.md +216 -0
  125. package/docs/testing/http.md +441 -0
  126. package/docs/testing/index.md +240 -0
  127. package/docs/testing/mocking.md +410 -0
  128. package/docs/upgrade.md +192 -0
  129. package/docs/validator.md +549 -0
  130. package/docs/view.md +453 -0
  131. package/package.json +60 -0
  132. package/src/bin/mcp.ts +73 -0
  133. package/src/config.ts +63 -0
  134. package/src/errors.ts +27 -0
  135. package/src/index.ts +56 -0
  136. package/src/install/ArchInstallCommand.ts +189 -0
  137. package/src/install/detect.ts +98 -0
  138. package/src/install/guidelines.ts +290 -0
  139. package/src/install/markers.ts +77 -0
  140. package/src/install/mcpConfig.ts +81 -0
  141. package/src/mcp/index.ts +27 -0
  142. package/src/mcp/jsonrpc.ts +133 -0
  143. package/src/mcp/server.ts +307 -0
  144. package/src/mcp/stdio.ts +111 -0
  145. package/src/mcp/types.ts +146 -0
  146. package/src/probe/ArchProbeCommand.ts +48 -0
  147. package/src/probe/sentinel.ts +13 -0
  148. package/src/probe/topics.ts +291 -0
  149. package/src/provider/ArchProvider.ts +101 -0
  150. package/src/tools/_probe.ts +164 -0
  151. package/src/tools/apiSurface.ts +241 -0
  152. package/src/tools/appInfo.ts +88 -0
  153. package/src/tools/baselines.ts +210 -0
  154. package/src/tools/context.ts +37 -0
  155. package/src/tools/doctor.ts +93 -0
  156. package/src/tools/index.ts +50 -0
  157. package/src/tools/logs.ts +265 -0
  158. package/src/tools/routes.ts +133 -0
  159. package/src/tools/schema.ts +119 -0
  160. 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.