@zerotal/arch 1.7.4 → 1.8.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 +86 -2
- package/README.md +4 -2
- package/api-surface.md +2 -0
- 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 +268 -1
- package/docs/client/index.md +13 -13
- package/docs/commands.md +36 -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 +99 -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 +21 -21
- package/docs/flow/layouts.md +99 -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 +29 -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/config.ts +8 -0
- package/src/index.ts +25 -1
- package/src/install/ArchInstallCommand.ts +46 -3
- package/src/install/guidelines.ts +84 -0
- package/src/install/shape.ts +177 -0
- package/src/install/skills.ts +271 -0
- package/src/provider/ArchProvider.ts +148 -0
- package/src/tools/searchDocs.ts +7 -0
package/docs/flow/decorators.md
CHANGED
|
@@ -16,7 +16,7 @@ Marks a property or method as part of the public contract with the browser.
|
|
|
16
16
|
- **On a property** — the value is included in the signed snapshot and synced to the client on every patch. The client can update it via a bound `value`/`checked` attribute or a client expression like `onClick={() => this.count++}`.
|
|
17
17
|
- **On a method** — makes it callable from the browser over the WebSocket via event bindings like `onClick={this.save}`.
|
|
18
18
|
|
|
19
|
-
```typescript
|
|
19
|
+
```typescript fragment
|
|
20
20
|
import { expose } from "@zerotal/flow";
|
|
21
21
|
|
|
22
22
|
export class CounterPage extends Component {
|
|
@@ -48,7 +48,7 @@ A `value={this.x}` binding on a `@locked` property renders as a read-only displa
|
|
|
48
48
|
|
|
49
49
|
Use `@locked` for anything the server owns: a record the route resolved, results loaded in `onMount()`, computed totals, child props from the parent:
|
|
50
50
|
|
|
51
|
-
```tsx
|
|
51
|
+
```tsx fragment
|
|
52
52
|
export class PostsPage extends Component {
|
|
53
53
|
@locked user!: User; // /users/:user — the record, already found
|
|
54
54
|
@locked posts: Post[] = [];
|
|
@@ -87,7 +87,7 @@ with no arguments, and used for [real-time validation](#real-time-validation) on
|
|
|
87
87
|
|
|
88
88
|
Combine with `@expose` for a property that is both two-way bound and validated:
|
|
89
89
|
|
|
90
|
-
```typescript
|
|
90
|
+
```typescript fragment
|
|
91
91
|
@expose @validate((rule) => rule.required().email()) email: string = "";
|
|
92
92
|
@expose @validate((rule) => rule.required().min(8)) password: string = "";
|
|
93
93
|
@expose @validate((rule) => rule.required().min(2).max(50)) name: string = "";
|
|
@@ -102,7 +102,7 @@ Combine with `@expose` for a property that is both two-way bound and validated:
|
|
|
102
102
|
|
|
103
103
|
The validation runs when you call `this.validate()` inside an action:
|
|
104
104
|
|
|
105
|
-
```typescript
|
|
105
|
+
```typescript fragment
|
|
106
106
|
@expose async register(): Promise<void> {
|
|
107
107
|
await this.validate(); // reads all @validate rules on the class
|
|
108
108
|
|
|
@@ -120,7 +120,7 @@ The validation runs when you call `this.validate()` inside an action:
|
|
|
120
120
|
|
|
121
121
|
You can also pass rules directly to `this.validate()` — they override the decorator-based rules:
|
|
122
122
|
|
|
123
|
-
```typescript
|
|
123
|
+
```typescript fragment
|
|
124
124
|
@expose async update(): Promise<void> {
|
|
125
125
|
await this.validate({
|
|
126
126
|
email: (rule) => rule.required().email(),
|
|
@@ -142,7 +142,7 @@ as the user edits, with no action call and without affecting any other field.
|
|
|
142
142
|
> form is not writable in TSX: the `.` in an attribute name is a parse error (`TS1003`), so
|
|
143
143
|
> copying it out of the emitted HTML into a component will not build.
|
|
144
144
|
|
|
145
|
-
```tsx
|
|
145
|
+
```tsx fragment
|
|
146
146
|
@expose @validate((rule) => rule.required().email()) email = "";
|
|
147
147
|
|
|
148
148
|
async render() {
|
|
@@ -165,7 +165,7 @@ See [Forms & Validation](/docs/flow/forms) for the full validation rules referen
|
|
|
165
165
|
|
|
166
166
|
Syncs a property to the browser URL query string. Initialised from the query string on the initial page load; updated in the URL on every patch. The URL becomes shareable and bookmark-friendly automatically:
|
|
167
167
|
|
|
168
|
-
```typescript
|
|
168
|
+
```typescript fragment
|
|
169
169
|
// Page and search stay in the URL: /posts?page=2&search=TypeScript
|
|
170
170
|
@url page: number = 1;
|
|
171
171
|
@url search: string = "";
|
|
@@ -189,14 +189,14 @@ Binds a property to the HTTP session, so the value survives a browser refresh. R
|
|
|
189
189
|
|
|
190
190
|
The field reads and writes the session key of the same name — the same value a controller or another component sees:
|
|
191
191
|
|
|
192
|
-
```typescript
|
|
192
|
+
```typescript fragment
|
|
193
193
|
@session userId: string = ""; // the session's `userId`
|
|
194
194
|
@session preferredTheme: string = "light";
|
|
195
195
|
```
|
|
196
196
|
|
|
197
197
|
Pass options to change the key, or to keep it to this component:
|
|
198
198
|
|
|
199
|
-
```typescript
|
|
199
|
+
```typescript fragment
|
|
200
200
|
// Read a differently-named key
|
|
201
201
|
@session({ key: "s" }) whatever: number = 0;
|
|
202
202
|
|
|
@@ -216,7 +216,7 @@ A `@session` field is **not** in the WebSocket snapshot: it is read from and wri
|
|
|
216
216
|
|
|
217
217
|
A getter derived from other state. Not stored in the snapshot — recomputed on every render pass. The result is **memoized for the duration of a single render**, so an expensive getter read multiple times in the same template runs only once:
|
|
218
218
|
|
|
219
|
-
```typescript
|
|
219
|
+
```typescript fragment
|
|
220
220
|
@computed get fullName(): string {
|
|
221
221
|
return `${this.firstName} ${this.lastName}`;
|
|
222
222
|
}
|
|
@@ -241,7 +241,7 @@ Use them freely in a template as a text child — `{this.fullName}` — where th
|
|
|
241
241
|
|
|
242
242
|
Excludes a property from the snapshot entirely. Reset to its class-level default on every WebSocket round-trip. Use for ephemeral UI state that shouldn't persist between server calls:
|
|
243
243
|
|
|
244
|
-
```typescript
|
|
244
|
+
```typescript fragment
|
|
245
245
|
@transient isUploading: boolean = false;
|
|
246
246
|
@transient dropzoneActive: boolean = false;
|
|
247
247
|
@transient tempMessage: string = "";
|
|
@@ -254,7 +254,7 @@ The pattern: start an upload, set `this.isUploading = true`, send a response. On
|
|
|
254
254
|
|
|
255
255
|
An `@expose`d method that runs on the server but **skips the re-render cycle**. Use for side-effects that don't change the UI: file downloads, external API calls, jobs that just need to fire:
|
|
256
256
|
|
|
257
|
-
```typescript
|
|
257
|
+
```typescript fragment
|
|
258
258
|
@expose @renderless async exportCsv(): Promise<void> {
|
|
259
259
|
const rows = await Report.all();
|
|
260
260
|
const csv = rows.map((r) => `${r.id},${r.name},${r.email}`).join("\n");
|
|
@@ -282,7 +282,7 @@ Because the render cycle is skipped, `@renderless` actions are faster and cheape
|
|
|
282
282
|
|
|
283
283
|
Registers a method as a listener for cross-component events dispatched via `this.dispatch()`. The method is implicitly exposed — no separate `@expose` needed:
|
|
284
284
|
|
|
285
|
-
```typescript
|
|
285
|
+
```typescript fragment
|
|
286
286
|
@on("post-created")
|
|
287
287
|
async handlePostCreated(data: { id: number; title: string }): Promise<void> {
|
|
288
288
|
this.posts = await Post.query().orderBy("created_at", "desc").limit(10).get();
|
|
@@ -319,7 +319,7 @@ For child components — marks a prop the parent can re-push whenever its value
|
|
|
319
319
|
|
|
320
320
|
Declare the reactive prop on the child:
|
|
321
321
|
|
|
322
|
-
```tsx
|
|
322
|
+
```tsx fragment
|
|
323
323
|
export class PriceTag extends Component {
|
|
324
324
|
@reactive currency = "USD";
|
|
325
325
|
@reactive amount = 0;
|
|
@@ -339,7 +339,7 @@ export class PriceTag extends Component {
|
|
|
339
339
|
|
|
340
340
|
Pass the prop from the parent:
|
|
341
341
|
|
|
342
|
-
```tsx
|
|
342
|
+
```tsx fragment
|
|
343
343
|
// When this.currency changes, PriceTag re-renders automatically:
|
|
344
344
|
<PriceTag currency={this.currency} amount={this.subtotal} />
|
|
345
345
|
```
|
|
@@ -350,7 +350,7 @@ Unlike `@locked`, `@reactive` props are **live** — the parent keeps them curre
|
|
|
350
350
|
|
|
351
351
|
A reactive prop that **also syncs back to the parent** (two-way). The parent property and the child prop stay in lock-step. Use for reusable input/control components that need to write a value back to their parent:
|
|
352
352
|
|
|
353
|
-
```tsx
|
|
353
|
+
```tsx fragment
|
|
354
354
|
export class StarRating extends Component {
|
|
355
355
|
@modelable rating: number = 0; // two-way bound to parent
|
|
356
356
|
|
|
@@ -409,7 +409,7 @@ See [Layouts & Composition](/docs/flow/layouts#two-way-props) for the full `@mod
|
|
|
409
409
|
|
|
410
410
|
These helpers are available inside any `@expose`d method:
|
|
411
411
|
|
|
412
|
-
```typescript
|
|
412
|
+
```typescript fragment
|
|
413
413
|
// Flash a toast notification to the browser
|
|
414
414
|
this.flash("Saved successfully!"); // level defaults to "success"
|
|
415
415
|
this.flash("Could not connect.", "error");
|
|
@@ -442,7 +442,7 @@ this.download("export.json", JSON.stringify(data, null, 2), "application/json");
|
|
|
442
442
|
|
|
443
443
|
`this.errors` is a typed proxy over the validation error bag. It is populated by `this.validate()` and `this.addError()`.
|
|
444
444
|
|
|
445
|
-
```tsx
|
|
445
|
+
```tsx fragment
|
|
446
446
|
// Check if any validation errors exist
|
|
447
447
|
if (this.errors.any()) {
|
|
448
448
|
return; // stop processing
|
package/docs/flow/events.md
CHANGED
|
@@ -11,7 +11,7 @@ Flow components communicate by dispatching named events. Listeners registered wi
|
|
|
11
11
|
|
|
12
12
|
Call `this.dispatch()` inside any `@expose`d action. The event is delivered to all matching `@on` listeners on the page after the action completes:
|
|
13
13
|
|
|
14
|
-
```typescript
|
|
14
|
+
```typescript fragment
|
|
15
15
|
@expose async save(): Promise<void> {
|
|
16
16
|
const post = await Post.create({
|
|
17
17
|
title: this.title,
|
|
@@ -27,7 +27,7 @@ Call `this.dispatch()` inside any `@expose`d action. The event is delivered to a
|
|
|
27
27
|
|
|
28
28
|
The second argument is the payload — any JSON-serialisable value. Omit it for events that carry no data:
|
|
29
29
|
|
|
30
|
-
```typescript
|
|
30
|
+
```typescript fragment
|
|
31
31
|
this.dispatch("cart-cleared");
|
|
32
32
|
```
|
|
33
33
|
|
|
@@ -35,7 +35,7 @@ this.dispatch("cart-cleared");
|
|
|
35
35
|
|
|
36
36
|
By default, `dispatch()` notifies every `@on` listener on the page. Use `dispatchTo` or `dispatchSelf` to narrow the target:
|
|
37
37
|
|
|
38
|
-
```typescript
|
|
38
|
+
```typescript fragment
|
|
39
39
|
// Default: all @on("post-created") listeners on the page
|
|
40
40
|
this.dispatch("post-created", { id });
|
|
41
41
|
|
|
@@ -52,7 +52,7 @@ this.dispatchSelf("refresh");
|
|
|
52
52
|
|
|
53
53
|
Register a method as an event listener with `@on`. The method is **implicitly exposed** — no `@expose` needed:
|
|
54
54
|
|
|
55
|
-
```typescript
|
|
55
|
+
```typescript fragment
|
|
56
56
|
import { on } from "@zerotal/flow";
|
|
57
57
|
|
|
58
58
|
export class PostList extends Component {
|
|
@@ -95,7 +95,7 @@ A component can have as many `@on` listeners as it needs. Each fires independent
|
|
|
95
95
|
|
|
96
96
|
Several components on the same page can all listen for the same event. Each component is updated independently — Flow sends a separate patch frame to each listener:
|
|
97
97
|
|
|
98
|
-
```typescript
|
|
98
|
+
```typescript fragment
|
|
99
99
|
// PostList.tsx
|
|
100
100
|
@on("post-created")
|
|
101
101
|
async onPostCreated(data: { id: number }) {
|
|
@@ -121,7 +121,7 @@ All three components update after a single `this.dispatch("post-created", ...)`
|
|
|
121
121
|
|
|
122
122
|
The same `dispatch`, `dispatchTo`, and `dispatchSelf` methods work inside **client expressions** — no server round-trip needed to start the dispatch. The `@on` listeners still run server-side when they're notified:
|
|
123
123
|
|
|
124
|
-
```tsx
|
|
124
|
+
```tsx fragment
|
|
125
125
|
{
|
|
126
126
|
/* Notify all listeners without a preceding server action */
|
|
127
127
|
}
|
|
@@ -158,7 +158,7 @@ declare module "@zerotal/flow" {
|
|
|
158
158
|
|
|
159
159
|
Every `dispatch` / `dispatchTo` / `dispatchSelf` site is now checked against it — in server actions **and** client expressions, since they call the same typed method:
|
|
160
160
|
|
|
161
|
-
```ts
|
|
161
|
+
```ts fragment
|
|
162
162
|
this.dispatch("post-created", { id: post.id, title: post.title }); // ✓
|
|
163
163
|
this.dispatch("post-created", { id: post.id }); // ✗ missing `title`
|
|
164
164
|
this.dispatch("post-created"); // ✗ payload required
|
|
@@ -168,7 +168,7 @@ this.dispatch("cart-cleared", { anything: 1 }); // ✗ void takes no payload
|
|
|
168
168
|
|
|
169
169
|
On the listener side, `@on` autocompletes to the known event names, and you annotate the handler's parameter with `EventPayload<K>` to type the payload against the same contract:
|
|
170
170
|
|
|
171
|
-
```ts
|
|
171
|
+
```ts fragment
|
|
172
172
|
import { on } from "@zerotal/flow";
|
|
173
173
|
import type { EventPayload } from "@zerotal/flow";
|
|
174
174
|
|
|
@@ -235,7 +235,7 @@ Listen for server-broadcast events over WebSockets with `@on("socket:…")`. Whe
|
|
|
235
235
|
|
|
236
236
|
The socket client is bundled into the Flow runtime and created the first time a page declares one of these listeners, so there is no script to add and nothing to publish on `window`. An app that needs a configured client — a different host, its own auth endpoint — assigns `window.Socket` before the runtime loads and that one is used instead. Pages with no such listener open no broadcast connection at all.
|
|
237
237
|
|
|
238
|
-
```typescript
|
|
238
|
+
```typescript fragment
|
|
239
239
|
export class OrderDashboard extends Component {
|
|
240
240
|
@locked orderCount: number = 0;
|
|
241
241
|
@locked recentOrders: Order[] = [];
|
|
@@ -299,7 +299,7 @@ Pass a resolver instead. It is called with the component when the snapshot is bu
|
|
|
299
299
|
[`@presence`](#presence--whos-here-multiplayer) and [`@shared`](#shared-state--everyone-converges-multiplayer)
|
|
300
300
|
resolve theirs:
|
|
301
301
|
|
|
302
|
-
```typescript
|
|
302
|
+
```typescript fragment
|
|
303
303
|
export class IssuePage extends Component {
|
|
304
304
|
@locked issue!: Issue;
|
|
305
305
|
@locked comments: Comment[] = [];
|
|
@@ -326,7 +326,7 @@ Broadcasting requires a global `window.Socket` client configured by your applica
|
|
|
326
326
|
|
|
327
327
|
If `window.Socket` is not present, all `socket:` listeners are silently inert — no errors, no subscriptions attempted.
|
|
328
328
|
|
|
329
|
-
```typescript
|
|
329
|
+
```typescript fragment
|
|
330
330
|
// In your frontend bootstrap (app.ts or similar):
|
|
331
331
|
import { Socket } from "@zerotal/client";
|
|
332
332
|
|
|
@@ -339,7 +339,7 @@ window.Socket = new Socket();
|
|
|
339
339
|
|
|
340
340
|
`@presence` binds a property to a broadcast **presence channel** and keeps it filled with the live member list — the framework joins the channel, seeds the list, and refreshes it as people join and leave. No event classes, no manual `@on` wiring:
|
|
341
341
|
|
|
342
|
-
```tsx
|
|
342
|
+
```tsx fragment
|
|
343
343
|
import { Component, presence } from "@zerotal/flow";
|
|
344
344
|
import type { PresenceMember } from "@zerotal/flow";
|
|
345
345
|
|
|
@@ -362,7 +362,7 @@ export class Board extends Component {
|
|
|
362
362
|
|
|
363
363
|
The channel is resolved on the server from the component (so it can't be forged from the client) and carried, signed, in the snapshot. `who` is server-controlled (like `@locked`): it lives in the snapshot and the client can't write it. Authorize the channel — and shape the member data — in `routes/channels.ts`:
|
|
364
364
|
|
|
365
|
-
```ts
|
|
365
|
+
```ts fragment
|
|
366
366
|
Broadcast.channel("board.[boardId]", (user, boardId) =>
|
|
367
367
|
user.canView(boardId) ? { id: user.id, name: user.name } : null,
|
|
368
368
|
);
|
|
@@ -385,7 +385,7 @@ Like all `socket:` features, presence needs a `window.Socket` client configured
|
|
|
385
385
|
|
|
386
386
|
Where `@presence` answers _who's here_, `@shared` answers _what do we all see_. It binds a property to convergent, **server-authoritative** state on a channel: mutate it in an action and the framework writes it to a per-channel **room store** and broadcasts to the channel, so every other subscriber re-reads and converges. No store to wire, no events, no dispatch:
|
|
387
387
|
|
|
388
|
-
```tsx
|
|
388
|
+
```tsx fragment
|
|
389
389
|
import { Component, presence, shared, expose } from "@zerotal/flow";
|
|
390
390
|
|
|
391
391
|
export class Board extends Component {
|
|
@@ -416,7 +416,7 @@ Broadcasting is an **optional peer**. With `window.Socket` and `BroadcastProvide
|
|
|
416
416
|
|
|
417
417
|
Calling `this.refresh()` inside an action inserts `onMount()` back into the WebSocket round-trip cycle:
|
|
418
418
|
|
|
419
|
-
```typescript
|
|
419
|
+
```typescript fragment
|
|
420
420
|
@expose async syncOrders(): Promise<void> {
|
|
421
421
|
await OrderSync.run();
|
|
422
422
|
this.refresh(); // onMount() re-runs → this.orders is freshly loaded
|
|
@@ -434,7 +434,7 @@ Calling `this.refresh()` inside an action inserts `onMount()` back into the WebS
|
|
|
434
434
|
|
|
435
435
|
In the browser, `this.refresh()` sends a lightweight re-render request with no data change — useful for polling or a manual reload button:
|
|
436
436
|
|
|
437
|
-
```tsx
|
|
437
|
+
```tsx fragment
|
|
438
438
|
{
|
|
439
439
|
/* Manual reload button */
|
|
440
440
|
}
|
package/docs/flow/forms.md
CHANGED
|
@@ -11,7 +11,7 @@ Validate input, bind form fields to component state with two-way binding, encaps
|
|
|
11
11
|
|
|
12
12
|
Attach rules with `@validate` on individual fields, or pass them explicitly to `this.validate()`:
|
|
13
13
|
|
|
14
|
-
```typescript
|
|
14
|
+
```typescript fragment
|
|
15
15
|
import { expose, validate } from "@zerotal/flow";
|
|
16
16
|
|
|
17
17
|
@expose @validate((rule) => rule.required().email()) email: string = "";
|
|
@@ -29,7 +29,7 @@ builder (`rule.number()`, …). The same `@validate` rule also powers
|
|
|
29
29
|
|
|
30
30
|
Call `this.validate()` at the start of your action. It reads the `@validate` rules and throws a `ValidationError` if any fail — Flow catches it, populates `this.errors`, and re-renders:
|
|
31
31
|
|
|
32
|
-
```typescript
|
|
32
|
+
```typescript fragment
|
|
33
33
|
@expose async register(): Promise<void> {
|
|
34
34
|
await this.validate(); // uses @validate rules declared on the class
|
|
35
35
|
|
|
@@ -46,7 +46,7 @@ Call `this.validate()` at the start of your action. It reads the `@validate` rul
|
|
|
46
46
|
|
|
47
47
|
Pass explicit rules to override or extend `@validate`:
|
|
48
48
|
|
|
49
|
-
```typescript
|
|
49
|
+
```typescript fragment
|
|
50
50
|
@expose async update(): Promise<void> {
|
|
51
51
|
await this.validate({
|
|
52
52
|
email: (rule) => rule.required().email(),
|
|
@@ -83,7 +83,7 @@ Pass explicit rules to override or extend `@validate`:
|
|
|
83
83
|
|
|
84
84
|
Pass a field off `this.errors` to the `error` prop. It renders the first message for that field and hides itself when the field is valid — no manual show/hide logic:
|
|
85
85
|
|
|
86
|
-
```tsx
|
|
86
|
+
```tsx fragment
|
|
87
87
|
<input value={this.email} />
|
|
88
88
|
<span error={this.errors.email} class="text-sm text-red-500" />
|
|
89
89
|
|
|
@@ -93,7 +93,7 @@ Pass a field off `this.errors` to the `error` prop. It renders the first message
|
|
|
93
93
|
|
|
94
94
|
Use the `<Field>` component for accessible label + error wiring:
|
|
95
95
|
|
|
96
|
-
```tsx
|
|
96
|
+
```tsx fragment
|
|
97
97
|
import { Field } from "@zerotal/flow";
|
|
98
98
|
|
|
99
99
|
<Field label="Email" error={this.errors.email}>
|
|
@@ -107,7 +107,7 @@ import { Field } from "@zerotal/flow";
|
|
|
107
107
|
|
|
108
108
|
Show all errors at once with `<Errors>`:
|
|
109
109
|
|
|
110
|
-
```tsx
|
|
110
|
+
```tsx fragment
|
|
111
111
|
import { Errors } from "@zerotal/flow";
|
|
112
112
|
|
|
113
113
|
<Errors /> {/* all current errors */}
|
|
@@ -116,7 +116,7 @@ import { Errors } from "@zerotal/flow";
|
|
|
116
116
|
|
|
117
117
|
### Checking errors in server code
|
|
118
118
|
|
|
119
|
-
```typescript
|
|
119
|
+
```typescript fragment
|
|
120
120
|
// Check if any errors exist
|
|
121
121
|
if (this.errors.any()) {
|
|
122
122
|
return;
|
|
@@ -131,7 +131,7 @@ if (this.errors.has("email")) {
|
|
|
131
131
|
|
|
132
132
|
### Manual errors
|
|
133
133
|
|
|
134
|
-
```typescript
|
|
134
|
+
```typescript fragment
|
|
135
135
|
this.addError("email", "That email address is already taken.");
|
|
136
136
|
this.addError("username", "Username must be unique.");
|
|
137
137
|
|
|
@@ -143,7 +143,7 @@ this.resetValidation("email"); // clear one field
|
|
|
143
143
|
|
|
144
144
|
Pass state to `value` (or `checked`). Flow makes it two-way for `@expose` properties and read-only for `@locked` ones — no helper needed:
|
|
145
145
|
|
|
146
|
-
```tsx
|
|
146
|
+
```tsx fragment
|
|
147
147
|
{/* Text inputs */}
|
|
148
148
|
<input value={this.name} />
|
|
149
149
|
<input value={this.email} />
|
|
@@ -194,7 +194,7 @@ export class LoginForm extends Form {
|
|
|
194
194
|
|
|
195
195
|
Mount the form on a component:
|
|
196
196
|
|
|
197
|
-
```typescript
|
|
197
|
+
```typescript fragment
|
|
198
198
|
export class LoginPage extends Component {
|
|
199
199
|
@expose form = new LoginForm();
|
|
200
200
|
|
|
@@ -219,7 +219,7 @@ export class LoginPage extends Component {
|
|
|
219
219
|
|
|
220
220
|
Bind fields with nested `value={this.form.email}`:
|
|
221
221
|
|
|
222
|
-
```tsx
|
|
222
|
+
```tsx fragment
|
|
223
223
|
<form onSubmit={this.login} class="space-y-4">
|
|
224
224
|
<Field label="Email" error={this.errors.email}>
|
|
225
225
|
<input value={this.form.email} type="email" live class="input" />
|
|
@@ -242,7 +242,7 @@ Bind fields with nested `value={this.form.email}`:
|
|
|
242
242
|
|
|
243
243
|
### Form helpers
|
|
244
244
|
|
|
245
|
-
```typescript
|
|
245
|
+
```typescript fragment
|
|
246
246
|
// Get all field values as a plain object
|
|
247
247
|
const data = this.form.data();
|
|
248
248
|
// { email: "alice@example.com", password: "…", remember: true }
|
|
@@ -273,7 +273,7 @@ See [Pagination](/docs/flow/pagination) for the full guide — `paginate()`, `Pa
|
|
|
273
273
|
|
|
274
274
|
Quick reference for in-memory pagination:
|
|
275
275
|
|
|
276
|
-
```typescript
|
|
276
|
+
```typescript fragment
|
|
277
277
|
import { paginate } from "@zerotal/flow";
|
|
278
278
|
|
|
279
279
|
export class PostsPage extends Component {
|
|
@@ -342,7 +342,7 @@ export class PostsPage extends Component {
|
|
|
342
342
|
|
|
343
343
|
Compose `Pagination` to get the page state and navigation methods automatically — no boilerplate:
|
|
344
344
|
|
|
345
|
-
```tsx
|
|
345
|
+
```tsx fragment
|
|
346
346
|
import { Component, Pagination } from "@zerotal/flow";
|
|
347
347
|
|
|
348
348
|
export class PostsPage extends Component.using(Pagination) {
|
|
@@ -404,7 +404,7 @@ export class PostsPage extends Component.using(Pagination) {
|
|
|
404
404
|
|
|
405
405
|
Reset the page when a filter changes to avoid showing an empty page:
|
|
406
406
|
|
|
407
|
-
```typescript
|
|
407
|
+
```typescript fragment
|
|
408
408
|
@expose async applyFilter(status: string): Promise<void> {
|
|
409
409
|
this.filter = status;
|
|
410
410
|
this.resetPage(); // go back to page 1
|
|
@@ -413,7 +413,7 @@ Reset the page when a filter changes to avoid showing an empty page:
|
|
|
413
413
|
|
|
414
414
|
Compose with other mixins:
|
|
415
415
|
|
|
416
|
-
```typescript
|
|
416
|
+
```typescript fragment
|
|
417
417
|
// `Sorting` here is a mixin you author yourself (see Layouts & Composition);
|
|
418
418
|
// `Pagination` is the one shipped by Flow.
|
|
419
419
|
export class PostsPage extends Component.using(Sorting, Pagination) {
|
|
@@ -425,7 +425,7 @@ export class PostsPage extends Component.using(Sorting, Pagination) {
|
|
|
425
425
|
|
|
426
426
|
For database queries, skip the in-memory `paginate()` and use the ORM query builder directly. Combine with `@url page`:
|
|
427
427
|
|
|
428
|
-
```typescript
|
|
428
|
+
```typescript fragment
|
|
429
429
|
export class PostsPage extends Component {
|
|
430
430
|
@url page = 1;
|
|
431
431
|
@locked posts: Post[] = [];
|
package/docs/flow/icons.md
CHANGED
|
@@ -8,7 +8,7 @@ description: 2,060 icons bundled with Flow's component library — typed by name
|
|
|
8
8
|
`<Icon>` draws an icon by name. The set ships inside `@zerotal/flow-ui`, so this
|
|
9
9
|
works in a new app with nothing installed and nothing configured:
|
|
10
10
|
|
|
11
|
-
```tsx
|
|
11
|
+
```tsx fragment
|
|
12
12
|
import { Icon } from "@zerotal/flow-ui";
|
|
13
13
|
|
|
14
14
|
<Icon name="inbox" />
|
|
@@ -42,7 +42,7 @@ An icon is `1em` square and painted in `currentColor`, so by default it matches
|
|
|
42
42
|
the text it sits beside — size, weight of colour, and all. Override with classes
|
|
43
43
|
rather than attributes:
|
|
44
44
|
|
|
45
|
-
```tsx
|
|
45
|
+
```tsx fragment
|
|
46
46
|
<p class="text-sm text-slate-600">
|
|
47
47
|
<Icon name="info" /> Saved a moment ago
|
|
48
48
|
</p>
|
|
@@ -63,7 +63,7 @@ would read the meaning out twice.
|
|
|
63
63
|
An icon that is the **only** content of a control is not decoration. Without a
|
|
64
64
|
label, that button has no accessible name at all:
|
|
65
65
|
|
|
66
|
-
```tsx
|
|
66
|
+
```tsx fragment
|
|
67
67
|
<button onClick={this.remove}>
|
|
68
68
|
<Icon name="trash-2" label="Delete order" />
|
|
69
69
|
</button>
|
|
@@ -74,7 +74,7 @@ label, that button has no accessible name at all:
|
|
|
74
74
|
A name from a database column or a URL segment is not a literal, so it does not
|
|
75
75
|
satisfy the union. `isIconName()` narrows it:
|
|
76
76
|
|
|
77
|
-
```tsx
|
|
77
|
+
```tsx fragment
|
|
78
78
|
import { Icon, isIconName } from "@zerotal/flow-ui";
|
|
79
79
|
|
|
80
80
|
override async render() {
|
|
@@ -113,7 +113,7 @@ differently: `git-branch` not `branch`, `file-json` not `json`, `paperclip` not
|
|
|
113
113
|
Three sign-in providers ship as brand marks, because `@zerotal/auth` has a code
|
|
114
114
|
path for each and a sign-in button wants the provider's actual logo:
|
|
115
115
|
|
|
116
|
-
```tsx
|
|
116
|
+
```tsx fragment
|
|
117
117
|
<button><Icon name="brand-google" /> Continue with Google</button>
|
|
118
118
|
<button><Icon name="brand-github" /> Continue with GitHub</button>
|
|
119
119
|
<button><Icon name="brand-apple" /> Continue with Apple</button>
|
package/docs/flow/index.md
CHANGED
|
@@ -93,7 +93,7 @@ middleware is a `_middleware.ts` in the page tree. It stacks from the root down,
|
|
|
93
93
|
covers every page beneath it, and — being route middleware — re-runs on every
|
|
94
94
|
action with nothing else to configure:
|
|
95
95
|
|
|
96
|
-
```typescript
|
|
96
|
+
```typescript fragment
|
|
97
97
|
// app/flow/pages/_middleware.ts
|
|
98
98
|
import { TenantMiddleware } from "../../middleware/TenantMiddleware.ts";
|
|
99
99
|
|
|
@@ -107,7 +107,7 @@ guards the pages inside it without changing their URLs. See
|
|
|
107
107
|
Reach for `persistentMiddleware` only when the middleware is genuinely **global** —
|
|
108
108
|
registered app-wide with `Application.use()` because non-Flow routes need it too:
|
|
109
109
|
|
|
110
|
-
```typescript
|
|
110
|
+
```typescript fragment
|
|
111
111
|
// app/providers/AppServiceProvider.ts — inside onRegister():
|
|
112
112
|
FlowProvider.persistMiddleware(TenantMiddleware);
|
|
113
113
|
```
|
|
@@ -172,7 +172,7 @@ The distinction is the syntax: a named method reference is always a server actio
|
|
|
172
172
|
|
|
173
173
|
Register the route:
|
|
174
174
|
|
|
175
|
-
```typescript
|
|
175
|
+
```typescript fragment
|
|
176
176
|
// routes/web.ts
|
|
177
177
|
import { Router } from "zerotal";
|
|
178
178
|
import { CounterPage } from "./components/CounterPage.tsx";
|
|
@@ -195,7 +195,7 @@ The names in use:
|
|
|
195
195
|
| Group | Names |
|
|
196
196
|
| ----------------- | --------------------------------------------------------------------------------------------------------------------- |
|
|
197
197
|
| Lifecycle | `onBoot` `onMount` `onHydrate` `onDehydrate` `onRendering` `onRendered` `onUpdate` `onUpdating` `onUpdated` `onError` |
|
|
198
|
-
| Rendering | `render` `layout` `placeholder` `slot` `hasSlot` `child`
|
|
198
|
+
| Rendering | `render` `layout` `placeholder` `slot` `hasSlot` `child` `isInteractive` |
|
|
199
199
|
| Actions & state | `bind` `validate` `resetValidation` `errors` `addError` `refresh` `$refresh` `$set` `cancelled` `signal` |
|
|
200
200
|
| Navigation | `redirect` `redirectRoute` `redirectIntended` `currentUrl` `navigateCurrent` |
|
|
201
201
|
| Events & realtime | `dispatch` `dispatchSelf` `dispatchTo` `stream` `client` `$` |
|
|
@@ -239,7 +239,7 @@ State that the server should never see — a hover flag, which accordion section
|
|
|
239
239
|
|
|
240
240
|
A list with per-row actions needs to tell the server _which_ row. Write the call as you would expect, and Flow compiles the arguments into the markup:
|
|
241
241
|
|
|
242
|
-
```tsx
|
|
242
|
+
```tsx fragment
|
|
243
243
|
{
|
|
244
244
|
this.enquiries.map((row) => (
|
|
245
245
|
<tr>
|
|
@@ -254,7 +254,7 @@ A list with per-row actions needs to tell the server _which_ row. Write the call
|
|
|
254
254
|
|
|
255
255
|
The arguments are evaluated **on the server, during the render** — where `row` exists — and travel with the action as `data-args`. Your action receives them as ordinary parameters:
|
|
256
256
|
|
|
257
|
-
```ts
|
|
257
|
+
```ts fragment
|
|
258
258
|
@expose async archive(id: number) {
|
|
259
259
|
await Enquiry.findOrFail(id).archive();
|
|
260
260
|
}
|
|
@@ -262,7 +262,7 @@ The arguments are evaluated **on the server, during the render** — where `row`
|
|
|
262
262
|
|
|
263
263
|
You can also write `data-args` yourself, which is useful when the handler is built dynamically:
|
|
264
264
|
|
|
265
|
-
```tsx
|
|
265
|
+
```tsx fragment
|
|
266
266
|
<button onClick={this.archive} data-args={JSON.stringify([row.id])}>
|
|
267
267
|
Archive
|
|
268
268
|
</button>
|
|
@@ -280,7 +280,7 @@ By contrast, a handler pointing at a method you forgot to `@expose` **is** a har
|
|
|
280
280
|
|
|
281
281
|
Bind an input by passing state straight to `value` (or `checked`). Flow wires up two-way binding when the property is `@expose`, and read-only reflection when it's `@locked`:
|
|
282
282
|
|
|
283
|
-
```tsx
|
|
283
|
+
```tsx fragment
|
|
284
284
|
<input value={this.name} /> {/* @expose → two-way */}
|
|
285
285
|
<input type="checkbox" checked={this.agree} /> {/* @expose → two-way */}
|
|
286
286
|
<input value={this.ownerName} /> {/* @locked → read-only */}
|
|
@@ -288,7 +288,7 @@ Bind an input by passing state straight to `value` (or `checked`). Flow wires up
|
|
|
288
288
|
|
|
289
289
|
A **radio group** is bound as a unit rather than per input, because every option writes the same property. Pass the option's own value as a second argument to `bind()`:
|
|
290
290
|
|
|
291
|
-
```tsx
|
|
291
|
+
```tsx fragment
|
|
292
292
|
{
|
|
293
293
|
["CUSTOM", "ROUTE", "TEAMS"].map((t) => (
|
|
294
294
|
<label>
|
|
@@ -302,7 +302,7 @@ Each option renders with the shared `flow:model="type"`, its own `value`, and `c
|
|
|
302
302
|
|
|
303
303
|
By default the value stays **local** — it updates the DOM instantly and is flushed to the server with your next action. Add `live` to sync to the server as you type, or `blur` to sync when the input loses focus:
|
|
304
304
|
|
|
305
|
-
```tsx
|
|
305
|
+
```tsx fragment
|
|
306
306
|
<input value={this.draft} /> {/* local; flushed with the next action */}
|
|
307
307
|
<input value={this.search} live /> {/* syncs to the server as you type */}
|
|
308
308
|
<input value={this.title} blur /> {/* syncs to the server on blur */}
|
|
@@ -312,20 +312,20 @@ By default the value stays **local** — it updates the DOM instantly and is flu
|
|
|
312
312
|
|
|
313
313
|
Two more modifiers clean the value at the edge, so the server never sees a numeric string or stray whitespace and you write no coercion in `onUpdated`:
|
|
314
314
|
|
|
315
|
-
```tsx
|
|
315
|
+
```tsx fragment
|
|
316
316
|
<input type="number" value={this.age} number /> {/* the bound value is a real number, not "42" */}
|
|
317
317
|
<input value={this.name} trim /> {/* whitespace stripped before it syncs */}
|
|
318
318
|
```
|
|
319
319
|
|
|
320
320
|
Add `draft="key"` to keep an unsubmitted value across a reload or crash — it mirrors to `localStorage` and restores on mount (only when the field is empty, so server content always wins), then clears itself once the server empties the field after a successful submit. It's a client-side safety net; the server snapshot stays the authority:
|
|
321
321
|
|
|
322
|
-
```tsx
|
|
322
|
+
```tsx fragment
|
|
323
323
|
<textarea value={this.body} draft="post-body" /> {/* survives a refresh until you submit */}
|
|
324
324
|
```
|
|
325
325
|
|
|
326
326
|
And two focus helpers close the loop after a re-render, where focus is easily lost:
|
|
327
327
|
|
|
328
|
-
```tsx
|
|
328
|
+
```tsx fragment
|
|
329
329
|
<input value={this.email} autoFocus /> {/* focus on mount (won't steal focus you've placed) */}
|
|
330
330
|
<input value={this.email} focusOnError /> {/* after a failed submit, focus jumps to the first invalid field */}
|
|
331
331
|
```
|
|
@@ -336,7 +336,7 @@ And two focus helpers close the loop after a re-render, where focus is easily lo
|
|
|
336
336
|
|
|
337
337
|
Pass a field off `this.errors` to the `error` prop and Flow renders that field's first validation message reactively — it appears when the field is invalid and clears when it's fixed:
|
|
338
338
|
|
|
339
|
-
```tsx
|
|
339
|
+
```tsx fragment
|
|
340
340
|
<input value={this.email} />
|
|
341
341
|
<span error={this.errors.email} class="text-red-500" />
|
|
342
342
|
```
|
|
@@ -347,7 +347,7 @@ No `errors.has(...)` checks, no manual show/hide.
|
|
|
347
347
|
|
|
348
348
|
A `className`/`class` (or `style`, `href`, …) that depends on `@expose` or `@locked` state compiles to a reactive client binding, so it updates without a round-trip:
|
|
349
349
|
|
|
350
|
-
```tsx
|
|
350
|
+
```tsx fragment
|
|
351
351
|
<span className={this.count > 10 ? "text-emerald-400" : "text-white"}>
|
|
352
352
|
{this.count}
|
|
353
353
|
</span>
|
|
@@ -361,7 +361,7 @@ A `className`/`class` (or `style`, `href`, …) that depends on `@expose` or `@l
|
|
|
361
361
|
|
|
362
362
|
Use normal TypeScript control flow in `render()` — `.map()`, ternaries, and `&&` are all supported:
|
|
363
363
|
|
|
364
|
-
```tsx
|
|
364
|
+
```tsx fragment
|
|
365
365
|
override async render() {
|
|
366
366
|
return (
|
|
367
367
|
<div>
|
|
@@ -389,7 +389,7 @@ Always provide a `key` when mapping over items — the morph algorithm uses it t
|
|
|
389
389
|
|
|
390
390
|
Common interaction states are first-class props:
|
|
391
391
|
|
|
392
|
-
```tsx
|
|
392
|
+
```tsx fragment
|
|
393
393
|
{/* Disable the button while the action is in flight */}
|
|
394
394
|
<button onClick={this.save} loadingAttr="disabled">Save</button>
|
|
395
395
|
|
|
@@ -420,7 +420,7 @@ Common interaction states are first-class props:
|
|
|
420
420
|
|
|
421
421
|
Add `transition` to animate the show/hide instead of an instant flip — a single prop covers **both** enter and leave (the leave half plain `show=` can't do, since the element would otherwise vanish before any animation runs):
|
|
422
422
|
|
|
423
|
-
```tsx
|
|
423
|
+
```tsx fragment
|
|
424
424
|
<div show={this.modal} transition class="modal">…</div> {/* default: fade */}
|
|
425
425
|
<div show={this.menu} transition="scale">…</div> {/* preset */}
|
|
426
426
|
<aside show={this.drawer} transition="slide-right">…</aside> {/* directional */}
|
|
@@ -446,7 +446,7 @@ The `flow:*` directives (`flow:click`, `flow:model`, `flow:show`, …) that the
|
|
|
446
446
|
|
|
447
447
|
A handful of client-only helpers — writing a prop and syncing it, toggling a boolean, calling a parent action, optimistic list mutations — don't belong to any one component and never run on the server. Rather than crowd the component class (and reserve common names like `set`, `on`, `watch`, `parent` that you might want for your own methods), they all live on a single global object, `$flow`. Framework helpers wear a `$`; the bare names stay yours.
|
|
448
448
|
|
|
449
|
-
```tsx
|
|
449
|
+
```tsx fragment
|
|
450
450
|
<button onClick={() => $flow.set("open", true)}>Open</button> {/* write + sync an @expose prop */}
|
|
451
451
|
<button onClick={() => $flow.toggle("open")}>Toggle</button>
|
|
452
452
|
<button onClick={() => $flow.parent.save()}>Save</button> {/* call a parent action */}
|
|
@@ -508,7 +508,7 @@ A page that _reads_ `$flow.store` in a binding must be AOT-compilable — the co
|
|
|
508
508
|
|
|
509
509
|
### Full page example
|
|
510
510
|
|
|
511
|
-
```tsx
|
|
511
|
+
```tsx fragment
|
|
512
512
|
import { Component, expose, locked, validate, url } from "@zerotal/flow";
|
|
513
513
|
import type { Post } from "#app/models/Post.ts";
|
|
514
514
|
|
|
@@ -576,4 +576,4 @@ Flow is a large surface. Each section below is its own page.
|
|
|
576
576
|
- [Session](/docs/session) — the store behind `@session` and `SessionMiddleware`.
|
|
577
577
|
- [Broadcasting](/docs/broadcasting) — drive `@on("socket:…")` real-time updates from the server.
|
|
578
578
|
- [Storage](/docs/storage) — configure the disks that file uploads write to.
|
|
579
|
-
- [Testing](/docs/testing
|
|
579
|
+
- [Testing](/docs/testing) — patterns for the `FlowTest` harness and the rest of the suite.
|