@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,550 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Flow Routing
|
|
3
|
+
description: Map URLs to Flow pages — file-based routes, route parameters, and navigation.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Routing
|
|
7
|
+
|
|
8
|
+
Flow pages are plain TypeScript classes. Register them as routes with `Router.flow()`, or place them in a directory and let file-based routing discover them automatically. Either way, the same page class powers both the initial HTTP render and all subsequent WebSocket updates.
|
|
9
|
+
|
|
10
|
+
## The Router.flow method
|
|
11
|
+
|
|
12
|
+
`Router.flow(path, PageClass, middleware?)` registers a `Component` subclass as a `GET` route. The third argument accepts an array of middleware classes that run on the **initial GET and on every WebSocket update** — this is Flow's persistent middleware model (no separate "attach middleware per WS frame" step required).
|
|
13
|
+
|
|
14
|
+
```typescript
|
|
15
|
+
// routes/web.ts
|
|
16
|
+
import { Router } from "zerotal";
|
|
17
|
+
import { DashboardPage } from "#app/flow/DashboardPage.tsx";
|
|
18
|
+
import { PostsPage } from "#app/flow/PostsPage.tsx";
|
|
19
|
+
import { PostDetailPage } from "#app/flow/PostDetailPage.tsx";
|
|
20
|
+
import { AdminPage } from "#app/flow/AdminPage.tsx";
|
|
21
|
+
import { RequireAuthMiddleware } from "#app/middleware/RequireAuth.ts";
|
|
22
|
+
import { RequireAdminMiddleware } from "#app/middleware/RequireAdmin.ts";
|
|
23
|
+
|
|
24
|
+
// No middleware — anyone can access
|
|
25
|
+
Router.flow("/posts", PostsPage);
|
|
26
|
+
|
|
27
|
+
// Single dynamic segment
|
|
28
|
+
Router.flow("/posts/:slug", PostDetailPage);
|
|
29
|
+
|
|
30
|
+
// Auth guard — middleware re-runs on every WebSocket update
|
|
31
|
+
Router.flow("/dashboard", DashboardPage, [RequireAuthMiddleware]);
|
|
32
|
+
|
|
33
|
+
// Multiple guards
|
|
34
|
+
Router.flow("/admin", AdminPage, [RequireAuthMiddleware, RequireAdminMiddleware]);
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Named routes
|
|
38
|
+
|
|
39
|
+
Chain `.name()` to give a route a name for reverse URL generation:
|
|
40
|
+
|
|
41
|
+
```typescript
|
|
42
|
+
Router.flow("/dashboard", DashboardPage, [RequireAuthMiddleware]).name("dashboard");
|
|
43
|
+
Router.flow("/posts", PostsPage).name("posts.index");
|
|
44
|
+
Router.flow("/posts/:slug", PostDetailPage).name("posts.show");
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Route groups
|
|
48
|
+
|
|
49
|
+
Use `Router.group()` to share a prefix and/or middleware across several Flow routes:
|
|
50
|
+
|
|
51
|
+
```typescript
|
|
52
|
+
Router.group({ prefix: "/app", middleware: [RequireAuthMiddleware] }, () => {
|
|
53
|
+
Router.flow("/dashboard", DashboardPage); // /app/dashboard
|
|
54
|
+
Router.flow("/profile", ProfilePage); // /app/profile
|
|
55
|
+
Router.flow("/settings", SettingsPage); // /app/settings
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
Router.group(
|
|
59
|
+
{ prefix: "/admin", middleware: [RequireAuthMiddleware, RequireAdminMiddleware] },
|
|
60
|
+
() => {
|
|
61
|
+
Router.flow("/", AdminDashboardPage); // /admin
|
|
62
|
+
Router.flow("/users", AdminUsersPage); // /admin/users
|
|
63
|
+
Router.flow("/posts", AdminPostsPage); // /admin/posts
|
|
64
|
+
},
|
|
65
|
+
);
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Middleware declared on the group is persistent: it re-runs on every WebSocket update for every page in the group.
|
|
69
|
+
|
|
70
|
+
## File-based routing
|
|
71
|
+
|
|
72
|
+
When you call `.fileBasedRouting()` on `Application`, the framework scans the given directory and auto-registers any file that exports a `Component` subclass. No import required in a route file.
|
|
73
|
+
|
|
74
|
+
```typescript
|
|
75
|
+
// bootstrap/app.ts
|
|
76
|
+
import { Application, basePath } from "zerotal";
|
|
77
|
+
import providers from "./providers.ts";
|
|
78
|
+
|
|
79
|
+
export default Application.create({ providers })
|
|
80
|
+
.routing({ web: basePath("routes/web.ts") })
|
|
81
|
+
.fileBasedRouting({ web: basePath("app/flow") });
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Any `Component` subclass exported from a file under `app/flow/` is registered automatically. The URL path is derived from the file path relative to the root directory:
|
|
85
|
+
|
|
86
|
+
| File | URL |
|
|
87
|
+
| ------------------------------------ | ----------------------- |
|
|
88
|
+
| `app/flow/DashboardPage.tsx` | `/dashboard-page` |
|
|
89
|
+
| `app/flow/PostsPage.tsx` | `/posts-page` |
|
|
90
|
+
| `app/flow/posts/IndexPage.tsx` | `/posts/index-page` |
|
|
91
|
+
| `app/flow/posts/[slug].tsx` | `/posts/:slug` |
|
|
92
|
+
| `app/flow/posts/[slug]/comments.tsx` | `/posts/:slug/comments` |
|
|
93
|
+
| `app/flow/admin/users/[id].tsx` | `/admin/users/:id` |
|
|
94
|
+
|
|
95
|
+
Class names become kebab-case URL segments: `DashboardPage` → `/dashboard-page`. For cleaner URLs, use explicit `Router.flow()` registration in a route file alongside file-based routing — both can coexist in the same app.
|
|
96
|
+
|
|
97
|
+
### Dynamic segments
|
|
98
|
+
|
|
99
|
+
Name a file (or directory) with square brackets to create a dynamic route segment:
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
app/flow/
|
|
103
|
+
posts/
|
|
104
|
+
[slug].tsx → /posts/:slug
|
|
105
|
+
[slug]/
|
|
106
|
+
comments.tsx → /posts/:slug/comments
|
|
107
|
+
users/
|
|
108
|
+
[id]/
|
|
109
|
+
profile.tsx → /users/:id/profile
|
|
110
|
+
edit.tsx → /users/:id/edit
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Per-file middleware
|
|
114
|
+
|
|
115
|
+
Export a `middleware` array from a file-route to attach middleware to that specific page:
|
|
116
|
+
|
|
117
|
+
```typescript
|
|
118
|
+
// app/flow/admin/DashboardPage.tsx
|
|
119
|
+
import { RequireAuthMiddleware } from "#app/middleware/RequireAuth.ts";
|
|
120
|
+
import { RequireAdminMiddleware } from "#app/middleware/RequireAdmin.ts";
|
|
121
|
+
|
|
122
|
+
export const middleware = [RequireAuthMiddleware, RequireAdminMiddleware];
|
|
123
|
+
|
|
124
|
+
export class DashboardPage extends Component {
|
|
125
|
+
// …
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Or use a `_middleware.ts` file in a directory to apply middleware to every file in that directory:
|
|
130
|
+
|
|
131
|
+
```typescript
|
|
132
|
+
// app/flow/admin/_middleware.ts
|
|
133
|
+
import { RequireAuthMiddleware } from "#app/middleware/RequireAuth.ts";
|
|
134
|
+
import { RequireAdminMiddleware } from "#app/middleware/RequireAdmin.ts";
|
|
135
|
+
|
|
136
|
+
export const middleware = [RequireAuthMiddleware, RequireAdminMiddleware];
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Accessing route parameters
|
|
140
|
+
|
|
141
|
+
### Query string parameters — @url
|
|
142
|
+
|
|
143
|
+
For query string parameters (`/posts?page=2&search=typescript`), use the `@url` decorator. The field is filled from the URL on the first render, and stays in sync as the value changes:
|
|
144
|
+
|
|
145
|
+
```typescript
|
|
146
|
+
@url page: number = 1;
|
|
147
|
+
@url search: string = "";
|
|
148
|
+
@url status: string = "all";
|
|
149
|
+
|
|
150
|
+
// Custom parameter name: /posts?q=typescript
|
|
151
|
+
@url({ as: "q" }) query: string = "";
|
|
152
|
+
|
|
153
|
+
// Push browser history on change (back button works)
|
|
154
|
+
@url({ history: "push" }) tab: string = "overview";
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### Query-aware navigation
|
|
158
|
+
|
|
159
|
+
Where `@url` syncs a reactive prop **to** the URL, `this.currentUrl()` and `this.navigateCurrent()` go the other way — they **build** a URL from the one you're on with some query params changed, for filter links and instant filtering. Both are client-only helpers (the compiler rewrites them to the client runtime), so they update without a server round-trip to start.
|
|
160
|
+
|
|
161
|
+
```typescript
|
|
162
|
+
// Build a URL from the current one — returns a string, does NOT navigate.
|
|
163
|
+
this.currentUrl({ query: { page: 3 } });
|
|
164
|
+
// /posts?search=ts&page=2 → /posts?search=ts&page=3
|
|
165
|
+
|
|
166
|
+
// Build that URL and SPA-navigate to it (layout stays mounted) — returns a Promise.
|
|
167
|
+
this.navigateCurrent({ query: { status: "active" } });
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
The merge rules:
|
|
171
|
+
|
|
172
|
+
- Params you pass are **added or updated**; params you don't mention are **preserved**.
|
|
173
|
+
- A value of `null`, `undefined`, or `""` **removes** that param (so `0` and `false` are kept).
|
|
174
|
+
- `hash: "section-2"` sets the hash; `hash: ""` clears it; omit it to leave the hash alone.
|
|
175
|
+
|
|
176
|
+
**Use `currentUrl()` in a binding** — an attribute value or a text child. The compiler turns it into a reactive client binding, so the link always reflects the current URL:
|
|
177
|
+
|
|
178
|
+
```tsx
|
|
179
|
+
// Pagination links that preserve the active search / filters
|
|
180
|
+
<a href={this.currentUrl({ query: { page: this.page - 1 } })} flow:navigate>Previous</a>
|
|
181
|
+
<a href={this.currentUrl({ query: { page: this.page + 1 } })} flow:navigate>Next</a>
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
**Use `navigateCurrent()` in a handler** — perfect for instant filters that navigate as the user picks:
|
|
185
|
+
|
|
186
|
+
```tsx
|
|
187
|
+
<select onChange={(e) => this.navigateCurrent({ query: { status: e.target.value || null } })}>
|
|
188
|
+
<option value="">All</option>
|
|
189
|
+
<option value="active">Active</option>
|
|
190
|
+
<option value="archived">Archived</option>
|
|
191
|
+
</select>
|
|
192
|
+
|
|
193
|
+
// Clear a filter — passing "" removes the param
|
|
194
|
+
<button onClick={() => this.navigateCurrent({ query: { search: "" } })}>Clear search</button>
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Like any navigation, this lands at the top of the page. A filter sitting partway
|
|
198
|
+
down is the case where that's wrong — the user is looking at the control they
|
|
199
|
+
just changed, and the results move out from under them. Pass `preserveScroll` to
|
|
200
|
+
leave the viewport where it is:
|
|
201
|
+
|
|
202
|
+
```tsx
|
|
203
|
+
<select
|
|
204
|
+
onChange={(e) =>
|
|
205
|
+
this.navigateCurrent({ query: { status: e.target.value || null }, preserveScroll: true })
|
|
206
|
+
}
|
|
207
|
+
>
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Pagination is the opposite case: page 2 should start at the top, so leave it off.
|
|
211
|
+
`<Link preserveScroll>` does the same thing for a link — see
|
|
212
|
+
[Components](/docs/flow/components).
|
|
213
|
+
|
|
214
|
+
> **Warning** — Both helpers are client-only: they have no value on the server and throw if called from a server action or `onMount()`. Use them directly inside JSX bindings (an attribute value, a text child, or an `onClick`/`onChange` handler) — the compiler rewrites those to the `$flow.currentUrl` / `$flow.navigateCurrent` client magics. They're fully type-checked, no `$`-prefixed syntax. To navigate from server code instead, return a `redirect()`.
|
|
215
|
+
|
|
216
|
+
### Path parameters
|
|
217
|
+
|
|
218
|
+
Name a field after the segment and it arrives filled. `/posts/:post` names the `Post` model, so the page receives the loaded record — no query, and no lookup code:
|
|
219
|
+
|
|
220
|
+
```typescript
|
|
221
|
+
// routes/web.ts — nothing to declare; :post is a Post
|
|
222
|
+
Router.flow("/posts/:post", PostDetailPage);
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
```typescript
|
|
226
|
+
export class PostDetailPage extends Component {
|
|
227
|
+
@locked post!: Post; // :post — the record, already loaded
|
|
228
|
+
|
|
229
|
+
@expose async deletePost(): Promise<void> {
|
|
230
|
+
await this.post.delete();
|
|
231
|
+
this.redirect("/posts");
|
|
232
|
+
this.flash("Post deleted.");
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
override async render() {
|
|
236
|
+
return (
|
|
237
|
+
<div>
|
|
238
|
+
<h1>{this.post.title}</h1>
|
|
239
|
+
<div class="prose">{this.post.body}</div>
|
|
240
|
+
<button onClick={this.deletePost}>Delete</button>
|
|
241
|
+
</div>
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
A segment that names a model arrives as the record; one that doesn't arrives as its plain string, so `@locked slug = ""` on `/posts/:slug` is filled the same way.
|
|
248
|
+
|
|
249
|
+
Flow does not decide how a segment resolves — the router does, and the page just receives the result. A record is looked up by primary key unless the model says otherwise, so resolving by a slug, or only publishing published posts, is [written once on the model](/docs/routing#the-model-owns-its-lookup) and changes nothing here.
|
|
250
|
+
|
|
251
|
+
Only `@locked` and `@expose` fields are filled, and only from segments the route actually matched. A plain undecorated property is never written from the URL.
|
|
252
|
+
|
|
253
|
+
When the field's name differs from the segment, `@param` says where it comes from — either the segment's name, or the model:
|
|
254
|
+
|
|
255
|
+
```typescript
|
|
256
|
+
export class PostDetailPage extends Component {
|
|
257
|
+
@locked @param(Post) article!: Post; // whichever segment resolved to a Post
|
|
258
|
+
@locked @param("post") alsoArticle!: Post; // or name the segment
|
|
259
|
+
@locked @param year: string = ""; // bare @param = the field's own name
|
|
260
|
+
}
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
Passing the model is the sturdier of the two — the field says what it wants and never has to track what a route called it. A model claims one segment name (its class name, or its `implicitBindingKey`), so there is nothing to be ambiguous about. Use the string form for the rare route that binds a second segment to the same model by hand; `@param(Model)` takes the first one.
|
|
264
|
+
|
|
265
|
+
### Reading the rest of the request
|
|
266
|
+
|
|
267
|
+
`onBoot()` and `onMount()` also receive the request itself — the same `HttpContext` a controller action gets — for anything the URL doesn't carry. It types `ctx.params` only, so the signed-in user is `{ user }`, not `params.user`:
|
|
268
|
+
|
|
269
|
+
```typescript
|
|
270
|
+
override async onMount({ user }: HttpContext) {
|
|
271
|
+
this.canEdit = user?.id === this.post.authorId;
|
|
272
|
+
}
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
The argument is optional, because a component can also be created outside a request — in a test, for example.
|
|
276
|
+
|
|
277
|
+
### Child components
|
|
278
|
+
|
|
279
|
+
A URL segment fills the **page**. `/posts/:post` gives the page its `post`; the components inside it get nothing from the URL, even if one of their fields happens to share the segment's name. If a child needs the post, the page hands it over:
|
|
280
|
+
|
|
281
|
+
```tsx
|
|
282
|
+
// in the page's render()
|
|
283
|
+
<PostCard post={this.post} compact />
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
A prop lands on the field of the same name, before any hook runs:
|
|
287
|
+
|
|
288
|
+
```typescript
|
|
289
|
+
export class PostCard extends Component {
|
|
290
|
+
@locked post!: Post; // required — no default
|
|
291
|
+
@locked compact = false; // optional — false when the page omits it
|
|
292
|
+
@locked heading = "";
|
|
293
|
+
|
|
294
|
+
override async onMount() {
|
|
295
|
+
this.heading = this.post.title.toUpperCase(); // the prop is already here
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
The initialiser is the convention: a field that has one is optional and falls back to it, and a field without one is required — so a page that forgets to pass it breaks on first use instead of quietly rendering the wrong thing.
|
|
301
|
+
|
|
302
|
+
A child still gets the request in `onBoot(ctx)` / `onMount(ctx)` — session, signed-in user, headers — like any page. It simply never reads the URL's segments, which is what lets the same `PostCard` work on `/posts/:post`, inside a list, and on a page with no segments at all.
|
|
303
|
+
|
|
304
|
+
Mark props `@locked` (or `@expose`) so their values survive round-trips in the child's own snapshot.
|
|
305
|
+
|
|
306
|
+
> **Warning** — Fields are filled from the URL on the first request only. A WebSocket action carries no URL, so the value comes back from the component's snapshot instead — which is why a model is never re-queried on every click. It also means you should not read `ctx.params` yourself in `onBoot()`: that hook runs on every request, after the snapshot has been restored, so it would blank the value on the first action.
|
|
307
|
+
|
|
308
|
+
### Integer route params
|
|
309
|
+
|
|
310
|
+
Coerce string params to numbers with `ctx.integer()`:
|
|
311
|
+
|
|
312
|
+
```typescript
|
|
313
|
+
override async onMount(ctx: HttpContext) {
|
|
314
|
+
this.userId = ctx.integer("id") ?? 0;
|
|
315
|
+
}
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
### All available request methods inside a Flow component
|
|
319
|
+
|
|
320
|
+
`onBoot()` and `onMount()` are handed the context directly. Everywhere else — an
|
|
321
|
+
`@expose`d action, or a service the page calls — reach for the `request()` helper,
|
|
322
|
+
which returns the same object from request-scoped storage:
|
|
323
|
+
|
|
324
|
+
```typescript
|
|
325
|
+
import { request } from "zerotal";
|
|
326
|
+
|
|
327
|
+
// Inside an @expose method, which takes its own arguments rather than a context:
|
|
328
|
+
const ctx = request();
|
|
329
|
+
|
|
330
|
+
ctx.params.slug; // string — matched route segment (initial GET only)
|
|
331
|
+
ctx.params.post; // Post — a resolved route-model binding (initial GET only)
|
|
332
|
+
ctx.url.searchParams.get("q"); // string | null — raw query string access
|
|
333
|
+
ctx.string("search"); // string | undefined — reads params + query string
|
|
334
|
+
ctx.integer("page", 1); // number — coerced integer with fallback
|
|
335
|
+
ctx.boolean("active"); // boolean
|
|
336
|
+
ctx.user; // AuthenticatedUser | undefined — set by AuthMiddleware
|
|
337
|
+
ctx.ip(); // string | null — client IP
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
## Accessing the session
|
|
341
|
+
|
|
342
|
+
### The @session decorator — a field backed by the session
|
|
343
|
+
|
|
344
|
+
`@session` binds a field to a session key, so its value survives a browser refresh. Reads and writes go straight to the session; nothing is kept in the component's snapshot, so the browser never sees the value:
|
|
345
|
+
|
|
346
|
+
```typescript
|
|
347
|
+
@session preferredTheme: string = "light"; // the session's `preferredTheme`
|
|
348
|
+
@session lastVisitedTab: string = "overview";
|
|
349
|
+
|
|
350
|
+
@session({ key: "cart_count" }) cartCount: number = 0; // a differently-named key
|
|
351
|
+
@session({ scoped: true }) wizardStep: number = 1; // flow:CheckoutPage:wizardStep
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
The key is the field's own name by default, so the value is the same one a controller or another component reads. Pass `scoped: true` for working state that belongs to this page alone — a wizard step, a half-finished draft — and the key is namespaced to the component instead.
|
|
355
|
+
|
|
356
|
+
Requires `SessionMiddleware` on the route. See [Decorators](/docs/flow/decorators#the-session-decorator) for the full options.
|
|
357
|
+
|
|
358
|
+
### Reading the session directly
|
|
359
|
+
|
|
360
|
+
For values that aren't a field on this component — a cart, a flash bag, anything you set elsewhere — use the [`Session`](/docs/session) facade. It resolves the in-flight request's session, so it works in any hook or action:
|
|
361
|
+
|
|
362
|
+
```typescript
|
|
363
|
+
import { Session } from "@zerotal/session";
|
|
364
|
+
|
|
365
|
+
override async onMount() {
|
|
366
|
+
if (Session.has("cart")) {
|
|
367
|
+
this.cartItems = Session.get<CartItem[]>("cart") ?? [];
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
@expose async addToCart(id: number): Promise<void> {
|
|
372
|
+
Session.set("cart", [...this.cartItems, await Product.findOrFail(id)]);
|
|
373
|
+
}
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
`ctx.session` is the same store if you already have the context in hand.
|
|
377
|
+
|
|
378
|
+
Reach for `@session` when the value is a field on the component; reach for the facade when it isn't.
|
|
379
|
+
|
|
380
|
+
## Accessing the authenticated user
|
|
381
|
+
|
|
382
|
+
When `AuthMiddleware` (or your `RequireAuthMiddleware`) runs, it populates `ctx.user`. Access it via `request()`:
|
|
383
|
+
|
|
384
|
+
```typescript
|
|
385
|
+
import { request } from "zerotal";
|
|
386
|
+
|
|
387
|
+
export class ProfilePage extends Component {
|
|
388
|
+
@locked userId: number = 0;
|
|
389
|
+
@locked userName: string = "";
|
|
390
|
+
|
|
391
|
+
override async onBoot() {
|
|
392
|
+
const user = request().user;
|
|
393
|
+
this.userId = user?.id ?? 0;
|
|
394
|
+
this.userName = user?.name ?? "";
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
override async onMount() {
|
|
398
|
+
if (!this.userId) {
|
|
399
|
+
this.redirect("/login");
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
this.profile = await Profile.where("user_id", this.userId).first();
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
Because `onBoot()` runs on every request (initial GET and WebSocket), the auth check stays current for the full session — even if the user logs out in another tab.
|
|
408
|
+
|
|
409
|
+
## Page metadata
|
|
410
|
+
|
|
411
|
+
Declare static properties on the page class to control the document `<title>`, inject `<head>` content, and attach a layout:
|
|
412
|
+
|
|
413
|
+
```typescript
|
|
414
|
+
export class DashboardPage extends Component {
|
|
415
|
+
// Sets <title>Dashboard</title> on the initial render
|
|
416
|
+
static title = "Dashboard";
|
|
417
|
+
|
|
418
|
+
// Injected into <head> on the initial render
|
|
419
|
+
static head = `
|
|
420
|
+
<link rel="preload" href="/fonts/inter.woff2" as="font" type="font/woff2" crossorigin>
|
|
421
|
+
<meta name="robots" content="noindex">
|
|
422
|
+
`;
|
|
423
|
+
|
|
424
|
+
// Wrap the page in this layout (layout is preserved on navigate)
|
|
425
|
+
static layout = AppLayout;
|
|
426
|
+
}
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
For per-action dynamic titles, use `this.title()` inside an action:
|
|
430
|
+
|
|
431
|
+
```typescript
|
|
432
|
+
@expose async loadPost(slug: string): Promise<void> {
|
|
433
|
+
this.post = await Post.where("slug", slug).firstOrFail();
|
|
434
|
+
this.title(`${this.post.title} — My App`);
|
|
435
|
+
}
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
For per-render `<head>` content (meta tags, OG tags), use `<Head>` inside `render()`:
|
|
439
|
+
|
|
440
|
+
```tsx
|
|
441
|
+
import { Head } from "@zerotal/flow";
|
|
442
|
+
|
|
443
|
+
override async render() {
|
|
444
|
+
return (
|
|
445
|
+
<div>
|
|
446
|
+
<Head>
|
|
447
|
+
<title>{this.post?.title ?? "Post"} — My App</title>
|
|
448
|
+
<meta name="description" content={this.post?.excerpt ?? ""} />
|
|
449
|
+
<meta property="og:image" content={this.post?.coverUrl ?? ""} />
|
|
450
|
+
</Head>
|
|
451
|
+
<h1>{this.post?.title}</h1>
|
|
452
|
+
</div>
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
## Complete example
|
|
458
|
+
|
|
459
|
+
A fully wired page with a dynamic route param, auth, session, query string, and layout:
|
|
460
|
+
|
|
461
|
+
```typescript
|
|
462
|
+
import { Component, expose, locked, url, session } from "@zerotal/flow";
|
|
463
|
+
import { request } from "zerotal";
|
|
464
|
+
import { RequireAuthMiddleware } from "#app/middleware/RequireAuth.ts";
|
|
465
|
+
import { AppLayout } from "#app/flow/AppLayout.tsx";
|
|
466
|
+
import { Post } from "#app/models/Post.ts";
|
|
467
|
+
|
|
468
|
+
export class PostDetailPage extends Component {
|
|
469
|
+
static layout = AppLayout;
|
|
470
|
+
static title = "Post";
|
|
471
|
+
|
|
472
|
+
// Route: /posts/:slug
|
|
473
|
+
// Register: Router.flow("/posts/:slug", PostDetailPage, [RequireAuthMiddleware]);
|
|
474
|
+
|
|
475
|
+
@locked slug: string = "";
|
|
476
|
+
@locked post: Post | null = null;
|
|
477
|
+
@expose editing: boolean = false;
|
|
478
|
+
|
|
479
|
+
@url tab: string = "content"; // ?tab=content|comments
|
|
480
|
+
@session viewed: boolean = false; // persists across browser refreshes
|
|
481
|
+
|
|
482
|
+
override async onBoot() {
|
|
483
|
+
// Read the :slug segment on every request (initial + WebSocket updates)
|
|
484
|
+
this.slug = request().params.slug ?? "";
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
override async onMount() {
|
|
488
|
+
this.post = await Post.query()
|
|
489
|
+
.where("slug", this.slug)
|
|
490
|
+
.withRelationships(["author", "tags"])
|
|
491
|
+
.first();
|
|
492
|
+
|
|
493
|
+
if (!this.post) {
|
|
494
|
+
this.redirect("/posts");
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Track that the user has viewed this post (session-persisted)
|
|
499
|
+
if (!this.viewed) {
|
|
500
|
+
await this.post.increment("view_count");
|
|
501
|
+
this.viewed = true;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
@expose async publish(): Promise<void> {
|
|
506
|
+
if (!this.post) return;
|
|
507
|
+
await this.post.fill({ status: "published" }).save();
|
|
508
|
+
this.flash(`"${this.post.title}" is now live.`);
|
|
509
|
+
this.editing = false;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
@expose async delete(): Promise<void> {
|
|
513
|
+
if (!this.post) return;
|
|
514
|
+
await this.post.delete();
|
|
515
|
+
this.dispatch("post-deleted", { slug: this.slug });
|
|
516
|
+
this.redirect("/posts");
|
|
517
|
+
this.flash("Post deleted.");
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
override async render() {
|
|
521
|
+
if (!this.post) return <div>Not found.</div>;
|
|
522
|
+
|
|
523
|
+
return (
|
|
524
|
+
<div class="max-w-3xl mx-auto py-12 px-6">
|
|
525
|
+
<h1 class="text-3xl font-bold mb-4">{this.post.title}</h1>
|
|
526
|
+
<p class="text-sm text-gray-500 mb-8">
|
|
527
|
+
By {this.post.author?.name} · {this.post.view_count} views
|
|
528
|
+
</p>
|
|
529
|
+
|
|
530
|
+
<div class="prose">{this.post.body}</div>
|
|
531
|
+
|
|
532
|
+
<div class="mt-8 flex gap-3">
|
|
533
|
+
<button onClick={() => (this.editing = !this.editing)}>
|
|
534
|
+
{this.editing ? "Cancel" : "Edit"}
|
|
535
|
+
</button>
|
|
536
|
+
{this.post.status === "draft" && (
|
|
537
|
+
<button onClick={this.publish}>Publish</button>
|
|
538
|
+
)}
|
|
539
|
+
<button onClick={this.delete} class="text-red-600">Delete</button>
|
|
540
|
+
</div>
|
|
541
|
+
</div>
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
```
|
|
546
|
+
|
|
547
|
+
## Next steps
|
|
548
|
+
|
|
549
|
+
- [Flow overview](/docs/flow) — the guide's front page and the rest of the sections.
|
|
550
|
+
- [Reference](/docs/flow/references) — every decorator, prop, and directive in one table.
|