remix 3.0.0-rc.4 → 3.0.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/INDEX.md +6 -21
- package/README.md +1 -1
- package/dist/component/dev/refresh.d.ts +2 -0
- package/dist/component/dev/refresh.d.ts.map +1 -0
- package/dist/{ui → component}/dev/refresh.js +1 -1
- package/dist/component/jsx-dev-runtime.d.ts +2 -0
- package/dist/component/jsx-dev-runtime.d.ts.map +1 -0
- package/dist/{ui → component}/jsx-dev-runtime.js +1 -1
- package/dist/component/jsx-runtime.d.ts +2 -0
- package/dist/component/jsx-runtime.d.ts.map +1 -0
- package/dist/{ui → component}/jsx-runtime.js +1 -1
- package/dist/component/server.d.ts +2 -0
- package/dist/component/server.d.ts.map +1 -0
- package/dist/{ui → component}/server.js +1 -1
- package/dist/component/test.d.ts +2 -0
- package/dist/component/test.d.ts.map +1 -0
- package/dist/{ui → component}/test.js +1 -1
- package/dist/component-hmr/assets.d.ts +2 -0
- package/dist/component-hmr/assets.d.ts.map +1 -0
- package/dist/{ui-hmr.js → component-hmr/assets.js} +1 -1
- package/dist/component-hmr/node.d.ts +3 -0
- package/dist/component-hmr/node.d.ts.map +1 -0
- package/dist/{ui-hmr → component-hmr}/node.js +1 -1
- package/dist/component-hmr/runtime/browser.d.ts +2 -0
- package/dist/component-hmr/runtime/browser.d.ts.map +1 -0
- package/dist/{ui-hmr → component-hmr}/runtime/browser.js +1 -1
- package/dist/component-hmr/runtime/server.d.ts +2 -0
- package/dist/component-hmr/runtime/server.d.ts.map +1 -0
- package/dist/{ui-hmr → component-hmr}/runtime/server.js +1 -1
- package/dist/component-hmr.d.ts +2 -0
- package/dist/component-hmr.d.ts.map +1 -0
- package/dist/{ui-hmr/assets.js → component-hmr.js} +1 -1
- package/dist/component.d.ts +2 -0
- package/dist/component.d.ts.map +1 -0
- package/dist/{ui/anchor.js → component.js} +1 -1
- package/guides/01-start-here.md +20 -20
- package/guides/02-routing-and-controllers.md +1 -1
- package/guides/03-request-handling.md +1 -1
- package/guides/04-rendering-ui.md +41 -307
- package/guides/05-interactivity.md +42 -37
- package/guides/06-streaming-ui-with-frames.md +28 -70
- package/guides/07-animation.md +41 -35
- package/guides/08-data-and-validation.md +423 -27
- package/guides/09-forms-and-mutations.md +250 -25
- package/guides/13-testing.md +10 -10
- package/guides/14-cli-and-tooling.md +2 -2
- package/package.json +96 -180
- package/src/assets/README.md +12 -12
- package/src/cli/README.md +3 -3
- package/src/{ui → component}/README.md +31 -75
- package/src/{ui → component}/dev/refresh.ts +1 -1
- package/src/{ui → component}/jsx-dev-runtime.ts +1 -1
- package/src/{ui → component}/jsx-runtime.ts +1 -1
- package/src/{ui → component}/server/README.md +8 -8
- package/src/{ui → component}/server.ts +1 -1
- package/src/{ui → component}/test/README.md +6 -6
- package/src/{ui → component}/test.ts +1 -1
- package/src/{ui-hmr → component-hmr}/README.md +13 -13
- package/src/{ui-hmr → component-hmr}/assets.ts +1 -1
- package/src/{ui-hmr → component-hmr}/node.ts +1 -1
- package/src/{ui-hmr → component-hmr}/runtime/browser.ts +1 -1
- package/src/{ui-hmr → component-hmr}/runtime/server.ts +1 -1
- package/{dist/ui/accordion.js → src/component-hmr.ts} +1 -1
- package/{dist/ui/tabs.js → src/component.ts} +1 -1
- package/src/data-table/README.md +2 -1
- package/src/multiple-import-maps-polyfill/README.md +3 -3
- package/src/node-hmr/README.md +6 -6
- package/src/render-middleware/README.md +5 -5
- package/src/spa/README.md +4 -4
- package/src/test/README.md +4 -4
- package/dist/ui/accordion/primitives.d.ts +0 -2
- package/dist/ui/accordion/primitives.d.ts.map +0 -1
- package/dist/ui/accordion/primitives.js +0 -2
- package/dist/ui/accordion.d.ts +0 -2
- package/dist/ui/accordion.d.ts.map +0 -1
- package/dist/ui/anchor.d.ts +0 -2
- package/dist/ui/anchor.d.ts.map +0 -1
- package/dist/ui/animation.d.ts +0 -2
- package/dist/ui/animation.d.ts.map +0 -1
- package/dist/ui/animation.js +0 -2
- package/dist/ui/breadcrumbs.d.ts +0 -2
- package/dist/ui/breadcrumbs.d.ts.map +0 -1
- package/dist/ui/breadcrumbs.js +0 -2
- package/dist/ui/button.d.ts +0 -3
- package/dist/ui/button.d.ts.map +0 -1
- package/dist/ui/button.js +0 -3
- package/dist/ui/checkbox.d.ts +0 -3
- package/dist/ui/checkbox.d.ts.map +0 -1
- package/dist/ui/checkbox.js +0 -3
- package/dist/ui/combobox/primitives.d.ts +0 -2
- package/dist/ui/combobox/primitives.d.ts.map +0 -1
- package/dist/ui/combobox/primitives.js +0 -2
- package/dist/ui/combobox.d.ts +0 -2
- package/dist/ui/combobox.d.ts.map +0 -1
- package/dist/ui/combobox.js +0 -2
- package/dist/ui/dev/refresh.d.ts +0 -2
- package/dist/ui/dev/refresh.d.ts.map +0 -1
- package/dist/ui/input.d.ts +0 -3
- package/dist/ui/input.d.ts.map +0 -1
- package/dist/ui/input.js +0 -3
- package/dist/ui/jsx-dev-runtime.d.ts +0 -2
- package/dist/ui/jsx-dev-runtime.d.ts.map +0 -1
- package/dist/ui/jsx-runtime.d.ts +0 -2
- package/dist/ui/jsx-runtime.d.ts.map +0 -1
- package/dist/ui/listbox.d.ts +0 -2
- package/dist/ui/listbox.d.ts.map +0 -1
- package/dist/ui/listbox.js +0 -2
- package/dist/ui/menu/primitives.d.ts +0 -2
- package/dist/ui/menu/primitives.d.ts.map +0 -1
- package/dist/ui/menu/primitives.js +0 -2
- package/dist/ui/menu.d.ts +0 -2
- package/dist/ui/menu.d.ts.map +0 -1
- package/dist/ui/menu.js +0 -2
- package/dist/ui/popover.d.ts +0 -2
- package/dist/ui/popover.d.ts.map +0 -1
- package/dist/ui/popover.js +0 -2
- package/dist/ui/radio.d.ts +0 -3
- package/dist/ui/radio.d.ts.map +0 -1
- package/dist/ui/radio.js +0 -3
- package/dist/ui/select/primitives.d.ts +0 -2
- package/dist/ui/select/primitives.d.ts.map +0 -1
- package/dist/ui/select/primitives.js +0 -2
- package/dist/ui/select.d.ts +0 -2
- package/dist/ui/select.d.ts.map +0 -1
- package/dist/ui/select.js +0 -2
- package/dist/ui/server.d.ts +0 -2
- package/dist/ui/server.d.ts.map +0 -1
- package/dist/ui/tabs/primitives.d.ts +0 -2
- package/dist/ui/tabs/primitives.d.ts.map +0 -1
- package/dist/ui/tabs/primitives.js +0 -2
- package/dist/ui/tabs.d.ts +0 -2
- package/dist/ui/tabs.d.ts.map +0 -1
- package/dist/ui/test.d.ts +0 -2
- package/dist/ui/test.d.ts.map +0 -1
- package/dist/ui/toggle/primitives.d.ts +0 -2
- package/dist/ui/toggle/primitives.d.ts.map +0 -1
- package/dist/ui/toggle/primitives.js +0 -2
- package/dist/ui/toggle.d.ts +0 -3
- package/dist/ui/toggle.d.ts.map +0 -1
- package/dist/ui/toggle.js +0 -3
- package/dist/ui-hmr/assets.d.ts +0 -2
- package/dist/ui-hmr/assets.d.ts.map +0 -1
- package/dist/ui-hmr/node.d.ts +0 -3
- package/dist/ui-hmr/node.d.ts.map +0 -1
- package/dist/ui-hmr/runtime/browser.d.ts +0 -2
- package/dist/ui-hmr/runtime/browser.d.ts.map +0 -1
- package/dist/ui-hmr/runtime/server.d.ts +0 -2
- package/dist/ui-hmr/runtime/server.d.ts.map +0 -1
- package/dist/ui-hmr.d.ts +0 -2
- package/dist/ui-hmr.d.ts.map +0 -1
- package/dist/ui.d.ts +0 -2
- package/dist/ui.d.ts.map +0 -1
- package/dist/ui.js +0 -2
- package/src/ui/accordion/README.md +0 -202
- package/src/ui/accordion/primitives.ts +0 -2
- package/src/ui/accordion.ts +0 -2
- package/src/ui/anchor/README.md +0 -196
- package/src/ui/anchor.ts +0 -2
- package/src/ui/animation/README.md +0 -318
- package/src/ui/animation.ts +0 -2
- package/src/ui/breadcrumbs/README.md +0 -55
- package/src/ui/breadcrumbs.ts +0 -2
- package/src/ui/button/README.md +0 -44
- package/src/ui/button.ts +0 -3
- package/src/ui/checkbox/README.md +0 -59
- package/src/ui/checkbox.ts +0 -3
- package/src/ui/combobox/README.md +0 -194
- package/src/ui/combobox/primitives.ts +0 -2
- package/src/ui/combobox.ts +0 -2
- package/src/ui/input/README.md +0 -52
- package/src/ui/input.ts +0 -3
- package/src/ui/listbox/README.md +0 -83
- package/src/ui/listbox.ts +0 -2
- package/src/ui/menu/README.md +0 -161
- package/src/ui/menu/primitives.ts +0 -2
- package/src/ui/menu.ts +0 -2
- package/src/ui/popover/README.md +0 -103
- package/src/ui/popover.ts +0 -2
- package/src/ui/radio/README.md +0 -53
- package/src/ui/radio.ts +0 -3
- package/src/ui/select/README.md +0 -117
- package/src/ui/select/primitives.ts +0 -2
- package/src/ui/select.ts +0 -2
- package/src/ui/tabs/README.md +0 -141
- package/src/ui/tabs/primitives.ts +0 -2
- package/src/ui/tabs.ts +0 -2
- package/src/ui/toggle/README.md +0 -56
- package/src/ui/toggle/primitives.ts +0 -2
- package/src/ui/toggle.ts +0 -3
- package/src/ui-hmr.ts +0 -2
- package/src/ui.ts +0 -2
|
@@ -3,50 +3,275 @@ title: Forms and Mutations
|
|
|
3
3
|
description: How native forms, action responses, validation failures, redirects, and enhanced mutations fit together.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
In [Data and Validation](/data-and-validation/), we moved our albums into SQLite and defined `albumForm` to validate edits.
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
- [Form data middleware](../src/form-data-middleware/README.md)
|
|
10
|
-
- [Form data schemas](../src/data-schema/README.md)
|
|
8
|
+
Let's connect the edit form to that database. We'll parse submissions, return the form with field errors when validation fails, and save valid edits before redirecting to the album. Then we'll add pending feedback.
|
|
11
9
|
|
|
12
|
-
|
|
10
|
+
## Keep the native form
|
|
13
11
|
|
|
14
|
-
|
|
12
|
+
Our existing `form("/albums/:albumId/edit")` route pairs two actions at the same URL. The GET `index` action loads the album for editing, and the POST `action` handler parses and saves a submission. Both belong to the controller mapped with `router.map(routes.albums.edit, albumsEditController)`.
|
|
15
13
|
|
|
16
|
-
|
|
14
|
+
We'll keep a native `<form method="post">`, named inputs, and a submit button. With JavaScript disabled, the browser still sends those fields to the action and displays its response. The router's `formData()` middleware already makes the parsed body available as `context.formData`.
|
|
17
15
|
|
|
18
|
-
##
|
|
16
|
+
## Load the edit form
|
|
19
17
|
|
|
20
|
-
|
|
18
|
+
First, replace the edit controller's array lookup with a database query. The `index` action loads the album, returns `404` if it is missing, and renders the edit page:
|
|
21
19
|
|
|
22
|
-
|
|
20
|
+
```tsx filename=app/actions/albums/edit/controller.tsx lines=[3,9-14]
|
|
21
|
+
import { createController } from "remix/router";
|
|
23
22
|
|
|
24
|
-
|
|
23
|
+
import { albums } from "../../../data/tables.ts";
|
|
24
|
+
import { routes } from "../../../routes.ts";
|
|
25
|
+
import { AlbumEditPage } from "./page.tsx";
|
|
25
26
|
|
|
26
|
-
|
|
27
|
+
export default createController(routes.albums.edit, {
|
|
28
|
+
actions: {
|
|
29
|
+
async index(context) {
|
|
30
|
+
let album = await context.db.find(albums, context.params.albumId);
|
|
31
|
+
if (album === null) return new Response("Album not found", { status: 404 });
|
|
27
32
|
|
|
28
|
-
|
|
33
|
+
return context.render(<AlbumEditPage album={album} />);
|
|
34
|
+
},
|
|
35
|
+
async action(context) {
|
|
36
|
+
/* ... */
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
```
|
|
29
41
|
|
|
30
|
-
|
|
42
|
+
Open `/albums/thriller/edit`. The form now starts with the album's stored values.
|
|
31
43
|
|
|
32
|
-
Return
|
|
44
|
+
## Return validation failures with the form
|
|
33
45
|
|
|
34
|
-
|
|
46
|
+
The page needs to distinguish the stored album from an unsuccessful edit. Add optional `values` and `issues` props to `AlbumEditPage`:
|
|
35
47
|
|
|
36
|
-
|
|
48
|
+
```tsx filename=app/actions/albums/edit/page.tsx lines=[1,10-11,22,28,31-33,41-42,44-45,47]
|
|
49
|
+
import type { Issue } from "remix/data-schema";
|
|
50
|
+
import type { Handle } from "remix/component";
|
|
37
51
|
|
|
38
|
-
|
|
52
|
+
import type { Album } from "../../../data/tables.ts";
|
|
53
|
+
import { routes } from "../../../routes.ts";
|
|
54
|
+
import { Document } from "../../document.tsx";
|
|
39
55
|
|
|
40
|
-
|
|
56
|
+
interface AlbumEditPageProps {
|
|
57
|
+
album: Album;
|
|
58
|
+
values?: { title: string; artist: string; year: string };
|
|
59
|
+
issues?: readonly Issue[];
|
|
60
|
+
}
|
|
41
61
|
|
|
42
|
-
|
|
62
|
+
const fields = [
|
|
63
|
+
{ name: "title", label: "Title" },
|
|
64
|
+
{ name: "artist", label: "Artist" },
|
|
65
|
+
{ name: "year", label: "Year" },
|
|
66
|
+
] as const;
|
|
43
67
|
|
|
44
|
-
|
|
68
|
+
export function AlbumEditPage(handle: Handle<AlbumEditPageProps>) {
|
|
69
|
+
return () => {
|
|
70
|
+
let { album, values, issues = [] } = handle.props;
|
|
45
71
|
|
|
46
|
-
|
|
72
|
+
return (
|
|
73
|
+
<Document title={`Edit ${album.title} — Albums`}>
|
|
74
|
+
<main>
|
|
75
|
+
<h1>Edit {album.title}</h1>
|
|
76
|
+
{issues.length > 0 && <p role="alert">Please correct the fields below.</p>}
|
|
77
|
+
<form method="post" action={routes.albums.edit.action.href({ albumId: album.id })}>
|
|
78
|
+
{fields.map(({ name, label }) => {
|
|
79
|
+
let issue = issues.find((issue) => issue.path?.[0] === name);
|
|
80
|
+
let inputId = `album-${name}`;
|
|
81
|
+
let errorId = `${inputId}-error`;
|
|
47
82
|
|
|
48
|
-
|
|
83
|
+
return (
|
|
84
|
+
<div key={name}>
|
|
85
|
+
<label htmlFor={inputId}>{label}</label>
|
|
86
|
+
<input
|
|
87
|
+
id={inputId}
|
|
88
|
+
name={name}
|
|
89
|
+
defaultValue={values?.[name] ?? String(album[name])}
|
|
90
|
+
inputMode={name === "year" ? "numeric" : undefined}
|
|
91
|
+
required
|
|
92
|
+
aria-invalid={issue ? true : undefined}
|
|
93
|
+
aria-describedby={issue ? errorId : undefined}
|
|
94
|
+
/>
|
|
95
|
+
{issue && <p id={errorId}>{issue.message}</p>}
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
})}
|
|
99
|
+
<button type="submit">Save album</button>
|
|
100
|
+
</form>
|
|
101
|
+
</main>
|
|
102
|
+
</Document>
|
|
103
|
+
);
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
```
|
|
49
107
|
|
|
50
|
-
|
|
108
|
+
The inputs use the stored album initially and preserve submitted `values` after validation fails. Each field displays its first validation issue, linked to the input with `aria-describedby`.
|
|
51
109
|
|
|
52
|
-
|
|
110
|
+
## Parse and save the submitted form
|
|
111
|
+
|
|
112
|
+
Pass `context.formData` to `s.parseSafe(albumForm, context.formData)` in the edit action. When `parsed.success` is false, validation errors are available in `parsed.issues`. On success, `parsed.value` contains the validated fields, with trimmed text and a numeric year.
|
|
113
|
+
|
|
114
|
+
Update the controller to load the album from SQLite, render invalid submissions with their errors, and save validated edits:
|
|
115
|
+
|
|
116
|
+
```tsx filename=app/actions/albums/edit/controller.tsx lines=[5,19-20,30-33,36-43]
|
|
117
|
+
import * as s from "remix/data-schema";
|
|
118
|
+
import { redirect } from "remix/response/redirect";
|
|
119
|
+
import { createController } from "remix/router";
|
|
120
|
+
|
|
121
|
+
import { albums } from "../../../data/tables.ts";
|
|
122
|
+
import { routes } from "../../../routes.ts";
|
|
123
|
+
import { AlbumEditPage } from "./page.tsx";
|
|
124
|
+
import { albumForm } from "./schema.ts";
|
|
125
|
+
|
|
126
|
+
export default createController(routes.albums.edit, {
|
|
127
|
+
actions: {
|
|
128
|
+
async index(context) {
|
|
129
|
+
let album = await context.db.find(albums, context.params.albumId);
|
|
130
|
+
if (album === null) return new Response("Album not found", { status: 404 });
|
|
131
|
+
|
|
132
|
+
return context.render(<AlbumEditPage album={album} />);
|
|
133
|
+
},
|
|
134
|
+
async action(context) {
|
|
135
|
+
let parsed = s.parseSafe(albumForm, context.formData);
|
|
136
|
+
if (!parsed.success) {
|
|
137
|
+
let album = await context.db.find(albums, context.params.albumId);
|
|
138
|
+
if (album === null) return new Response("Album not found", { status: 404 });
|
|
139
|
+
|
|
140
|
+
let values = {
|
|
141
|
+
title: readText(context.formData, "title"),
|
|
142
|
+
artist: readText(context.formData, "artist"),
|
|
143
|
+
year: readText(context.formData, "year"),
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
return context.render(
|
|
147
|
+
<AlbumEditPage album={album} values={values} issues={parsed.issues} />,
|
|
148
|
+
{ status: 400 },
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
await context.db.update(albums, context.params.albumId, parsed.value);
|
|
154
|
+
} catch (error) {
|
|
155
|
+
console.error("Failed to save album:", error);
|
|
156
|
+
return new Response("Unable to save album", { status: 500 });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return redirect(routes.albums.show.href({ albumId: context.params.albumId }), 303);
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
function readText(formData: FormData, name: string): string {
|
|
165
|
+
let value = formData.get(name);
|
|
166
|
+
return typeof value === "string" ? value : "";
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
`readText()` collects text for display without converting a missing entry or a file into a string. These values never become the database update. That still comes from `parsed.value`, after trimming, coercion, and validation have succeeded.
|
|
171
|
+
|
|
172
|
+
The failure branch renders directly with status `400`. Redirecting back to the GET action would load the stored album again and lose the user's changes and errors. For forms with sensitive fields, choose which values to return. Leave password fields empty, and ask the user to select files again.
|
|
173
|
+
|
|
174
|
+
## Redirect after saving
|
|
175
|
+
|
|
176
|
+
After parsing succeeds, `db.update(...)` saves `parsed.value` to the album selected by the route's `albumId`. If the update fails, the action logs the error on the server and returns `500` without exposing database details to the browser. For other read and write helpers and their error behavior, see the [`data-table` overview](../src/data-table/README.md).
|
|
177
|
+
|
|
178
|
+
After a successful save, the action returns `303 See Other` with the album page's URL. The browser follows that redirect with a GET:
|
|
179
|
+
|
|
180
|
+
```txt
|
|
181
|
+
POST /albums/thriller/edit
|
|
182
|
+
→ update the album
|
|
183
|
+
→ 303 Location: /albums/thriller
|
|
184
|
+
GET /albums/thriller
|
|
185
|
+
→ render the saved album
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Refreshing the resulting page repeats the GET instead of submitting the edit again.
|
|
189
|
+
|
|
190
|
+
If the destination should display “Album saved,” session flash data can carry that message across the redirect. We'll add sessions in [Auth, Sessions, and Security](/auth-sessions-security/).
|
|
191
|
+
|
|
192
|
+
## Add pending feedback
|
|
193
|
+
|
|
194
|
+
With the app's browser runtime running, Remix handles the form submission as a navigation. It renders validation responses into the page and follows redirects after a successful save.
|
|
195
|
+
|
|
196
|
+
In [Start Here](/start-here/), we hydrated the whole form and set pending state when its submit event fired. Here, the button can follow the containing frame's reload events, which tell us when the navigation starts and when it finishes.
|
|
197
|
+
|
|
198
|
+
We can show progress by hydrating just the save button. Put it in the edit action's `public/` directory:
|
|
199
|
+
|
|
200
|
+
```tsx filename=app/actions/albums/edit/public/save-button.tsx
|
|
201
|
+
import { clientEntry } from "remix/component";
|
|
202
|
+
import type { Handle } from "remix/component";
|
|
203
|
+
|
|
204
|
+
export const SaveButton = clientEntry(import.meta.url, function SaveButton(handle: Handle) {
|
|
205
|
+
let pending = false;
|
|
206
|
+
|
|
207
|
+
handle.frame.addEventListener(
|
|
208
|
+
"reloadStart",
|
|
209
|
+
() => {
|
|
210
|
+
pending = true;
|
|
211
|
+
handle.update();
|
|
212
|
+
},
|
|
213
|
+
{ signal: handle.signal },
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
handle.frame.addEventListener(
|
|
217
|
+
"reloadComplete",
|
|
218
|
+
() => {
|
|
219
|
+
pending = false;
|
|
220
|
+
handle.update();
|
|
221
|
+
},
|
|
222
|
+
{ signal: handle.signal },
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
return () => (
|
|
226
|
+
<button type="submit" disabled={pending}>
|
|
227
|
+
{pending ? "Saving…" : "Save album"}
|
|
228
|
+
</button>
|
|
229
|
+
);
|
|
230
|
+
});
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
Back in `page.tsx`, import `SaveButton` and replace the plain submit button:
|
|
234
|
+
|
|
235
|
+
```tsx filename=app/actions/albums/edit/page.tsx
|
|
236
|
+
import { SaveButton } from "./public/save-button.tsx";
|
|
237
|
+
|
|
238
|
+
// Inside AlbumEditPage's render function:
|
|
239
|
+
<form method="post" action={routes.albums.edit.action.href({ albumId: album.id })}>
|
|
240
|
+
{/* Keep the existing fields and validation messages. */}
|
|
241
|
+
<SaveButton />
|
|
242
|
+
</form>;
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
The button listens to its containing frame: `reloadStart` disables it and shows “Saving…”, and `reloadComplete` restores it after the navigation settles, including a validation response or request failure. Passing `handle.signal` removes the listeners when the component is removed.
|
|
246
|
+
|
|
247
|
+
## Use other HTTP methods when the route calls for them
|
|
248
|
+
|
|
249
|
+
Our edit route works with POST. If your app instead exposes a PATCH update route, HTML forms need a method override because their submission methods are GET and POST.
|
|
250
|
+
|
|
251
|
+
Change the edit route's action method with `form("/albums/:albumId/edit", { formMethod: "PATCH" })`, and add this hidden field inside its existing POST form:
|
|
252
|
+
|
|
253
|
+
```html
|
|
254
|
+
<input type="hidden" name="_method" value="PATCH" />
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
Install method override in the router's middleware stack, immediately after form parsing:
|
|
258
|
+
|
|
259
|
+
```ts lines=[2,10]
|
|
260
|
+
// In app/router.ts:
|
|
261
|
+
import { methodOverride } from "remix/middleware/method-override";
|
|
262
|
+
|
|
263
|
+
// Keep the other middleware and controller mappings:
|
|
264
|
+
export const router = createRouter({
|
|
265
|
+
middleware: [
|
|
266
|
+
staticFiles("./public", { index: false }),
|
|
267
|
+
loadDatabase(),
|
|
268
|
+
formData(),
|
|
269
|
+
methodOverride(),
|
|
270
|
+
render({ assets }),
|
|
271
|
+
],
|
|
272
|
+
});
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
The browser sends POST. After parsing `_method`, the middleware sets `context.method` to PATCH before route matching. The same controller's `action` now matches that PATCH route. Installing method override on the controller would be too late to select the route. PUT and DELETE work the same way when you define matching routes.
|
|
276
|
+
|
|
277
|
+
In [Auth, Sessions, and Security](/auth-sessions-security/), we'll decide who is allowed to edit an album and connect those decisions to the request's session.
|
package/guides/13-testing.md
CHANGED
|
@@ -13,12 +13,12 @@ This chapter starts at that boundary, then moves outward to browser component te
|
|
|
13
13
|
|
|
14
14
|
Choose the smallest boundary that includes the behavior you want to prove:
|
|
15
15
|
|
|
16
|
-
| Test boundary | Use it for | How to test it
|
|
17
|
-
| ---------------------- | -------------------------------------------------------------------- |
|
|
18
|
-
| Unit test | A data helper, schema, utility, or other isolated module | Import it, call it, and assert on the result
|
|
19
|
-
| Router test | An action, response, middleware, session, or database-backed request | Send a request through `router.fetch(...)`
|
|
20
|
-
| Browser component test | A component event, DOM update, or browser API | Render the component with `remix/
|
|
21
|
-
| End-to-end test | Navigation or a complete browser/server flow | Run the router behind a test server and drive it with Playwright
|
|
16
|
+
| Test boundary | Use it for | How to test it | Runner type |
|
|
17
|
+
| ---------------------- | -------------------------------------------------------------------- | -------------------------------------------------------------------- | ----------- |
|
|
18
|
+
| Unit test | A data helper, schema, utility, or other isolated module | Import it, call it, and assert on the result | `server` |
|
|
19
|
+
| Router test | An action, response, middleware, session, or database-backed request | Send a request through `router.fetch(...)` | `server` |
|
|
20
|
+
| Browser component test | A component event, DOM update, or browser API | Render the component with `remix/component/test`'s `render()` helper | `browser` |
|
|
21
|
+
| End-to-end test | Navigation or a complete browser/server flow | Run the router behind a test server and drive it with Playwright | `e2e` |
|
|
22
22
|
|
|
23
23
|
A controller that returns the wrong status belongs in a router test. A submit button that does not enter its pending state belongs in a browser component test. Use an end-to-end test when browser and server behavior must work together, such as submitting a form and following its redirect to the updated page.
|
|
24
24
|
|
|
@@ -219,8 +219,8 @@ npx playwright install
|
|
|
219
219
|
Consider a small client component that tracks whether an album is a favorite:
|
|
220
220
|
|
|
221
221
|
```tsx filename=app/actions/albums/public/favorite-button.tsx
|
|
222
|
-
import { clientEntry, on } from "remix/
|
|
223
|
-
import type { Handle } from "remix/
|
|
222
|
+
import { clientEntry, on } from "remix/component";
|
|
223
|
+
import type { Handle } from "remix/component";
|
|
224
224
|
|
|
225
225
|
export const FavoriteButton = clientEntry(
|
|
226
226
|
import.meta.url,
|
|
@@ -247,12 +247,12 @@ export const FavoriteButton = clientEntry(
|
|
|
247
247
|
);
|
|
248
248
|
```
|
|
249
249
|
|
|
250
|
-
`render(...)` from [`remix/
|
|
250
|
+
`render(...)` from [`remix/component/test`](../src/component/test/README.md) mounts the component, flushes its initial render, and returns helpers for querying and interacting with the DOM:
|
|
251
251
|
|
|
252
252
|
```tsx filename=app/actions/albums/favorite-button.test.browser.tsx
|
|
253
253
|
import * as assert from "remix/assert";
|
|
254
254
|
import { describe, it } from "remix/test";
|
|
255
|
-
import { render } from "remix/
|
|
255
|
+
import { render } from "remix/component/test";
|
|
256
256
|
|
|
257
257
|
import { FavoriteButton } from "./public/favorite-button.tsx";
|
|
258
258
|
|
|
@@ -13,7 +13,7 @@ The installed `remix` package provides the CLI and the runtime subpaths used by
|
|
|
13
13
|
|
|
14
14
|
## Create an app with remix new {#remix-new}
|
|
15
15
|
|
|
16
|
-
Use `npx remix
|
|
16
|
+
Use `npx remix new <target-dir>` before the package is installed locally, or `remix new <target-dir>` afterward. Cover `--app-name`, `--force`, the generated `app/actions` layout, and the scripts in the starter `package.json`.
|
|
17
17
|
|
|
18
18
|
## Inspect route ownership with remix routes {#remix-routes}
|
|
19
19
|
|
|
@@ -37,7 +37,7 @@ Cover `remix help`, command-level `--help`, `remix version` and `--version`, glo
|
|
|
37
37
|
|
|
38
38
|
## TypeScript and JSX setup {#typescript-and-jsx-setup}
|
|
39
39
|
|
|
40
|
-
Explain the generated `NodeNext` module settings, `.ts` extensions in relative imports, type-only imports, `jsx: react-jsx`, and `jsxImportSource: remix/
|
|
40
|
+
Explain the generated `NodeNext` module settings, `.ts` extensions in relative imports, type-only imports, `jsx: react-jsx`, and `jsxImportSource: remix/component`. Keep `tsc --noEmit` as a separate typecheck because runtime transformation is not type checking.
|
|
41
41
|
|
|
42
42
|
## Run source files with remix/node-tsx {#using-remix-node-tsx}
|
|
43
43
|
|