diff --git a/.changeset/shaky-ties-look.md b/.changeset/shaky-ties-look.md new file mode 100644 index 000000000000..16d5acb30264 --- /dev/null +++ b/.changeset/shaky-ties-look.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': minor +--- + +breaking: update experimental `form` API diff --git a/documentation/docs/20-core-concepts/60-remote-functions.md b/documentation/docs/20-core-concepts/60-remote-functions.md index 4d7e637b1d2b..01633b9b7edc 100644 --- a/documentation/docs/20-core-concepts/60-remote-functions.md +++ b/documentation/docs/20-core-concepts/60-remote-functions.md @@ -229,7 +229,6 @@ export const getWeather = query.batch(v.string(), async (cities) => { 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)... - ```ts /// file: src/routes/blog/data.remote.js // @filename: ambient.d.ts @@ -293,115 +292,188 @@ export const createPost = form(

Create a new post

+
+ + + +
+``` + +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. + +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`. + +### Fields + +A form is composed of a set of _fields_, which are defined by the schema. In the case of `createPost`, we have two fields, `title` and `content`, which are both strings. To get the attributes for a field, call its `.as(...)` method, specifying which [input type](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input#input_types) to use: + +```svelte
``` -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. +These attributes allow SvelteKit to set the correct input type, set a `name` that is used to construct the `data` passed to the handler, populate the `value` of the form (for example following a failed submission, to save the user having to re-enter everything), and set the [`aria-invalid`](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-invalid) state. -```ts -/// file: src/routes/count.remote.js +Fields can be nested in objects and arrays, and their values can be strings, numbers, booleans or `File` objects. For example, if your schema looked like this... + +```js +/// file: data.remote.js import * as v from 'valibot'; import { form } from '$app/server'; - -export const setCount = form( - v.object({ - // Valibot: - count: v.pipe(v.string(), v.transform((s) => Number(s)), v.number()), - // Zod: - // count: z.coerce.number() +// ---cut--- +const datingProfile = v.object({ + name: v.string(), + photo: v.file(), + info: v.object({ + height: v.number(), + likesDogs: v.optional(v.boolean(), false) }), - async ({ count }) => { - // ... - } -); + attributes: v.array(v.string()) +}); + +export const createProfile = form(datingProfile, (data) => { /* ... */ }); ``` -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: +...your form could look like this: ```svelte - - - -{#each jobs as job, idx} - - -{/each} + + +
+ + + + + + + + +

My best attributes

+ + + + + +
``` -To indicate a repeated field, use a `[]` suffix: +Because our form contains a `file` input, we've added an `enctype="multipart/form-data"` attribute. The values for `info.height` and `info.likesDogs` are coerced to a number and a boolean respectively. + +> [!NOTE] If a `checkbox` input is unchecked, the value is not included in the [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData) object that SvelteKit constructs the data from. As such, we have to make the value optional in our schema. In Valibot that means using `v.optional(v.boolean(), false)` instead of just `v.boolean()`, whereas in Zod it would mean using `z.coerce.boolean()`. + +In the case of `radio` and `checkbox` inputs that all belong to the same field, the `value` must be specified as a second argument to `.as(...)`: + +```js +/// file: data.remote.js +import * as v from 'valibot'; +import { form } from '$app/server'; +// ---cut--- +export const survey = form( + v.object({ + operatingSystem: v.picklist(['windows', 'mac', 'linux']), + languages: v.optional(v.array(v.picklist(['html', 'css', 'js'])), []) + }), + (data) => { /* ... */ } +); +``` ```svelte - - - +
+

Which operating system do you use?

+ + {#each ['windows', 'mac', 'linux'] as os} + + {/each} + +

Which languages do you write code in?

+ + {#each ['html', 'css', 'js'] as language} + + {/each} + + +
``` -If you'd like type safety and autocomplete when setting `name` attributes, use the form object's `field` method: +Alternatively, you could use `select` and `select multiple`: ```svelte - -``` +
+

Which operating system do you use?

-This will error during typechecking if `title` does not exist on your schema. + -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. +

Which languages do you write code in?

+ + + + +
+``` + +> [!NOTE] As with unchecked `checkbox` inputs, if no selections are made then the data will be `undefined`. For this reason, the `languages` field uses `v.optional(v.array(...), [])` rather than just `v.array(...)`. ### Validation -If the submitted data doesn't pass the schema, the callback will not run. Instead, the form object's `issues` object will be populated: +If the submitted data doesn't pass the schema, the callback will not run. Instead, each invalid field's `issues()` method will return an array of `{ message: string }` objects, and the `aria-invalid` attribute (returned from `as(...)`) will be set to `true`: ```svelte
@@ -418,7 +490,7 @@ You don't need to wait until the form is submitted to validate the data — you 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 })`. -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: +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: ```svelte +``` + ### Handling sensitive data -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. +In the case of a non-progressively-enhanced form submission (i.e. where JavaScript is unavailable, for whatever reason) `value()` is also populated if the submitted data is invalid, so that the user does not need to fill the entire form out from scratch. 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: @@ -465,20 +565,12 @@ You can prevent sensitive data (such as passwords and credit card numbers) from @@ -680,12 +772,12 @@ This attribute exists on the `buttonProps` property of a form object: diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index f06e771a400d..934d307d9066 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -1813,78 +1813,127 @@ export interface Snapshot { // If T is unknown or has an index signature, the types below will recurse indefinitely and create giant unions that TS can't handle type WillRecurseIndefinitely = unknown extends T ? true : string extends keyof T ? true : false; -// Helper type to convert union to intersection -type UnionToIntersection = (U extends any ? (k: U) => void : never) extends (k: infer I) => void - ? I - : never; - -type FlattenInput = T extends string | number | boolean | null | undefined - ? { [P in Prefix]: string } - : WillRecurseIndefinitely extends true - ? { [key: string]: string } - : T extends Array - ? U extends string | File - ? { [P in Prefix]: string[] } - : FlattenInput - : T extends File - ? { [P in Prefix]: string } - : { - // Required is crucial here to avoid an undefined type to sneak into the union, which would turn the intersection into never - [K in keyof Required]: FlattenInput< - T[K], - Prefix extends '' ? K & string : `${Prefix}.${K & string}` - >; - }[keyof T]; - -type FlattenIssues = T extends - | string - | number - | boolean - | null - | undefined - ? { [P in Prefix]: RemoteFormIssue[] } - : WillRecurseIndefinitely extends true - ? { [key: string]: RemoteFormIssue[] } - : T extends Array - ? { [P in Prefix | `${Prefix}[${number}]`]: RemoteFormIssue[] } & FlattenIssues< - U, - `${Prefix}[${number}]` - > - : T extends File - ? { [P in Prefix]: RemoteFormIssue[] } - : { - // Required is crucial here to avoid an undefined type to sneak into the union, which would turn the intersection into never - [K in keyof Required]: FlattenIssues< - T[K], - Prefix extends '' ? K & string : `${Prefix}.${K & string}` - >; - }[keyof T]; - -type FlattenKeys = T extends string | number | boolean | null | undefined - ? { [P in Prefix]: string } - : WillRecurseIndefinitely extends true - ? { [key: string]: string } - : T extends Array - ? U extends string | File - ? { [P in `${Prefix}[]`]: string[] } - : FlattenKeys - : T extends File - ? { [P in Prefix]: string } - : { - // Required is crucial here to avoid an undefined type to sneak into the union, which would turn the intersection into never - [K in keyof Required]: FlattenKeys< - T[K], - Prefix extends '' ? K & string : `${Prefix}.${K & string}` - >; - }[keyof T]; +// Input type mappings for form fields +type InputTypeMap = { + text: string; + email: string; + password: string; + url: string; + tel: string; + search: string; + number: number; + range: number; + date: string; + 'datetime-local': string; + time: string; + month: string; + week: string; + color: string; + checkbox: boolean | string[]; + radio: string; + file: File; + hidden: string; + submit: string; + button: string; + reset: string; + image: string; + select: string; + 'select multiple': string[]; + 'file multiple': File[]; +}; + +// Valid input types for a given value type +export type RemoteFormFieldType = { + [K in keyof InputTypeMap]: T extends InputTypeMap[K] ? K : never; +}[keyof InputTypeMap]; + +// Input element properties based on type +type InputElementProps = T extends 'checkbox' | 'radio' + ? { + type: T; + 'aria-invalid': boolean | 'false' | 'true' | undefined; + get checked(): boolean; + set checked(value: boolean); + } + : T extends 'file' + ? { + type: 'file'; + 'aria-invalid': boolean | 'false' | 'true' | undefined; + get files(): FileList | null; + set files(v: FileList | null); + } + : { + type: T; + 'aria-invalid': boolean | 'false' | 'true' | undefined; + get value(): string | number; + set value(v: string | number); + }; + +type RemoteFormFieldMethods = { + /** The values that will be submitted */ + value(): T; + /** Set the values that will be submitted */ + set(input: T): T; + /** Validation issues, if any */ + issues(): RemoteFormIssue[] | undefined; +}; + +export type RemoteFormFieldValue = string | string[] | number | boolean | File | File[]; + +type AsArgs = Type extends 'checkbox' + ? Value extends string[] + ? [type: 'checkbox', value: Value[number] | (string & {})] + : [type: Type] + : Type extends 'radio' + ? [type: 'radio', value: Value | (string & {})] + : [type: Type]; + +/** + * Form field accessor type that provides name(), value(), and issues() methods + */ +export type RemoteFormField = RemoteFormFieldMethods & { + /** + * Returns an object that can be spread onto an input element with the correct type attribute, + * aria-invalid attribute if the field is invalid, and appropriate value/checked property getters/setters. + * @example + * ```svelte + * + * + * + * ``` + */ + as>(...args: AsArgs): InputElementProps; +}; + +type RemoteFormFieldContainer = RemoteFormFieldMethods & { + /** Validation issues belonging to this or any of the fields that belong to it, if any */ + allIssues(): RemoteFormIssue[] | undefined; +}; + +/** + * Recursive type to build form fields structure with proxy access + */ +type RemoteFormFields = + WillRecurseIndefinitely extends true + ? RecursiveFormFields + : NonNullable extends string | number | boolean | File + ? RemoteFormField> + : T extends string[] | File[] + ? RemoteFormField & { [K in number]: RemoteFormField } + : T extends Array + ? RemoteFormFieldContainer & { [K in number]: RemoteFormFields } + : RemoteFormFieldContainer & { [K in keyof T]-?: RemoteFormFields }; + +// By breaking this out into its own type, we avoid the TS recursion depth limit +type RecursiveFormFields = RemoteFormField & { [key: string]: RecursiveFormFields }; + +type MaybeArray = T | T[]; export interface RemoteFormInput { - [key: string]: FormDataEntryValue | FormDataEntryValue[] | RemoteFormInput | RemoteFormInput[]; + [key: string]: MaybeArray; } export interface RemoteFormIssue { - name: string; - path: Array; message: string; } @@ -1926,14 +1975,6 @@ export type RemoteForm = { * ``` */ for(key: string | number | boolean): Omit, 'for'>; - /** - * This method exists to allow you to typecheck `name` attributes. It returns its argument - * @example - * ```svelte - * - * ``` - **/ - field>>(string: Name): Name; /** Preflight checks */ preflight(schema: StandardSchemaV1): RemoteForm; /** Validate the form contents programmatically */ @@ -1946,10 +1987,8 @@ export type RemoteForm = { get result(): Output | undefined; /** The number of pending submissions */ get pending(): number; - /** The submitted values */ - input: null | UnionToIntersection>; - /** Validation issues */ - issues: null | UnionToIntersection>; + /** Access form fields using object notation */ + fields: Input extends void ? never : RemoteFormFields; /** Spread this onto a ` -

set_message.input.message: {set_message.input.message}

+

set_message.input.message: {set_message.fields.message.value()}

set_message.pending: {set_message.pending}

set_message.result: {set_message.result}

set_reverse_message.result: {set_reverse_message.result}

@@ -39,15 +39,15 @@
- {#if scoped.issues.message} -

{scoped.issues.message[0].message}

+ {#if scoped.fields.message.issues()} +

{scoped.fields.message.issues()[0].message}

{/if} - +
-

scoped.input.message: {scoped.input.message}

+

scoped.input.message: {scoped.fields.message.value()}

scoped.pending: {scoped.pending}

scoped.result: {scoped.result}

@@ -59,15 +59,15 @@ await submit().updates(get_message().withOverride(() => data.message + ' (override)')); })} > - {#if enhanced.issues.message} -

{enhanced.issues.message[0].message}

+ {#if enhanced.fields.message.issues()} +

{enhanced.fields.message.issues()[0].message}

{/if} - + -

enhanced.input.message: {enhanced.input.message}

+

enhanced.input.message: {enhanced.fields.message.value()}

enhanced.pending: {enhanced.pending}

enhanced.result: {enhanced.result}

diff --git a/packages/kit/test/apps/basics/src/routes/remote/form/preflight/+page.svelte b/packages/kit/test/apps/basics/src/routes/remote/form/preflight/+page.svelte index d799742bd29c..38d1248d4515 100644 --- a/packages/kit/test/apps/basics/src/routes/remote/form/preflight/+page.svelte +++ b/packages/kit/test/apps/basics/src/routes/remote/form/preflight/+page.svelte @@ -7,12 +7,7 @@ const enhanced = set_number.for('enhanced'); const schema = v.object({ - number: v.pipe( - v.string(), - v.regex(/^\d+$/), - v.transform((n) => +n), - v.maxValue(20, 'too big') - ) + number: v.pipe(v.number(), v.maxValue(20, 'too big')) }); @@ -26,15 +21,15 @@
- {#if set_number.issues.number} -

{set_number.issues.number[0].message}

- {/if} + {#each set_number.fields.number.issues() as issue} +

{issue.message}

+ {/each} - +
-

set_number.input.number: {set_number.input.number}

+

set_number.input.number: {set_number.fields.number.value()}

set_number.pending: {set_number.pending}

set_number.result: {set_number.result}

@@ -46,14 +41,14 @@ await submit(); })} > - {#if enhanced.issues.number} -

{enhanced.issues.number[0].message}

- {/if} + {#each enhanced.fields.number.issues() as issue} +

{issue.message}

+ {/each} - + -

enhanced.input.number: {enhanced.input.number}

+

enhanced.input.number: {enhanced.fields.number.value()}

enhanced.pending: {enhanced.pending}

enhanced.result: {enhanced.result}

diff --git a/packages/kit/test/apps/basics/src/routes/remote/form/preflight/form.remote.ts b/packages/kit/test/apps/basics/src/routes/remote/form/preflight/form.remote.ts index e4a1f2378587..79bf75d226bc 100644 --- a/packages/kit/test/apps/basics/src/routes/remote/form/preflight/form.remote.ts +++ b/packages/kit/test/apps/basics/src/routes/remote/form/preflight/form.remote.ts @@ -9,12 +9,7 @@ export const get_number = query(() => { export const set_number = form( v.object({ - number: v.pipe( - v.string(), - v.regex(/^\d+$/), - v.transform((n) => +n), - v.minValue(10, 'too small') - ) + number: v.pipe(v.number(), v.minValue(10, 'too small')) }), async (data) => { number = data.number; diff --git a/packages/kit/test/apps/basics/src/routes/remote/form/submitter/+page.svelte b/packages/kit/test/apps/basics/src/routes/remote/form/submitter/+page.svelte index f51a477890c6..5b76ef3a1f2e 100644 --- a/packages/kit/test/apps/basics/src/routes/remote/form/submitter/+page.svelte +++ b/packages/kit/test/apps/basics/src/routes/remote/form/submitter/+page.svelte @@ -3,7 +3,7 @@
- +

{my_form.result}

diff --git a/packages/kit/test/apps/basics/src/routes/remote/form/submitter/form.remote.ts b/packages/kit/test/apps/basics/src/routes/remote/form/submitter/form.remote.ts index b06d4eafeddb..08c7f22dcf00 100644 --- a/packages/kit/test/apps/basics/src/routes/remote/form/submitter/form.remote.ts +++ b/packages/kit/test/apps/basics/src/routes/remote/form/submitter/form.remote.ts @@ -6,6 +6,7 @@ export const my_form = form( submitter: v.string() }), async (data) => { + console.log('!!!', data); return data.submitter; } ); diff --git a/packages/kit/test/apps/basics/src/routes/remote/form/underscore/+page.svelte b/packages/kit/test/apps/basics/src/routes/remote/form/underscore/+page.svelte index 620a7d63baef..6c8a0aff7e4f 100644 --- a/packages/kit/test/apps/basics/src/routes/remote/form/underscore/+page.svelte +++ b/packages/kit/test/apps/basics/src/routes/remote/form/underscore/+page.svelte @@ -3,23 +3,13 @@
- - - + +
-
{JSON.stringify(register.issues, null, '  ')}
+
{JSON.stringify(register.fields.issues(), null, '  ')}