Skip to content

Commit 4c77bcd

Browse files
Rich-Harrisbenmccanndummdidumm
authored
Validated forms (#14383)
* add convert_formdata function * WIP * fix some types * fix test * fix * fix test * missed a spot * simplify a bit * add validation * maybe this? the type stuff is too complicated for me * get rid of dot/array notation, it adds too much complexity * tighten up * fix * fix * oops * revert * sort of working * implement input/issues * make input reactive * enforce 1:1 relationship * fix * lint * WIP * WIP * tidy up * lint * lint * fix * only enforce input parity * use validation output, not input * preflight should return instance * DRY out * implement preflight * WIP validate method * programmatic validation * fix/tidy * generate types * docs * add field(...) method * more docs * oops * fix test * fix docs * Apply suggestions from code review * guard against prototype pollution * doc tweaks, fixes * add valibot to playground * changesets * tell people about the breaking change * remind ourselves to remove the warning * fix changeset * filter out files * better file handling * widen helper type to also catch unknown/the general type which will all result in infinite recursion type errors * mentiond coercion * only reset after a successful submission * same for buttonProps * typo * redact sensitive info * document underscore * regenerate * rename * regenerate * guard against array prototype pollution --------- Co-authored-by: Ben McCann <[email protected]> Co-authored-by: Simon Holthausen <[email protected]>
1 parent 3120d17 commit 4c77bcd

File tree

27 files changed

+1622
-434
lines changed

27 files changed

+1622
-434
lines changed

.changeset/khaki-forks-learn.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@sveltejs/kit': minor
3+
---
4+
5+
feat: enhance remote form functions with schema support, `input` and `issues` properties

.changeset/olive-dodos-flash.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@sveltejs/kit': minor
3+
---
4+
5+
breaking: remote form functions get passed a parsed POJO instead of a `FormData` object now

documentation/docs/20-core-concepts/60-remote-functions.md

Lines changed: 233 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,7 @@ export const getWeather = query.batch(v.string(), async (cities) => {
227227
228228
## form
229229
230-
The `form` function makes it easy to write data to the server. It takes a callback that receives the current [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData)...
230+
The `form` function makes it easy to write data to the server. It takes a callback that receives `data` constructed from the submitted [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData)...
231231
232232
233233
```ts
@@ -259,30 +259,28 @@ export const getPosts = query(async () => { /* ... */ });
259259

260260
export const getPost = query(v.string(), async (slug) => { /* ... */ });
261261

262-
export const createPost = form(async (data) => {
263-
// Check the user is logged in
264-
const user = await auth.getUser();
265-
if (!user) error(401, 'Unauthorized');
266-
267-
const title = data.get('title');
268-
const content = data.get('content');
269-
270-
// Check the data is valid
271-
if (typeof title !== 'string' || typeof content !== 'string') {
272-
error(400, 'Title and content are required');
262+
export const createPost = form(
263+
v.object({
264+
title: v.pipe(v.string(), v.nonEmpty()),
265+
content:v.pipe(v.string(), v.nonEmpty())
266+
}),
267+
async ({ title, content }) => {
268+
// Check the user is logged in
269+
const user = await auth.getUser();
270+
if (!user) error(401, 'Unauthorized');
271+
272+
const slug = title.toLowerCase().replace(/ /g, '-');
273+
274+
// Insert into the database
275+
await db.sql`
276+
INSERT INTO post (slug, title, content)
277+
VALUES (${slug}, ${title}, ${content})
278+
`;
279+
280+
// Redirect to the newly created page
281+
redirect(303, `/blog/${slug}`);
273282
}
274-
275-
const slug = title.toLowerCase().replace(/ /g, '-');
276-
277-
// Insert into the database
278-
await db.sql`
279-
INSERT INTO post (slug, title, content)
280-
VALUES (${slug}, ${title}, ${content})
281-
`;
282-
283-
// Redirect to the newly created page
284-
redirect(303, `/blog/${slug}`);
285-
});
283+
);
286284
```
287285
288286
...and returns an object that can be spread onto a `<form>` element. The callback is called whenever the form is submitted.
@@ -310,7 +308,184 @@ export const createPost = form(async (data) => {
310308
</form>
311309
```
312310
313-
The form object contains `method` and `action` properties that allow it to work without JavaScript (i.e. it submits data and reloads the page). It also has an `onsubmit` handler that progressively enhances the form when JavaScript is available, submitting data *without* reloading the entire page.
311+
As with `query`, if the callback uses the submitted `data`, it should be [validated](#query-Query-arguments) by passing a [Standard Schema](https://standardschema.dev) as the first argument to `form`. The one difference is to `query` is that the schema inputs must all be of type `string` or `File`, since that's all the original `FormData` provides. You can however coerce the value into a different type — how to do that depends on the validation library you use.
312+
313+
```ts
314+
/// file: src/routes/count.remote.js
315+
import * as v from 'valibot';
316+
import { form } from '$app/server';
317+
318+
export const setCount = form(
319+
v.object({
320+
// Valibot:
321+
count: v.pipe(v.string(), v.transform((s) => Number(s)), v.number()),
322+
// Zod:
323+
// count: v.coerce.number()
324+
}),
325+
async ({ count }) => {
326+
// ...
327+
}
328+
);
329+
```
330+
331+
The `name` attributes on the form controls must correspond to the properties of the schema — `title` and `content` in this case. If you schema contains objects, use object notation:
332+
333+
```svelte
334+
<!--
335+
results in a
336+
{
337+
name: { first: string, last: string },
338+
jobs: Array<{ title: string, company: string }>
339+
}
340+
object
341+
-->
342+
<input name="name.first" />
343+
<input name="name.last" />
344+
{#each jobs as job, idx}
345+
<input name="jobs[{idx}].title">
346+
<input name="jobs[{idx}].company">
347+
{/each}
348+
```
349+
350+
To indicate a repeated field, use a `[]` suffix:
351+
352+
```svelte
353+
<label><input type="checkbox" name="language[]" value="html" /> HTML</label>
354+
<label><input type="checkbox" name="language[]" value="css" /> CSS</label>
355+
<label><input type="checkbox" name="language[]" value="js" /> JS</label>
356+
```
357+
358+
If you'd like type safety and autocomplete when setting `name` attributes, use the form object's `field` method:
359+
360+
```svelte
361+
<label>
362+
<h2>Title</h2>
363+
<input name={+++createPost.field('title')+++} />
364+
</label>
365+
```
366+
367+
This will error during typechecking if `title` does not exist on your schema.
368+
369+
The form object contains `method` and `action` properties that allow it to work without JavaScript (i.e. it submits data and reloads the page). It also has an [attachment](/docs/svelte/@attach) that progressively enhances the form when JavaScript is available, submitting data *without* reloading the entire page.
370+
371+
### Validation
372+
373+
If the submitted data doesn't pass the schema, the callback will not run. Instead, the form object's `issues` object will be populated:
374+
375+
```svelte
376+
<form {...createPost}>
377+
<label>
378+
<h2>Title</h2>
379+
380+
+++ {#if createPost.issues.title}
381+
{#each createPost.issues.title as issue}
382+
<p class="issue">{issue.message}</p>
383+
{/each}
384+
{/if}+++
385+
386+
<input
387+
name="title"
388+
+++aria-invalid={!!createPost.issues.title}+++
389+
/>
390+
</label>
391+
392+
<label>
393+
<h2>Write your post</h2>
394+
395+
+++ {#if createPost.issues.content}
396+
{#each createPost.issues.content as issue}
397+
<p class="issue">{issue.message}</p>
398+
{/each}
399+
{/if}+++
400+
401+
<textarea
402+
name="content"
403+
+++aria-invalid={!!createPost.issues.content}+++
404+
></textarea>
405+
</label>
406+
407+
<button>Publish!</button>
408+
</form>
409+
```
410+
411+
You don't need to wait until the form is submitted to validate the data — you can call `validate()` programmatically, for example in an `oninput` callback (which will validate the data on every keystroke) or an `onchange` callback:
412+
413+
```svelte
414+
<form {...createPost} oninput={() => createPost.validate()}>
415+
<!-- -->
416+
</form>
417+
```
418+
419+
By default, issues will be ignored if they belong to form controls that haven't yet been interacted with. To validate _all_ inputs, call `validate({ includeUntouched: true })`.
420+
421+
For client-side validation, you can specify a _preflight_ schema which will populate `issues` and prevent data being sent to the server if the data doesn't validate:
422+
423+
```svelte
424+
<script>
425+
import * as v from 'valibot';
426+
import { createPost } from '../data.remote';
427+
428+
const schema = v.object({
429+
title: v.pipe(v.string(), v.nonEmpty()),
430+
content:v.pipe(v.string(), v.nonEmpty())
431+
});
432+
</script>
433+
434+
<h1>Create a new post</h1>
435+
436+
<form {...+++createPost.preflight(schema)+++}>
437+
<!-- -->
438+
</form>
439+
```
440+
441+
> [!NOTE] The preflight schema can be the same object as your server-side schema, if appropriate, though it won't be able to do server-side checks like 'this value already exists in the database'. Note that you cannot export a schema from a `.remote.ts` or `.remote.js` file, so the schema must either be exported from a shared module, or from a `<script module>` block in the component containing the `<form>`.
442+
443+
### Live inputs
444+
445+
The form object contains a `input` property which reflects its current value. As the user interacts with the form, `input` is automatically updated:
446+
447+
```svelte
448+
<form {...createPost}>
449+
<!-- -->
450+
</form>
451+
452+
<div class="preview">
453+
<h2>{createPost.input.title}</h2>
454+
<div>{@html render(createPost.input.content)}</div>
455+
</div>
456+
```
457+
458+
### Handling sensitive data
459+
460+
In the case of a non-progressively-enhanced form submission (i.e. where JavaScript is unavailable, for whatever reason) `input` is also populated if the submitted data is invalid, so that the user does not need to fill the entire form out from scratch.
461+
462+
You can prevent sensitive data (such as passwords and credit card numbers) from being sent back to the user by using a name with a leading underscore:
463+
464+
```svelte
465+
<form {...register}>
466+
<label>
467+
Username
468+
<input
469+
name="username"
470+
value={register.input.username}
471+
aria-invalid={!!register.issues.username}
472+
/>
473+
</label>
474+
475+
<label>
476+
Password
477+
<input
478+
type="password"
479+
+++name="_password"+++
480+
+++aria-invalid={!!register.issues._password}+++
481+
/>
482+
</label>
483+
484+
<button>Sign up!</button>
485+
</form>
486+
```
487+
488+
In this example, if the data does not validate, only the first `<input>` will be populated when the page reloads.
314489
315490
### Single-flight mutations
316491
@@ -331,25 +506,31 @@ export const getPosts = query(async () => { /* ... */ });
331506

332507
export const getPost = query(v.string(), async (slug) => { /* ... */ });
333508

334-
export const createPost = form(async (data) => {
335-
// form logic goes here...
509+
export const createPost = form(
510+
v.object({/* ... */}),
511+
async (data) => {
512+
// form logic goes here...
336513

337-
// Refresh `getPosts()` on the server, and send
338-
// the data back with the result of `createPost`
339-
+++await getPosts().refresh();+++
514+
// Refresh `getPosts()` on the server, and send
515+
// the data back with the result of `createPost`
516+
+++await getPosts().refresh();+++
340517

341-
// Redirect to the newly created page
342-
redirect(303, `/blog/${slug}`);
343-
});
518+
// Redirect to the newly created page
519+
redirect(303, `/blog/${slug}`);
520+
}
521+
);
344522

345-
export const updatePost = form(async (data) => {
346-
// form logic goes here...
347-
const result = externalApi.update(post);
523+
export const updatePost = form(
524+
v.object({/* ... */}),
525+
async (data) => {
526+
// form logic goes here...
527+
const result = externalApi.update(post);
348528

349-
// The API already gives us the updated post,
350-
// no need to refresh it, we can set it directly
351-
+++await getPost(post.id).set(result);+++
352-
});
529+
// The API already gives us the updated post,
530+
// no need to refresh it, we can set it directly
531+
+++await getPost(post.id).set(result);+++
532+
}
533+
);
353534
```
354535
355536
The second is to drive the single-flight mutation from the client, which we'll see in the section on [`enhance`](#form-enhance).
@@ -387,11 +568,14 @@ export const getPosts = query(async () => { /* ... */ });
387568
export const getPost = query(v.string(), async (slug) => { /* ... */ });
388569

389570
// ---cut---
390-
export const createPost = form(async (data) => {
391-
// ...
571+
export const createPost = form(
572+
v.object({/* ... */}),
573+
async (data) => {
574+
// ...
392575

393-
return { success: true };
394-
});
576+
return { success: true };
577+
}
578+
);
395579
```
396580
397581
```svelte
@@ -402,7 +586,9 @@ export const createPost = form(async (data) => {
402586

403587
<h1>Create a new post</h1>
404588

405-
<form {...createPost}><!-- ... --></form>
589+
<form {...createPost}>
590+
<!-- -->
591+
</form>
406592

407593
{#if createPost.result?.success}
408594
<p>Successfully published!</p>
@@ -438,9 +624,7 @@ We can customize what happens when the form is submitted with the `enhance` meth
438624
showToast('Oh no! Something went wrong');
439625
}
440626
})}>
441-
<input name="title" />
442-
<textarea name="content"></textarea>
443-
<button>publish</button>
627+
<!-- -->
444628
</form>
445629
```
446630
@@ -517,7 +701,7 @@ The `command` function, like `form`, allows you to write data to the server. Unl
517701
518702
> [!NOTE] Prefer `form` where possible, since it gracefully degrades if JavaScript is disabled or fails to load.
519703
520-
As with `query`, if the function accepts an argument, it should be [validated](#query-Query-arguments) by passing a [Standard Schema](https://standardschema.dev) as the first argument to `command`.
704+
As with `query` and `form`, if the function accepts an argument, it should be [validated](#query-Query-arguments) by passing a [Standard Schema](https://standardschema.dev) as the first argument to `command`.
521705
522706
```ts
523707
/// file: likes.remote.js

0 commit comments

Comments
 (0)