@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,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.