@zerotal/arch 1.7.2 → 1.7.4
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/api-surface.md +4 -4
- package/docs/about.md +4 -4
- package/docs/changelog.md +122 -6
- package/docs/client/index.md +277 -70
- package/docs/contributing.md +20 -2
- package/docs/errors.md +22 -0
- package/docs/flow/components.md +4 -8
- package/docs/flow/decorators.md +17 -13
- package/docs/flow/events.md +3 -3
- package/docs/flow/icons.md +199 -0
- package/docs/flow/index.md +11 -7
- package/docs/flow/layouts.md +1 -1
- package/docs/flow/lifecycle.md +31 -16
- package/docs/flow/models.md +284 -0
- package/docs/flow/performance.md +6 -6
- package/docs/flow/references.md +3 -3
- package/docs/flow/routing.md +14 -2
- package/docs/getting-started.md +9 -9
- package/docs/i18n.md +3 -3
- package/docs/inertia/index.md +6 -1
- package/docs/inertia/props.md +1 -1
- package/docs/inertia/rendering.md +79 -0
- package/docs/routing.md +11 -0
- package/docs/support-policy.md +28 -5
- package/docs/upgrade.md +2 -0
- package/package.json +3 -3
- package/docs/client/auth.md +0 -113
- package/docs/client/errors.md +0 -139
- package/docs/client/files.md +0 -118
- package/docs/client/references.md +0 -58
- package/docs/client/requests.md +0 -131
- package/docs/client/resilience.md +0 -141
- package/docs/client/testing.md +0 -146
package/docs/errors.md
CHANGED
|
@@ -77,6 +77,28 @@ The full set exported from `zerotal`:
|
|
|
77
77
|
> and `ServiceUnavailableError(reason, retryAfter)` populate the `Allow` and
|
|
78
78
|
> `Retry-After` response headers for you when you pass those arguments.
|
|
79
79
|
|
|
80
|
+
## Errors the framework raises
|
|
81
|
+
|
|
82
|
+
The errors above are the ones you throw. These the framework throws at you, and
|
|
83
|
+
each is written to name the fix rather than only the symptom. Most surface at
|
|
84
|
+
boot, before a request is ever served:
|
|
85
|
+
|
|
86
|
+
| Class | `code` | Raised when |
|
|
87
|
+
| ---------------------------- | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
|
88
|
+
| `BootCheckError` | `E_BOOT_CHECK_FAILED` | The boot-time doctor found one or more wiring problems. |
|
|
89
|
+
| `ConfigValidationError` | `E_CONFIG_VALIDATION_FAILED` | Config validation refused a production boot, listing every fatal issue by namespace. |
|
|
90
|
+
| `FacadeBindingMissingError` | `E_FACADE_BEFORE_BOOT` | A facade was used after boot but nothing provides its binding — almost always a `ServiceProvider` missing from `bootstrap/providers.ts`. |
|
|
91
|
+
| `ContextOutsideRequestError` | `E_CONTEXT_OUTSIDE_REQUEST` | `RequestContext` was read with no request in scope, such as from module-level code or a background task. Renders as a 500. |
|
|
92
|
+
|
|
93
|
+
`BootCheckError` carries the whole list rather than the first failure it hit. Its
|
|
94
|
+
`failures` array holds one `BootCheckFailure` per culprit — the `token`, the
|
|
95
|
+
`provider` that declared it in `static provides`, and the `reason` — so a single
|
|
96
|
+
restart tells you everything there is to fix.
|
|
97
|
+
|
|
98
|
+
These four are not re-exported from `zerotal`, because throwing them yourself is
|
|
99
|
+
not the point. Catch one by class from `@zerotal/core/errors`, or match on `code`
|
|
100
|
+
the way you would any other framework error.
|
|
101
|
+
|
|
80
102
|
## Custom exception classes
|
|
81
103
|
|
|
82
104
|
Extend `ZerotalError` for domain errors, or `HttpError` for HTTP errors. The
|
package/docs/flow/components.md
CHANGED
|
@@ -489,25 +489,21 @@ A dropzone bound to an `@expose` property. Choosing a file POSTs the bytes to `/
|
|
|
489
489
|
import { Component, expose, FileUpload, FileUploads, TemporaryUploadedFile } from "@zerotal/flow";
|
|
490
490
|
|
|
491
491
|
export class AvatarPage extends Component.using(FileUploads) {
|
|
492
|
+
@locked user!: User; // /users/:user/avatar — the record, already found
|
|
492
493
|
@expose photo: TemporaryUploadedFile | null = null;
|
|
493
|
-
@locked photoUrl: string = "";
|
|
494
|
-
|
|
495
|
-
override async onMount() {
|
|
496
|
-
const user = await User.find(this.userId);
|
|
497
|
-
this.photoUrl = user?.avatarUrl ?? "";
|
|
498
|
-
}
|
|
499
494
|
|
|
500
495
|
@expose async save(): Promise<void> {
|
|
501
496
|
if (!this.photo) return;
|
|
502
497
|
const path = await this.photo.store("avatars"); // moves to permanent storage
|
|
503
|
-
|
|
498
|
+
this.user.avatarUrl = path;
|
|
499
|
+
await this.user.save();
|
|
504
500
|
this.flash("Avatar updated.", "success");
|
|
505
501
|
}
|
|
506
502
|
|
|
507
503
|
override async render() {
|
|
508
504
|
return (
|
|
509
505
|
<div class="space-y-4">
|
|
510
|
-
{this.
|
|
506
|
+
{this.user.avatarUrl && <img src={this.user.avatarUrl} class="h-24 w-24 rounded-full" />}
|
|
511
507
|
|
|
512
508
|
<FileUpload bind={this.photo} accept="image/*" maxSize="5mb" />
|
|
513
509
|
|
package/docs/flow/decorators.md
CHANGED
|
@@ -42,18 +42,19 @@ Only `@expose` properties are two-way: the client can push updates back to the s
|
|
|
42
42
|
|
|
43
43
|
Sent to the client for display, but the client cannot mutate it. Included in the snapshot so it survives WebSocket round-trips without re-loading from the database.
|
|
44
44
|
|
|
45
|
+
An ORM model is the exception: only its id travels in the snapshot and the row is re-read on every round-trip, so what the page shows is always current. See [Models in Components](/docs/flow/models).
|
|
46
|
+
|
|
45
47
|
A `value={this.x}` binding on a `@locked` property renders as a read-only display — not an editable field.
|
|
46
48
|
|
|
47
|
-
Use `@locked` for
|
|
49
|
+
Use `@locked` for anything the server owns: a record the route resolved, results loaded in `onMount()`, computed totals, child props from the parent:
|
|
48
50
|
|
|
49
51
|
```tsx
|
|
50
52
|
export class PostsPage extends Component {
|
|
53
|
+
@locked user!: User; // /users/:user — the record, already found
|
|
51
54
|
@locked posts: Post[] = [];
|
|
52
|
-
@locked user: User | null = null;
|
|
53
55
|
@locked total: number = 0;
|
|
54
56
|
|
|
55
57
|
override async onMount() {
|
|
56
|
-
this.user = await User.findOrFail(this.userId);
|
|
57
58
|
this.posts = await Post.query()
|
|
58
59
|
.where("user_id", this.user.id)
|
|
59
60
|
.where("status", "published")
|
|
@@ -65,7 +66,7 @@ export class PostsPage extends Component {
|
|
|
65
66
|
override async render() {
|
|
66
67
|
return (
|
|
67
68
|
<div>
|
|
68
|
-
<h1>Posts by {this.user
|
|
69
|
+
<h1>Posts by {this.user.name}</h1>
|
|
69
70
|
<p>{this.total} posts</p>
|
|
70
71
|
<ul>
|
|
71
72
|
{this.posts.map((p) => (
|
|
@@ -269,7 +270,8 @@ An `@expose`d method that runs on the server but **skips the re-render cycle**.
|
|
|
269
270
|
}
|
|
270
271
|
|
|
271
272
|
@expose @renderless async archivePost(): Promise<void> {
|
|
272
|
-
|
|
273
|
+
this.post.status = "archived"; // this.post came from the route
|
|
274
|
+
await this.post.save();
|
|
273
275
|
this.redirect("/posts");
|
|
274
276
|
}
|
|
275
277
|
```
|
|
@@ -307,6 +309,8 @@ async onOrderCancelled(payload: { id: number }): Promise<void> {
|
|
|
307
309
|
}
|
|
308
310
|
```
|
|
309
311
|
|
|
312
|
+
Both forms are a `ListenerName`: the event string itself, or a resolver handed the component instance that returns one.
|
|
313
|
+
|
|
310
314
|
See [Events & Broadcasting](/docs/flow/events) for dispatch methods, targeting, broadcasting, and native event integration.
|
|
311
315
|
|
|
312
316
|
## The @reactive decorator
|
|
@@ -396,8 +400,8 @@ See [Layouts & Composition](/docs/flow/layouts#two-way-props) for the full `@mod
|
|
|
396
400
|
| `@expose method` | Callable from the browser via WebSocket |
|
|
397
401
|
| `@expose @renderless method` | Callable from browser; skips re-render cycle |
|
|
398
402
|
| `@on("event") method` | Listens for cross-component events (auto-exposed) |
|
|
399
|
-
| `@on("socket:channel,Event") method`
|
|
400
|
-
| `@on((self) => "socket:…") method`
|
|
403
|
+
| `@on("socket:channel,Event") method` | Listens for real-time server broadcasts |
|
|
404
|
+
| `@on((self) => "socket:…") method` | Same, with the channel resolved per instance (record ids) |
|
|
401
405
|
| `@reactive prop` | Child prop; parent re-pushes on change, child re-renders |
|
|
402
406
|
| `@modelable prop` | Two-way child prop; writes from child flow back to parent |
|
|
403
407
|
|
|
@@ -421,13 +425,13 @@ this.redirectIntended("/dashboard"); // back to where AuthMiddleware intercepted
|
|
|
421
425
|
// Force onMount() to re-run this round-trip (useful for reloading stale data)
|
|
422
426
|
this.refresh();
|
|
423
427
|
|
|
424
|
-
//
|
|
425
|
-
this.title("Edit post — My App");
|
|
428
|
+
// The document title is `static title` on the class, not an action — see Routing.
|
|
426
429
|
|
|
427
|
-
// Run
|
|
428
|
-
|
|
429
|
-
this.
|
|
430
|
-
this.
|
|
430
|
+
// Run JavaScript in the browser after the DOM patch is applied. `$` is a tagged
|
|
431
|
+
// template, so interpolated values are encoded for you.
|
|
432
|
+
this.$`$refs.titleInput.focus()`;
|
|
433
|
+
this.$`window.scrollTo({ top: 0, behavior: 'smooth' })`;
|
|
434
|
+
this.$`$dispatch('toast', { message: ${this.message} })`;
|
|
431
435
|
|
|
432
436
|
// Trigger a file download in the browser
|
|
433
437
|
this.download("report.csv", csvContent, "text/csv;charset=utf-8");
|
package/docs/flow/events.md
CHANGED
|
@@ -276,8 +276,8 @@ export class OrderDashboard extends Component {
|
|
|
276
276
|
|
|
277
277
|
### Channel name formats
|
|
278
278
|
|
|
279
|
-
| Format
|
|
280
|
-
|
|
|
279
|
+
| Format | Channel type |
|
|
280
|
+
| ------------------------------------ | --------------------------------------- |
|
|
281
281
|
| `socket:channel,Event` | Public channel |
|
|
282
282
|
| `socket-private:channel,Event` | Private channel (requires auth) |
|
|
283
283
|
| `socket-presence:room,joining` | Presence channel — member joined |
|
|
@@ -315,7 +315,7 @@ The resolver runs once per render, after `onMount()`, so it can read anything th
|
|
|
315
315
|
loaded. If it throws — a field it reads is still null, say — that one listener is dropped and the
|
|
316
316
|
page renders without it, rather than the render failing.
|
|
317
317
|
|
|
318
|
-
Resolve the
|
|
318
|
+
Resolve the _narrowest_ channel the reader is entitled to. A static `issues` channel with an
|
|
319
319
|
`if (payload.issueId !== this.issue.id) return` in the handler looks equivalent and is not: the
|
|
320
320
|
broadcast still reaches every subscriber's browser, so every reader receives every issue's
|
|
321
321
|
comment bodies and discards them after the fact.
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Icons
|
|
3
|
+
description: 2,060 icons bundled with Flow's component library — typed by name, rendered on the server, nothing to install.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Icons
|
|
7
|
+
|
|
8
|
+
`<Icon>` draws an icon by name. The set ships inside `@zerotal/flow-ui`, so this
|
|
9
|
+
works in a new app with nothing installed and nothing configured:
|
|
10
|
+
|
|
11
|
+
```tsx
|
|
12
|
+
import { Icon } from "@zerotal/flow-ui";
|
|
13
|
+
|
|
14
|
+
<Icon name="inbox" />
|
|
15
|
+
<Icon name="chevron-right" />
|
|
16
|
+
<Icon name="trash-2" class="size-5 text-red-600" />
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The name is a union of every bundled icon, so a typo is a compile error rather
|
|
20
|
+
than a blank space nobody notices until it is in front of a user:
|
|
21
|
+
|
|
22
|
+
```text
|
|
23
|
+
Type '"inbxo"' is not assignable to type 'IconName'. Did you mean '"inbox"'?
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
That works on install — there is no generator to run first. The icons belong to
|
|
27
|
+
the framework, so the names are known before your app exists.
|
|
28
|
+
|
|
29
|
+
## Props
|
|
30
|
+
|
|
31
|
+
`IconProps` — anything else you pass lands on the rendered `<svg>`.
|
|
32
|
+
|
|
33
|
+
| Prop | Type | Description |
|
|
34
|
+
| ------- | ---------- | ------------------------------------------------------------------------------ |
|
|
35
|
+
| `name` | `IconName` | Which icon. Checked at compile time against the bundled and registered names. |
|
|
36
|
+
| `label` | `string` | Accessible name. Omit for decoration — the icon is hidden from screen readers. |
|
|
37
|
+
| `class` | `string` | Merged with the defaults rather than replacing them. |
|
|
38
|
+
|
|
39
|
+
## Sizing and colour
|
|
40
|
+
|
|
41
|
+
An icon is `1em` square and painted in `currentColor`, so by default it matches
|
|
42
|
+
the text it sits beside — size, weight of colour, and all. Override with classes
|
|
43
|
+
rather than attributes:
|
|
44
|
+
|
|
45
|
+
```tsx
|
|
46
|
+
<p class="text-sm text-slate-600">
|
|
47
|
+
<Icon name="info" /> Saved a moment ago
|
|
48
|
+
</p>
|
|
49
|
+
|
|
50
|
+
<Icon name="triangle-alert" class="size-8 text-amber-500" />
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Sizing through CSS is what lets an icon line up with a label without either being
|
|
54
|
+
measured. `class="size-5"` sets both dimensions; `text-red-600` on the icon — or
|
|
55
|
+
on anything above it — colours it.
|
|
56
|
+
|
|
57
|
+
## Labelling
|
|
58
|
+
|
|
59
|
+
An icon is decoration by default and hidden from screen readers, which is right
|
|
60
|
+
when it sits next to text that already says the same thing. Announcing it there
|
|
61
|
+
would read the meaning out twice.
|
|
62
|
+
|
|
63
|
+
An icon that is the **only** content of a control is not decoration. Without a
|
|
64
|
+
label, that button has no accessible name at all:
|
|
65
|
+
|
|
66
|
+
```tsx
|
|
67
|
+
<button onClick={this.remove}>
|
|
68
|
+
<Icon name="trash-2" label="Delete order" />
|
|
69
|
+
</button>
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## A name that isn't known until runtime
|
|
73
|
+
|
|
74
|
+
A name from a database column or a URL segment is not a literal, so it does not
|
|
75
|
+
satisfy the union. `isIconName()` narrows it:
|
|
76
|
+
|
|
77
|
+
```tsx
|
|
78
|
+
import { Icon, isIconName } from "@zerotal/flow-ui";
|
|
79
|
+
|
|
80
|
+
override async render() {
|
|
81
|
+
const glyph = this.status.icon; // string, from a row
|
|
82
|
+
return isIconName(glyph) ? <Icon name={glyph} /> : <Icon name="circle-help" />;
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
It is a shape check, not an existence check — it says the string could name an
|
|
87
|
+
icon, not that anything answers to it. An icon that resolves to nothing renders
|
|
88
|
+
nothing rather than throwing, because taking a page down over a missing glyph is
|
|
89
|
+
the worse failure.
|
|
90
|
+
|
|
91
|
+
## Drawn for the gaps
|
|
92
|
+
|
|
93
|
+
Four names are drawn here rather than coming from the set, because the flows they
|
|
94
|
+
label are ones Zerotal ships and the set has no icon for as a concept:
|
|
95
|
+
|
|
96
|
+
| Name | For |
|
|
97
|
+
| ------------ | -------------------------------------------------------------- |
|
|
98
|
+
| `passkey` | WebAuthn sign-in — a fingerprint that ends in a key |
|
|
99
|
+
| `two-factor` | TOTP — a second device that has to agree |
|
|
100
|
+
| `otp` | An emailed one-time code — the separate slots it is typed into |
|
|
101
|
+
| `magic-link` | Passwordless sign-in by link |
|
|
102
|
+
|
|
103
|
+
The set has `key-round`, `fingerprint` and `shield-check` — the parts — and a login
|
|
104
|
+
page needs the whole. They are drawn on the same 24×24 stroke grid, so they sit
|
|
105
|
+
beside the other 2,060 without announcing themselves.
|
|
106
|
+
|
|
107
|
+
Nearly everything else that looked missing was there under a name that reads
|
|
108
|
+
differently: `git-branch` not `branch`, `file-json` not `json`, `paperclip` not
|
|
109
|
+
`attachment`, `venetian-mask` for impersonation. Search before you draw.
|
|
110
|
+
|
|
111
|
+
## Brand marks
|
|
112
|
+
|
|
113
|
+
Three sign-in providers ship as brand marks, because `@zerotal/auth` has a code
|
|
114
|
+
path for each and a sign-in button wants the provider's actual logo:
|
|
115
|
+
|
|
116
|
+
```tsx
|
|
117
|
+
<button><Icon name="brand-google" /> Continue with Google</button>
|
|
118
|
+
<button><Icon name="brand-github" /> Continue with GitHub</button>
|
|
119
|
+
<button><Icon name="brand-apple" /> Continue with Apple</button>
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
They come from [Simple Icons](https://simpleicons.org) (**CC0-1.0**, public
|
|
123
|
+
domain), so the paths are the real ones rather than approximations — an
|
|
124
|
+
approximated logo reads as a forgery, not as an icon.
|
|
125
|
+
|
|
126
|
+
The `brand-` prefix is deliberate: the bundled set has its own stroke-style
|
|
127
|
+
`github` and `apple`, and prefixing means neither silently shadows the other, so a
|
|
128
|
+
page picks a style rather than inheriting one. There is no plain `google` — the
|
|
129
|
+
set never had one, which is what made this worth doing.
|
|
130
|
+
|
|
131
|
+
Unlike the rest, brand marks are **solid**: each body carries its own
|
|
132
|
+
`fill="currentColor"`, so it still takes its colour from the text around it.
|
|
133
|
+
|
|
134
|
+
> **CC0 covers copyright, not trademark.** The marks belong to their owners.
|
|
135
|
+
> Labelling a sign-in button with one is nominative use and what brand guidelines
|
|
136
|
+
> contemplate; using one as your own logo is not. For a provider not listed here,
|
|
137
|
+
> `registerIcons()` keeps that decision — and its licence — yours.
|
|
138
|
+
|
|
139
|
+
## Your own icons
|
|
140
|
+
|
|
141
|
+
A wordmark, a product glyph, a shape nobody has drawn: register it once, from a
|
|
142
|
+
provider's `register()`, and it is available everywhere `<Icon>` is.
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
import { registerIcons } from "@zerotal/flow-ui";
|
|
146
|
+
|
|
147
|
+
registerIcons({
|
|
148
|
+
"acme-wordmark": {
|
|
149
|
+
body: '<path fill="currentColor" d="M4 4h16v16H4z"/>',
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Each entry is an `IconBody` — the markup that goes **inside** the `<svg>`, plus an
|
|
155
|
+
optional `width`/`height` when it was drawn against a box other than 24×24. A name
|
|
156
|
+
you register shadows a bundled one, which is how you substitute your own drawing
|
|
157
|
+
without renaming every call site.
|
|
158
|
+
|
|
159
|
+
Registering supplies the body; the compiler needs telling separately. Declare the
|
|
160
|
+
names on `CustomIconRegistry` and they join the same union as the bundled ones —
|
|
161
|
+
`IconName` widens, and `CustomIconName` is the set you added:
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
declare module "@zerotal/flow-ui" {
|
|
165
|
+
interface CustomIconRegistry {
|
|
166
|
+
"acme-wordmark": true;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
> **The body is inserted as markup, not text.** Register only SVG you control.
|
|
172
|
+
> A body built from user input is the same hole as any other unescaped HTML.
|
|
173
|
+
|
|
174
|
+
### Matching the set
|
|
175
|
+
|
|
176
|
+
Icons drawn to a different grid look wrong beside ones that aren't. The bundled
|
|
177
|
+
set is 24×24 **stroke**: no fills, `stroke="currentColor"`, `stroke-width="2"`,
|
|
178
|
+
round caps and joins. Copy the shape of an existing icon rather than exporting
|
|
179
|
+
from a design tool, which will hand you absolute fills on a half-pixel grid.
|
|
180
|
+
|
|
181
|
+
## What ships, and why it can
|
|
182
|
+
|
|
183
|
+
The bundled set is [Lucide](https://lucide.dev), which is ISC-licensed — the
|
|
184
|
+
reason it can be shipped inside the package at all. Redistributing it carries a
|
|
185
|
+
notice (`LICENSE-ICONS.md` in `@zerotal/flow-ui`) and asks nothing of your
|
|
186
|
+
application's UI.
|
|
187
|
+
|
|
188
|
+
Most sets are not so simple. Font Awesome Free is CC BY 4.0 — usable, and only
|
|
189
|
+
with attribution _you_ would have to display — and Font Awesome Pro may not be
|
|
190
|
+
redistributed at any price. Bundling either would relicense someone else's artwork
|
|
191
|
+
on behalf of every app that installed Flow. If you are entitled to a set we cannot
|
|
192
|
+
ship, `registerIcons()` is how you bring it: your artwork, your licence.
|
|
193
|
+
|
|
194
|
+
## Cost
|
|
195
|
+
|
|
196
|
+
None on the client. Flow renders on the server, so an icon reaches the browser as
|
|
197
|
+
markup that is already in the page — no icon font, no sprite sheet, no request per
|
|
198
|
+
glyph, and nothing for a strict [Content Security Policy](/docs/flow/performance)
|
|
199
|
+
to block. The set is read once per process and never sent.
|
package/docs/flow/index.md
CHANGED
|
@@ -139,7 +139,7 @@ component in the project is covered — the Flow scaffold already writes this:
|
|
|
139
139
|
> is a type error rather than a wrong render. The two JSX runtimes produce different element
|
|
140
140
|
> types: a view `FC` returns `SafeHtml` (`{ value }`) and Flow's JSX expects `HtmlNode`
|
|
141
141
|
> (`{ html }`), so using one inside the other is `TS2786: 'Box' cannot be used as a JSX
|
|
142
|
-
|
|
142
|
+
component`. A shared component library has to target one runtime; share class-name constants
|
|
143
143
|
> or plain strings across the two instead of components.
|
|
144
144
|
|
|
145
145
|
### Your first component
|
|
@@ -183,22 +183,26 @@ Router.flow("/counter", CounterPage);
|
|
|
183
183
|
### Reserved member names
|
|
184
184
|
|
|
185
185
|
`Component` brings its own members, and a property of yours that collides with one is a
|
|
186
|
-
type error. It is caught at compile time and the message is specific,
|
|
187
|
-
|
|
188
|
-
|
|
186
|
+
type error. It is caught at compile time and the message is specific, so the cost is the
|
|
187
|
+
surprise rather than the failure — which is why the list is here.
|
|
188
|
+
|
|
189
|
+
`title` used to be on it, and was the name that caught people most: an obvious field for a
|
|
190
|
+
row representing a media item, a guide or a review. The document title is
|
|
191
|
+
[`static title`](#the-page-title) now, so the instance name is yours.
|
|
189
192
|
|
|
190
193
|
The names in use:
|
|
191
194
|
|
|
192
195
|
| Group | Names |
|
|
193
196
|
| ----------------- | --------------------------------------------------------------------------------------------------------------------- |
|
|
194
197
|
| Lifecycle | `onBoot` `onMount` `onHydrate` `onDehydrate` `onRendering` `onRendered` `onUpdate` `onUpdating` `onUpdated` `onError` |
|
|
195
|
-
| Rendering | `render` `layout` `placeholder` `slot` `hasSlot` `child`
|
|
198
|
+
| Rendering | `render` `layout` `placeholder` `slot` `hasSlot` `child` |
|
|
196
199
|
| Actions & state | `bind` `validate` `resetValidation` `errors` `addError` `refresh` `$refresh` `$set` `cancelled` `signal` |
|
|
197
200
|
| Navigation | `redirect` `redirectRoute` `redirectIntended` `currentUrl` `navigateCurrent` |
|
|
198
|
-
| Events & realtime | `dispatch` `dispatchSelf` `dispatchTo` `stream` `client`
|
|
201
|
+
| Events & realtime | `dispatch` `dispatchSelf` `dispatchTo` `stream` `client` `$` |
|
|
199
202
|
| Misc | `flash` `download` `clearDurable` |
|
|
200
203
|
|
|
201
|
-
Anything beginning with `_` is also framework-internal, as
|
|
204
|
+
Anything beginning with `_` is also framework-internal, as are the statics `durable`
|
|
205
|
+
and `title`.
|
|
202
206
|
|
|
203
207
|
If the natural name is taken, the usual fix is a more specific one — `headline`,
|
|
204
208
|
`mediaTitle` — which often reads better than `title` did.
|
package/docs/flow/layouts.md
CHANGED
|
@@ -149,7 +149,7 @@ Flow assembles the outer document itself and emits `<html lang="en">`, and a `La
|
|
|
149
149
|
change it. `head` injects into `<head>`; the `<html>` attributes are not reachable from a layout.
|
|
150
150
|
|
|
151
151
|
For a localised app, put `lang` (and any locale-dependent class) on the layout's own wrapper element.
|
|
152
|
-
Both are valid on a `div` and apply to every descendant, so the
|
|
152
|
+
Both are valid on a `div` and apply to every descendant, so the _content_ is correctly marked up —
|
|
153
153
|
but the document still declares English to anything reading the root element, which is wrong for a
|
|
154
154
|
screen reader announcing the page in the wrong voice:
|
|
155
155
|
|
package/docs/flow/lifecycle.md
CHANGED
|
@@ -94,11 +94,19 @@ Avoid expensive database queries in `onBoot()` — it fires on every round-trip,
|
|
|
94
94
|
|
|
95
95
|
Runs once on the initial `GET` render, then is skipped on all subsequent WebSocket updates. It's the primary place to load data for the page.
|
|
96
96
|
|
|
97
|
-
|
|
97
|
+
A page on a dynamic segment needs no code here for the record it is about. A field of the model's type is [filled from the segment](/docs/flow/routing#path-parameters) before `onMount()` runs, so the query, the id field, and the 404 all belong to the route:
|
|
98
98
|
|
|
99
99
|
```typescript
|
|
100
|
-
|
|
101
|
-
|
|
100
|
+
export class PostPage extends Component {
|
|
101
|
+
@locked post!: Post; // /posts/:post — nothing to load
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
What `onMount()` is for is everything the URL does not carry. It receives the route `HttpContext` — the same argument a controller action gets — which is where the signed-in user lives, and `ctx.params` is still there for a segment no field claimed:
|
|
106
|
+
|
|
107
|
+
```typescript
|
|
108
|
+
override async onMount({ user }: HttpContext) {
|
|
109
|
+
this.canEdit = user?.id === this.post.authorId;
|
|
102
110
|
}
|
|
103
111
|
```
|
|
104
112
|
|
|
@@ -106,21 +114,23 @@ The context is passed to `onBoot()` too, but only the initial `GET` populates `c
|
|
|
106
114
|
|
|
107
115
|
```typescript
|
|
108
116
|
override async onMount() {
|
|
109
|
-
const [posts,
|
|
117
|
+
const [posts, drafts] = await Promise.all([
|
|
110
118
|
Post.query()
|
|
111
119
|
.where("status", "published")
|
|
112
120
|
.orderBy("created_at", "desc")
|
|
113
121
|
.limit(20)
|
|
114
122
|
.get(),
|
|
115
|
-
|
|
123
|
+
Post.query().where("status", "draft").count(),
|
|
116
124
|
]);
|
|
117
125
|
|
|
118
|
-
this.posts
|
|
119
|
-
this.
|
|
120
|
-
this.total
|
|
126
|
+
this.posts = posts;
|
|
127
|
+
this.drafts = drafts;
|
|
128
|
+
this.total = posts.length;
|
|
121
129
|
}
|
|
122
130
|
```
|
|
123
131
|
|
|
132
|
+
Lists and counts are what belongs here — the things no route resolved and no parent passed down.
|
|
133
|
+
|
|
124
134
|
To force `onMount()` to re-run during a WebSocket action — for example after creating a new record and wanting to reload the list — call `this.refresh()` inside the action:
|
|
125
135
|
|
|
126
136
|
```typescript
|
|
@@ -139,25 +149,30 @@ Runs on every WebSocket round-trip, immediately after state is restored from the
|
|
|
139
149
|
|
|
140
150
|
```typescript
|
|
141
151
|
export class PostEditorPage extends Component {
|
|
142
|
-
@
|
|
143
|
-
@transient
|
|
152
|
+
@expose post!: Post; // /posts/:post/edit — re-read from the row every round-trip
|
|
153
|
+
@transient wordCount = 0; // NOT persisted — derived again each time
|
|
144
154
|
|
|
145
155
|
override async onHydrate() {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
this.post = await Post.findOrFail(this.postId);
|
|
149
|
-
}
|
|
156
|
+
await this.post.loadMissing(["author"]); // relations do not survive the round-trip
|
|
157
|
+
this.wordCount = this.post.body.split(/\s+/).length;
|
|
150
158
|
}
|
|
151
159
|
|
|
152
160
|
@expose async updateTitle(title: string): Promise<void> {
|
|
153
|
-
if (!this.post) return;
|
|
154
161
|
await this.post.fill({ title }).save();
|
|
155
162
|
this.flash("Title updated.");
|
|
156
163
|
}
|
|
157
164
|
}
|
|
158
165
|
```
|
|
159
166
|
|
|
160
|
-
This
|
|
167
|
+
> **This used to say to hold the id and re-query the model here.** A component could not hold a
|
|
168
|
+
> model then, so `@locked postId` plus a `@transient post` re-fetched in `onHydrate()` was the
|
|
169
|
+
> way to keep one fresh. It is no longer needed: a model held directly is [re-read from its row
|
|
170
|
+
> on every round-trip](/docs/flow/models#freshness), which is the same guarantee with none of
|
|
171
|
+
> the bookkeeping. The pattern still works — it is just two fields and a query doing what one
|
|
172
|
+
> field now does.
|
|
173
|
+
|
|
174
|
+
What is left for `onHydrate()` is the state a snapshot genuinely cannot carry: relations, which
|
|
175
|
+
are not part of a re-read, and anything derived from them.
|
|
161
176
|
|
|
162
177
|
## Intercepting client writes
|
|
163
178
|
|