Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Form actions #6469

Merged
merged 93 commits into from
Sep 6, 2022
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
93 commits
Select commit Hold shift + click to select a range
1c3ba7a
basic actions method
dummdidumm Aug 31, 2022
9ac19bc
add form store
dummdidumm Aug 31, 2022
7b71fa3
remove export let errors in favor of form store
dummdidumm Aug 31, 2022
d880096
make actions an object
dummdidumm Aug 31, 2022
102851b
implement FilesFormData
dummdidumm Sep 1, 2022
e650cb3
restrict values type, fix conversion, switch argument order
dummdidumm Sep 1, 2022
678361d
Merge branch 'master' into form-actions
dummdidumm Sep 1, 2022
5ece9c0
validation->invalid
dummdidumm Sep 1, 2022
9c9dfbe
start writing docs
dummdidumm Sep 1, 2022
b11c578
implement handleFile
dummdidumm Sep 1, 2022
3450eb4
fix tests
dummdidumm Sep 1, 2022
38a253c
test for files
dummdidumm Sep 1, 2022
2da72d9
complete migration message
dummdidumm Sep 1, 2022
62fa93c
support validation error thrown in endpoints
dummdidumm Sep 1, 2022
225be5c
infer file type from handleFile hook
dummdidumm Sep 1, 2022
7ce58ea
add handleFile to build
dummdidumm Sep 1, 2022
9dcb6be
types, cleanup
dummdidumm Sep 1, 2022
f1941a5
$form -> $submitted
dummdidumm Sep 1, 2022
71ed352
woops
dummdidumm Sep 1, 2022
ba0f204
allow arbitrary data on invalid, persist data in success case
dummdidumm Sep 2, 2022
f6be2c5
fix infered FileType
dummdidumm Sep 2, 2022
d8e7137
give JSON response a well-defined shape
dummdidumm Sep 2, 2022
b2e30b9
provide form state through form prop and $page.form
dummdidumm Sep 2, 2022
29294d0
return invalid instead of throwing it
dummdidumm Sep 2, 2022
ba4aa8f
types for actions
dummdidumm Sep 2, 2022
be35a3a
Merge remote-tracking branch 'origin/master' into form-actions
dummdidumm Sep 2, 2022
855c1b8
fix, skip test
dummdidumm Sep 2, 2022
09c10af
Merge remote-tracking branch 'origin/master' into form-actions
dummdidumm Sep 3, 2022
2a14eaf
updateForm (simple version)
dummdidumm Sep 3, 2022
792b659
making a start at enhance
dummdidumm Sep 3, 2022
ca3cae9
bye bye method overrides
dummdidumm Sep 3, 2022
8d9fe0b
update create-svelte default template
dummdidumm Sep 3, 2022
823153f
Merge remote-tracking branch 'origin/master' into form-actions
dummdidumm Sep 3, 2022
6213bf0
remove handleFile
dummdidumm Sep 4, 2022
646013b
full blown enhance and updateForm through $app/forms
dummdidumm Sep 4, 2022
d82933f
fix type reference
dummdidumm Sep 4, 2022
16f1ad4
adjust default template
dummdidumm Sep 4, 2022
9eef9a7
remove $page.form for now (too much of a footgun due to resets)
dummdidumm Sep 4, 2022
f17ef01
tests, ensure form is only reset on page changes
dummdidumm Sep 4, 2022
c208888
?????
dummdidumm Sep 4, 2022
f0d0222
cleanup
dummdidumm Sep 4, 2022
d43a085
docs about multiple forms
dummdidumm Sep 4, 2022
f3926c4
make docs build
dummdidumm Sep 4, 2022
1045828
lint
Rich-Harris Sep 4, 2022
2123945
fix
Rich-Harris Sep 4, 2022
e1aadca
add toggle action
Rich-Harris Sep 4, 2022
b19bb4b
rename generated FormData type to ActionData
Rich-Harris Sep 4, 2022
2647a10
reset form on navigation, not invalidation
Rich-Harris Sep 5, 2022
0eb68e2
silence missing/unused form prop warnings, DRY out code a bit
Rich-Harris Sep 5, 2022
6fe185a
Update packages/kit/src/runtime/server/page/actions.js
Rich-Harris Sep 5, 2022
21b9f46
Update packages/kit/src/runtime/server/page/actions.js
Rich-Harris Sep 5, 2022
7bc739e
Update packages/kit/src/runtime/server/page/actions.js
Rich-Harris Sep 5, 2022
c3c8369
change message to reference actions
Rich-Harris Sep 5, 2022
732093c
Update packages/kit/src/runtime/server/page/actions.js
Rich-Harris Sep 5, 2022
333b87a
Update packages/kit/src/runtime/server/page/actions.js
Rich-Harris Sep 5, 2022
b561522
be more specific about what content-type is accepted
Rich-Harris Sep 5, 2022
e17acfd
Update packages/kit/src/runtime/server/page/render.js
Rich-Harris Sep 5, 2022
a3a21ca
Update packages/kit/src/runtime/server/page/render.js
Rich-Harris Sep 5, 2022
260e0d4
unskip test
Rich-Harris Sep 5, 2022
fa78907
Merge branch 'form-actions' of github.com:sveltejs/kit into form-actions
Rich-Harris Sep 5, 2022
7c8c360
fix types
dummdidumm Sep 5, 2022
b081e8c
tweak enhance function and make todos example work again
dummdidumm Sep 5, 2022
60b8341
changeset
dummdidumm Sep 5, 2022
3be2e9e
deduplicate type usage
dummdidumm Sep 5, 2022
4119273
merge
Rich-Harris Sep 5, 2022
26908ba
lint
dummdidumm Sep 5, 2022
34e515b
wording, make updateForm (now applySubmissionResult) more powerful
dummdidumm Sep 5, 2022
e62a6ea
fix, docs
dummdidumm Sep 5, 2022
91b143c
applySubmissionResult -> applyAction
dummdidumm Sep 5, 2022
b94a89a
change enhance function signature
dummdidumm Sep 5, 2022
5c15c23
fix template
dummdidumm Sep 5, 2022
3275ebb
fix template
dummdidumm Sep 5, 2022
45580ac
merge
Rich-Harris Sep 5, 2022
d11c059
Update .changeset/spicy-pugs-applaud.md
Rich-Harris Sep 5, 2022
4198872
Update packages/kit/src/runtime/app/forms.js
Rich-Harris Sep 6, 2022
a09aad3
Apply suggestions from code review
Rich-Harris Sep 6, 2022
840f714
prettier
Rich-Harris Sep 6, 2022
7eb124b
rename SubmissionResult to ActionResult
Rich-Harris Sep 6, 2022
7ff7e7f
Update packages/kit/types/ambient.d.ts
Rich-Harris Sep 6, 2022
ff023d4
Update packages/kit/types/ambient.d.ts
Rich-Harris Sep 6, 2022
1b194bb
updateForm -> applyAction
Rich-Harris Sep 6, 2022
7b6e988
use RequestEvent instead of ActionEvent
Rich-Harris Sep 6, 2022
9385bd0
fix
Rich-Harris Sep 6, 2022
c393862
invalidate first, delegate redirect/error handling to applyAction
Rich-Harris Sep 6, 2022
bb910be
remove token stuff, simplify a bit
Rich-Harris Sep 6, 2022
e28d384
show +error page without reloading route
Rich-Harris Sep 6, 2022
7ee1425
check action return data can be serialized as JSON
Rich-Harris Sep 6, 2022
3906922
add note to render.js
Rich-Harris Sep 6, 2022
0f1f5c3
merge FetchFormResponse and ActionResult
Rich-Harris Sep 6, 2022
d899b30
update docs
Rich-Harris Sep 6, 2022
5ee4fcd
remove logging
Rich-Harris Sep 6, 2022
a0d7580
tiny docs tweak
dummdidumm Sep 6, 2022
f2cdab2
merge
Rich-Harris Sep 6, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 1 addition & 66 deletions documentation/docs/03-routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,72 +114,7 @@ Like `+page.js`, `+page.server.js` can export [page options](/docs/page-options)

#### Actions

`+page.server.js` can also declare _actions_, which correspond to the `POST`, `PATCH`, `PUT` and `DELETE` HTTP methods. A request made to the page with one of these methods will invoke the corresponding action before rendering the page.

An action can return a `{ status?, errors }` object if there are validation errors (`status` defaults to `400`), or an optional `{ location }` object to redirect the user to another page:

```js
/// file: src/routes/login/+page.server.js

// @filename: ambient.d.ts
declare global {
const createSessionCookie: (userid: string) => string;
const hash: (content: string) => string;
const db: {
findUser: (name: string) => Promise<{
id: string;
username: string;
password: string;
}>
}
}

export {};

// @filename: index.js
// ---cut---
import { error } from '@sveltejs/kit';

/** @type {import('./$types').Action} */
export async function POST({ request, setHeaders, url }) {
const values = await request.formData();

const username = /** @type {string} */ (values.get('username'));
const password = /** @type {string} */ (values.get('password'));

const user = await db.findUser(username);

if (!user) {
return {
status: 403,
errors: {
username: 'No user with this username'
}
};
}

if (user.password !== hash(password)) {
return {
status: 403,
errors: {
password: 'Incorrect password'
}
};
}

setHeaders({
'set-cookie': createSessionCookie(user.id)
});

return {
location: url.searchParams.get('redirectTo') ?? '/'
};
}
```

If validation `errors` are returned, they will be available inside `+page.svelte` as `export let errors`.

> The actions API will likely change in the near future: https://github.com/sveltejs/kit/discussions/5875
`+page.server.js` can also declare _actions_ which are specifically designed for form interactions. It enables things like preserving user input in case of a full page reload with validation errors while making progressive enhancement through JavaScript possible. You can learn more about them in [form actions](/docs/form-actions).

### +error

Expand Down
254 changes: 254 additions & 0 deletions documentation/docs/06-form-actions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
---
title: Form Actions
---

`+page.server.js` can declare _actions_ which are specifically designed for form interactions. It enables things like preserving user input in case of a full page reload with validation errors while making progressive enhancement through JavaScript possible.

## Defining actions by name

Actions are defined through `export const actions = {...}`, with each key being the name of the action and the value being the function that is invoked when the form with that action is submitted. A `POST` request made to the page will invoke the corresponding action using a query parameter that start's with a `/` - so for example `POST todos?/addTodo` will invoke the `addTodo` action. The `default` action is called when no such query parameter is given.

```svelte
/// file: src/routes/todos/+page.svelte
<script>
/** @type {import('./$types').PageData} */
export let data;
</script>

<form action="?/addTodo" method="post">
<input type="text" name="text" />
<button>Add todo</button>
</form>

<ul>
{#each data.todos as todo}
<li>
<form action="?/editTodo" method="post">
<input type="hidden" name="id" value={todo.id} />
<input type="text" name="text" value={todo.text} />
<button>Edit todo</button>
</form>
</li>
{/each}
</ul>
```

```js
/// file: src/routes/todos/+page.server.js
/** @type {import('./$types').Actions} */
export const actions = {
addTodo: (event) => {
// ...
},
editTodo: (event) => {
// ...
}
};
```

## Files and strings are separated

Since `actions` are meant to be used with forms, we can make your life easier by awaiting the `FormData` and separating the form fields which contain strings from those who contain files and running the latter through the [`handleFile`](/docs/hooks#handleFile) hook before passing it as `fields` and `files` into the action function.

```js
/// file: src/routes/todos/+page.server.js
/** @type {import('./$types').Actions} */
export const actions = {
default: ({ fields, files }) => {
const name = fields.get('name'); // typed as string
const image = files.get('image'); // typed as the return type of the handleFile hook
// ...
}
};
```

## Validation

A core part of form submissions is validation. For this, an action can `throw` the `invalid` helper method exported from `@sveltejs/kit` if there are validation errors. `invalid` expects a `status`, possibly the form `values` (make sure to remove any user sensitive information such as passwords) and an `error` object. In case of a native form submit they populate the `$submitted` store which is available inside your components so you can preserve user input.

```js
/// file: src/routes/login/+page.server.js

// @filename: ambient.d.ts
declare global {
const db: {
findUser: (name: string) => Promise<{
id: string;
username: string;
password: string;
}>
}
}

export {};

// @filename: index.js
// ---cut---
import { invalid } from '@sveltejs/kit';

/** @type {import('./$types').Actions} */
export const actions = {
default: async ({ fields, setHeaders, url }) => {
const username = fields.get('username');
const password = fields.get('password');

const user = await db.findUser(username);

if (!user) {
throw invalid(403, { username }, {
username: 'No user with this username'
});
}

// ...
}
};
```

```svelte
/// file: src/routes/login/+page.svelte
<script>
import { submitted } from '$app/stores';
</script>

<form action="?/addTodo" method="post">
<input type="text" name="username" value={$submitted?.values?.username} />
{#if $submitted?.errors?.username}
<span>{$submitted?.errors?.username}</span>
{/if}
<input type="password" name="password" />
<button>Login</button>
</form>
```

## Success

If everything is valid, an action can return a JSON object with data that is part of the JSON response in the case of a JavaScript fetch - it's discarded in case of a full page reload. Alternatively it can `throw` a `redirect` to redirect the user to another page.

```js
/// file: src/routes/login/+page.server.js
import { redirect } from '@sveltejs/kit';

/** @type {import('./$types').Actions} */
export const actions = {
default: async ({ url }) => {
// ...

if (url.searchParams.get('redirectTo')) {
throw redirect(303, url.searchParams.get('redirectTo'));
} else {
return {
success: true
};
}
}
};
```

## Progressive enhancement

So far, all the code examples run native form submissions - that is, when the user pressed the submit button, the page is reloaded. It's good that this use case is supported since JavaScript may not be loaded all the time. When it is though, it might be a better user experience to use the powers JavaScript gives us to provide a better user experience - this is called progressive enhancement.

First we need to ensure that the page is _not_ reloaded on submission. For this, we prevent the default behavior. Afterwards, we run our JavaScript code instead which does the form submission through `fetch` instead.

```svelte
/// file: src/routes/login/+page.svelte
<script>
import { submitted } from '$app/stores';
import { invalidateAll, goto } from '$app/navigation';

async function login(event) {
event.preventDefault(); // prevent native form submission
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we calling preventDefault again here?
The form already has on:submit|preventDefault={login}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good catch, this should be deduplicated - not sure which way though. |preventDefault is more Svelte-like, but as a reader you may not have seen that before, and with event.preventDefault we can put an explanatory comment next to it - so I probably lean towards the latter.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel |preventDefault looks clean and makes sense for docs to use more of svelte way.
But yeah we loose comment option.

const data = new FormData(this); // create FormData
const response = await fetch(this.action, { // call action using fetch
method: 'POST',
headers: {
accept: 'application/json'
},
body: data
});
const { errors, location, values } = await response.json(); // destructure response object
if (response.ok) { // success, redirect
invalidateAll();
goto(location);
} else { // validation error, update $submitted store
$submitted = { errors, values } };
}
}
</script>

<form action="?/addTodo" method="post" on:submit|preventDefault={login}>
<input type="text" name="username" value={$submitted?.values?.username} />
{#if $submitted?.errors.username}
<span>{$submitted.errors.username}</span>
{/if}
<input type="password" name="password" />
<button>Login</button>
</form>
```

## `<Form>` component

As you can see, progressive enhancement is doable, but it may become a little cumbersome over time. That's why we will soon provide an opinionated wrapper component which does all the heavy lifting for you. Here's how the same login page would look like using the `<Form>` component:

```svelte
/// file: src/routes/login/+page.svelte
<script>
import { Form } from '@sveltejs/kit';
import { goto } from '$app/navigation';

async function redirect({ location }) {
goto(location);
}
</script>

<Form action="?/addTodo" on:success={redirect} let:errors let:values>
<input type="text" name="username" value={values?.username} />
{#if errors?.username}
<span>{errors.username}</span>
{/if}
<input type="password" name="password" />
<button>Login</button>
</Form>
```

## Alternatives

In case you don't need your forms to work without JavaScript, you want to use HTTP verbs other than `POST`, or you want to send arbitrary JSON instead of being restricted to `FormData`, then you can resort to interacting with your API through `+server.js` endpoints (which will be possible to place next to `+page` files, soon).

```svelte
<script>
import { invalidateAll, goto } from '$app/navigation';

let errors = {};

async function login(event) {
event.preventDefault(); // prevent native form submission
const data = Object.fromEntries(new FormData(this)); // create JSON from FormData
const response = await fetch('/api/login', { // call your API using fetch
method: 'POST',
headers: {
accept: 'application/json'
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
const json = await response.json(); // destructure response object
if (response.ok) { // success, redirect
invalidateAll();
goto(json.location);
} else { // validation error, errors variable
errors = json.errors;
}
}
</script>

<form on:submit|preventDefault={login}>
<input type="text" name="username" />
{#if errors.username}
<span>{errors.username}</span>
{/if}
<input type="password" name="password" />
<button>Login</button>
</form>
```
17 changes: 17 additions & 0 deletions documentation/docs/06-hooks.md → documentation/docs/07-hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,23 @@ export function handleError({ error, event }) {

> `handleError` is only called for _unexpected_ errors. It is not called for errors created with the [`error`](/docs/modules#sveltejs-kit-error) function imported from `@sveltejs/kit`, as these are _expected_ errors.

### handleFile

Any files encountered during processing of [form actions](/docs/form-actions) are routed through this handler before being passed into the action function. The return value of `handleFile` becomes the type of each entry of the property `files` on the action; promises are awaited.

```js
/// file: src/hooks.js
// @filename: ambient.d.ts
const ImageService: any;

// @filename: index.js
// ---cut---
/** @type {import('@sveltejs/kit').HandleFile} */
export function handleFile({ file }) {
return ImageService.upload(file);
}
```

### externalFetch

This function allows you to modify (or replace) a `fetch` request for an external resource that happens inside a `load` function that runs on the server (or during pre-rendering).
Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
9 changes: 5 additions & 4 deletions packages/kit/src/core/sync/write_root.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,16 @@ export function write_root(manifest_data, output) {

let l = max_depth;

let pyramid = `<svelte:component this={components[${l}]} data={data_${l}} {errors}/>`;
let pyramid = `<svelte:component this={components[${l}]} data={data_${l}} />`;

while (l--) {
pyramid = `
{#if components[${l + 1}]}
<svelte:component this={components[${l}]} data={data_${l}} {errors}>
<svelte:component this={components[${l}]} data={data_${l}}>
${pyramid.replace(/\n/g, '\n\t\t\t\t\t')}
</svelte:component>
{:else}
<svelte:component this={components[${l}]} data={data_${l}} {errors} />
<svelte:component this={components[${l}]} data={data_${l}} />
{/if}
`
.replace(/^\t\t\t/gm, '')
Expand All @@ -51,13 +51,14 @@ export function write_root(manifest_data, output) {

export let components;
${levels.map((l) => `export let data_${l} = null;`).join('\n\t\t\t\t')}
export let errors;
export let submitted;

if (!browser) {
setContext('__svelte__', stores);
}

$: stores.page.set(page);
$: stores.submitted.set(submitted);
afterUpdate(stores.page.notify);

let mounted = false;
Expand Down
Loading