@zerotal/arch 1.7.3 → 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/api-surface.md +4 -4
- 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 +165 -1
- package/docs/client/index.md +277 -70
- 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 +46 -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 +199 -0
- 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 +12 -7
- package/docs/inertia/props.md +22 -22
- package/docs/inertia/rendering.md +86 -7
- 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 +36 -8
- 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/client/auth.md +0 -113
- package/docs/client/errors.md +0 -139
- package/docs/client/files.md +0 -118
- package/docs/client/references.md +0 -58
- package/docs/client/requests.md +0 -131
- package/docs/client/resilience.md +0 -141
- package/docs/client/testing.md +0 -146
package/docs/helpers.md
CHANGED
|
@@ -23,7 +23,7 @@ Read an environment variable with an optional, type-coerced fallback. This is th
|
|
|
23
23
|
canonical way to read env values — every config file uses it. The return type
|
|
24
24
|
follows the fallback's type.
|
|
25
25
|
|
|
26
|
-
```typescript
|
|
26
|
+
```typescript fragment
|
|
27
27
|
// config/app.ts
|
|
28
28
|
env("APP_NAME", "Zerotal App"); // string
|
|
29
29
|
env("APP_DEBUG", false); // boolean — coerces 'true' / '1'
|
|
@@ -36,7 +36,7 @@ env("APP_KEY"); // string | undefined — no fallback
|
|
|
36
36
|
Read a variable that must exist. Throws a `ConfigError` at boot if it's missing —
|
|
37
37
|
use it for secrets your app cannot run without.
|
|
38
38
|
|
|
39
|
-
```typescript
|
|
39
|
+
```typescript fragment
|
|
40
40
|
// config/app.ts
|
|
41
41
|
const key = requireEnv("APP_KEY"); // throws ConfigError if unset
|
|
42
42
|
```
|
|
@@ -47,7 +47,7 @@ Resolve a path relative to the project root (`process.cwd()`), regardless of whi
|
|
|
47
47
|
file calls it. Use it when declaring route files so paths don't depend on the
|
|
48
48
|
caller's directory.
|
|
49
49
|
|
|
50
|
-
```typescript
|
|
50
|
+
```typescript fragment
|
|
51
51
|
// bootstrap/app.ts
|
|
52
52
|
Application.create({ providers })
|
|
53
53
|
.routing({ web: basePath("routes/web.ts") })
|
|
@@ -61,7 +61,7 @@ managed `zt.ts`; you rarely call it yourself. `serve`/`start`/`s` → `web`,
|
|
|
61
61
|
`worker`/`queue:work` → `worker`, anything else → `console`. A no-op if `APP_ENV`
|
|
62
62
|
is already a valid runtime mode.
|
|
63
63
|
|
|
64
|
-
```typescript
|
|
64
|
+
```typescript fragment
|
|
65
65
|
// zt.ts
|
|
66
66
|
setAppEnv(process.argv[2]);
|
|
67
67
|
const { default: app } = await import("./bootstrap/app.ts");
|
|
@@ -73,7 +73,7 @@ const { default: app } = await import("./bootstrap/app.ts");
|
|
|
73
73
|
|
|
74
74
|
Read (and write) loaded configuration by dot-path, anywhere after boot.
|
|
75
75
|
|
|
76
|
-
```typescript
|
|
76
|
+
```typescript fragment
|
|
77
77
|
// in a controller
|
|
78
78
|
config("app.name"); // string — typed from the registered config shape
|
|
79
79
|
config("app.port", 3000); // number — fallback must match the path's type
|
|
@@ -96,7 +96,7 @@ Reach the current request's `HttpContext` from anywhere in the async call chain
|
|
|
96
96
|
no thread-through required. With a key, it reads a single merged input value (route
|
|
97
97
|
params, then parsed body, then query string).
|
|
98
98
|
|
|
99
|
-
```typescript
|
|
99
|
+
```typescript fragment
|
|
100
100
|
// in a controller
|
|
101
101
|
request(); // the HttpContext
|
|
102
102
|
request("email"); // string | undefined — input named 'email'
|
|
@@ -114,7 +114,7 @@ request<number>("page", 1); // typed input with a fallback
|
|
|
114
114
|
Run a side effect on a value and return the value unchanged — perfect for emitting
|
|
115
115
|
an event or logging in the middle of a chain without breaking it.
|
|
116
116
|
|
|
117
|
-
```typescript
|
|
117
|
+
```typescript fragment
|
|
118
118
|
// in a controller
|
|
119
119
|
return tap(await User.create(data), (user) => Events.emit(new UserRegistered(user.id)));
|
|
120
120
|
|
|
@@ -128,7 +128,7 @@ return await tapAsync(await User.create(data), async (user) => {
|
|
|
128
128
|
The sibling of `tap` — pass a value through a transformer and return the _result_.
|
|
129
129
|
Use `pipe` when the value should change, `tap` when it shouldn't.
|
|
130
130
|
|
|
131
|
-
```typescript
|
|
131
|
+
```typescript fragment
|
|
132
132
|
// in a controller
|
|
133
133
|
const slug = pipe(post.title, (t) => t.toLowerCase().replace(/\s+/g, "-"));
|
|
134
134
|
const hashed = await pipeAsync(password, (p) => Hash.make(p));
|
|
@@ -140,7 +140,7 @@ Run a callback and fall back to a value (or a function of the caught error) inst
|
|
|
140
140
|
of throwing. `rescue` awaits; `rescueSync` is for hot paths that can't await
|
|
141
141
|
(JSON parsing, attribute decoding).
|
|
142
142
|
|
|
143
|
-
```typescript
|
|
143
|
+
```typescript fragment
|
|
144
144
|
// in a controller
|
|
145
145
|
const price = await rescue(() => stripe.getPrice(id), 0);
|
|
146
146
|
const user = await rescue(
|
|
@@ -160,7 +160,7 @@ Safely read a deeply nested value by dot-notation, returning a default when any
|
|
|
160
160
|
segment is absent. Built for untyped JSON — webhooks, third-party API responses —
|
|
161
161
|
where optional chaining gets unwieldy. Numeric segments index into arrays.
|
|
162
162
|
|
|
163
|
-
```typescript
|
|
163
|
+
```typescript fragment
|
|
164
164
|
// in a webhook handler
|
|
165
165
|
data_get(payload, "user.address.city"); // 'Cape Town' or undefined
|
|
166
166
|
data_get(payload, "items.0.price", 0); // first item's price, or 0
|
|
@@ -171,7 +171,7 @@ data_get(payload, "items.0.price", 0); // first item's price, or 0
|
|
|
171
171
|
`Str` is a namespace of pure string utilities. The common case-conversions are
|
|
172
172
|
also exported individually (`camelCase`, `snakeCase`).
|
|
173
173
|
|
|
174
|
-
```typescript
|
|
174
|
+
```typescript fragment
|
|
175
175
|
// in a controller
|
|
176
176
|
Str.camelCase("user_id"); // 'userId'
|
|
177
177
|
Str.snakeCase("userId"); // 'user_id'
|
|
@@ -270,7 +270,7 @@ flat, and `DeepPartial<XConfigShape>` when it nests — both satisfy the package
|
|
|
270
270
|
An array in the override replaces the base array entirely. It is **never**
|
|
271
271
|
concatenated, de-duplicated, or merged element-by-element:
|
|
272
272
|
|
|
273
|
-
```typescript
|
|
273
|
+
```typescript fragment
|
|
274
274
|
// in a config factory
|
|
275
275
|
deepMerge({ hosts: ["a", "b"] }, { hosts: ["c"] });
|
|
276
276
|
// → { hosts: ['c'] } (not ['a','b','c'], not ['c','b'])
|
|
@@ -301,7 +301,7 @@ matches how you want overrides to behave.
|
|
|
301
301
|
Wrap any value to chain `.pipe()` transforms and `.tap()` side effects, then unwrap
|
|
302
302
|
with `.get()`. Useful for readable builder-style code over a plain value.
|
|
303
303
|
|
|
304
|
-
```typescript
|
|
304
|
+
```typescript fragment
|
|
305
305
|
// in a controller
|
|
306
306
|
const user = fluent(await User.find(id))
|
|
307
307
|
.tap((u) => log(`loaded ${u.email}`))
|
|
@@ -314,7 +314,7 @@ Wrap an array in a `Collection` for chainable, immutable transformations — `ma
|
|
|
314
314
|
`filter`, `reduce`, `groupBy`, `pluck`, `sum`, `first`, `unique`, and more — a
|
|
315
315
|
fluent collection pipeline.
|
|
316
316
|
|
|
317
|
-
```typescript
|
|
317
|
+
```typescript fragment
|
|
318
318
|
// in a controller
|
|
319
319
|
const topNames = collect(orders)
|
|
320
320
|
.filter((o) => o.paid)
|
|
@@ -332,7 +332,7 @@ These build and send the HTTP response for the current request. The terminal
|
|
|
332
332
|
helpers (`json`, `view`, `html`, `markdown`, `file`) set `ctx.response` directly;
|
|
333
333
|
`redirect()` and `redirectTo()` return a chainable `ResponseBuilder`.
|
|
334
334
|
|
|
335
|
-
```typescript
|
|
335
|
+
```typescript fragment
|
|
336
336
|
// in a controller
|
|
337
337
|
import { json, view, html, markdown, redirect, redirectTo, abort } from "zerotal";
|
|
338
338
|
|
|
@@ -352,7 +352,7 @@ redirectTo("posts.show", { id }); // redirect to a named route
|
|
|
352
352
|
`redirect()` and `redirect().back()` return a `ResponseBuilder` that lets you flash
|
|
353
353
|
data and messages onto the redirect:
|
|
354
354
|
|
|
355
|
-
```typescript
|
|
355
|
+
```typescript fragment
|
|
356
356
|
// in a controller
|
|
357
357
|
return redirect("/posts").withSuccess("Post created.").with("highlight", post.id);
|
|
358
358
|
|
|
@@ -366,7 +366,7 @@ return redirect().back().withErrors({ title: "Title is required." });
|
|
|
366
366
|
|
|
367
367
|
`abort()` throws a framework error that the exception handler renders:
|
|
368
368
|
|
|
369
|
-
```typescript
|
|
369
|
+
```typescript fragment
|
|
370
370
|
// in a controller
|
|
371
371
|
abort("Something went wrong."); // → 500 with a message
|
|
372
372
|
abort(403, "You can't do that."); // status + message
|
package/docs/i18n.md
CHANGED
|
@@ -101,7 +101,7 @@ themselves. An unmatched lookup returns the key, and the key is the English.
|
|
|
101
101
|
`LocaleMiddleware` resolves the locale for every request and exposes `ctx.locale`
|
|
102
102
|
and `ctx.__()` on the request context:
|
|
103
103
|
|
|
104
|
-
```typescript
|
|
104
|
+
```typescript fragment
|
|
105
105
|
// in a controller
|
|
106
106
|
async show(ctx: HttpContext) {
|
|
107
107
|
ctx.__("Hello, {name}!", { name: "Alice" }); // active locale
|
|
@@ -140,7 +140,7 @@ Lang.translate("Sign in"); // the facade, when you want the instance
|
|
|
140
140
|
A React interface cannot reach the server's `Translator`, so the active locale's
|
|
141
141
|
catalog travels to it as a shared Inertia prop:
|
|
142
142
|
|
|
143
|
-
```typescript
|
|
143
|
+
```typescript fragment
|
|
144
144
|
// bootstrap/app.ts
|
|
145
145
|
import { share } from "@zerotal/inertia";
|
|
146
146
|
import fr from "../resources/lang/fr.json";
|
|
@@ -157,7 +157,7 @@ Client-side, keep the catalog in module state and hand it to the translator from
|
|
|
157
157
|
the component in, which is what makes the first render after a language change
|
|
158
158
|
come out in the new language:
|
|
159
159
|
|
|
160
|
-
```tsx
|
|
160
|
+
```tsx fragment
|
|
161
161
|
// resources/js/app.tsx
|
|
162
162
|
createInertiaApp({
|
|
163
163
|
resolve: async (name, incoming) => {
|
|
@@ -179,7 +179,7 @@ guarantees the assignment has run:
|
|
|
179
179
|
|
|
180
180
|
A component then calls it with no import and no hook:
|
|
181
181
|
|
|
182
|
-
```tsx
|
|
182
|
+
```tsx fragment
|
|
183
183
|
// resources/js/pages/login.tsx
|
|
184
184
|
<TextField label={__("Email")} type="email" />
|
|
185
185
|
```
|
|
@@ -267,7 +267,7 @@ English strings as well — the ambiguity was there before the translator found
|
|
|
267
267
|
Enum values and column names cannot be passed to `__()` — `in_progress` is not
|
|
268
268
|
English. Map them to English first, then translate the result:
|
|
269
269
|
|
|
270
|
-
```typescript
|
|
270
|
+
```typescript fragment
|
|
271
271
|
const STATUS_LABEL: Record<string, string> = {
|
|
272
272
|
backlog: "Backlog",
|
|
273
273
|
in_progress: "In progress",
|
|
@@ -310,7 +310,7 @@ things.
|
|
|
310
310
|
**Persist a user's choice** by writing the `locale` cookie; the `cookie` resolver
|
|
311
311
|
picks it up on every subsequent request:
|
|
312
312
|
|
|
313
|
-
```typescript
|
|
313
|
+
```typescript fragment
|
|
314
314
|
// in a controller — save the visitor's language choice
|
|
315
315
|
async setLocale(ctx: HttpContext) {
|
|
316
316
|
const { locale } = await ctx.body<{ locale: string }>();
|
|
@@ -328,7 +328,7 @@ async setLocale(ctx: HttpContext) {
|
|
|
328
328
|
user — by running code inside `I18nContext.run()`. The `Lang` facade and `__()`
|
|
329
329
|
helper use the supplied locale for the duration of the callback:
|
|
330
330
|
|
|
331
|
-
```typescript
|
|
331
|
+
```typescript fragment
|
|
332
332
|
// in a controller
|
|
333
333
|
import { I18nContext, __ } from "@zerotal/i18n";
|
|
334
334
|
|
|
@@ -355,7 +355,7 @@ belonging to whoever triggered the job was never the right answer anyway. Mail
|
|
|
355
355
|
should arrive in the language of the person opening it, so pass the locale
|
|
356
356
|
explicitly as the third argument:
|
|
357
357
|
|
|
358
|
-
```typescript
|
|
358
|
+
```typescript fragment
|
|
359
359
|
// app/notifications/IssueAssignedNotification.ts
|
|
360
360
|
toMail(notifiable: Notifiable): MailMessage {
|
|
361
361
|
const recipient = notifiable as { name?: string; locale?: string | null };
|
|
@@ -389,7 +389,7 @@ See [Validator](/docs/validator) for how the validator itself reports errors.
|
|
|
389
389
|
|
|
390
390
|
## Using __ in JSX views
|
|
391
391
|
|
|
392
|
-
```tsx
|
|
392
|
+
```tsx fragment
|
|
393
393
|
// app/views/PostCard.tsx
|
|
394
394
|
import { __ } from "@zerotal/i18n";
|
|
395
395
|
|
|
@@ -469,7 +469,7 @@ test("reports the strings each locale still needs", async () => {
|
|
|
469
469
|
separately — a correct catalogue served under the wrong locale looks like a
|
|
470
470
|
missing translation:
|
|
471
471
|
|
|
472
|
-
```typescript
|
|
472
|
+
```typescript fragment
|
|
473
473
|
// tests/http/locale.test.ts
|
|
474
474
|
const res = await app.get("/", { "Accept-Language": "fr-CA,fr;q=0.9" });
|
|
475
475
|
|
package/docs/inertia/devtools.md
CHANGED
|
@@ -84,7 +84,7 @@ INERTIA_DEVTOOLS_ENABLED=true # on — see the warning below
|
|
|
84
84
|
|
|
85
85
|
Or configure it:
|
|
86
86
|
|
|
87
|
-
```typescript
|
|
87
|
+
```typescript fragment
|
|
88
88
|
// config/inertia.ts
|
|
89
89
|
import { InertiaConfig } from "zerotal/inertia";
|
|
90
90
|
|
|
@@ -129,7 +129,7 @@ Matching is a case-insensitive substring, so `password` also covers
|
|
|
129
129
|
|
|
130
130
|
Add your own:
|
|
131
131
|
|
|
132
|
-
```typescript
|
|
132
|
+
```typescript fragment
|
|
133
133
|
// config/inertia.ts
|
|
134
134
|
export default InertiaConfig({
|
|
135
135
|
devtools: {
|
|
@@ -148,7 +148,7 @@ request.
|
|
|
148
148
|
The read API never records itself. Add anything else that would bury the
|
|
149
149
|
timeline:
|
|
150
150
|
|
|
151
|
-
```typescript
|
|
151
|
+
```typescript fragment
|
|
152
152
|
// config/inertia.ts
|
|
153
153
|
export default InertiaConfig({
|
|
154
154
|
devtools: { except: ["/health", "/metrics"] },
|
|
@@ -157,7 +157,7 @@ export default InertiaConfig({
|
|
|
157
157
|
|
|
158
158
|
### Gating a shared environment
|
|
159
159
|
|
|
160
|
-
```typescript
|
|
160
|
+
```typescript fragment
|
|
161
161
|
// config/inertia.ts
|
|
162
162
|
export default InertiaConfig({
|
|
163
163
|
devtools: {
|
package/docs/inertia/index.md
CHANGED
|
@@ -128,7 +128,7 @@ object and root `<div>` are injected on every response:
|
|
|
128
128
|
|
|
129
129
|
A controller action calls `inertia(component, props)`:
|
|
130
130
|
|
|
131
|
-
```ts
|
|
131
|
+
```ts fragment
|
|
132
132
|
// app/controllers/DashboardController.ts
|
|
133
133
|
import type { HttpContext } from "zerotal";
|
|
134
134
|
import { inertia } from "@zerotal/inertia";
|
|
@@ -147,7 +147,7 @@ argument** — and sets the response as a side effect, so the action returns
|
|
|
147
147
|
`Promise<void>`. The component name (`"Dashboard"`) maps to
|
|
148
148
|
`resources/js/pages/Dashboard.tsx`.
|
|
149
149
|
|
|
150
|
-
```tsx
|
|
150
|
+
```tsx fragment
|
|
151
151
|
// resources/js/pages/Dashboard.tsx
|
|
152
152
|
import { Link } from "@inertiajs/react";
|
|
153
153
|
|
|
@@ -162,7 +162,7 @@ export default function Dashboard({ posts, auth }: Props) {
|
|
|
162
162
|
<h1>Dashboard</h1>
|
|
163
163
|
{auth.user && <p>Welcome back, {auth.user.name}</p>}
|
|
164
164
|
{posts.map((post) => (
|
|
165
|
-
<Link key={post.id} href={
|
|
165
|
+
<Link key={post.id} href={route("posts.show", { slug: post.slug })}>
|
|
166
166
|
{post.title}
|
|
167
167
|
</Link>
|
|
168
168
|
))}
|
|
@@ -176,6 +176,11 @@ Note `auth` is available without the controller passing it — see
|
|
|
176
176
|
[`make:page`](/docs/inertia/build#generating-a-page) and bundle them with
|
|
177
177
|
[`inertia:build`](/docs/inertia/build#building-assets).
|
|
178
178
|
|
|
179
|
+
`route("posts.show", { slug })` builds the URL from the route's **name** rather than
|
|
180
|
+
hard-coding the path, so renaming a route updates every link to it and a typo fails
|
|
181
|
+
the build. Prefer it over a literal `href` anywhere you link — see
|
|
182
|
+
[Building URLs](/docs/inertia/rendering#building-urls-with-route).
|
|
183
|
+
|
|
179
184
|
## Testing
|
|
180
185
|
|
|
181
186
|
Set your suite up once as described in [Testing](/docs/testing). An Inertia route
|
|
@@ -186,7 +191,7 @@ one it wants.
|
|
|
186
191
|
is the assertion you want in almost every route test — it checks the component
|
|
187
192
|
and its props without parsing markup:
|
|
188
193
|
|
|
189
|
-
```typescript
|
|
194
|
+
```typescript fragment
|
|
190
195
|
// tests/http/dashboard.test.ts
|
|
191
196
|
import { test } from "bun:test";
|
|
192
197
|
import { createApp } from "../helpers.ts";
|
|
@@ -206,7 +211,7 @@ test("the dashboard renders with its stats", async () => {
|
|
|
206
211
|
embedded in a `data-page` attribute — right for asserting the first paint, wrong
|
|
207
212
|
for asserting props:
|
|
208
213
|
|
|
209
|
-
```typescript
|
|
214
|
+
```typescript fragment
|
|
210
215
|
// tests/http/dashboard.test.ts
|
|
211
216
|
const res = await app.actingAs(user).get("/dashboard");
|
|
212
217
|
|
|
@@ -217,7 +222,7 @@ res.assertHeader("Vary", "X-Inertia"); // the response varies by that header
|
|
|
217
222
|
**Test partial reloads by the props they omit.** A partial reload that quietly
|
|
218
223
|
returns everything is a performance bug no page-level assertion catches:
|
|
219
224
|
|
|
220
|
-
```typescript
|
|
225
|
+
```typescript fragment
|
|
221
226
|
// tests/http/dashboard.test.ts
|
|
222
227
|
const res = await app.actingAs(user).get("/dashboard", {
|
|
223
228
|
"X-Inertia": "true",
|
|
@@ -233,7 +238,7 @@ expect(page.props.notifications).toBeUndefined(); // excluded, as asked
|
|
|
233
238
|
**A version mismatch is a `409`, not an error.** It tells the client to reload so
|
|
234
239
|
it picks up new assets — worth a test if you set `ASSET_VERSION` on deploy:
|
|
235
240
|
|
|
236
|
-
```typescript
|
|
241
|
+
```typescript fragment
|
|
237
242
|
// tests/http/dashboard.test.ts
|
|
238
243
|
const res = await app.get("/dashboard", { "X-Inertia": "true", "X-Inertia-Version": "stale" });
|
|
239
244
|
|
package/docs/inertia/props.md
CHANGED
|
@@ -32,7 +32,7 @@ The server evaluates and returns just those props; the client keeps the rest. Ze
|
|
|
32
32
|
`X-Inertia-Partial-Data` / `X-Inertia-Partial-Except` / `X-Inertia-Partial-Component` headers
|
|
33
33
|
automatically — you don't write any special code for the route, you just make props lazy.
|
|
34
34
|
|
|
35
|
-
```tsx
|
|
35
|
+
```tsx fragment
|
|
36
36
|
// client
|
|
37
37
|
router.reload({ only: ["users"] });
|
|
38
38
|
```
|
|
@@ -41,7 +41,7 @@ router.reload({ only: ["users"] });
|
|
|
41
41
|
|
|
42
42
|
Wrap optional data in a function so it's only evaluated when actually included:
|
|
43
43
|
|
|
44
|
-
```ts
|
|
44
|
+
```ts fragment
|
|
45
45
|
// in a controller
|
|
46
46
|
return inertia("Users/Index", {
|
|
47
47
|
users: () => User.all(), // evaluated every full visit, and on partial reloads that ask for it
|
|
@@ -57,7 +57,7 @@ it from a partial reload also skips the query.
|
|
|
57
57
|
`optional(fn)` — never sent on a normal visit; only when explicitly requested via `only`. Ideal for
|
|
58
58
|
expensive data the page can load on demand.
|
|
59
59
|
|
|
60
|
-
```ts
|
|
60
|
+
```ts fragment
|
|
61
61
|
// in a controller
|
|
62
62
|
return inertia("Users/Index", {
|
|
63
63
|
users: optional(() => User.all()), // only when reloaded with only: ["users"]
|
|
@@ -83,7 +83,7 @@ shared `errors` bag uses this internally.)
|
|
|
83
83
|
request — great for below-the-fold or slow data. Group props to control parallelism (each group is
|
|
84
84
|
one request).
|
|
85
85
|
|
|
86
|
-
```ts
|
|
86
|
+
```ts fragment
|
|
87
87
|
// in a controller
|
|
88
88
|
return inertia("Users/Index", {
|
|
89
89
|
users: () => User.all(),
|
|
@@ -96,7 +96,7 @@ return inertia("Users/Index", {
|
|
|
96
96
|
The first response carries a `deferredProps` map; the client then partial-reloads each group. On the
|
|
97
97
|
client, wrap the UI in `<Deferred>`:
|
|
98
98
|
|
|
99
|
-
```tsx
|
|
99
|
+
```tsx fragment
|
|
100
100
|
// in a page component
|
|
101
101
|
import { Deferred } from "@inertiajs/react";
|
|
102
102
|
|
|
@@ -110,7 +110,7 @@ import { Deferred } from "@inertiajs/react";
|
|
|
110
110
|
Pass `{ rescue: true }` so a thrown error is swallowed and the key reported in `rescuedProps` (the
|
|
111
111
|
client renders the `<Deferred rescue>` slot) instead of failing the whole response:
|
|
112
112
|
|
|
113
|
-
```ts
|
|
113
|
+
```ts fragment
|
|
114
114
|
// in a controller
|
|
115
115
|
permissions: defer(() => Permission.all(), "default", { rescue: true }),
|
|
116
116
|
```
|
|
@@ -121,7 +121,7 @@ By default a reloaded prop _replaces_ the client value. `merge()` / `deepMerge()
|
|
|
121
121
|
**combine** the new data with what it already has — the basis for paginated "load more" lists. Merging
|
|
122
122
|
only happens on partial reloads (full visits always replace).
|
|
123
123
|
|
|
124
|
-
```ts
|
|
124
|
+
```ts fragment
|
|
125
125
|
// in a controller
|
|
126
126
|
return inertia("Feed", {
|
|
127
127
|
posts: merge(() => Post.paginate(15, page)), // append at root
|
|
@@ -130,7 +130,7 @@ return inertia("Feed", {
|
|
|
130
130
|
|
|
131
131
|
Chainable targeting:
|
|
132
132
|
|
|
133
|
-
```ts
|
|
133
|
+
```ts fragment
|
|
134
134
|
// in a controller
|
|
135
135
|
merge(users).append("data").matchOn("data.id"); // append to users.data, replace items matching id
|
|
136
136
|
merge(items).prepend(); // prepend at root
|
|
@@ -147,7 +147,7 @@ before merging fresh data (e.g. on a new search) — Zerotal honors the `X-Inert
|
|
|
147
147
|
paginator's `data` array and emits a `scrollProps` entry describing the current/next/previous page,
|
|
148
148
|
so the client knows when (and which way) to load more:
|
|
149
149
|
|
|
150
|
-
```ts
|
|
150
|
+
```ts fragment
|
|
151
151
|
// app/controllers/PostController.ts
|
|
152
152
|
import { inertia, scroll } from "@zerotal/inertia";
|
|
153
153
|
|
|
@@ -176,7 +176,7 @@ This produces a page object like:
|
|
|
176
176
|
`X-Inertia-Infinite-Scroll-Merge-Intent: prepend` and Zerotal prepends the new page instead of
|
|
177
177
|
appending — no controller change needed. On the client:
|
|
178
178
|
|
|
179
|
-
```tsx
|
|
179
|
+
```tsx fragment
|
|
180
180
|
// in a page component
|
|
181
181
|
import { InfiniteScroll } from "@inertiajs/react";
|
|
182
182
|
|
|
@@ -193,7 +193,7 @@ Chain `.once()` onto an optional/merge/defer prop so it's resolved a single time
|
|
|
193
193
|
client across navigations. The client sends `X-Inertia-Except-Once-Props` with the keys it already
|
|
194
194
|
holds; the server skips re-resolving them.
|
|
195
195
|
|
|
196
|
-
```ts
|
|
196
|
+
```ts fragment
|
|
197
197
|
// in a controller
|
|
198
198
|
return inertia("Billing/Plans", {
|
|
199
199
|
plans: optional(() => Plan.all()).once(),
|
|
@@ -231,7 +231,7 @@ export default InertiaConfig({ encryptHistory: true });
|
|
|
231
231
|
`Inertia.location(url)` performs a full-page visit to an external URL — a `409` with
|
|
232
232
|
`X-Inertia-Location` for Inertia requests, a `302` otherwise:
|
|
233
233
|
|
|
234
|
-
```ts
|
|
234
|
+
```ts fragment
|
|
235
235
|
// in a controller
|
|
236
236
|
return Inertia.location("https://billing.stripe.com/session/abc");
|
|
237
237
|
```
|
|
@@ -247,7 +247,7 @@ side effects** — perfect for live, inline validation. Register `PrecognitionMi
|
|
|
247
247
|
`FormRequest`; when the client sends `Precognition: true`, `FormRequest.validate()` short-circuits
|
|
248
248
|
with a `204` (valid) or `422` (errors), optionally limited to the fields in `Precognition-Validate-Only`.
|
|
249
249
|
|
|
250
|
-
```ts
|
|
250
|
+
```ts fragment
|
|
251
251
|
// bootstrap — register PrecognitionMiddleware globally
|
|
252
252
|
import { PrecognitionMiddleware } from "@zerotal/inertia";
|
|
253
253
|
|
|
@@ -302,7 +302,7 @@ same key wins.
|
|
|
302
302
|
|
|
303
303
|
### What's provided
|
|
304
304
|
|
|
305
|
-
```ts
|
|
305
|
+
```ts fragment
|
|
306
306
|
// the shared bag sharedProps() returns
|
|
307
307
|
{
|
|
308
308
|
auth: {
|
|
@@ -325,7 +325,7 @@ that bag. The other shared props (`auth`/`flash`/`old`) are ordinary props, so a
|
|
|
325
325
|
|
|
326
326
|
Every page component can read these without the controller passing them:
|
|
327
327
|
|
|
328
|
-
```tsx
|
|
328
|
+
```tsx fragment
|
|
329
329
|
// resources/js/pages/Page.tsx
|
|
330
330
|
import { usePage } from "@inertiajs/react";
|
|
331
331
|
|
|
@@ -339,7 +339,7 @@ export default function Page() {
|
|
|
339
339
|
return (
|
|
340
340
|
<>
|
|
341
341
|
{flash.success && <div className="toast">{flash.success}</div>}
|
|
342
|
-
{auth.user ? <span>{auth.user.name}</span> : <a href="
|
|
342
|
+
{auth.user ? <span>{auth.user.name}</span> : <a href={route("login")}>Sign in</a>}
|
|
343
343
|
</>
|
|
344
344
|
);
|
|
345
345
|
}
|
|
@@ -365,7 +365,7 @@ Register props once — typically in a provider's boot or in middleware — and
|
|
|
365
365
|
every page. Values may be plain values, factory functions (evaluated lazily per request), or any
|
|
366
366
|
[prop wrapper](#data-props):
|
|
367
367
|
|
|
368
|
-
```ts
|
|
368
|
+
```ts fragment
|
|
369
369
|
// in a provider's boot or middleware
|
|
370
370
|
import { Inertia } from "@zerotal/inertia";
|
|
371
371
|
|
|
@@ -384,7 +384,7 @@ over during instant visits.
|
|
|
384
384
|
|
|
385
385
|
#### Merge in the controller
|
|
386
386
|
|
|
387
|
-
```ts
|
|
387
|
+
```ts fragment
|
|
388
388
|
// in a controller
|
|
389
389
|
return inertia("Dashboard", {
|
|
390
390
|
notifications: await Notification.query().where("user_id", user.id).unread().get(),
|
|
@@ -397,7 +397,7 @@ return inertia("Dashboard", {
|
|
|
397
397
|
When several pages need the same extra data, set it on the context in a middleware
|
|
398
398
|
and read it back in the controller — keeping the controller body clean:
|
|
399
399
|
|
|
400
|
-
```ts
|
|
400
|
+
```ts fragment
|
|
401
401
|
// in a middleware:
|
|
402
402
|
ctx.setInternal("unreadCount", await Notification.unreadCount(ctx.user!.id));
|
|
403
403
|
|
|
@@ -415,7 +415,7 @@ displays the value on every page.
|
|
|
415
415
|
The props a controller passes are checked against the props the page component
|
|
416
416
|
declares:
|
|
417
417
|
|
|
418
|
-
```tsx
|
|
418
|
+
```tsx fragment
|
|
419
419
|
// resources/js/pages/Posts/Show.tsx
|
|
420
420
|
interface Props {
|
|
421
421
|
post: Post;
|
|
@@ -425,7 +425,7 @@ interface Props {
|
|
|
425
425
|
export default function Show({ post, related, stats }: Props) { … }
|
|
426
426
|
```
|
|
427
427
|
|
|
428
|
-
```ts
|
|
428
|
+
```ts fragment
|
|
429
429
|
// in a controller
|
|
430
430
|
return Inertia.render("Posts/Show", {
|
|
431
431
|
post,
|
|
@@ -455,7 +455,7 @@ the component receives `Post[]` — so each prop accepts its value, a factory fo
|
|
|
455
455
|
it, or a wrapper carrying it, and the wrapper's payload is checked against the
|
|
456
456
|
prop it fills:
|
|
457
457
|
|
|
458
|
-
```ts
|
|
458
|
+
```ts fragment
|
|
459
459
|
Inertia.render("Posts/Show", { post, related: merge(() => [1, 2]) });
|
|
460
460
|
// ✗ number[] is not Post[]
|
|
461
461
|
```
|
|
@@ -11,7 +11,7 @@ routes, and how redirects behave.
|
|
|
11
11
|
|
|
12
12
|
## The inertia helper
|
|
13
13
|
|
|
14
|
-
```ts
|
|
14
|
+
```ts fragment
|
|
15
15
|
function inertia(component: PageName, props?: RenderProps): Promise<void>;
|
|
16
16
|
```
|
|
17
17
|
|
|
@@ -20,7 +20,7 @@ Call `inertia()` from any controller action. It reads the active request from
|
|
|
20
20
|
it sets `ctx.response` as a side effect. It is **async** (it resolves lazy/deferred props), so it
|
|
21
21
|
returns `Promise<void>` — always `return inertia(...)` (or `await` it):
|
|
22
22
|
|
|
23
|
-
```ts
|
|
23
|
+
```ts fragment
|
|
24
24
|
// app/controllers/PostController.ts
|
|
25
25
|
import type { HttpContext } from "zerotal";
|
|
26
26
|
import { inertia } from "@zerotal/inertia";
|
|
@@ -61,7 +61,7 @@ action.
|
|
|
61
61
|
Props can be more than plain values. Wrap them to make them lazy, optional, deferred, or mergeable —
|
|
62
62
|
the foundation for partial reloads, "load more" lists, and deferred content:
|
|
63
63
|
|
|
64
|
-
```ts
|
|
64
|
+
```ts fragment
|
|
65
65
|
// in a controller
|
|
66
66
|
import { inertia, optional, defer, merge } from "@zerotal/inertia";
|
|
67
67
|
|
|
@@ -130,7 +130,7 @@ variants separately. The page object always includes the current `url` and asset
|
|
|
130
130
|
For pages that need no controller logic (marketing pages, static dashboards), render
|
|
131
131
|
straight from the route with the `Router.inertia()` macro (added by the package):
|
|
132
132
|
|
|
133
|
-
```ts
|
|
133
|
+
```ts fragment
|
|
134
134
|
// routes/web.ts
|
|
135
135
|
import { Router } from "zerotal";
|
|
136
136
|
|
|
@@ -142,18 +142,97 @@ Router.inertia("/admin", "Admin/Dashboard", [AuthMiddleware]); // middleware sho
|
|
|
142
142
|
The third argument is polymorphic: pass a **props object**, or pass a **middleware
|
|
143
143
|
array** directly as a shorthand. To use both, pass props third and middleware fourth:
|
|
144
144
|
|
|
145
|
-
```ts
|
|
145
|
+
```ts fragment
|
|
146
146
|
// routes/web.ts
|
|
147
147
|
Router.inertia("/admin", "Admin/Dashboard", { title: "Admin" }, [AuthMiddleware]);
|
|
148
148
|
```
|
|
149
149
|
|
|
150
|
+
## Building URLs with route()
|
|
151
|
+
|
|
152
|
+
A hard-coded `href="/posts/hello"` is a string nothing checks. Rename the route and
|
|
153
|
+
every link to it keeps compiling and starts 404ing — a bug that surfaces when
|
|
154
|
+
someone clicks, not when someone builds.
|
|
155
|
+
|
|
156
|
+
Name the route instead, and let the URL be derived:
|
|
157
|
+
|
|
158
|
+
```tsx fragment
|
|
159
|
+
import { Link } from "@inertiajs/react";
|
|
160
|
+
|
|
161
|
+
<Link href={route("posts.show", { slug: post.slug })}>{post.title}</Link>
|
|
162
|
+
<Link href={route("posts.index", {}, { page: 2 })}>Next</Link>
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
No import for `route` — `defineRoutes()` installs it globally, and the names are
|
|
166
|
+
checked against the same registry your controllers use, so `route("posts.shwo")`
|
|
167
|
+
fails the build. [Routing](/docs/routing#route-in-the-browser) owns the mechanics:
|
|
168
|
+
the generated table, wiring your entry point, typing, and `route.dynamic()` for a
|
|
169
|
+
name only known at runtime.
|
|
170
|
+
|
|
171
|
+
### Forms submit to a name too
|
|
172
|
+
|
|
173
|
+
A form's action is the same kind of string as a link's `href`, and gets the same
|
|
174
|
+
treatment. `useForm()` and `router` both take a URL, so hand them one that was built
|
|
175
|
+
from the route name:
|
|
176
|
+
|
|
177
|
+
```tsx fragment
|
|
178
|
+
import { useForm, router } from "@inertiajs/react";
|
|
179
|
+
|
|
180
|
+
export default function Edit({ post }: Props) {
|
|
181
|
+
const form = useForm({ title: post.title, body: post.body });
|
|
182
|
+
|
|
183
|
+
const submit = (e: React.FormEvent) => {
|
|
184
|
+
e.preventDefault();
|
|
185
|
+
form.put(route("posts.update", { slug: post.slug }));
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const destroy = () => {
|
|
189
|
+
router.delete(route("posts.destroy", { slug: post.slug }));
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
return (
|
|
193
|
+
<form onSubmit={submit}>
|
|
194
|
+
<input value={form.data.title} onChange={(e) => form.setData("title", e.target.value)} />
|
|
195
|
+
{form.errors.title && <span>{form.errors.title}</span>}
|
|
196
|
+
<button disabled={form.processing}>Save</button>
|
|
197
|
+
<button type="button" onClick={destroy}>
|
|
198
|
+
Delete
|
|
199
|
+
</button>
|
|
200
|
+
</form>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
The names follow the same convention the router generates: a `POST` is
|
|
206
|
+
`posts.store`, `PUT`/`PATCH` is `posts.update`, `DELETE` is `posts.destroy`. So the
|
|
207
|
+
name in the component and the route the controller is mounted on cannot drift apart
|
|
208
|
+
silently — change the URL and both ends move together.
|
|
209
|
+
|
|
210
|
+
This matters more for a form than for a link. A broken link 404s where someone can
|
|
211
|
+
see it; a form posting to a stale URL fails **after** the user has filled it in, and
|
|
212
|
+
the data goes with it.
|
|
213
|
+
|
|
214
|
+
Build the URL the same way for [Precognition](/docs/inertia/props#precognition), so
|
|
215
|
+
live validation and the real submit cannot end up aimed at different routes — the
|
|
216
|
+
failure there is a form that validates clean and then rejects on save.
|
|
217
|
+
|
|
218
|
+
### One thing Inertia adds: define the routes in _both_ entries
|
|
219
|
+
|
|
220
|
+
An Inertia page renders twice — once in the SSR process, once in the browser — so a
|
|
221
|
+
component calling `route()` runs in both. A table defined in only one of them throws
|
|
222
|
+
in the other: miss the SSR entry and `POST /__ssr` answers `500` with
|
|
223
|
+
`[Inertia] SSR render failed` in the log, for a page the browser then renders
|
|
224
|
+
perfectly well.
|
|
225
|
+
|
|
226
|
+
Call `defineRoutes(ROUTES)` in your browser entry **and** in your
|
|
227
|
+
[SSR entry](/docs/inertia/ssr). Same static import, same table.
|
|
228
|
+
|
|
150
229
|
## Redirects
|
|
151
230
|
|
|
152
231
|
After a non-GET action (a form POST/PUT/DELETE), redirect as usual — return a 302 and
|
|
153
232
|
[`InertiaMiddleware`](/docs/inertia/middleware) upgrades it to a **303** so the
|
|
154
233
|
browser issues a GET on the target instead of replaying the form:
|
|
155
234
|
|
|
156
|
-
```ts
|
|
235
|
+
```ts fragment
|
|
157
236
|
// in a controller
|
|
158
237
|
async store(ctx: HttpContext): Promise<void> {
|
|
159
238
|
const post = await Post.create(await ctx.body());
|
|
@@ -172,7 +251,7 @@ To send the browser to an external URL (or force a full-page visit), use `Inerti
|
|
|
172
251
|
an Inertia request it returns a `409` + `X-Inertia-Location` so the client does a `window.location`
|
|
173
252
|
visit; on a normal request it's a plain `302`:
|
|
174
253
|
|
|
175
|
-
```ts
|
|
254
|
+
```ts fragment
|
|
176
255
|
// in a controller
|
|
177
256
|
import { Inertia } from "@zerotal/inertia";
|
|
178
257
|
|