@zerotal/arch 1.7.4 → 1.7.5
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 +34 -2
- package/README.md +4 -2
- package/docs/about.md +16 -16
- package/docs/admin/actions.md +9 -9
- package/docs/admin/auth.md +2 -2
- package/docs/admin/dashboard.md +5 -5
- package/docs/admin/extending-ui.md +12 -12
- package/docs/admin/extending.md +16 -7
- package/docs/admin/forms.md +4 -4
- package/docs/admin/index.md +2 -2
- package/docs/admin/operations.md +9 -9
- package/docs/admin/resources.md +3 -3
- package/docs/admin/structure.md +6 -6
- package/docs/admin/tables.md +8 -8
- package/docs/admin/testing.md +4 -4
- package/docs/ai.md +118 -11
- package/docs/application.md +12 -12
- package/docs/arch.md +25 -3
- package/docs/assets.md +11 -11
- package/docs/audit.md +13 -13
- package/docs/authentication.md +39 -39
- package/docs/authorization.md +13 -13
- package/docs/broadcasting/channels.md +2 -2
- package/docs/broadcasting/client.md +2 -2
- package/docs/broadcasting/events.md +9 -10
- package/docs/broadcasting/index.md +1 -1
- package/docs/broadcasting/testing.md +5 -5
- package/docs/cache.md +15 -13
- package/docs/carbon.md +35 -35
- package/docs/changelog.md +105 -1
- package/docs/client/index.md +13 -13
- package/docs/commands.md +4 -4
- package/docs/components.md +116 -115
- package/docs/config-system.md +4 -4
- package/docs/container.md +27 -27
- package/docs/context.md +27 -27
- package/docs/contributing.md +28 -0
- package/docs/controllers.md +10 -10
- package/docs/conventions.md +23 -15
- package/docs/cookies.md +3 -3
- package/docs/csrf.md +6 -6
- package/docs/database.md +22 -17
- package/docs/deployment.md +2 -2
- package/docs/devtools.md +16 -16
- package/docs/email-verification.md +8 -8
- package/docs/encryption.md +7 -7
- package/docs/errors.md +6 -6
- package/docs/events.md +12 -11
- package/docs/flow/components.md +32 -32
- package/docs/flow/decorators.md +18 -18
- package/docs/flow/events.md +16 -16
- package/docs/flow/forms.md +17 -17
- package/docs/flow/icons.md +5 -5
- package/docs/flow/index.md +20 -20
- package/docs/flow/layouts.md +35 -35
- package/docs/flow/lifecycle.md +16 -16
- package/docs/flow/models.md +9 -9
- package/docs/flow/pagination.md +8 -8
- package/docs/flow/performance.md +7 -7
- package/docs/flow/references.md +3 -3
- package/docs/flow/routing.md +26 -26
- package/docs/flow/testing.md +20 -20
- package/docs/getting-started.md +18 -13
- package/docs/health.md +17 -8
- package/docs/helpers.md +17 -17
- package/docs/i18n.md +10 -10
- package/docs/inertia/devtools.md +4 -4
- package/docs/inertia/index.md +6 -6
- package/docs/inertia/props.md +21 -21
- package/docs/inertia/rendering.md +9 -9
- package/docs/inertia/ssr.md +2 -2
- package/docs/lifecycle.md +2 -2
- package/docs/lock.md +11 -11
- package/docs/logger.md +12 -12
- package/docs/media.md +22 -22
- package/docs/middleware.md +17 -17
- package/docs/migrations.md +13 -13
- package/docs/monitor.md +6 -6
- package/docs/notifications.md +24 -24
- package/docs/orm/casts.md +14 -14
- package/docs/orm/factories.md +8 -8
- package/docs/orm/index.md +22 -20
- package/docs/orm/lifecycle.md +13 -13
- package/docs/orm/queries.md +28 -28
- package/docs/orm/relationships.md +16 -16
- package/docs/orm/serialization.md +11 -11
- package/docs/package-development.md +10 -10
- package/docs/pagination.md +14 -14
- package/docs/password-reset.md +6 -6
- package/docs/providers.md +11 -11
- package/docs/query-builder.md +18 -18
- package/docs/queue.md +14 -12
- package/docs/rate-limiting.md +6 -6
- package/docs/responses.md +14 -14
- package/docs/roles-and-2fa.md +9 -9
- package/docs/routing.md +44 -44
- package/docs/scaffolding.md +1 -1
- package/docs/scheduler.md +10 -10
- package/docs/seeding.md +6 -6
- package/docs/session.md +16 -14
- package/docs/social.md +14 -14
- package/docs/storage.md +25 -13
- package/docs/structure.md +2 -2
- package/docs/support-policy.md +23 -10
- package/docs/telemetry.md +12 -12
- package/docs/tenancy.md +34 -40
- package/docs/testing/browser.md +6 -6
- package/docs/testing/console.md +3 -3
- package/docs/testing/database.md +10 -10
- package/docs/testing/flow-browser.md +6 -6
- package/docs/testing/http.md +12 -12
- package/docs/testing/index.md +3 -3
- package/docs/testing/mocking.md +8 -8
- package/docs/upgrade.md +1 -1
- package/docs/validator.md +19 -19
- package/docs/view.md +14 -14
- package/package.json +4 -4
- package/src/index.ts +25 -1
- package/src/tools/searchDocs.ts +7 -0
package/docs/flow/layouts.md
CHANGED
|
@@ -49,7 +49,7 @@ export class AppLayout extends Layout {
|
|
|
49
49
|
|
|
50
50
|
Attach the layout to a page:
|
|
51
51
|
|
|
52
|
-
```tsx
|
|
52
|
+
```tsx fragment
|
|
53
53
|
export class DashboardPage extends Component {
|
|
54
54
|
static layout = AppLayout;
|
|
55
55
|
|
|
@@ -69,7 +69,7 @@ The layout's `render(slot)` receives the page's HTML as `slot`. Multiple pages c
|
|
|
69
69
|
|
|
70
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
71
|
|
|
72
|
-
```tsx
|
|
72
|
+
```tsx fragment
|
|
73
73
|
import { Component } from "@zerotal/flow";
|
|
74
74
|
import type { HtmlNode } from "@zerotal/flow";
|
|
75
75
|
import { AppLayout } from "#app/layouts/AppLayout.tsx";
|
|
@@ -102,7 +102,7 @@ export class DashboardPage extends Component {
|
|
|
102
102
|
|
|
103
103
|
`AppLayout` is an ordinary function component — nothing framework-specific:
|
|
104
104
|
|
|
105
|
-
```tsx
|
|
105
|
+
```tsx fragment
|
|
106
106
|
export function AppLayout(props: { title?: string; actions?: HtmlNode; children?: unknown }) {
|
|
107
107
|
return (
|
|
108
108
|
<div data-flow-layout="app" class="min-h-screen bg-gray-50">
|
|
@@ -127,7 +127,7 @@ Two things to know:
|
|
|
127
127
|
|
|
128
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
129
|
|
|
130
|
-
```tsx
|
|
130
|
+
```tsx fragment
|
|
131
131
|
import { Head } from "@zerotal/flow";
|
|
132
132
|
|
|
133
133
|
override async render() {
|
|
@@ -153,7 +153,7 @@ Both are valid on a `div` and apply to every descendant, so the _content_ is cor
|
|
|
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
|
|
|
156
|
-
```tsx
|
|
156
|
+
```tsx fragment
|
|
157
157
|
override render(slot: HtmlNode) {
|
|
158
158
|
return <div lang={activeLocale()}>{slot}</div>;
|
|
159
159
|
}
|
|
@@ -176,7 +176,7 @@ import { SectionOutlet } from "@zerotal/flow";
|
|
|
176
176
|
</header>;
|
|
177
177
|
```
|
|
178
178
|
|
|
179
|
-
```tsx
|
|
179
|
+
```tsx fragment
|
|
180
180
|
// In any page — fill it
|
|
181
181
|
import { SectionContent } from "@zerotal/flow";
|
|
182
182
|
|
|
@@ -188,7 +188,7 @@ import { SectionContent } from "@zerotal/flow";
|
|
|
188
188
|
`<SectionContent>` renders nothing where it appears. Children of `<SectionOutlet>` are the default,
|
|
189
189
|
used when no page published anything:
|
|
190
190
|
|
|
191
|
-
```tsx
|
|
191
|
+
```tsx fragment
|
|
192
192
|
<SectionOutlet name="toolbar">
|
|
193
193
|
<span class="text-sm text-gray-500">No actions</span>
|
|
194
194
|
</SectionOutlet>
|
|
@@ -259,7 +259,7 @@ export function Sorting<T extends Constructor<Component>>(Base: T) {
|
|
|
259
259
|
}
|
|
260
260
|
```
|
|
261
261
|
|
|
262
|
-
```tsx
|
|
262
|
+
```tsx fragment
|
|
263
263
|
export class UsersPage extends Component.using(Sorting, Pagination) {}
|
|
264
264
|
```
|
|
265
265
|
|
|
@@ -269,7 +269,7 @@ export class UsersPage extends Component.using(Sorting, Pagination) {}
|
|
|
269
269
|
an app-level base carry its own state and actions and still take mixins, without being flattened
|
|
270
270
|
out of the prototype chain:
|
|
271
271
|
|
|
272
|
-
```tsx
|
|
272
|
+
```tsx fragment
|
|
273
273
|
abstract class AdminPage extends Component {
|
|
274
274
|
@expose breadcrumb = "admin";
|
|
275
275
|
|
|
@@ -297,7 +297,7 @@ live on it.
|
|
|
297
297
|
The composed class carries `using` itself, so composition can be built up in stages — useful when
|
|
298
298
|
a shared base is defined in one file and extended in another:
|
|
299
299
|
|
|
300
|
-
```tsx
|
|
300
|
+
```tsx fragment
|
|
301
301
|
const AdminBase = Component.using(Pagination).using(Sorting);
|
|
302
302
|
export class ReportsPage extends AdminBase.using(FileUploads) {}
|
|
303
303
|
```
|
|
@@ -311,7 +311,7 @@ export class ReportsPage extends AdminBase.using(FileUploads) {}
|
|
|
311
311
|
|
|
312
312
|
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).
|
|
313
313
|
|
|
314
|
-
```tsx
|
|
314
|
+
```tsx fragment
|
|
315
315
|
import { StatsWidget } from "./StatsWidget.tsx";
|
|
316
316
|
import { ActivityFeed } from "./ActivityFeed.tsx";
|
|
317
317
|
|
|
@@ -340,7 +340,7 @@ export class DashboardPage extends Component {
|
|
|
340
340
|
|
|
341
341
|
Each prop the parent passes is assigned onto the same-named field before any lifecycle hook runs — the field's initialiser is the default:
|
|
342
342
|
|
|
343
|
-
```tsx
|
|
343
|
+
```tsx fragment
|
|
344
344
|
export class CounterWidget extends Component {
|
|
345
345
|
@locked step: number = 1;
|
|
346
346
|
@locked label: string = "Count";
|
|
@@ -369,7 +369,7 @@ Props that need to survive WebSocket round-trips must be `@locked` so they are i
|
|
|
369
369
|
|
|
370
370
|
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.
|
|
371
371
|
|
|
372
|
-
```tsx
|
|
372
|
+
```tsx fragment
|
|
373
373
|
// The reusable shell — header and footer are optional.
|
|
374
374
|
export class Card extends Component {
|
|
375
375
|
override async render() {
|
|
@@ -415,7 +415,7 @@ Two consequences worth knowing:
|
|
|
415
415
|
|
|
416
416
|
Give every child rendered inside a `.map()` a `key` tied to the row's own identity:
|
|
417
417
|
|
|
418
|
-
```tsx
|
|
418
|
+
```tsx fragment
|
|
419
419
|
{
|
|
420
420
|
this.settings.map((s) => <SettingRow key={`setting-${s.id}`} settingKey={s.key} />);
|
|
421
421
|
}
|
|
@@ -433,7 +433,7 @@ Keys are sanitised to `[a-zA-Z0-9_-]`, so dots are stripped and `a.b` collides w
|
|
|
433
433
|
|
|
434
434
|
### Lazy, deferred, and streamed loading
|
|
435
435
|
|
|
436
|
-
```tsx
|
|
436
|
+
```tsx fragment
|
|
437
437
|
// Defer mount until the placeholder enters the viewport (intersection observer)
|
|
438
438
|
<HeavyChart key="chart" lazy />
|
|
439
439
|
|
|
@@ -454,7 +454,7 @@ works before and without Alpine). See [Streaming the initial render](#streaming-
|
|
|
454
454
|
|
|
455
455
|
Override `placeholder()` to customise the skeleton shown while a lazy component loads:
|
|
456
456
|
|
|
457
|
-
```tsx
|
|
457
|
+
```tsx fragment
|
|
458
458
|
export class HeavyChart extends Component {
|
|
459
459
|
override placeholder() {
|
|
460
460
|
return <div class="h-64 w-full rounded-xl bg-gray-200 animate-pulse" />;
|
|
@@ -472,7 +472,7 @@ export class HeavyChart extends Component {
|
|
|
472
472
|
|
|
473
473
|
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:
|
|
474
474
|
|
|
475
|
-
```tsx
|
|
475
|
+
```tsx fragment
|
|
476
476
|
export class PriceTag extends Component {
|
|
477
477
|
@reactive currency = "USD";
|
|
478
478
|
@reactive amount = 0;
|
|
@@ -497,7 +497,7 @@ export class PriceTag extends Component {
|
|
|
497
497
|
|
|
498
498
|
`@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:
|
|
499
499
|
|
|
500
|
-
```tsx
|
|
500
|
+
```tsx fragment
|
|
501
501
|
export class StarRating extends Component {
|
|
502
502
|
@modelable rating: number = 0; // two-way bound to parent
|
|
503
503
|
|
|
@@ -537,7 +537,7 @@ finishes. They solve different problems and do not interact.
|
|
|
537
537
|
Mark a child `stream` and the page paints immediately with that child's placeholder; its real markup
|
|
538
538
|
is appended to the same response as soon as it finishes rendering:
|
|
539
539
|
|
|
540
|
-
```tsx
|
|
540
|
+
```tsx fragment
|
|
541
541
|
override async render() {
|
|
542
542
|
return (
|
|
543
543
|
<div>
|
|
@@ -569,13 +569,13 @@ so `stream` degrades to an ordinary inline child render.
|
|
|
569
569
|
|
|
570
570
|
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:
|
|
571
571
|
|
|
572
|
-
```tsx
|
|
572
|
+
```tsx fragment
|
|
573
573
|
{/* In the template: declare the stream target */}
|
|
574
574
|
<div stream="answer" class="prose" />
|
|
575
575
|
<div stream="status" class="text-sm text-gray-500" />
|
|
576
576
|
```
|
|
577
577
|
|
|
578
|
-
```typescript
|
|
578
|
+
```typescript fragment
|
|
579
579
|
@expose async generate(): Promise<void> {
|
|
580
580
|
this.stream("status", "Generating…");
|
|
581
581
|
|
|
@@ -596,7 +596,7 @@ this.stream("output", freshContent, { replace: true });
|
|
|
596
596
|
|
|
597
597
|
`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:
|
|
598
598
|
|
|
599
|
-
```tsx
|
|
599
|
+
```tsx fragment
|
|
600
600
|
import { Component, task, expose } from "@zerotal/flow";
|
|
601
601
|
|
|
602
602
|
export class Chat extends Component {
|
|
@@ -612,7 +612,7 @@ export class Chat extends Component {
|
|
|
612
612
|
}
|
|
613
613
|
```
|
|
614
614
|
|
|
615
|
-
```tsx
|
|
615
|
+
```tsx fragment
|
|
616
616
|
{/* Bind the streamed field REACTIVELY (text={…} → flow:text, or x-text) so each write updates
|
|
617
617
|
this element live off the client store — no flow:stream element, no re-render per chunk. */}
|
|
618
618
|
<button onClick={this.generate} loadingAttr="disabled">Generate</button>
|
|
@@ -632,7 +632,7 @@ This is the primitive for AI answers, build/deploy logs, and progress feeds —
|
|
|
632
632
|
|
|
633
633
|
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:
|
|
634
634
|
|
|
635
|
-
```typescript
|
|
635
|
+
```typescript fragment
|
|
636
636
|
import { Router } from "zerotal";
|
|
637
637
|
import { RequireAuthMiddleware } from "#app/middleware/RequireAuth.ts";
|
|
638
638
|
import { RequireAdminMiddleware } from "#app/middleware/RequireAdmin.ts";
|
|
@@ -657,7 +657,7 @@ See [Testing](/docs/flow/testing) for the full guide — mounting, calling actio
|
|
|
657
657
|
|
|
658
658
|
### Mounting a composed component
|
|
659
659
|
|
|
660
|
-
```typescript
|
|
660
|
+
```typescript fragment
|
|
661
661
|
import { FlowTest } from "@zerotal/flow/testing";
|
|
662
662
|
import { CounterPage } from "../app/flow/CounterPage.tsx";
|
|
663
663
|
|
|
@@ -672,14 +672,14 @@ t.assertDontSee("Count: 1");
|
|
|
672
672
|
|
|
673
673
|
Mount with initial props (seed state before `onMount`):
|
|
674
674
|
|
|
675
|
-
```typescript
|
|
675
|
+
```typescript fragment
|
|
676
676
|
const t = await FlowTest.mount(PostsPage, { page: 2, search: "TypeScript" });
|
|
677
677
|
expect(t.page().search).toBe("TypeScript");
|
|
678
678
|
```
|
|
679
679
|
|
|
680
680
|
### Calling composed actions
|
|
681
681
|
|
|
682
|
-
```typescript
|
|
682
|
+
```typescript fragment
|
|
683
683
|
// call() drives: onBoot → onHydrate → action() → onUpdate → render → onDehydrate
|
|
684
684
|
await t.call("increment");
|
|
685
685
|
expect(t.page().count).toBe(1);
|
|
@@ -694,7 +694,7 @@ expect(t.page().count).toBe(42);
|
|
|
694
694
|
|
|
695
695
|
Two ways to change a property between calls:
|
|
696
696
|
|
|
697
|
-
```typescript
|
|
697
|
+
```typescript fragment
|
|
698
698
|
// set() — direct assignment, no hooks fire
|
|
699
699
|
await t.set("draft", "Hello world");
|
|
700
700
|
|
|
@@ -708,14 +708,14 @@ Use `set()` to seed state for a specific scenario. Use `update()` to test that y
|
|
|
708
708
|
|
|
709
709
|
**HTML assertions:**
|
|
710
710
|
|
|
711
|
-
```typescript
|
|
711
|
+
```typescript fragment
|
|
712
712
|
t.assertSee("Published post"); // HTML contains this string
|
|
713
713
|
t.assertDontSee("Error"); // HTML does NOT contain this string
|
|
714
714
|
```
|
|
715
715
|
|
|
716
716
|
**Validation assertions:**
|
|
717
717
|
|
|
718
|
-
```typescript
|
|
718
|
+
```typescript fragment
|
|
719
719
|
await t.call("save");
|
|
720
720
|
t.assertHasErrors("email"); // field has at least one error
|
|
721
721
|
t.assertHasErrors("email", "required"); // error message contains "required"
|
|
@@ -724,14 +724,14 @@ t.assertNoErrors(); // no errors at all
|
|
|
724
724
|
|
|
725
725
|
**Redirect assertions:**
|
|
726
726
|
|
|
727
|
-
```typescript
|
|
727
|
+
```typescript fragment
|
|
728
728
|
t.assertRedirectedTo("/dashboard");
|
|
729
729
|
t.assertNotRedirected();
|
|
730
730
|
```
|
|
731
731
|
|
|
732
732
|
**Flash assertions:**
|
|
733
733
|
|
|
734
|
-
```typescript
|
|
734
|
+
```typescript fragment
|
|
735
735
|
t.assertFlashed("success", "Saved."); // level + message substring
|
|
736
736
|
t.assertFlashed("error"); // just check the level
|
|
737
737
|
t.assertFlashed(undefined, "Something went"); // just check the message substring
|
|
@@ -739,13 +739,13 @@ t.assertFlashed(undefined, "Something went"); // just check the message substrin
|
|
|
739
739
|
|
|
740
740
|
**Event assertions:**
|
|
741
741
|
|
|
742
|
-
```typescript
|
|
742
|
+
```typescript fragment
|
|
743
743
|
t.assertDispatched("post-created");
|
|
744
744
|
```
|
|
745
745
|
|
|
746
746
|
### Composed accessors
|
|
747
747
|
|
|
748
|
-
```typescript
|
|
748
|
+
```typescript fragment
|
|
749
749
|
t.page(); // the Component instance — inspect properties directly
|
|
750
750
|
t.html(); // the rendered HTML string
|
|
751
751
|
t.errors(); // current validation error bag: Record<string, string[]>
|
|
@@ -755,7 +755,7 @@ t.snapshot(); // the serialised snapshot
|
|
|
755
755
|
|
|
756
756
|
### Full test example
|
|
757
757
|
|
|
758
|
-
```typescript
|
|
758
|
+
```typescript fragment
|
|
759
759
|
import { describe, test, expect, beforeEach } from "bun:test";
|
|
760
760
|
import { FlowTest } from "@zerotal/flow/testing";
|
|
761
761
|
import { LoginPage } from "#app/flow/LoginPage.tsx";
|
package/docs/flow/lifecycle.md
CHANGED
|
@@ -46,7 +46,7 @@ GET /page (initial render) WebSocket action frame (subsequent)
|
|
|
46
46
|
|
|
47
47
|
A child gets its data from its parent, not from the URL. Each prop lands on the field of the same name before `onBoot()` and `onMount()` run, so the field's initialiser is its default and a hook can use the value straight away:
|
|
48
48
|
|
|
49
|
-
```typescript
|
|
49
|
+
```typescript fragment
|
|
50
50
|
export class CounterWidget extends Component {
|
|
51
51
|
@locked label: string = "Count"; // ← <CounterWidget label="Views" />
|
|
52
52
|
@locked step: number = 1; // ← defaults to 1 when the parent omits it
|
|
@@ -76,7 +76,7 @@ A child receives the request `HttpContext` in `onBoot(ctx)` / `onMount(ctx)` lik
|
|
|
76
76
|
|
|
77
77
|
Runs on **every** request: the initial `GET` and every WebSocket update. Use it for setup that must be fresh on every round-trip — resolving the authenticated user from context, initialising i18n, wiring up per-request services:
|
|
78
78
|
|
|
79
|
-
```typescript
|
|
79
|
+
```typescript fragment
|
|
80
80
|
import { request } from "zerotal";
|
|
81
81
|
|
|
82
82
|
override async onBoot() {
|
|
@@ -96,7 +96,7 @@ Runs once on the initial `GET` render, then is skipped on all subsequent WebSock
|
|
|
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
|
-
```typescript
|
|
99
|
+
```typescript fragment
|
|
100
100
|
export class PostPage extends Component {
|
|
101
101
|
@locked post!: Post; // /posts/:post — nothing to load
|
|
102
102
|
}
|
|
@@ -104,7 +104,7 @@ export class PostPage extends Component {
|
|
|
104
104
|
|
|
105
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
106
|
|
|
107
|
-
```typescript
|
|
107
|
+
```typescript fragment
|
|
108
108
|
override async onMount({ user }: HttpContext) {
|
|
109
109
|
this.canEdit = user?.id === this.post.authorId;
|
|
110
110
|
}
|
|
@@ -112,7 +112,7 @@ override async onMount({ user }: HttpContext) {
|
|
|
112
112
|
|
|
113
113
|
The context is passed to `onBoot()` too, but only the initial `GET` populates `ctx.params` — see the [warning in Routing](/docs/flow/routing#path-parameters). The argument is optional because a component can also be mounted outside a request (in a test, for example).
|
|
114
114
|
|
|
115
|
-
```typescript
|
|
115
|
+
```typescript fragment
|
|
116
116
|
override async onMount() {
|
|
117
117
|
const [posts, drafts] = await Promise.all([
|
|
118
118
|
Post.query()
|
|
@@ -133,7 +133,7 @@ Lists and counts are what belongs here — the things no route resolved and no p
|
|
|
133
133
|
|
|
134
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:
|
|
135
135
|
|
|
136
|
-
```typescript
|
|
136
|
+
```typescript fragment
|
|
137
137
|
@expose async createPost(): Promise<void> {
|
|
138
138
|
await Post.create({ title: this.title, body: this.body });
|
|
139
139
|
this.title = "";
|
|
@@ -147,7 +147,7 @@ To force `onMount()` to re-run during a WebSocket action — for example after c
|
|
|
147
147
|
|
|
148
148
|
Runs on every WebSocket round-trip, immediately after state is restored from the snapshot. Use it to re-derive transient or protected state that wasn't persisted in the snapshot:
|
|
149
149
|
|
|
150
|
-
```typescript
|
|
150
|
+
```typescript fragment
|
|
151
151
|
export class PostEditorPage extends Component {
|
|
152
152
|
@expose post!: Post; // /posts/:post/edit — re-read from the row every round-trip
|
|
153
153
|
@transient wordCount = 0; // NOT persisted — derived again each time
|
|
@@ -178,7 +178,7 @@ are not part of a re-read, and anything derived from them.
|
|
|
178
178
|
|
|
179
179
|
Fires **before** a client-written property value is applied to the component. Throw to reject the write — the value is discarded, an error is added, and the component re-renders:
|
|
180
180
|
|
|
181
|
-
```typescript
|
|
181
|
+
```typescript fragment
|
|
182
182
|
override async onUpdating(prop: string, value: unknown, key?: string) {
|
|
183
183
|
// Prevent role escalation
|
|
184
184
|
if (prop === "role" && value === "super_admin") {
|
|
@@ -198,7 +198,7 @@ override async onUpdating(prop: string, value: unknown, key?: string) {
|
|
|
198
198
|
|
|
199
199
|
Fires **after** a client-written property is applied. Use it to normalise values, enforce computed side-effects, or trigger cascading updates:
|
|
200
200
|
|
|
201
|
-
```typescript
|
|
201
|
+
```typescript fragment
|
|
202
202
|
override async onUpdated(prop: string, value: unknown) {
|
|
203
203
|
if (prop === "categoryId") {
|
|
204
204
|
// When the category changes, reload the subcategories
|
|
@@ -212,7 +212,7 @@ override async onUpdated(prop: string, value: unknown) {
|
|
|
212
212
|
|
|
213
213
|
Instead of branching on `prop` inside `onUpdating`/`onUpdated`, define a per-property method named `onUpdating<PropName>` or `onUpdated<PropName>` (Pascal-cased). Flow calls it automatically and keeps the generic fallback as a catch-all:
|
|
214
214
|
|
|
215
|
-
```typescript
|
|
215
|
+
```typescript fragment
|
|
216
216
|
@expose username = "";
|
|
217
217
|
@expose email = "";
|
|
218
218
|
@expose tags: string[] = [];
|
|
@@ -243,7 +243,7 @@ The per-property form is cleaner and TypeScript-friendly: the parameter type mat
|
|
|
243
243
|
|
|
244
244
|
Runs once after the invoked action completes, before the render cycle. Use it to apply cross-cutting logic that should happen after any action:
|
|
245
245
|
|
|
246
|
-
```typescript
|
|
246
|
+
```typescript fragment
|
|
247
247
|
override async onUpdate() {
|
|
248
248
|
// Always log the current state to the audit trail after any action:
|
|
249
249
|
await AuditLog.create({
|
|
@@ -260,7 +260,7 @@ Unlike `onUpdated` (which fires per property, before the action), `onUpdate()` f
|
|
|
260
260
|
|
|
261
261
|
Runs immediately before `render()` on every request (initial and WebSocket). Use it for template-level setup that shouldn't be in `render()` itself — resolving shared view data, picking a layout variant, etc.:
|
|
262
262
|
|
|
263
|
-
```typescript
|
|
263
|
+
```typescript fragment
|
|
264
264
|
override async onRendering() {
|
|
265
265
|
// Decide which layout variant to use based on the user's subscription
|
|
266
266
|
if (this.user?.isPro) {
|
|
@@ -275,7 +275,7 @@ Avoid async database calls here unless truly necessary — `onMount()` and `onHy
|
|
|
275
275
|
|
|
276
276
|
Receives the rendered HTML string. Use it to post-process the output, measure render time, or send the HTML to a cache:
|
|
277
277
|
|
|
278
|
-
```typescript
|
|
278
|
+
```typescript fragment
|
|
279
279
|
override async onRendered(html: string) {
|
|
280
280
|
// Log very long renders for investigation
|
|
281
281
|
if (html.length > 100_000) {
|
|
@@ -293,7 +293,7 @@ The `html` parameter is the raw HTML of this component only — not the full pag
|
|
|
293
293
|
|
|
294
294
|
Runs just before the component state is serialised into the snapshot at the end of every request. Use it to strip sensitive or ephemeral state that shouldn't be persisted:
|
|
295
295
|
|
|
296
|
-
```typescript
|
|
296
|
+
```typescript fragment
|
|
297
297
|
override async onDehydrate() {
|
|
298
298
|
// Never persist raw upload paths between round-trips
|
|
299
299
|
this.tempUploadPath = null;
|
|
@@ -314,7 +314,7 @@ After `onDehydrate()`, the snapshot is signed and sent to the browser as an encr
|
|
|
314
314
|
|
|
315
315
|
Called when an `@expose`d action throws an unhandled error. The default behaviour flashes the error message with level `"error"`. Override to log to an error tracker or display a custom message:
|
|
316
316
|
|
|
317
|
-
```typescript
|
|
317
|
+
```typescript fragment
|
|
318
318
|
override async onError(error: Error) {
|
|
319
319
|
// Log to your error tracker
|
|
320
320
|
await Sentry.captureException(error, {
|
|
@@ -331,7 +331,7 @@ override async onError(error: Error) {
|
|
|
331
331
|
|
|
332
332
|
If you want some errors to propagate normally and only handle specific types:
|
|
333
333
|
|
|
334
|
-
```typescript
|
|
334
|
+
```typescript fragment
|
|
335
335
|
override async onError(error: Error) {
|
|
336
336
|
if (error instanceof DatabaseConnectionError) {
|
|
337
337
|
this.flash("Database is temporarily unavailable. Please try again.", "error");
|
package/docs/flow/models.md
CHANGED
|
@@ -28,7 +28,7 @@ binds to and how a model resolves by something other than its primary key.
|
|
|
28
28
|
|
|
29
29
|
The other way a model arrives is from a parent that already has it, as a prop:
|
|
30
30
|
|
|
31
|
-
```tsx
|
|
31
|
+
```tsx fragment
|
|
32
32
|
<PostCard post={this.post} />
|
|
33
33
|
```
|
|
34
34
|
|
|
@@ -53,7 +53,7 @@ one it edits.** Both put the model on the client; only `@expose` accepts anythin
|
|
|
53
53
|
The snapshot carries the model's **id** and the result of its `toJSON()` — the same
|
|
54
54
|
serialisation your API responses use, honouring `visible`, `hidden` and `appends`.
|
|
55
55
|
|
|
56
|
-
```ts
|
|
56
|
+
```ts fragment
|
|
57
57
|
@table("users")
|
|
58
58
|
export class User extends BaseModel {
|
|
59
59
|
static fillable = ["name", "email", "password"];
|
|
@@ -98,7 +98,7 @@ server error. To refuse a value loudly instead, or to vet one before it lands, t
|
|
|
98
98
|
|
|
99
99
|
Bind to a field of an `@expose`d model and it is two-way:
|
|
100
100
|
|
|
101
|
-
```tsx
|
|
101
|
+
```tsx fragment
|
|
102
102
|
export class EditProfile extends Component {
|
|
103
103
|
@expose user!: User;
|
|
104
104
|
|
|
@@ -149,7 +149,7 @@ and fails whatever the field actually holds. Two ways round it:
|
|
|
149
149
|
the difference: fillable because a user sets it, hidden because the stored hash must never
|
|
150
150
|
reach the page.
|
|
151
151
|
|
|
152
|
-
```tsx
|
|
152
|
+
```tsx fragment
|
|
153
153
|
<input type="password" value={this.user.password} blur />
|
|
154
154
|
```
|
|
155
155
|
|
|
@@ -166,7 +166,7 @@ listed in the model's `hashable` — see [Password hashing](/docs/orm#password-h
|
|
|
166
166
|
A relation that is loaded when the page renders travels with the model, through its own
|
|
167
167
|
`toJSON()`. It does not survive the round-trip:
|
|
168
168
|
|
|
169
|
-
```tsx
|
|
169
|
+
```tsx fragment
|
|
170
170
|
override async onMount(): Promise<void> {
|
|
171
171
|
await this.post.loadMissing(["author"]); // this.post came from the route
|
|
172
172
|
}
|
|
@@ -178,7 +178,7 @@ throws the ORM's guard: `Relation "author" was accessed on Post without eager lo
|
|
|
178
178
|
|
|
179
179
|
Load what an action needs, where it needs it:
|
|
180
180
|
|
|
181
|
-
```ts
|
|
181
|
+
```ts fragment
|
|
182
182
|
@expose async approve(): Promise<void> {
|
|
183
183
|
await this.post.loadMissing(["author"]);
|
|
184
184
|
this.post.approved = true;
|
|
@@ -192,7 +192,7 @@ When the page _displays_ the relation, load it once per round-trip in
|
|
|
192
192
|
the case worth watching: a template reading `this.post.author.name` works on the first paint
|
|
193
193
|
and throws on every interaction after it.
|
|
194
194
|
|
|
195
|
-
```ts
|
|
195
|
+
```ts fragment
|
|
196
196
|
override async onHydrate(): Promise<void> {
|
|
197
197
|
await this.post.loadMissing(["author"]);
|
|
198
198
|
}
|
|
@@ -221,7 +221,7 @@ deleted — or soft-deleted — while a page holds it makes the next interaction
|
|
|
221
221
|
patched, and the browser console carries the error. So follow a delete with a navigation
|
|
222
222
|
rather than leaving the prop pointing at a row that is gone:
|
|
223
223
|
|
|
224
|
-
```ts
|
|
224
|
+
```ts fragment
|
|
225
225
|
@expose async destroy(): Promise<void> {
|
|
226
226
|
await this.post.delete();
|
|
227
227
|
this.redirect("/posts");
|
|
@@ -232,7 +232,7 @@ rather than leaving the prop pointing at a row that is gone:
|
|
|
232
232
|
|
|
233
233
|
An array of models is sent as ids and re-read with a single `whereIn` query:
|
|
234
234
|
|
|
235
|
-
```tsx
|
|
235
|
+
```tsx fragment
|
|
236
236
|
@locked posts: Post[] = [];
|
|
237
237
|
```
|
|
238
238
|
|
package/docs/flow/pagination.md
CHANGED
|
@@ -11,7 +11,7 @@ Paginate in the database with `Model.paginate(perPage)` — it returns the page
|
|
|
11
11
|
|
|
12
12
|
For arrays already held in memory, `paginate(items, page, perPage)` slices the data and returns a rich paginator object with metadata and a windowed page list:
|
|
13
13
|
|
|
14
|
-
```typescript
|
|
14
|
+
```typescript fragment
|
|
15
15
|
import { paginate } from "@zerotal/flow";
|
|
16
16
|
|
|
17
17
|
export class PostsPage extends Component {
|
|
@@ -89,7 +89,7 @@ export class PostsPage extends Component {
|
|
|
89
89
|
|
|
90
90
|
`Pagination` is a class mixin that adds page state, URL sync, and navigation methods automatically. Compose it with [`Component.using(...)`](/docs/flow/layouts#composing-behaviour-with-mixins).
|
|
91
91
|
|
|
92
|
-
```tsx
|
|
92
|
+
```tsx fragment
|
|
93
93
|
import { Component, Pagination, Pager } from "@zerotal/flow";
|
|
94
94
|
|
|
95
95
|
export class PostsPage extends Component.using(Pagination) {
|
|
@@ -137,7 +137,7 @@ Optional update hooks fire around a page change: define any of `updatingPage(pag
|
|
|
137
137
|
|
|
138
138
|
Always call `this.resetPage()` when a filter changes — otherwise the current page may exceed the new total and return an empty result set:
|
|
139
139
|
|
|
140
|
-
```typescript
|
|
140
|
+
```typescript fragment
|
|
141
141
|
@url search = "";
|
|
142
142
|
@url status = "all";
|
|
143
143
|
|
|
@@ -187,7 +187,7 @@ For large datasets, avoid loading all rows in `onMount()`. Paginate in the datab
|
|
|
187
187
|
|
|
188
188
|
`Model.paginate(perPage)` returns the page the request is on. Compose the mixin, query in `render()`, and there is no page to pass, no state to hold, and nothing to refresh — a page change re-renders, and the re-render re-queries:
|
|
189
189
|
|
|
190
|
-
```tsx
|
|
190
|
+
```tsx fragment
|
|
191
191
|
export class PostsPage extends Component.using(Pagination) {
|
|
192
192
|
override async render() {
|
|
193
193
|
const posts = await Post.paginate(10); // this component's page
|
|
@@ -223,14 +223,14 @@ Outside a component, `paginate()` reads `?page=` from the query string — what
|
|
|
223
223
|
|
|
224
224
|
Pass the page explicitly when it isn't the request's — a report job, a fixed first page, a second paginator driven by something other than the mixin:
|
|
225
225
|
|
|
226
|
-
```typescript
|
|
226
|
+
```typescript fragment
|
|
227
227
|
const first = await Post.paginate(10, 1); // always page 1
|
|
228
228
|
const invoices = await Invoice.paginate(10, undefined, "invoices"); // a named paginator
|
|
229
229
|
```
|
|
230
230
|
|
|
231
231
|
The query builder takes the same arguments when you need to build the query up first. Keep the **result** on the component — it already carries the page, the total, the last page, and the URL helpers, so there is nothing to copy out of it:
|
|
232
232
|
|
|
233
|
-
```typescript
|
|
233
|
+
```typescript fragment
|
|
234
234
|
export class PostsPage extends Component.using(Pagination) {
|
|
235
235
|
@url search = "";
|
|
236
236
|
@url status = "all";
|
|
@@ -291,7 +291,7 @@ The ORM's `.paginate()` issues two queries — a `COUNT(*)` for the total and a
|
|
|
291
291
|
|
|
292
292
|
Use the `<InfiniteScroll>` component to load more pages as the user scrolls down, without explicit page navigation:
|
|
293
293
|
|
|
294
|
-
```tsx
|
|
294
|
+
```tsx fragment
|
|
295
295
|
import { InfiniteScroll } from "@zerotal/flow";
|
|
296
296
|
|
|
297
297
|
export class FeedPage extends Component {
|
|
@@ -345,7 +345,7 @@ export class FeedPage extends Component {
|
|
|
345
345
|
|
|
346
346
|
For very large tables where `OFFSET` pagination is slow, use cursor-based pagination via the ORM. The cursor encodes the last-seen row's sort key and is more efficient for deep pages:
|
|
347
347
|
|
|
348
|
-
```typescript
|
|
348
|
+
```typescript fragment
|
|
349
349
|
export class ActivityPage extends Component {
|
|
350
350
|
@url cursor: string | null = null;
|
|
351
351
|
@locked items: Activity[] = [];
|
package/docs/flow/performance.md
CHANGED
|
@@ -74,7 +74,7 @@ paying for a navigation you could have paid for during the hover. Flow has a sma
|
|
|
74
74
|
|
|
75
75
|
`<Skeleton>` is a pulsing placeholder block — pure markup plus a bundled animation, so it needs no app CSS. Use it for the shape of content that hasn't loaded yet:
|
|
76
76
|
|
|
77
|
-
```tsx
|
|
77
|
+
```tsx fragment
|
|
78
78
|
import { Skeleton } from "@zerotal/flow";
|
|
79
79
|
|
|
80
80
|
<Skeleton height="1.5rem" width="60%" /> {/* one bar */}
|
|
@@ -84,7 +84,7 @@ import { Skeleton } from "@zerotal/flow";
|
|
|
84
84
|
|
|
85
85
|
The most useful place is a lazy child's `placeholder()` — what shows while the component mounts on viewport entry — and inside a `<Loading>` region (`<Loading skeleton />` renders one for you):
|
|
86
86
|
|
|
87
|
-
```tsx
|
|
87
|
+
```tsx fragment
|
|
88
88
|
export class ChartWidget extends Component {
|
|
89
89
|
override placeholder() {
|
|
90
90
|
return <Skeleton height="12rem" rounded="0.75rem" />;
|
|
@@ -101,7 +101,7 @@ Tune the tone with the `--flow-skeleton-color` CSS variable; the pulse respects
|
|
|
101
101
|
|
|
102
102
|
Because state is a server-authoritative snapshot, optimistic UI and its rollback are already built in. A client expression that changes a prop and then calls an action applies the change **instantly**; when the server responds, the authoritative snapshot reconciles the prop — so if the server **rejects** the change, it snaps back on its own. There is no separate optimistic library and no compensation code to write:
|
|
103
103
|
|
|
104
|
-
```tsx
|
|
104
|
+
```tsx fragment
|
|
105
105
|
@expose liked = false;
|
|
106
106
|
@expose confirmLike(): void { /* … persist … */ }
|
|
107
107
|
|
|
@@ -124,7 +124,7 @@ Optimistic UI for a scalar is easy (flip a bool, reconcile on the patch). A **li
|
|
|
124
124
|
|
|
125
125
|
**`<For>` — a reactive list.** Render the list with `<For>` and it compiles to an Alpine `x-for`, so any client change to the array re-renders it instantly (server patches still keep it authoritative):
|
|
126
126
|
|
|
127
|
-
```tsx
|
|
127
|
+
```tsx fragment
|
|
128
128
|
import { For } from "@zerotal/flow";
|
|
129
129
|
|
|
130
130
|
<For each={this.todos} keyBy="id">
|
|
@@ -141,7 +141,7 @@ The item template supports element structure, static attributes, `class`/`classN
|
|
|
141
141
|
|
|
142
142
|
**`appendOptimistic` / `removeOptimistic` — instant add/remove with rollback.** Mutate the array optimistically, then dispatch the action that persists it:
|
|
143
143
|
|
|
144
|
-
```tsx
|
|
144
|
+
```tsx fragment
|
|
145
145
|
<button
|
|
146
146
|
onClick={() => {
|
|
147
147
|
$flow.appendOptimistic("todos", { id: `tmp-${Date.now()}`, text: this.draft });
|
|
@@ -170,7 +170,7 @@ mirror image for deletes.
|
|
|
170
170
|
|
|
171
171
|
Add `hover` to a `navigate` link and Flow fetches the target after a brief hover dwell (and on first touch on mobile), caching the HTML so the click swaps instantly instead of waiting on a cold request:
|
|
172
172
|
|
|
173
|
-
```tsx
|
|
173
|
+
```tsx fragment
|
|
174
174
|
<a href="/posts" navigate hover>
|
|
175
175
|
Posts
|
|
176
176
|
</a>;
|
|
@@ -251,7 +251,7 @@ A multi-step form, a long editor, or any in-progress flow then resumes
|
|
|
251
251
|
**exactly** — whether the user reloads the page, closes and reopens the tab, or
|
|
252
252
|
switches device. Nothing is stored in the browser:
|
|
253
253
|
|
|
254
|
-
```tsx
|
|
254
|
+
```tsx fragment
|
|
255
255
|
export class Wizard extends Component {
|
|
256
256
|
static durable = true; // or { ttl: "1h", scope: "user" | "session" }
|
|
257
257
|
|
package/docs/flow/references.md
CHANGED
|
@@ -129,7 +129,7 @@ So Flow re-derives instead. Any component left mid-action is refreshed from the
|
|
|
129
129
|
|
|
130
130
|
The refresh is announced first, so you can say something rather than have the UI change under the user:
|
|
131
131
|
|
|
132
|
-
```ts
|
|
132
|
+
```ts fragment
|
|
133
133
|
document.addEventListener("flow:desync", (e) => {
|
|
134
134
|
const { components } = (e as CustomEvent<{ components: string[] }>).detail;
|
|
135
135
|
toast(`Reconnected — refreshing ${components.length} component(s).`);
|
|
@@ -156,7 +156,7 @@ Mark a container with `onSort` (the reorder action) and each child with `sortIte
|
|
|
156
156
|
| `sortIgnore` | Exclude this child from dragging/reordering | `flow:sort:ignore` |
|
|
157
157
|
| `sortGroup="tasks"` | Allow dragging between containers sharing the group name | `flow:sort:group` |
|
|
158
158
|
|
|
159
|
-
```tsx
|
|
159
|
+
```tsx fragment
|
|
160
160
|
@expose async reorder(key: string, index: number) {
|
|
161
161
|
const moved = this.items.find((i) => String(i.id) === key);
|
|
162
162
|
if (!moved) return;
|
|
@@ -272,7 +272,7 @@ A write syncs to the server after the expression finishes, unless the same expre
|
|
|
272
272
|
|
|
273
273
|
`$flow.store` is app-wide UI state that shouldn't round-trip to the server — side-panel visibility, colour scheme, notification drawer, etc. Declare its shape with `defineStore(...)` at app start and type it by augmenting `FlowStore` (see [The global client store](/docs/flow#the-global-client-store)):
|
|
274
274
|
|
|
275
|
-
```tsx
|
|
275
|
+
```tsx fragment
|
|
276
276
|
<button onClick={() => ($flow.store.ui.sidebarOpen = true)}>Open sidebar</button>
|
|
277
277
|
<aside show={$flow.store.ui.sidebarOpen}>Sidebar content</aside>
|
|
278
278
|
```
|