@zerotal/arch 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/LICENSE +21 -0
- package/README.md +67 -0
- package/api-surface.md +283 -0
- package/docs/about.md +676 -0
- package/docs/admin/actions.md +183 -0
- package/docs/admin/auth.md +89 -0
- package/docs/admin/dashboard.md +155 -0
- package/docs/admin/extending-ui.md +229 -0
- package/docs/admin/extending.md +205 -0
- package/docs/admin/forms.md +155 -0
- package/docs/admin/index.md +144 -0
- package/docs/admin/operations.md +224 -0
- package/docs/admin/references.md +174 -0
- package/docs/admin/resources.md +132 -0
- package/docs/admin/structure.md +158 -0
- package/docs/admin/tables.md +185 -0
- package/docs/admin/testing.md +138 -0
- package/docs/ai.md +373 -0
- package/docs/application.md +435 -0
- package/docs/arch.md +263 -0
- package/docs/assets.md +755 -0
- package/docs/audit.md +367 -0
- package/docs/authentication.md +1000 -0
- package/docs/authorization.md +529 -0
- package/docs/broadcasting/channels.md +170 -0
- package/docs/broadcasting/client.md +108 -0
- package/docs/broadcasting/events.md +190 -0
- package/docs/broadcasting/index.md +163 -0
- package/docs/broadcasting/references.md +84 -0
- package/docs/broadcasting/testing.md +132 -0
- package/docs/cache.md +387 -0
- package/docs/carbon.md +830 -0
- package/docs/changelog.md +465 -0
- package/docs/client/auth.md +113 -0
- package/docs/client/errors.md +139 -0
- package/docs/client/files.md +118 -0
- package/docs/client/index.md +128 -0
- package/docs/client/references.md +58 -0
- package/docs/client/requests.md +131 -0
- package/docs/client/resilience.md +141 -0
- package/docs/client/testing.md +146 -0
- package/docs/commands.md +450 -0
- package/docs/components.md +2427 -0
- package/docs/config-system.md +258 -0
- package/docs/container.md +596 -0
- package/docs/context.md +579 -0
- package/docs/contributing.md +164 -0
- package/docs/controllers.md +354 -0
- package/docs/conventions.md +466 -0
- package/docs/cookies.md +101 -0
- package/docs/csrf.md +206 -0
- package/docs/database.md +476 -0
- package/docs/deployment.md +434 -0
- package/docs/devtools.md +1013 -0
- package/docs/email-verification.md +320 -0
- package/docs/encryption.md +380 -0
- package/docs/errors.md +317 -0
- package/docs/events.md +420 -0
- package/docs/flow/components.md +846 -0
- package/docs/flow/decorators.md +458 -0
- package/docs/flow/events.md +420 -0
- package/docs/flow/forms.md +551 -0
- package/docs/flow/index.md +568 -0
- package/docs/flow/layouts.md +793 -0
- package/docs/flow/lifecycle.md +336 -0
- package/docs/flow/pagination.md +402 -0
- package/docs/flow/performance.md +262 -0
- package/docs/flow/references.md +317 -0
- package/docs/flow/routing.md +550 -0
- package/docs/flow/testing.md +447 -0
- package/docs/getting-started.md +286 -0
- package/docs/health.md +269 -0
- package/docs/helpers.md +423 -0
- package/docs/i18n.md +414 -0
- package/docs/index.md +65 -0
- package/docs/inertia/build.md +95 -0
- package/docs/inertia/devtools.md +197 -0
- package/docs/inertia/index.md +267 -0
- package/docs/inertia/middleware.md +81 -0
- package/docs/inertia/props.md +507 -0
- package/docs/inertia/references.md +144 -0
- package/docs/inertia/rendering.md +189 -0
- package/docs/inertia/ssr.md +102 -0
- package/docs/inspirations.md +257 -0
- package/docs/lifecycle.md +331 -0
- package/docs/lock.md +454 -0
- package/docs/logger.md +517 -0
- package/docs/media.md +582 -0
- package/docs/middleware.md +509 -0
- package/docs/migrations.md +529 -0
- package/docs/monitor.md +377 -0
- package/docs/notifications.md +848 -0
- package/docs/orm/casts.md +365 -0
- package/docs/orm/factories.md +231 -0
- package/docs/orm/index.md +663 -0
- package/docs/orm/lifecycle.md +421 -0
- package/docs/orm/queries.md +692 -0
- package/docs/orm/relationships.md +440 -0
- package/docs/orm/serialization.md +251 -0
- package/docs/package-development.md +536 -0
- package/docs/pagination.md +340 -0
- package/docs/password-reset.md +335 -0
- package/docs/providers.md +447 -0
- package/docs/query-builder.md +465 -0
- package/docs/queue.md +549 -0
- package/docs/rate-limiting.md +253 -0
- package/docs/responses.md +336 -0
- package/docs/roles-and-2fa.md +418 -0
- package/docs/routing.md +1008 -0
- package/docs/scaffolding.md +335 -0
- package/docs/scheduler.md +533 -0
- package/docs/seeding.md +229 -0
- package/docs/session.md +451 -0
- package/docs/social.md +524 -0
- package/docs/storage.md +622 -0
- package/docs/structure.md +230 -0
- package/docs/support-policy.md +114 -0
- package/docs/telemetry.md +505 -0
- package/docs/tenancy.md +608 -0
- package/docs/testing/browser.md +153 -0
- package/docs/testing/console.md +130 -0
- package/docs/testing/database.md +238 -0
- package/docs/testing/flow-browser.md +216 -0
- package/docs/testing/http.md +441 -0
- package/docs/testing/index.md +240 -0
- package/docs/testing/mocking.md +410 -0
- package/docs/upgrade.md +192 -0
- package/docs/validator.md +549 -0
- package/docs/view.md +453 -0
- package/package.json +60 -0
- package/src/bin/mcp.ts +73 -0
- package/src/config.ts +63 -0
- package/src/errors.ts +27 -0
- package/src/index.ts +56 -0
- package/src/install/ArchInstallCommand.ts +189 -0
- package/src/install/detect.ts +98 -0
- package/src/install/guidelines.ts +290 -0
- package/src/install/markers.ts +77 -0
- package/src/install/mcpConfig.ts +81 -0
- package/src/mcp/index.ts +27 -0
- package/src/mcp/jsonrpc.ts +133 -0
- package/src/mcp/server.ts +307 -0
- package/src/mcp/stdio.ts +111 -0
- package/src/mcp/types.ts +146 -0
- package/src/probe/ArchProbeCommand.ts +48 -0
- package/src/probe/sentinel.ts +13 -0
- package/src/probe/topics.ts +291 -0
- package/src/provider/ArchProvider.ts +101 -0
- package/src/tools/_probe.ts +164 -0
- package/src/tools/apiSurface.ts +241 -0
- package/src/tools/appInfo.ts +88 -0
- package/src/tools/baselines.ts +210 -0
- package/src/tools/context.ts +37 -0
- package/src/tools/doctor.ts +93 -0
- package/src/tools/index.ts +50 -0
- package/src/tools/logs.ts +265 -0
- package/src/tools/routes.ts +133 -0
- package/src/tools/schema.ts +119 -0
- package/src/tools/searchDocs.ts +345 -0
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Flow Forms, Validation & Uploads
|
|
3
|
+
description: Two-way bound inputs, real-time validation, and server-handled file uploads.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Forms & Validation
|
|
7
|
+
|
|
8
|
+
Validate input, bind form fields to component state with two-way binding, encapsulate complex forms in form objects, and paginate result sets.
|
|
9
|
+
|
|
10
|
+
## Validation rules
|
|
11
|
+
|
|
12
|
+
Attach rules with `@validate` on individual fields, or pass them explicitly to `this.validate()`:
|
|
13
|
+
|
|
14
|
+
```typescript
|
|
15
|
+
import { expose, validate } from "@zerotal/flow";
|
|
16
|
+
|
|
17
|
+
@expose @validate((rule) => rule.required().email()) email: string = "";
|
|
18
|
+
@expose @validate((rule) => rule.required().min(8)) password: string = "";
|
|
19
|
+
@expose @validate((rule) => rule.required().min(2).max(50)) name: string = "";
|
|
20
|
+
@expose @validate((rule) => rule.required().in(["admin", "user", "guest"])) role: string = "user";
|
|
21
|
+
@expose @validate((rule) => rule.number().min(0)) age?: number;
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Rules use the framework validator's fluent chain (`@zerotal/validator`'s `RuleBuilder`), not
|
|
25
|
+
strings. `rule.required()` is shorthand for a required string; for other types start from the typed
|
|
26
|
+
builder (`rule.number()`, …). The same `@validate` rule also powers
|
|
27
|
+
[real-time validation](/docs/flow/decorators#real-time-validation) when a field is bound with
|
|
28
|
+
`flow:model.live`.
|
|
29
|
+
|
|
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
|
+
|
|
32
|
+
```typescript
|
|
33
|
+
@expose async register(): Promise<void> {
|
|
34
|
+
await this.validate(); // uses @validate rules declared on the class
|
|
35
|
+
|
|
36
|
+
// Only reached if all fields are valid:
|
|
37
|
+
const user = await User.create({
|
|
38
|
+
name: this.name,
|
|
39
|
+
email: this.email,
|
|
40
|
+
password: this.password,
|
|
41
|
+
role: this.role,
|
|
42
|
+
});
|
|
43
|
+
this.redirect("/dashboard");
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Pass explicit rules to override or extend `@validate`:
|
|
48
|
+
|
|
49
|
+
```typescript
|
|
50
|
+
@expose async update(): Promise<void> {
|
|
51
|
+
await this.validate({
|
|
52
|
+
email: (rule) => rule.required().email(),
|
|
53
|
+
password: (rule) => rule.string().optional().min(8),
|
|
54
|
+
role: (rule) => rule.required().in(["admin", "user"]),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
await this.user.fill({ email: this.email, role: this.role }).save();
|
|
58
|
+
this.flash("Profile updated.");
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Available validation rules
|
|
63
|
+
|
|
64
|
+
| Rule | Description |
|
|
65
|
+
| --------------------- | ------------------------------------------------------ |
|
|
66
|
+
| `required` | Field must be present and non-empty |
|
|
67
|
+
| `nullable` | Field can be null/undefined — skip further rules if so |
|
|
68
|
+
| `string` | Must be a string |
|
|
69
|
+
| `numeric` | Must be numeric |
|
|
70
|
+
| `integer` | Must be an integer |
|
|
71
|
+
| `boolean` | Must be true/false |
|
|
72
|
+
| `array` | Must be an array |
|
|
73
|
+
| `email` | Must be a valid email address |
|
|
74
|
+
| `min:N` | String: min length N; Number: min value N |
|
|
75
|
+
| `max:N` | String: max length N; Number: max value N |
|
|
76
|
+
| `between:N,M` | Value must be between N and M |
|
|
77
|
+
| `in:a,b,c` | Must be one of the listed values |
|
|
78
|
+
| `confirmed` | Must match `{fieldName}_confirmation` |
|
|
79
|
+
| `unique:table,column` | Must not exist in the database table |
|
|
80
|
+
| `exists:table,column` | Must exist in the database table |
|
|
81
|
+
|
|
82
|
+
## Showing validation errors in the template
|
|
83
|
+
|
|
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
|
+
|
|
86
|
+
```tsx
|
|
87
|
+
<input value={this.email} />
|
|
88
|
+
<span error={this.errors.email} class="text-sm text-red-500" />
|
|
89
|
+
|
|
90
|
+
<input value={this.name} />
|
|
91
|
+
<span error={this.errors.name} class="text-sm text-red-500" />
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Use the `<Field>` component for accessible label + error wiring:
|
|
95
|
+
|
|
96
|
+
```tsx
|
|
97
|
+
import { Field } from "@zerotal/flow";
|
|
98
|
+
|
|
99
|
+
<Field label="Email" error={this.errors.email}>
|
|
100
|
+
<input value={this.email} class="input" />
|
|
101
|
+
</Field>
|
|
102
|
+
|
|
103
|
+
<Field label="Password" description="At least 8 characters." error={this.errors.password}>
|
|
104
|
+
<input type="password" value={this.password} class="input" />
|
|
105
|
+
</Field>
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Show all errors at once with `<Errors>`:
|
|
109
|
+
|
|
110
|
+
```tsx
|
|
111
|
+
import { Errors } from "@zerotal/flow";
|
|
112
|
+
|
|
113
|
+
<Errors /> {/* all current errors */}
|
|
114
|
+
<Errors only={["email", "name"]} /> {/* just these fields */}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Checking errors in server code
|
|
118
|
+
|
|
119
|
+
```typescript
|
|
120
|
+
// Check if any errors exist
|
|
121
|
+
if (this.errors.any()) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Check a specific field
|
|
126
|
+
if (this.errors.has("email")) {
|
|
127
|
+
this.flash("Please fix the email field.", "error");
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### Manual errors
|
|
133
|
+
|
|
134
|
+
```typescript
|
|
135
|
+
this.addError("email", "That email address is already taken.");
|
|
136
|
+
this.addError("username", "Username must be unique.");
|
|
137
|
+
|
|
138
|
+
this.resetValidation(); // clear all errors
|
|
139
|
+
this.resetValidation("email"); // clear one field
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Two-way model binding
|
|
143
|
+
|
|
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
|
+
|
|
146
|
+
```tsx
|
|
147
|
+
{/* Text inputs */}
|
|
148
|
+
<input value={this.name} />
|
|
149
|
+
<input value={this.email} />
|
|
150
|
+
<textarea value={this.bio} />
|
|
151
|
+
|
|
152
|
+
{/* Live sync on each keystroke (triggers a server round-trip per keystroke) */}
|
|
153
|
+
<input value={this.search} live placeholder="Search…" />
|
|
154
|
+
|
|
155
|
+
{/* Sync on blur (round-trip when the input loses focus) */}
|
|
156
|
+
<input value={this.title} blur />
|
|
157
|
+
|
|
158
|
+
{/* Checkboxes */}
|
|
159
|
+
<input type="checkbox" checked={this.agree} />
|
|
160
|
+
|
|
161
|
+
{/* Select — the <option> matching the bound value is marked `selected` automatically,
|
|
162
|
+
so the control shows (and submits) the right choice on first render */}
|
|
163
|
+
<select value={this.role}>
|
|
164
|
+
<option value="admin">Admin</option>
|
|
165
|
+
<option value="user">User</option>
|
|
166
|
+
<option value="guest">Guest</option>
|
|
167
|
+
</select>
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
The bound `<select>` resolves its `flow:model` from `value={this.role}` and marks the matching `<option selected>` for you — including when the options are mapped inside a wrapper component (`<MySelect value={this.role} options={…} />`). You never write `selected` by hand.
|
|
171
|
+
|
|
172
|
+
## Form objects
|
|
173
|
+
|
|
174
|
+
Bundle related fields, their validation rules, and reset/fill helpers into a reusable `Form` subclass, then mount it on a component as a single `@expose` property. This keeps your component class lean and makes the form reusable.
|
|
175
|
+
|
|
176
|
+
```typescript
|
|
177
|
+
import { Form } from "@zerotal/flow";
|
|
178
|
+
import type { RuleBuilder } from "@zerotal/validator";
|
|
179
|
+
|
|
180
|
+
export class LoginForm extends Form {
|
|
181
|
+
email = "";
|
|
182
|
+
password = "";
|
|
183
|
+
remember = false;
|
|
184
|
+
|
|
185
|
+
rules(v: RuleBuilder) {
|
|
186
|
+
return {
|
|
187
|
+
email: v.string().email(),
|
|
188
|
+
password: v.string().min(8),
|
|
189
|
+
remember: v.boolean().optional(),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Mount the form on a component:
|
|
196
|
+
|
|
197
|
+
```typescript
|
|
198
|
+
export class LoginPage extends Component {
|
|
199
|
+
@expose form = new LoginForm();
|
|
200
|
+
|
|
201
|
+
@expose async login(): Promise<void> {
|
|
202
|
+
this.validate(this.form); // runs the form's rules
|
|
203
|
+
|
|
204
|
+
const ok = await Auth.attempt({
|
|
205
|
+
email: this.form.email,
|
|
206
|
+
password: this.form.password,
|
|
207
|
+
remember: this.form.remember,
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
if (!ok) {
|
|
211
|
+
this.addError("email", "These credentials do not match.");
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
this.redirect("/dashboard");
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Bind fields with nested `value={this.form.email}`:
|
|
221
|
+
|
|
222
|
+
```tsx
|
|
223
|
+
<form onSubmit={this.login} class="space-y-4">
|
|
224
|
+
<Field label="Email" error={this.errors.email}>
|
|
225
|
+
<input value={this.form.email} type="email" live class="input" />
|
|
226
|
+
</Field>
|
|
227
|
+
|
|
228
|
+
<Field label="Password" error={this.errors.password}>
|
|
229
|
+
<input value={this.form.password} type="password" class="input" />
|
|
230
|
+
</Field>
|
|
231
|
+
|
|
232
|
+
<label class="flex items-center gap-2">
|
|
233
|
+
<input type="checkbox" checked={this.form.remember} />
|
|
234
|
+
Remember me
|
|
235
|
+
</label>
|
|
236
|
+
|
|
237
|
+
<button type="submit" loadingAttr="disabled">
|
|
238
|
+
Sign in
|
|
239
|
+
</button>
|
|
240
|
+
</form>
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
### Form helpers
|
|
244
|
+
|
|
245
|
+
```typescript
|
|
246
|
+
// Get all field values as a plain object
|
|
247
|
+
const data = this.form.data();
|
|
248
|
+
// { email: "alice@example.com", password: "…", remember: true }
|
|
249
|
+
|
|
250
|
+
// Fill the form from an existing record
|
|
251
|
+
await this.form.fill(post);
|
|
252
|
+
|
|
253
|
+
// Reset to defaults
|
|
254
|
+
this.form.reset();
|
|
255
|
+
|
|
256
|
+
// Reset specific fields only
|
|
257
|
+
this.form.reset("email", "password");
|
|
258
|
+
|
|
259
|
+
// Run the form's validation manually
|
|
260
|
+
this.form.validate();
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
### Why form objects?
|
|
264
|
+
|
|
265
|
+
- **Reuse**: the same `LoginForm` can be mounted on a `LoginModal` and a `LoginPage`.
|
|
266
|
+
- **Reset**: `this.form.reset()` restores all fields to defaults in one call.
|
|
267
|
+
- **Isolation**: errors land on the component's error bag, not on the form itself, so `<span error={this.errors.email} />` works unchanged.
|
|
268
|
+
- **Survival**: form class instances are re-created from the snapshot on each round-trip — the synthesizer handles it, so methods and defaults are always available.
|
|
269
|
+
|
|
270
|
+
## Paginated results
|
|
271
|
+
|
|
272
|
+
See [Pagination](/docs/flow/pagination) for the full guide — `paginate()`, `Pagination` mixin, database pagination, infinite scroll, and cursor pagination.
|
|
273
|
+
|
|
274
|
+
Quick reference for in-memory pagination:
|
|
275
|
+
|
|
276
|
+
```typescript
|
|
277
|
+
import { paginate } from "@zerotal/flow";
|
|
278
|
+
|
|
279
|
+
export class PostsPage extends Component {
|
|
280
|
+
@url page = 1;
|
|
281
|
+
@locked all: Post[] = [];
|
|
282
|
+
|
|
283
|
+
override async onMount() {
|
|
284
|
+
this.all = await Post.query().orderBy("created_at", "desc").get();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
@expose goTo(n: number): void {
|
|
288
|
+
this.page = n;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
override async render() {
|
|
292
|
+
const p = paginate(this.all, this.page, 10); // (items, page, perPage)
|
|
293
|
+
|
|
294
|
+
return (
|
|
295
|
+
<div>
|
|
296
|
+
<ul>
|
|
297
|
+
{p.data.map((post) => (
|
|
298
|
+
<li key={String(post.id)}>{post.title}</li>
|
|
299
|
+
))}
|
|
300
|
+
</ul>
|
|
301
|
+
|
|
302
|
+
<p>
|
|
303
|
+
Showing {p.from}–{p.to} of {p.total}
|
|
304
|
+
</p>
|
|
305
|
+
|
|
306
|
+
<nav class="flex gap-1">
|
|
307
|
+
{p.elements().map((el) =>
|
|
308
|
+
el === "..." ? (
|
|
309
|
+
<span class="px-2">…</span>
|
|
310
|
+
) : (
|
|
311
|
+
<button
|
|
312
|
+
onClick={() => this.goTo(el as number)}
|
|
313
|
+
class={el === p.page ? "font-bold underline" : ""}
|
|
314
|
+
>
|
|
315
|
+
{el}
|
|
316
|
+
</button>
|
|
317
|
+
),
|
|
318
|
+
)}
|
|
319
|
+
</nav>
|
|
320
|
+
</div>
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
**Paginator properties:**
|
|
327
|
+
|
|
328
|
+
| Property | Type | Description |
|
|
329
|
+
| ----------------- | --------------------- | ------------------------------------ |
|
|
330
|
+
| `data` | `T[]` | Items on the current page |
|
|
331
|
+
| `total` | `number` | Total item count across all pages |
|
|
332
|
+
| `page` | `number` | Current page number |
|
|
333
|
+
| `perPage` | `number` | Items per page |
|
|
334
|
+
| `lastPage` | `number` | Last page number |
|
|
335
|
+
| `from` | `number` | Index of the first item on this page |
|
|
336
|
+
| `to` | `number` | Index of the last item on this page |
|
|
337
|
+
| `onFirstPage` | `boolean` | True if on page 1 |
|
|
338
|
+
| `hasMorePages` | `boolean` | True if there are more pages |
|
|
339
|
+
| `elements(each?)` | `(number \| "...")[]` | Windowed page list with ellipsis |
|
|
340
|
+
|
|
341
|
+
### Pagination mixin
|
|
342
|
+
|
|
343
|
+
Compose `Pagination` to get the page state and navigation methods automatically — no boilerplate:
|
|
344
|
+
|
|
345
|
+
```tsx
|
|
346
|
+
import { Component, Pagination } from "@zerotal/flow";
|
|
347
|
+
|
|
348
|
+
export class PostsPage extends Component.using(Pagination) {
|
|
349
|
+
@locked all: Post[] = [];
|
|
350
|
+
|
|
351
|
+
override async onMount() {
|
|
352
|
+
this.all = await Post.query().orderBy("created_at", "desc").get();
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
override async render() {
|
|
356
|
+
const posts = await Post.paginate(10); // uses this.page automatically
|
|
357
|
+
|
|
358
|
+
return (
|
|
359
|
+
<div>
|
|
360
|
+
<ul>
|
|
361
|
+
{p.data.map((post) => (
|
|
362
|
+
<li key={String(post.id)}>{post.title}</li>
|
|
363
|
+
))}
|
|
364
|
+
</ul>
|
|
365
|
+
|
|
366
|
+
<nav class="flex items-center gap-1">
|
|
367
|
+
<button onClick={this.previousPage} disabled={p.onFirstPage}>
|
|
368
|
+
‹
|
|
369
|
+
</button>
|
|
370
|
+
|
|
371
|
+
{p.elements().map((el) =>
|
|
372
|
+
el === "..." ? (
|
|
373
|
+
<span class="px-2">…</span>
|
|
374
|
+
) : (
|
|
375
|
+
<a href={`?page=${el}`} navigate class={el === p.page ? "font-bold" : ""}>
|
|
376
|
+
{el}
|
|
377
|
+
</a>
|
|
378
|
+
),
|
|
379
|
+
)}
|
|
380
|
+
|
|
381
|
+
<button onClick={this.nextPage} disabled={!p.hasMorePages}>
|
|
382
|
+
›
|
|
383
|
+
</button>
|
|
384
|
+
</nav>
|
|
385
|
+
|
|
386
|
+
<p class="text-sm text-gray-500">
|
|
387
|
+
Showing {p.from}–{p.to} of {p.total}
|
|
388
|
+
</p>
|
|
389
|
+
</div>
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
**`Pagination` adds:**
|
|
396
|
+
|
|
397
|
+
| Member | Type | Description |
|
|
398
|
+
| ---------------- | ---------------- | ------------------------------------------ |
|
|
399
|
+
| `page` | `@url number` | Current page, synced to `?page=` |
|
|
400
|
+
| `gotoPage(n)` | `@expose method` | Jump to a specific page |
|
|
401
|
+
| `resetPage()` | method | Reset to page 1 (call when filters change) |
|
|
402
|
+
| `nextPage()` | `@expose method` | Go to next page |
|
|
403
|
+
| `previousPage()` | `@expose method` | Go to previous page |
|
|
404
|
+
|
|
405
|
+
Reset the page when a filter changes to avoid showing an empty page:
|
|
406
|
+
|
|
407
|
+
```typescript
|
|
408
|
+
@expose async applyFilter(status: string): Promise<void> {
|
|
409
|
+
this.filter = status;
|
|
410
|
+
this.resetPage(); // go back to page 1
|
|
411
|
+
}
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
Compose with other mixins:
|
|
415
|
+
|
|
416
|
+
```typescript
|
|
417
|
+
// `Sorting` here is a mixin you author yourself (see Layouts & Composition);
|
|
418
|
+
// `Pagination` is the one shipped by Flow.
|
|
419
|
+
export class PostsPage extends Component.using(Sorting, Pagination) {
|
|
420
|
+
// gets both pagination AND sorting for free
|
|
421
|
+
}
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
### Database-backed pagination
|
|
425
|
+
|
|
426
|
+
For database queries, skip the in-memory `paginate()` and use the ORM query builder directly. Combine with `@url page`:
|
|
427
|
+
|
|
428
|
+
```typescript
|
|
429
|
+
export class PostsPage extends Component {
|
|
430
|
+
@url page = 1;
|
|
431
|
+
@locked posts: Post[] = [];
|
|
432
|
+
@locked total = 0;
|
|
433
|
+
@locked lastPage = 1;
|
|
434
|
+
|
|
435
|
+
override async onMount() {
|
|
436
|
+
await this.load();
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
@expose async load(): Promise<void> {
|
|
440
|
+
const result = await Post.query()
|
|
441
|
+
.where("status", "published")
|
|
442
|
+
.orderBy("created_at", "desc")
|
|
443
|
+
.paginate(15);
|
|
444
|
+
|
|
445
|
+
this.posts = result.data;
|
|
446
|
+
this.total = result.total;
|
|
447
|
+
this.lastPage = result.lastPage;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
override async render() {
|
|
451
|
+
return (
|
|
452
|
+
<div>
|
|
453
|
+
<ul>
|
|
454
|
+
{this.posts.map((post) => (
|
|
455
|
+
<li key={String(post.id)}>{post.title}</li>
|
|
456
|
+
))}
|
|
457
|
+
</ul>
|
|
458
|
+
<p>Page {this.page} of {this.lastPage} — {this.total} total</p>
|
|
459
|
+
</div>
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
## File uploads
|
|
466
|
+
|
|
467
|
+
Flow supports server-handled file uploads. Bind a file input with `flow:model`; the bytes are
|
|
468
|
+
uploaded over HTTP to `/__flow/upload`, stored on a temporary disk, and the bound property
|
|
469
|
+
becomes a `TemporaryUploadedFile`. In an action you call `.store()` to move it to permanent
|
|
470
|
+
storage.
|
|
471
|
+
|
|
472
|
+
> **Note** — Requires `zerotal/storage` configured (a default disk) and `APP_KEY` set (used to sign the
|
|
473
|
+
> temp-file reference). The upload endpoint requires an authenticated user (`ctx.user`).
|
|
474
|
+
|
|
475
|
+
### Component
|
|
476
|
+
|
|
477
|
+
```tsx
|
|
478
|
+
// app/flow/AvatarUploader.tsx
|
|
479
|
+
import { Component, expose } from "@zerotal/flow";
|
|
480
|
+
import type { TemporaryUploadedFile } from "@zerotal/flow";
|
|
481
|
+
import { Storage } from "zerotal/storage";
|
|
482
|
+
|
|
483
|
+
export class AvatarUploader extends Component {
|
|
484
|
+
@expose avatar: TemporaryUploadedFile | null = null; // single file
|
|
485
|
+
// @expose files: TemporaryUploadedFile[] = []; // for <input multiple>
|
|
486
|
+
|
|
487
|
+
@expose async save(): Promise<void> {
|
|
488
|
+
if (!this.avatar) {
|
|
489
|
+
this.flash("Choose a file first.", "warning");
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
const path = await this.avatar.store("avatars", "public"); // → permanent disk, returns path
|
|
493
|
+
const url = Storage.disk("public").url(path);
|
|
494
|
+
// …persist `url` on your model…
|
|
495
|
+
this.avatar = null; // clear the temp ref
|
|
496
|
+
this.flash("Uploaded.", "success");
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
override async render() {
|
|
500
|
+
return (
|
|
501
|
+
<div>
|
|
502
|
+
<input type="file" flow:model="avatar" accept="image/*" />
|
|
503
|
+
<div id="bar" style="height:3px;width:0" />
|
|
504
|
+
<button onClick={this.save} disabled={!this.avatar}>
|
|
505
|
+
Save
|
|
506
|
+
</button>
|
|
507
|
+
<script
|
|
508
|
+
dangerouslySetInnerHTML={{
|
|
509
|
+
__html: `
|
|
510
|
+
addEventListener('flow:upload-progress', e => { document.getElementById('bar').style.width = e.detail.percent + '%'; });
|
|
511
|
+
addEventListener('flow:upload-finish', () => { document.getElementById('bar').style.width = '100%'; });
|
|
512
|
+
`,
|
|
513
|
+
}}
|
|
514
|
+
/>
|
|
515
|
+
</div>
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
### The TemporaryUploadedFile object
|
|
522
|
+
|
|
523
|
+
- `name`, `mime`, `size`, `extension()`, `isImage()`
|
|
524
|
+
- `await store(directory, disk?, filename?)` → stored path (moves temp → permanent)
|
|
525
|
+
- `await bytes()` → `Uint8Array`
|
|
526
|
+
- `await temporaryUrl(ttlSeconds?)` → preview URL (signed/expiring where the driver supports it)
|
|
527
|
+
|
|
528
|
+
### Client events
|
|
529
|
+
|
|
530
|
+
`flow:upload-start` · `flow:upload-progress` (`{key, name, percent}`) · `flow:upload-finish` ·
|
|
531
|
+
`flow:upload-error` (`{key, error}`).
|
|
532
|
+
|
|
533
|
+
### Security
|
|
534
|
+
|
|
535
|
+
- The temp reference is HMAC-signed with `APP_KEY`; the server verifies it on `$set` and refuses
|
|
536
|
+
forged paths. Once in the snapshot it's covered by the snapshot HMAC.
|
|
537
|
+
- The endpoint enforces a 25 MB ceiling (apps should validate stricter — size/mime — in the
|
|
538
|
+
`save` action via the file's `size`/`mime`).
|
|
539
|
+
- Temp files (`flow-tmp/…`, random UUID names) are garbage-collected after 6h; `.store()`
|
|
540
|
+
removes the temp copy immediately.
|
|
541
|
+
|
|
542
|
+
### Notes / future
|
|
543
|
+
|
|
544
|
+
- v1 proxies bytes through the server to the default disk. Direct-to-S3 presigned uploads and a
|
|
545
|
+
dedicated `temp` disk are natural follow-ups; the `TemporaryUploadedFile` API is designed to
|
|
546
|
+
absorb them without changing component code.
|
|
547
|
+
|
|
548
|
+
## Next steps
|
|
549
|
+
|
|
550
|
+
- [Flow overview](/docs/flow) — the guide's front page and the rest of the sections.
|
|
551
|
+
- [Reference](/docs/flow/references) — every decorator, prop, and directive in one table.
|