@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,793 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Flow Layouts & Composition
|
|
3
|
+
description: Wrap pages in layouts, compose behaviour with mixins, nest components, and pass markup between them.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Layouts & Composition
|
|
7
|
+
|
|
8
|
+
Wrap pages in layouts, compose reusable behaviour with mixins, nest components as islands, stream slow content progressively, attach middleware, and test it all.
|
|
9
|
+
|
|
10
|
+
## Layouts
|
|
11
|
+
|
|
12
|
+
A `Layout` wraps the page content with persistent shell UI — the nav bar, sidebar, footer. The layout is never re-rendered on WebSocket updates; only the page content swaps. `navigate` links update the page content without a full reload while keeping the layout mounted.
|
|
13
|
+
|
|
14
|
+
```tsx
|
|
15
|
+
import { Layout } from "@zerotal/flow";
|
|
16
|
+
import type { HtmlNode } from "@zerotal/flow";
|
|
17
|
+
import { Link, Flash } from "@zerotal/flow";
|
|
18
|
+
|
|
19
|
+
export class AppLayout extends Layout {
|
|
20
|
+
// Injected into <head> on every initial render
|
|
21
|
+
static override head = `
|
|
22
|
+
<link rel="stylesheet" href="/app.css">
|
|
23
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
override render(slot: HtmlNode) {
|
|
27
|
+
return (
|
|
28
|
+
<div class="min-h-screen bg-gray-50">
|
|
29
|
+
<nav class="flex items-center px-6 py-4 border-b bg-white">
|
|
30
|
+
<Link href="/dashboard" navigate class="font-semibold text-lg">
|
|
31
|
+
My App
|
|
32
|
+
</Link>
|
|
33
|
+
<div class="ml-auto flex gap-4">
|
|
34
|
+
<Link href="/posts" navigate class="data-[current]:font-bold">
|
|
35
|
+
Posts
|
|
36
|
+
</Link>
|
|
37
|
+
<Link href="/settings" navigate class="data-[current]:font-bold">
|
|
38
|
+
Settings
|
|
39
|
+
</Link>
|
|
40
|
+
</div>
|
|
41
|
+
</nav>
|
|
42
|
+
<main class="p-8">{slot}</main>
|
|
43
|
+
<Flash position="bottom-right" />
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Attach the layout to a page:
|
|
51
|
+
|
|
52
|
+
```tsx
|
|
53
|
+
export class DashboardPage extends Component {
|
|
54
|
+
static layout = AppLayout;
|
|
55
|
+
|
|
56
|
+
override async render() {
|
|
57
|
+
return (
|
|
58
|
+
<div>
|
|
59
|
+
<h1>Dashboard</h1>
|
|
60
|
+
</div>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The layout's `render(slot)` receives the page's HTML as `slot`. Multiple pages can share a layout; each keeps its own state and snapshot.
|
|
67
|
+
|
|
68
|
+
### The `layout(page)` hook — layouts as plain JSX
|
|
69
|
+
|
|
70
|
+
`static layout = SomeLayout` is the class form. The JSX-native alternative — and the same convention the framework's React/Inertia pages use — is to override the `layout(page)` method and wrap the rendered page in **any JSX** you like. There is no separate `Layout` base class and no named-slot mechanism: a layout is just a component you wrap the page in, and its regions are ordinary **props**.
|
|
71
|
+
|
|
72
|
+
```tsx
|
|
73
|
+
import { Component } from "@zerotal/flow";
|
|
74
|
+
import type { HtmlNode } from "@zerotal/flow";
|
|
75
|
+
import { AppLayout } from "#app/layouts/AppLayout.tsx";
|
|
76
|
+
|
|
77
|
+
export class DashboardPage extends Component {
|
|
78
|
+
static title = "Dashboard";
|
|
79
|
+
|
|
80
|
+
// `page` is the rendered <div data-flow-root>…</div>. Wrap it however you like;
|
|
81
|
+
// named regions like `title` / `actions` are just props on your layout component.
|
|
82
|
+
override layout(page: HtmlNode) {
|
|
83
|
+
return (
|
|
84
|
+
<AppLayout
|
|
85
|
+
title={DashboardPage.title}
|
|
86
|
+
actions={<button onClick={this.refresh}>Refresh</button>}
|
|
87
|
+
>
|
|
88
|
+
{page}
|
|
89
|
+
</AppLayout>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
override async render() {
|
|
94
|
+
return (
|
|
95
|
+
<div>
|
|
96
|
+
<h1>Dashboard</h1>
|
|
97
|
+
</div>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`AppLayout` is an ordinary function component — nothing framework-specific:
|
|
104
|
+
|
|
105
|
+
```tsx
|
|
106
|
+
export function AppLayout(props: { title?: string; actions?: HtmlNode; children?: unknown }) {
|
|
107
|
+
return (
|
|
108
|
+
<div data-flow-layout="app" class="min-h-screen bg-gray-50">
|
|
109
|
+
<header class="flex items-center px-6 py-4 border-b">
|
|
110
|
+
<span class="font-semibold">{props.title}</span>
|
|
111
|
+
<div class="ml-auto">{props.actions}</div>
|
|
112
|
+
</header>
|
|
113
|
+
<main class="p-8">{props.children}</main>
|
|
114
|
+
</div>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Two things to know:
|
|
120
|
+
|
|
121
|
+
- **Give the shell root a stable `data-flow-layout="app"`.** That marker is what lets `navigate` swap only the page root and keep the shell mounted across visits. If you omit it, Flow derives one from the wrapper source — which matches for a plain `(page) => <AppLayout>{page}</AppLayout>`, but not when the wrapper passes page-specific props — so declaring it on the layout component is the reliable choice.
|
|
122
|
+
- **The shell lives outside the reactive root.** Like `static layout`, the shell renders once and is never re-rendered or re-sent on WebSocket actions. So layout regions are for **display** (titles, breadcrumbs, status); an `onClick={this.save}` placed in the shell has no owning component root above it — keep interactive controls in the page body.
|
|
123
|
+
|
|
124
|
+
`static layout` and the `layout(page)` hook are interchangeable; when a page has both, the `layout(page)` method wins. Global stylesheets/fonts for a JSX-native layout go through a `<Head>` inside the layout component (or the page's `static head`), rather than `static head` on a `Layout` class.
|
|
125
|
+
|
|
126
|
+
### Layout head
|
|
127
|
+
|
|
128
|
+
`static head` injects content into the `<head>` element on the initial render. For per-page head content (title, meta), use `<Head>` inside the page's `render()` — it's hoisted into `<head>` on load and on every `navigate` visit:
|
|
129
|
+
|
|
130
|
+
```tsx
|
|
131
|
+
import { Head } from "@zerotal/flow";
|
|
132
|
+
|
|
133
|
+
override async render() {
|
|
134
|
+
return (
|
|
135
|
+
<div>
|
|
136
|
+
<Head>
|
|
137
|
+
<title>{this.post.title} — My App</title>
|
|
138
|
+
<meta name="description" content={this.post.excerpt} />
|
|
139
|
+
</Head>
|
|
140
|
+
<h1>{this.post.title}</h1>
|
|
141
|
+
</div>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Sections
|
|
147
|
+
|
|
148
|
+
A layout owns regions a page cannot reach. When a page needs to put something _there_ — a toolbar
|
|
149
|
+
button, a breadcrumb trail, a heading — the alternatives are threading it through every component in
|
|
150
|
+
between as props, or the layout knowing about every page that might contribute. Sections invert
|
|
151
|
+
that: the component that owns the content declares it, and the layout declares a hole.
|
|
152
|
+
|
|
153
|
+
```tsx
|
|
154
|
+
// In the layout — declare the hole
|
|
155
|
+
import { SectionOutlet } from "@zerotal/flow";
|
|
156
|
+
|
|
157
|
+
<header class="flex items-center gap-2">
|
|
158
|
+
<h1>Admin</h1>
|
|
159
|
+
<SectionOutlet name="toolbar" />
|
|
160
|
+
</header>;
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
```tsx
|
|
164
|
+
// In any page — fill it
|
|
165
|
+
import { SectionContent } from "@zerotal/flow";
|
|
166
|
+
|
|
167
|
+
<SectionContent name="toolbar">
|
|
168
|
+
<button onClick={this.publish}>Publish</button>
|
|
169
|
+
</SectionContent>;
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
`<SectionContent>` renders nothing where it appears. Children of `<SectionOutlet>` are the default,
|
|
173
|
+
used when no page published anything:
|
|
174
|
+
|
|
175
|
+
```tsx
|
|
176
|
+
<SectionOutlet name="toolbar">
|
|
177
|
+
<span class="text-sm text-gray-500">No actions</span>
|
|
178
|
+
</SectionOutlet>
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Two components may publish to the same name; their content accumulates in render order rather than
|
|
182
|
+
one replacing the other.
|
|
183
|
+
|
|
184
|
+
**Order does not matter.** An outlet reserves its place and is filled after the page _and_ the
|
|
185
|
+
layout have rendered — which is what makes the usual arrangement work at all, since the layout wraps
|
|
186
|
+
a page that has already rendered.
|
|
187
|
+
|
|
188
|
+
> **Sections resolve once per document render.** A WebSocket patch re-renders a component, not the
|
|
189
|
+
> layout, so content published during one does not reach an outlet outside the component being
|
|
190
|
+
> patched. Put values that change on interaction in the component that renders them, and use
|
|
191
|
+
> sections for content that is settled by the time the page paints.
|
|
192
|
+
|
|
193
|
+
## Composing behaviour with mixins
|
|
194
|
+
|
|
195
|
+
A layout wraps a page's _markup_. A mixin composes a page's _behaviour_ — page state, actions,
|
|
196
|
+
lifecycle — so a feature lives in one reusable place instead of being copied into every page that
|
|
197
|
+
needs it. Compose them with the `Component.using(...)` static:
|
|
198
|
+
|
|
199
|
+
```tsx
|
|
200
|
+
import { Component, Pagination, FileUploads } from "@zerotal/flow";
|
|
201
|
+
|
|
202
|
+
export class PostsPage extends Component.using(Pagination, FileUploads) {
|
|
203
|
+
override async render() {
|
|
204
|
+
return <div data-flow-root>Page {this.page}</div>;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Mixins fold left to right, and everything flows through to the final page: `Component`'s own
|
|
210
|
+
surface (`flash()`, `redirect()`, `validate()`, the client magics), plus every mixin's `@expose` /
|
|
211
|
+
`@locked` members. Mixin props register on the mixin's prototype, which sits in the page's
|
|
212
|
+
prototype chain, so the snapshot, reactivity, client writes and `@url` sync all treat them exactly
|
|
213
|
+
like props declared on the page itself.
|
|
214
|
+
|
|
215
|
+
Flow ships two mixins — [`Pagination`](/docs/flow/pagination) and
|
|
216
|
+
[`FileUploads`](/docs/flow/forms) — and you write your own the same way.
|
|
217
|
+
|
|
218
|
+
### Writing a mixin
|
|
219
|
+
|
|
220
|
+
A mixin is a function taking a base class and returning a class that extends it. Bind the base to
|
|
221
|
+
`Constructor<Component>` to require a Component lineage, and return an `abstract class` so the
|
|
222
|
+
mixin does not have to implement `render()` — the final page supplies that:
|
|
223
|
+
|
|
224
|
+
```tsx
|
|
225
|
+
// app/flow/mixins/sorting.ts
|
|
226
|
+
import { Component, expose, url, type Constructor } from "@zerotal/flow";
|
|
227
|
+
|
|
228
|
+
export function Sorting<T extends Constructor<Component>>(Base: T) {
|
|
229
|
+
abstract class WithSorting extends Base {
|
|
230
|
+
@url sortBy = "id";
|
|
231
|
+
@url sortDir: "asc" | "desc" = "asc";
|
|
232
|
+
|
|
233
|
+
@expose toggleSort(column: string): void {
|
|
234
|
+
if (this.sortBy === column) {
|
|
235
|
+
this.sortDir = this.sortDir === "asc" ? "desc" : "asc";
|
|
236
|
+
} else {
|
|
237
|
+
this.sortBy = column;
|
|
238
|
+
this.sortDir = "asc";
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return WithSorting;
|
|
243
|
+
}
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
```tsx
|
|
247
|
+
export class UsersPage extends Component.using(Sorting, Pagination) {}
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
### Composing onto a shared base
|
|
251
|
+
|
|
252
|
+
`using` composes onto whatever class you call it on, not onto `Component` specifically. That lets
|
|
253
|
+
an app-level base carry its own state and actions and still take mixins, without being flattened
|
|
254
|
+
out of the prototype chain:
|
|
255
|
+
|
|
256
|
+
```tsx
|
|
257
|
+
abstract class AdminPage extends Component {
|
|
258
|
+
@expose breadcrumb = "admin";
|
|
259
|
+
|
|
260
|
+
@expose async guard() {
|
|
261
|
+
/* shared authorization for every admin page */
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export class DashboardPage extends AdminPage.using(Pagination) {
|
|
266
|
+
override async render() {
|
|
267
|
+
return (
|
|
268
|
+
<div data-flow-root>
|
|
269
|
+
{this.breadcrumb} — page {this.page}
|
|
270
|
+
</div>
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
`DashboardPage` is still an `AdminPage`, so the base's `@expose` members and the mixin's are both
|
|
277
|
+
live on it.
|
|
278
|
+
|
|
279
|
+
### Chaining
|
|
280
|
+
|
|
281
|
+
The composed class carries `using` itself, so composition can be built up in stages — useful when
|
|
282
|
+
a shared base is defined in one file and extended in another:
|
|
283
|
+
|
|
284
|
+
```tsx
|
|
285
|
+
const AdminBase = Component.using(Pagination).using(Sorting);
|
|
286
|
+
export class ReportsPage extends AdminBase.using(FileUploads) {}
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
> **Note** — a page composed with `using(...)` renders through the runtime path rather than the
|
|
290
|
+
> ahead-of-time compiler, which only statically sees a page's own `extends Component` plus its
|
|
291
|
+
> locally declared members. This is the same fallback complex pages already use; behaviour is
|
|
292
|
+
> identical, you just do not get the compile step for that page.
|
|
293
|
+
|
|
294
|
+
## Nested components
|
|
295
|
+
|
|
296
|
+
Embed other `Component` subclasses as child components. Each child has its own isolated state, its own snapshot, and its own WebSocket update cycle. A parent re-render does not re-render existing children — their DOM and state are preserved (island architecture).
|
|
297
|
+
|
|
298
|
+
```tsx
|
|
299
|
+
import { StatsWidget } from "./StatsWidget.tsx";
|
|
300
|
+
import { ActivityFeed } from "./ActivityFeed.tsx";
|
|
301
|
+
|
|
302
|
+
export class DashboardPage extends Component {
|
|
303
|
+
static layout = AppLayout;
|
|
304
|
+
|
|
305
|
+
override async render() {
|
|
306
|
+
return (
|
|
307
|
+
<div class="grid grid-cols-3 gap-6">
|
|
308
|
+
<h1 class="col-span-3">Dashboard</h1>
|
|
309
|
+
|
|
310
|
+
{/* Embed child components */}
|
|
311
|
+
<StatsWidget />
|
|
312
|
+
<ActivityFeed />
|
|
313
|
+
|
|
314
|
+
{/* Multiple instances of the same class — use key to distinguish */}
|
|
315
|
+
<CounterWidget key="counter-a" step={1} label="Likes" />
|
|
316
|
+
<CounterWidget key="counter-b" step={5} label="Views" />
|
|
317
|
+
</div>
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
### Passing props
|
|
324
|
+
|
|
325
|
+
Each prop the parent passes is assigned onto the same-named field before any lifecycle hook runs — the field's initialiser is the default:
|
|
326
|
+
|
|
327
|
+
```tsx
|
|
328
|
+
export class CounterWidget extends Component {
|
|
329
|
+
@locked step: number = 1;
|
|
330
|
+
@locked label: string = "Count";
|
|
331
|
+
|
|
332
|
+
@expose count: number = 0;
|
|
333
|
+
|
|
334
|
+
@expose increment(): void {
|
|
335
|
+
this.count += this.step;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
override async render() {
|
|
339
|
+
return (
|
|
340
|
+
<div class="card">
|
|
341
|
+
<p class="text-sm text-gray-500">{this.label}</p>
|
|
342
|
+
<p class="text-3xl font-bold">{this.count}</p>
|
|
343
|
+
<button onClick={this.increment}>+{this.step}</button>
|
|
344
|
+
</div>
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
Props that need to survive WebSocket round-trips must be `@locked` so they are included in the snapshot. A `@locked` prop is set once at mount and stays fixed for the child's lifetime.
|
|
351
|
+
|
|
352
|
+
### Slots
|
|
353
|
+
|
|
354
|
+
Where props pass **data** into a child, slots pass **markup**. A child component's plain children become its **default slot**; a `slots={{ … }}` prop supplies **named slots**. Inside the child, place each with `this.slot(name)` (or `this.slot()` for the default), and branch on `this.hasSlot(name)` to drop an optional wrapper entirely. This is the pattern for reusable shells — cards, modals, panels, page headers — where the container is fixed but the contents vary per use.
|
|
355
|
+
|
|
356
|
+
```tsx
|
|
357
|
+
// The reusable shell — header and footer are optional.
|
|
358
|
+
export class Card extends Component {
|
|
359
|
+
override async render() {
|
|
360
|
+
return (
|
|
361
|
+
<div class="rounded-xl border bg-white shadow-sm">
|
|
362
|
+
{this.hasSlot("header") && (
|
|
363
|
+
<header class="border-b px-5 py-3 font-semibold">{this.slot("header")}</header>
|
|
364
|
+
)}
|
|
365
|
+
<div class="px-5 py-4">{this.slot()}</div>
|
|
366
|
+
{this.hasSlot("footer") && (
|
|
367
|
+
<footer class="border-t px-5 py-3 text-right">{this.slot("footer")}</footer>
|
|
368
|
+
)}
|
|
369
|
+
</div>
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// A page using it — default children fill the body; named slots fill header/footer.
|
|
375
|
+
export class BillingPage extends Component {
|
|
376
|
+
override async render() {
|
|
377
|
+
return (
|
|
378
|
+
<Card
|
|
379
|
+
slots={{
|
|
380
|
+
header: <h2>Payment method</h2>,
|
|
381
|
+
footer: <button onClick={this.save}>Save</button>,
|
|
382
|
+
}}
|
|
383
|
+
>
|
|
384
|
+
<p>Your card ending in 4242 is active.</p>
|
|
385
|
+
</Card>
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
Slot content is rendered in the **parent's** scope and carried in the child's snapshot (signed, so it can't be forged from the client). Because it lives in the snapshot, it survives the child's own round-trips — a child action re-renders with the same header/body/footer without the parent running again.
|
|
392
|
+
|
|
393
|
+
Two consequences worth knowing:
|
|
394
|
+
|
|
395
|
+
- **Slots are set at mount, not reactive.** They reflect the parent's state at the moment the child mounts. If the parent later re-renders, the existing child island is preserved (its DOM and snapshot are kept), so the slot HTML does not change underneath it. For a value that must track the parent live, pass it as a `@reactive` prop instead of as slot markup.
|
|
396
|
+
- **Prefer plain markup in slots.** Interactive `onClick={this.method}` handlers inside a slot bind to the _parent's_ actions (the slot was rendered in the parent's scope), which is usually what you want for a footer button. Nesting another _stateful child component_ inside a slot is not supported — embed it in the child's own `render()` instead.
|
|
397
|
+
|
|
398
|
+
### `key` in a list
|
|
399
|
+
|
|
400
|
+
Give every child rendered inside a `.map()` a `key` tied to the row's own identity:
|
|
401
|
+
|
|
402
|
+
```tsx
|
|
403
|
+
{
|
|
404
|
+
this.settings.map((s) => <SettingRow key={`setting-${s.id}`} settingKey={s.key} />);
|
|
405
|
+
}
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
This is a correctness requirement, not an optimisation, and it is worth understanding why.
|
|
409
|
+
|
|
410
|
+
A child's `data-flow-id` is how the client morph pairs the incoming markup with the island already on the page — and a parent re-render deliberately emits an already-mounted child as an **empty stub**, on the understanding that the pairing will preserve the child's live DOM. When two renders disagree about which child owns an id, that stub is what lands in the page.
|
|
411
|
+
|
|
412
|
+
Without a `key`, the id is derived from the child's seed props (`@reactive` and `@modelable` props are excluded, because those exist to change without remounting). That is stable enough for the common case: remove an item from the middle of a list and the rows around it keep their islands. But two siblings whose props are identical are, as far as the framework can see, the same child — they share an id, and therefore share DOM and state. Flow logs a warning the first time it sees that, in development only.
|
|
413
|
+
|
|
414
|
+
None of this is visible from the server. SSR, snapshot assertions and `FlowTest.mount(...).call(...)` all render the full child every time, because they never take the already-mounted branch — only a real browser applying a real WebSocket patch does. So the guard is the `key`, not the test suite.
|
|
415
|
+
|
|
416
|
+
Keys are sanitised to `[a-zA-Z0-9_-]`, so dots are stripped and `a.b` collides with `ab`.
|
|
417
|
+
|
|
418
|
+
### Lazy, deferred, and streamed loading
|
|
419
|
+
|
|
420
|
+
```tsx
|
|
421
|
+
// Defer mount until the placeholder enters the viewport (intersection observer)
|
|
422
|
+
<HeavyChart key="chart" lazy />
|
|
423
|
+
|
|
424
|
+
// Mount immediately after page paint (non-blocking)
|
|
425
|
+
<Sidebar defer />
|
|
426
|
+
|
|
427
|
+
// Render on the SAME response — placeholder first, real markup streamed after
|
|
428
|
+
<SalesReport stream />
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
`lazy` and `defer` both mount over the socket on a **second round trip**, which is what you want for
|
|
432
|
+
content that may never be needed: a widget below the fold, a tab nobody opens.
|
|
433
|
+
|
|
434
|
+
`stream` is for content that is definitely needed and merely slow. The shell reaches the browser
|
|
435
|
+
without waiting for it, and the child's markup arrives as a trailing chunk of the same response —
|
|
436
|
+
no second request, no socket, and no client runtime needed (the swap happens during parse, so it
|
|
437
|
+
works before and without Alpine). See [Streaming the initial render](#streaming-the-initial-render).
|
|
438
|
+
|
|
439
|
+
Override `placeholder()` to customise the skeleton shown while a lazy component loads:
|
|
440
|
+
|
|
441
|
+
```tsx
|
|
442
|
+
export class HeavyChart extends Component {
|
|
443
|
+
override placeholder() {
|
|
444
|
+
return <div class="h-64 w-full rounded-xl bg-gray-200 animate-pulse" />;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
override async onMount() {
|
|
448
|
+
this.data = await Analytics.fetchChartData();
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// …
|
|
452
|
+
}
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
## Reactive props
|
|
456
|
+
|
|
457
|
+
A `@locked` prop is frozen after mount. Mark a prop `@reactive` instead and the parent re-pushes its value whenever it changes, re-rendering the child — while the child keeps the rest of its own state intact:
|
|
458
|
+
|
|
459
|
+
```tsx
|
|
460
|
+
export class PriceTag extends Component {
|
|
461
|
+
@reactive currency = "USD";
|
|
462
|
+
@reactive amount = 0;
|
|
463
|
+
|
|
464
|
+
@computed get formatted(): string {
|
|
465
|
+
return new Intl.NumberFormat("en-US", {
|
|
466
|
+
style: "currency",
|
|
467
|
+
currency: this.currency,
|
|
468
|
+
}).format(this.amount);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
override async render() {
|
|
472
|
+
return <span class="price">{this.formatted}</span>;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Parent — when this.currency changes, PriceTag automatically re-renders:
|
|
477
|
+
<PriceTag currency={this.currency} amount={this.subtotal} />;
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
## Two-way props
|
|
481
|
+
|
|
482
|
+
`@modelable` is a reactive prop that also syncs **back** to the parent. The parent property and the child prop stay in lock-step, so you can build reusable input/control components:
|
|
483
|
+
|
|
484
|
+
```tsx
|
|
485
|
+
export class StarRating extends Component {
|
|
486
|
+
@modelable rating: number = 0; // two-way bound to parent
|
|
487
|
+
|
|
488
|
+
@expose set(n: number): void {
|
|
489
|
+
this.rating = n; // updating it flows up to the parent
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
override async render() {
|
|
493
|
+
return (
|
|
494
|
+
<div class="flex gap-1">
|
|
495
|
+
{[1, 2, 3, 4, 5].map((n) => (
|
|
496
|
+
<button
|
|
497
|
+
key={String(n)}
|
|
498
|
+
onClick={() => this.set(n)}
|
|
499
|
+
class={n <= this.rating ? "text-yellow-400" : "text-gray-300"}
|
|
500
|
+
>
|
|
501
|
+
★
|
|
502
|
+
</button>
|
|
503
|
+
))}
|
|
504
|
+
</div>
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Parent — this.productRating and StarRating.rating stay in sync both ways:
|
|
510
|
+
<StarRating value={this.productRating} />;
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
## Streaming
|
|
514
|
+
|
|
515
|
+
Flow streams in two distinct places: **during the initial page response**, so a slow child does not
|
|
516
|
+
hold up the shell, and **during an action**, so a long-running method can push progress before it
|
|
517
|
+
finishes. They solve different problems and do not interact.
|
|
518
|
+
|
|
519
|
+
### Streaming the initial render
|
|
520
|
+
|
|
521
|
+
Mark a child `stream` and the page paints immediately with that child's placeholder; its real markup
|
|
522
|
+
is appended to the same response as soon as it finishes rendering:
|
|
523
|
+
|
|
524
|
+
```tsx
|
|
525
|
+
override async render() {
|
|
526
|
+
return (
|
|
527
|
+
<div>
|
|
528
|
+
<h1>Dashboard</h1>
|
|
529
|
+
<Totals /> {/* fast — rendered inline */}
|
|
530
|
+
<SalesReport stream /> {/* slow — placeholder now, markup later */}
|
|
531
|
+
</div>
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
```
|
|
535
|
+
|
|
536
|
+
The browser receives the document up to the placeholder, then a `<template>` carrying the finished
|
|
537
|
+
markup and a one-line script that swaps it in. That script runs during parse, so the content appears
|
|
538
|
+
without waiting for the runtime — and without a second request. Override `placeholder()` on the
|
|
539
|
+
child to control what shows in the meantime.
|
|
540
|
+
|
|
541
|
+
A child that fails while streaming is replaced with a notice rather than failing the response: the
|
|
542
|
+
shell is already on the wire by then, so there is nothing left to fail. Everything else on the page
|
|
543
|
+
is unaffected.
|
|
544
|
+
|
|
545
|
+
Streaming applies to the initial `GET` only. Over the socket there is no open response to append to,
|
|
546
|
+
so `stream` degrades to an ordinary inline child render.
|
|
547
|
+
|
|
548
|
+
> **Streaming needs an unbuffered response.** Flow sets `X-Accel-Buffering: no` for nginx. Behind a
|
|
549
|
+
> proxy that buffers anyway, the browser simply receives the whole document at once — the page is
|
|
550
|
+
> correct, just not progressive.
|
|
551
|
+
|
|
552
|
+
### Streaming during an action
|
|
553
|
+
|
|
554
|
+
Push content to the client mid-action — before the final patch — using `this.stream()`. Useful for LLM token streaming, long-running progress updates, or any content that takes time:
|
|
555
|
+
|
|
556
|
+
```tsx
|
|
557
|
+
{/* In the template: declare the stream target */}
|
|
558
|
+
<div stream="answer" class="prose" />
|
|
559
|
+
<div stream="status" class="text-sm text-gray-500" />
|
|
560
|
+
```
|
|
561
|
+
|
|
562
|
+
```typescript
|
|
563
|
+
@expose async generate(): Promise<void> {
|
|
564
|
+
this.stream("status", "Generating…");
|
|
565
|
+
|
|
566
|
+
for await (const token of llm.stream(this.prompt)) {
|
|
567
|
+
this.stream("answer", token); // appended progressively
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
this.stream("status", "Done.");
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Replace instead of append:
|
|
574
|
+
this.stream("output", freshContent, { replace: true });
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
`this.stream()` is a no-op during the initial SSR render — it only works inside WebSocket action handlers.
|
|
578
|
+
|
|
579
|
+
### `@task` — streaming, cancellable actions
|
|
580
|
+
|
|
581
|
+
`this.stream()` is the low-level primitive: it pushes raw HTML into a `flow:stream` target, but that content isn't part of the snapshot, so you must _also_ write the accumulated result to a field for the final render, and there's no built-in cancellation. `@task` handles both. Mark an async method `@task` and just **write the field** — the framework streams it:
|
|
582
|
+
|
|
583
|
+
```tsx
|
|
584
|
+
import { Component, task, expose } from "@zerotal/flow";
|
|
585
|
+
|
|
586
|
+
export class Chat extends Component {
|
|
587
|
+
@expose answer = "";
|
|
588
|
+
|
|
589
|
+
@task async generate() {
|
|
590
|
+
this.answer = "";
|
|
591
|
+
for await (const token of llm.stream(this.prompt, { signal: this.signal })) {
|
|
592
|
+
if (this.cancelled) break; // cooperative cancellation
|
|
593
|
+
this.answer += token; // ← streams to the browser as it's written
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
```
|
|
598
|
+
|
|
599
|
+
```tsx
|
|
600
|
+
{/* Bind the streamed field REACTIVELY (text={…} → flow:text, or x-text) so each write updates
|
|
601
|
+
this element live off the client store — no flow:stream element, no re-render per chunk. */}
|
|
602
|
+
<button onClick={this.generate} loadingAttr="disabled">Generate</button>
|
|
603
|
+
<button onClick={() => $flow.cancel()} showOnLoading>Cancel</button>
|
|
604
|
+
<div text={this.answer} />
|
|
605
|
+
```
|
|
606
|
+
|
|
607
|
+
What `@task` gives you over a plain `@expose async` action:
|
|
608
|
+
|
|
609
|
+
- **Incremental streaming without a re-render.** While the task runs, the framework flushes throttled **field-level** snapshot diffs — the changed fields only, no HTML — so a reactive binding of the field (`text={this.answer}`, `x-text="$flow.answer"`, a reactive `:class`/`:attr`) updates the DOM straight off the client store as `this.answer += token` runs, with no per-chunk component re-render. The field _is_ the snapshot, so the final patch re-renders once to reconcile any **static** template positions (a plain `{this.answer}` text child updates then), and everything stays consistent — no double-writing. Bind the streamed field reactively for a live token-by-token view.
|
|
610
|
+
- **A loading state that spans the whole run.** The triggering control stays in its loading state (`loadingAttr`, `showOnLoading`, `<Loading>`) for the task's entire duration — partial patches don't clear it; only completion does.
|
|
611
|
+
- **First-class cancellation.** `this.signal` is a standard `AbortSignal` (pass it to `fetch`/an SDK); `this.cancelled` is a convenience boolean. On the client, `$flow.cancel()` stops the task — it's sent out-of-band (bypassing the per-component send queue, which the running task still occupies), and the server trips the task's `AbortSignal`. Cancellation is cooperative: check `this.cancelled`/`this.signal.aborted`, or let an aborted `signal` reject the async work you're awaiting.
|
|
612
|
+
|
|
613
|
+
This is the primitive for AI answers, build/deploy logs, and progress feeds — the streaming shape that usually needs a second endpoint and a client state library, here in one server method. Outside a running task, `this.signal` is an inert signal that never aborts, so `@task` code reads the same whether or not a cancellation is in flight.
|
|
614
|
+
|
|
615
|
+
## Middleware
|
|
616
|
+
|
|
617
|
+
Middleware attached to a `Router.flow()` call runs on the initial HTTP `GET` **and** on every WebSocket update for that page. This keeps auth gates active across the entire session, not just at page load:
|
|
618
|
+
|
|
619
|
+
```typescript
|
|
620
|
+
import { Router } from "zerotal";
|
|
621
|
+
import { RequireAuthMiddleware } from "#app/middleware/RequireAuth.ts";
|
|
622
|
+
import { RequireAdminMiddleware } from "#app/middleware/RequireAdmin.ts";
|
|
623
|
+
import { AdminDashboard } from "#app/flow/AdminDashboard.tsx";
|
|
624
|
+
import { PublicPosts } from "#app/flow/PublicPosts.tsx";
|
|
625
|
+
|
|
626
|
+
// No middleware — anyone can view
|
|
627
|
+
Router.flow("/posts", PublicPosts);
|
|
628
|
+
|
|
629
|
+
// Requires auth on every request (initial GET + WS)
|
|
630
|
+
Router.flow("/dashboard", DashboardPage, [RequireAuthMiddleware]);
|
|
631
|
+
|
|
632
|
+
// Requires auth AND admin role
|
|
633
|
+
Router.flow("/admin", AdminDashboard, [RequireAuthMiddleware, RequireAdminMiddleware]);
|
|
634
|
+
```
|
|
635
|
+
|
|
636
|
+
## Testing composed components
|
|
637
|
+
|
|
638
|
+
See [Testing](/docs/flow/testing) for the full guide — mounting, calling actions, seeding state, all assertions, database integration, and event testing.
|
|
639
|
+
|
|
640
|
+
`FlowTest` runs the full server-side pipeline in-process — no WebSocket connection needed. It's available from `@zerotal/flow/testing`.
|
|
641
|
+
|
|
642
|
+
### Mounting a composed component
|
|
643
|
+
|
|
644
|
+
```typescript
|
|
645
|
+
import { FlowTest } from "@zerotal/flow/testing";
|
|
646
|
+
import { CounterPage } from "../app/flow/CounterPage.tsx";
|
|
647
|
+
|
|
648
|
+
// Mount drives: onBoot → onMount → render → onDehydrate
|
|
649
|
+
const t = await FlowTest.mount(CounterPage);
|
|
650
|
+
|
|
651
|
+
// Inspect initial state
|
|
652
|
+
expect(t.page().count).toBe(0);
|
|
653
|
+
t.assertSee("Count: 0");
|
|
654
|
+
t.assertDontSee("Count: 1");
|
|
655
|
+
```
|
|
656
|
+
|
|
657
|
+
Mount with initial props (seed state before `onMount`):
|
|
658
|
+
|
|
659
|
+
```typescript
|
|
660
|
+
const t = await FlowTest.mount(PostsPage, { page: 2, search: "TypeScript" });
|
|
661
|
+
expect(t.page().search).toBe("TypeScript");
|
|
662
|
+
```
|
|
663
|
+
|
|
664
|
+
### Calling composed actions
|
|
665
|
+
|
|
666
|
+
```typescript
|
|
667
|
+
// call() drives: onBoot → onHydrate → action() → onUpdate → render → onDehydrate
|
|
668
|
+
await t.call("increment");
|
|
669
|
+
expect(t.page().count).toBe(1);
|
|
670
|
+
t.assertSee("Count: 1");
|
|
671
|
+
|
|
672
|
+
// Call with arguments
|
|
673
|
+
await t.call("setCount", 42);
|
|
674
|
+
expect(t.page().count).toBe(42);
|
|
675
|
+
```
|
|
676
|
+
|
|
677
|
+
### Updating composed state
|
|
678
|
+
|
|
679
|
+
Two ways to change a property between calls:
|
|
680
|
+
|
|
681
|
+
```typescript
|
|
682
|
+
// set() — direct assignment, no hooks fire
|
|
683
|
+
await t.set("draft", "Hello world");
|
|
684
|
+
|
|
685
|
+
// update() — simulates a client input, fires onUpdating/onUpdated hooks
|
|
686
|
+
await t.update("username", "alice");
|
|
687
|
+
```
|
|
688
|
+
|
|
689
|
+
Use `set()` to seed state for a specific scenario. Use `update()` to test that your `onUpdating`/`onUpdated` hooks work correctly.
|
|
690
|
+
|
|
691
|
+
### Assertions
|
|
692
|
+
|
|
693
|
+
**HTML assertions:**
|
|
694
|
+
|
|
695
|
+
```typescript
|
|
696
|
+
t.assertSee("Published post"); // HTML contains this string
|
|
697
|
+
t.assertDontSee("Error"); // HTML does NOT contain this string
|
|
698
|
+
```
|
|
699
|
+
|
|
700
|
+
**Validation assertions:**
|
|
701
|
+
|
|
702
|
+
```typescript
|
|
703
|
+
await t.call("save");
|
|
704
|
+
t.assertHasErrors("email"); // field has at least one error
|
|
705
|
+
t.assertHasErrors("email", "required"); // error message contains "required"
|
|
706
|
+
t.assertNoErrors(); // no errors at all
|
|
707
|
+
```
|
|
708
|
+
|
|
709
|
+
**Redirect assertions:**
|
|
710
|
+
|
|
711
|
+
```typescript
|
|
712
|
+
t.assertRedirectedTo("/dashboard");
|
|
713
|
+
t.assertNotRedirected();
|
|
714
|
+
```
|
|
715
|
+
|
|
716
|
+
**Flash assertions:**
|
|
717
|
+
|
|
718
|
+
```typescript
|
|
719
|
+
t.assertFlashed("success", "Saved."); // level + message substring
|
|
720
|
+
t.assertFlashed("error"); // just check the level
|
|
721
|
+
t.assertFlashed(undefined, "Something went"); // just check the message substring
|
|
722
|
+
```
|
|
723
|
+
|
|
724
|
+
**Event assertions:**
|
|
725
|
+
|
|
726
|
+
```typescript
|
|
727
|
+
t.assertDispatched("post-created");
|
|
728
|
+
```
|
|
729
|
+
|
|
730
|
+
### Composed accessors
|
|
731
|
+
|
|
732
|
+
```typescript
|
|
733
|
+
t.page(); // the Component instance — inspect properties directly
|
|
734
|
+
t.html(); // the rendered HTML string
|
|
735
|
+
t.errors(); // current validation error bag: Record<string, string[]>
|
|
736
|
+
t.effects(); // effects from the last action (flashes, redirects, events, downloads)
|
|
737
|
+
t.snapshot(); // the serialised snapshot
|
|
738
|
+
```
|
|
739
|
+
|
|
740
|
+
### Full test example
|
|
741
|
+
|
|
742
|
+
```typescript
|
|
743
|
+
import { describe, test, expect, beforeEach } from "bun:test";
|
|
744
|
+
import { FlowTest } from "@zerotal/flow/testing";
|
|
745
|
+
import { LoginPage } from "#app/flow/LoginPage.tsx";
|
|
746
|
+
import { User } from "#app/models/User.ts";
|
|
747
|
+
import { withDatabase } from "#tests/helpers.ts";
|
|
748
|
+
|
|
749
|
+
describe("LoginPage", () => {
|
|
750
|
+
test("redirects to dashboard on valid credentials", async () => {
|
|
751
|
+
await User.create({ email: "alice@example.com", password: "secret123" });
|
|
752
|
+
|
|
753
|
+
const t = await FlowTest.mount(LoginPage);
|
|
754
|
+
await t.set("email", "alice@example.com");
|
|
755
|
+
await t.set("password", "secret123");
|
|
756
|
+
await t.call("login");
|
|
757
|
+
|
|
758
|
+
t.assertRedirectedTo("/dashboard");
|
|
759
|
+
t.assertNoErrors();
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
test("shows error on invalid credentials", async () => {
|
|
763
|
+
const t = await FlowTest.mount(LoginPage);
|
|
764
|
+
await t.set("email", "alice@example.com");
|
|
765
|
+
await t.set("password", "wrongpassword");
|
|
766
|
+
await t.call("login");
|
|
767
|
+
|
|
768
|
+
t.assertNotRedirected();
|
|
769
|
+
t.assertHasErrors("email", "credentials");
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
test("validates required fields", async () => {
|
|
773
|
+
const t = await FlowTest.mount(LoginPage);
|
|
774
|
+
await t.call("login"); // no email or password set
|
|
775
|
+
|
|
776
|
+
t.assertHasErrors("email", "required");
|
|
777
|
+
t.assertHasErrors("password", "required");
|
|
778
|
+
t.assertNotRedirected();
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
test("normalises email to lowercase via onUpdated hook", async () => {
|
|
782
|
+
const t = await FlowTest.mount(LoginPage);
|
|
783
|
+
await t.update("email", "ALICE@EXAMPLE.COM");
|
|
784
|
+
|
|
785
|
+
expect(t.page().email).toBe("alice@example.com");
|
|
786
|
+
});
|
|
787
|
+
});
|
|
788
|
+
```
|
|
789
|
+
|
|
790
|
+
## Next steps
|
|
791
|
+
|
|
792
|
+
- [Flow overview](/docs/flow) — the guide's front page and the rest of the sections.
|
|
793
|
+
- [Reference](/docs/flow/references) — every decorator, prop, and directive in one table.
|