diff --git a/.changeset/khaki-forks-learn.md b/.changeset/khaki-forks-learn.md new file mode 100644 index 000000000000..d7725c8bd66b --- /dev/null +++ b/.changeset/khaki-forks-learn.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': minor +--- + +feat: enhance remote form functions with schema support, `input` and `issues` properties diff --git a/.changeset/olive-dodos-flash.md b/.changeset/olive-dodos-flash.md new file mode 100644 index 000000000000..bbc41830987d --- /dev/null +++ b/.changeset/olive-dodos-flash.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': minor +--- + +breaking: remote form functions get passed a parsed POJO instead of a `FormData` object now diff --git a/documentation/docs/20-core-concepts/60-remote-functions.md b/documentation/docs/20-core-concepts/60-remote-functions.md index 4af669e42ed1..479e789089fc 100644 --- a/documentation/docs/20-core-concepts/60-remote-functions.md +++ b/documentation/docs/20-core-concepts/60-remote-functions.md @@ -227,7 +227,7 @@ export const getWeather = query.batch(v.string(), async (cities) => { ## form -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)... +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 @@ -259,30 +259,28 @@ export const getPosts = query(async () => { /* ... */ }); export const getPost = query(v.string(), async (slug) => { /* ... */ }); -export const createPost = form(async (data) => { - // Check the user is logged in - const user = await auth.getUser(); - if (!user) error(401, 'Unauthorized'); - - const title = data.get('title'); - const content = data.get('content'); - - // Check the data is valid - if (typeof title !== 'string' || typeof content !== 'string') { - error(400, 'Title and content are required'); +export const createPost = form( + v.object({ + title: v.pipe(v.string(), v.nonEmpty()), + content:v.pipe(v.string(), v.nonEmpty()) + }), + async ({ title, content }) => { + // Check the user is logged in + const user = await auth.getUser(); + if (!user) error(401, 'Unauthorized'); + + const slug = title.toLowerCase().replace(/ /g, '-'); + + // Insert into the database + await db.sql` + INSERT INTO post (slug, title, content) + VALUES (${slug}, ${title}, ${content}) + `; + + // Redirect to the newly created page + redirect(303, `/blog/${slug}`); } - - const slug = title.toLowerCase().replace(/ /g, '-'); - - // Insert into the database - await db.sql` - INSERT INTO post (slug, title, content) - VALUES (${slug}, ${title}, ${content}) - `; - - // Redirect to the newly created page - redirect(303, `/blog/${slug}`); -}); +); ``` ...and returns an object that can be spread onto a `
` element. The callback is called whenever the form is submitted. @@ -310,7 +308,184 @@ export const createPost = form(async (data) => {
``` -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. +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. + +```ts +/// file: src/routes/count.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: v.coerce.number() + }), + async ({ count }) => { + // ... + } +); +``` + +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: + +```svelte + + + +{#each jobs as job, idx} + + +{/each} +``` + +To indicate a repeated field, use a `[]` suffix: + +```svelte + + + +``` + +If you'd like type safety and autocomplete when setting `name` attributes, use the form object's `field` method: + +```svelte + +``` + +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. + +### 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: + +```svelte +
+ + + + + +
+``` + +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: + +```svelte +
createPost.validate()}> + +
+``` + +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: + +```svelte + + +

Create a new post

+ +
+ +
+``` + +> [!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 ` +

message.current: {message.current}

+ -

{#await current_task then task}{task}{/await}

+{#await message then m} +

await get_message(): {m}

+{/await} - -

Form pending: {task_one.pending}

-

Button pending: {task_two.buttonProps.pending}

+
-
- - - -
+
+ {#if set_message.issues.message} +

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

+ {/if} - { - const task = data.get('task'); - if (task === 'abort') return; - await submit(); - })} -> - - - + + +
-
{ - const task = data.get('task'); - await submit().updates(current_task.withOverride(() => task + ' (overridden)')); - })} -> - - - +

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

+

set_message.pending: {set_message.pending}

+

set_message.result: {set_message.result}

+

set_reverse_message.result: {set_reverse_message.result}

+ +
+ + + {#if scoped.issues.message} +

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

+ {/if} + + +
- +

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

+

scoped.pending: {scoped.pending}

+

scoped.result: {scoped.result}

+ +
+
{ - const task = data.get('task'); - await submit(); + data-enhanced + {...enhanced.enhance(async ({ data, submit }) => { + await submit().updates(get_message().withOverride(() => data.message + ' (override)')); })} > - - + {#if enhanced.issues.message} +

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

+ {/if} + + +
-

{task_one.result}

-

{task_two.result}

+

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

+

enhanced.pending: {enhanced.pending}

+

enhanced.result: {enhanced.result}

-
- -
+
-{#each ['foo', 'bar'] as item} -
- {task_one.for(item).result} - - -
-{/each} - -
- {task_two.for('foo').result} - - + +
diff --git a/packages/kit/test/apps/basics/src/routes/remote/form/form.remote.js b/packages/kit/test/apps/basics/src/routes/remote/form/form.remote.js deleted file mode 100644 index 6136de792054..000000000000 --- a/packages/kit/test/apps/basics/src/routes/remote/form/form.remote.js +++ /dev/null @@ -1,54 +0,0 @@ -import { form, query } from '$app/server'; -import { error, redirect } from '@sveltejs/kit'; - -let task; -const deferreds = []; - -export const get_task = query(() => { - return task; -}); - -export const resolve_deferreds = form(async () => { - for (const deferred of deferreds) { - deferred.resolve(); - } - deferreds.length = 0; - return 'resolved'; -}); - -export const task_one = form(async (form_data) => { - task = /** @type {string} */ (form_data.get('task')); - - if (task === 'error') { - error(400, { message: 'Expected error' }); - } - if (task === 'redirect') { - redirect(303, '/remote'); - } - if (task === 'deferred') { - const deferred = Promise.withResolvers(); - deferreds.push(deferred); - await deferred.promise; - } else if (task === 'override') { - await new Promise((resolve) => setTimeout(resolve, 500)); - } - - return task; -}); - -export const task_two = form(async (form_data) => { - task = /** @type {string} */ (form_data.get('task')); - - if (task === 'error') { - throw new Error('Unexpected error'); - } - if (task === 'deferred') { - const deferred = Promise.withResolvers(); - deferreds.push(deferred); - await deferred.promise; - } else if (task === 'override') { - await new Promise((resolve) => setTimeout(resolve, 500)); - } - - return task; -}); diff --git a/packages/kit/test/apps/basics/src/routes/remote/form/form.remote.ts b/packages/kit/test/apps/basics/src/routes/remote/form/form.remote.ts new file mode 100644 index 000000000000..fa1a82e1e76b --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/remote/form/form.remote.ts @@ -0,0 +1,54 @@ +import { form, getRequestEvent, query } from '$app/server'; +import { error, redirect } from '@sveltejs/kit'; +import * as v from 'valibot'; + +let message = 'initial'; +const deferreds = []; + +export const get_message = query(() => { + return message; +}); + +export const set_message = form( + v.object({ + message: v.picklist( + ['hello', 'goodbye', 'unexpected error', 'expected error', 'redirect'], + 'message is invalid' + ) + }), + async (data) => { + if (data.message === 'unexpected error') { + throw new Error('oops'); + } + + if (data.message === 'expected error') { + error(500, 'oops'); + } + + if (data.message === 'redirect') { + redirect(303, '/remote'); + } + + message = data.message; + + if (getRequestEvent().isRemoteRequest) { + const deferred = Promise.withResolvers(); + deferreds.push(deferred); + await deferred.promise; + } + + return message; + } +); + +export const set_reverse_message = form(v.object({ message: v.string() }), (data) => { + message = data.message.split('').reverse().join(''); + return message; +}); + +export const resolve_deferreds = form(async () => { + for (const deferred of deferreds) { + deferred.resolve(); + } + deferreds.length = 0; +}); 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 new file mode 100644 index 000000000000..d799742bd29c --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/remote/form/preflight/+page.svelte @@ -0,0 +1,59 @@ + + +

number.current: {number.current}

+ + +{#await number then n} +

await get_number(): {n}

+{/await} + +
+ +
+ {#if set_number.issues.number} +

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

+ {/if} + + + +
+ +

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

+

set_number.pending: {set_number.pending}

+

set_number.result: {set_number.result}

+ +
+ +
{ + await submit(); + })} +> + {#if enhanced.issues.number} +

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

+ {/if} + + + +
+ +

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

+

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 new file mode 100644 index 000000000000..e4a1f2378587 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/remote/form/preflight/form.remote.ts @@ -0,0 +1,23 @@ +import { form, query } from '$app/server'; +import * as v from 'valibot'; + +let number = 0; + +export const get_number = query(() => { + return number; +}); + +export const set_number = form( + v.object({ + number: v.pipe( + v.string(), + v.regex(/^\d+$/), + v.transform((n) => +n), + v.minValue(10, 'too small') + ) + }), + async (data) => { + number = data.number; + get_number().refresh(); + } +); 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 new file mode 100644 index 000000000000..620a7d63baef --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/remote/form/underscore/+page.svelte @@ -0,0 +1,28 @@ + + +
+ + + + + +
+ +
{JSON.stringify(register.issues, null, '  ')}
+ + diff --git a/packages/kit/test/apps/basics/src/routes/remote/form/underscore/form.remote.ts b/packages/kit/test/apps/basics/src/routes/remote/form/underscore/form.remote.ts new file mode 100644 index 000000000000..ed6f2bff3e8a --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/remote/form/underscore/form.remote.ts @@ -0,0 +1,10 @@ +import { form } from '$app/server'; +import * as v from 'valibot'; + +export const register = form( + v.object({ + username: v.pipe(v.string(), v.minLength(8)), + _password: v.pipe(v.string(), v.minLength(8)) + }), + async (data) => {} +); diff --git a/packages/kit/test/apps/basics/src/routes/remote/form/validate/+page.svelte b/packages/kit/test/apps/basics/src/routes/remote/form/validate/+page.svelte new file mode 100644 index 000000000000..0876bdea0f22 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/remote/form/validate/+page.svelte @@ -0,0 +1,25 @@ + + +
my_form.validate()}> + {#if my_form.issues.foo} +

{my_form.issues.foo[0].message}

+ {/if} + + + + {#if my_form.issues.bar} +

{my_form.issues.bar[0].message}

+ {/if} + + + + +
diff --git a/packages/kit/test/apps/basics/src/routes/remote/form/validate/form.remote.ts b/packages/kit/test/apps/basics/src/routes/remote/form/validate/form.remote.ts new file mode 100644 index 000000000000..c24ed501afc9 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/remote/form/validate/form.remote.ts @@ -0,0 +1,12 @@ +import { form } from '$app/server'; +import * as v from 'valibot'; + +export const my_form = form( + v.object({ + foo: v.picklist(['a', 'b', 'c']), + bar: v.picklist(['d', 'e', 'f']) + }), + async (data) => { + console.log(data); + } +); diff --git a/packages/kit/test/apps/basics/test/client.test.js b/packages/kit/test/apps/basics/test/client.test.js index b62dd253b5ed..9e69d0e25aef 100644 --- a/packages/kit/test/apps/basics/test/client.test.js +++ b/packages/kit/test/apps/basics/test/client.test.js @@ -1764,69 +1764,6 @@ test.describe('remote functions', () => { await expect(page.locator('p')).toHaveText('post'); }); - test('form.enhance works', async ({ page }) => { - await page.goto('/remote/form'); - await page.fill('#input-task-enhance', 'abort'); - await page.click('#submit-btn-enhance-one'); - await page.waitForTimeout(100); // allow Svelte to update in case this does submit after (which it shouldn't) - await expect(page.locator('#form-result-1')).toHaveText(''); - - await page.fill('#input-task-enhance', 'hi'); - await page.click('#submit-btn-enhance-one'); - await expect(page.locator('#form-result-1')).toHaveText('hi'); - - await page.fill('#input-task-enhance', 'error'); - await page.click('#submit-btn-enhance-one'); - expect(await page.textContent('#message')).toBe( - 'This is your custom error page saying: "Expected error"' - ); - }); - - test('form.buttonProps.enhance works', async ({ page }) => { - await page.goto('/remote/form'); - await page.fill('#input-task-enhance', 'abort'); - await page.click('#submit-btn-enhance-two'); - await page.waitForTimeout(100); // allow Svelte to update in case this does submit after (which it shouldn't) - await expect(page.locator('#form-result-2')).toHaveText(''); - - await page.fill('#input-task-enhance', 'hi'); - await page.click('#submit-btn-enhance-two'); - await expect(page.locator('#form-result-2')).toHaveText('hi'); - - await page.fill('#input-task-enhance', 'error'); - await page.click('#submit-btn-enhance-two'); - expect(await page.textContent('#message')).toBe( - 'This is your custom error page saying: "Unexpected error (500 Internal Error)"' - ); - }); - - test('form.enhance with override works', async ({ page }) => { - await page.goto('/remote/form'); - await page.fill('#input-task-override', 'override'); - page.click('#submit-btn-override-one'); - await expect(page.locator('#get-task')).toHaveText('override (overridden)'); - await expect(page.locator('#form-result-1')).toHaveText('override'); - await expect(page.locator('#get-task')).toHaveText('override'); - }); - - test('form.buttonProps.enhance with override works', async ({ page }) => { - await page.goto('/remote/form'); - await page.fill('#input-task-override', 'override'); - page.click('#submit-btn-override-one'); - await expect(page.locator('#get-task')).toHaveText('override (overridden)'); - await expect(page.locator('#form-result-1')).toHaveText('override'); - await expect(page.locator('#get-task')).toHaveText('override'); - }); - - test('form.buttonProps.enhance works with nested elements (issue #14159)', async ({ page }) => { - await page.goto('/remote/form'); - await page.fill('#input-task-nested', 'nested-test'); - - // Click on the span inside the button to test the event.target vs event.currentTarget issue - await page.click('#submit-btn-nested-span span'); - await expect(page.locator('#form-result-2')).toHaveText('nested-test'); - }); - test('prerendered entries not called in prod', async ({ page }) => { let request_count = 0; page.on('request', (r) => (request_count += r.url().includes('/_app/remote') ? 1 : 0)); @@ -1926,33 +1863,6 @@ test.describe('remote functions', () => { await expect(page.locator('#command-pending')).toHaveText('Command pending: 0'); }); - test('form pending state is tracked correctly', async ({ page }) => { - await page.goto('/remote/form'); - - // Initially no pending forms - await expect(page.locator('#form-pending')).toHaveText('Form pending: 0'); - await expect(page.locator('#form-button-pending')).toHaveText('Button pending: 0'); - - // Fill form with slow operation - await page.fill('#input-task', 'deferred'); - - // Submit form - this will hang until we resolve it - await page.click('#submit-btn-one'); - - // Check that pending has incremented to 1 - await expect(page.locator('#form-pending')).toHaveText('Form pending: 1'); - - // Resolve the deferred form submission - await page.click('#resolve-deferreds'); - - // Wait for form submission to complete and verify results - await expect(page.locator('#get-task')).toHaveText('deferred'); - - // Verify pending count returns to 0 - await expect(page.locator('#form-pending')).toHaveText('Form pending: 0'); - await expect(page.locator('#form-button-pending')).toHaveText('Button pending: 0'); - }); - // TODO once we have async SSR adjust the test and move this into test.js test('query.batch works', async ({ page }) => { await page.goto('/remote/batch'); diff --git a/packages/kit/test/apps/basics/test/test.js b/packages/kit/test/apps/basics/test/test.js index 3703e45d9ace..da7aec216f71 100644 --- a/packages/kit/test/apps/basics/test/test.js +++ b/packages/kit/test/apps/basics/test/test.js @@ -1629,59 +1629,206 @@ test.describe('remote functions', () => { await expect(page.locator('#redirected')).toHaveText('redirected'); }); - test('form works', async ({ page }) => { + test('form works', async ({ page, javaScriptEnabled }) => { await page.goto('/remote/form'); - await page.fill('#input-task', 'hi'); - await page.click('#submit-btn-one'); - await expect(page.locator('#form-result-1')).toHaveText('hi'); - await expect(page.locator('#input-task')).toHaveValue(''); + + if (javaScriptEnabled) { + // TODO remove the `if` — once async SSR lands these assertions should always succeed + await expect(page.getByText('message.current:')).toHaveText('message.current: initial'); + await expect(page.getByText('await get_message():')).toHaveText( + 'await get_message(): initial' + ); + } + + await page.fill('[data-unscoped] input', 'hello'); + await page.getByText('set message').click(); + + if (javaScriptEnabled) { + await expect(page.getByText('set_message.pending:')).toHaveText('set_message.pending: 1'); + await page.getByText('resolve deferreds').click(); + await expect(page.getByText('set_message.pending:')).toHaveText('set_message.pending: 0'); + + await expect(page.getByText('message.current:')).toHaveText('message.current: hello'); + await expect(page.getByText('await get_message():')).toHaveText('await get_message(): hello'); + } + + await expect(page.getByText('set_message.result')).toHaveText('set_message.result: hello'); + await expect(page.locator('[data-unscoped] input')).toHaveValue(''); }); - test('form error works', async ({ page }) => { + test('form updates inputs live', async ({ page, javaScriptEnabled }) => { await page.goto('/remote/form'); - await page.fill('#input-task', 'error'); - await page.click('#submit-btn-one'); - expect(await page.textContent('h1')).toBe('400'); - expect(await page.textContent('#message')).toBe( - 'This is your custom error page saying: "Expected error"' + + await page.fill('input', 'hello'); + + if (javaScriptEnabled) { + await expect(page.getByText('set_message.input.message:')).toHaveText( + 'set_message.input.message: hello' + ); + } + + await page.getByText('set message').click(); + + if (javaScriptEnabled) { + await page.getByText('resolve deferreds').click(); + } + + await expect(page.getByText('set_message.input.message:')).toHaveText( + 'set_message.input.message:' ); }); - test('form redirect works', async ({ page }) => { + test('form reports validation issues', async ({ page }) => { await page.goto('/remote/form'); - await page.fill('#input-task', 'redirect'); - await page.click('#submit-btn-one'); - expect(await page.textContent('#echo-result')).toBe('Hello world'); + + await page.fill('input', 'invalid'); + await page.getByText('set message').click(); + + await page.getByText('message is invalid').waitFor(); }); - test('form.buttonProps works', async ({ page }) => { + test('form handles unexpected error', async ({ page }) => { await page.goto('/remote/form'); - await page.fill('#input-task', 'hi'); - await page.click('#submit-btn-two'); - await expect(page.locator('#form-result-2')).toHaveText('hi'); + + await page.fill('input', 'unexpected error'); + await page.getByText('set message').click(); + + await page + .getByText('This is your custom error page saying: "oops (500 Internal Error)"') + .waitFor(); }); - test('form.buttonProps error works', async ({ page }) => { + test('form handles expected error', async ({ page }) => { await page.goto('/remote/form'); - await page.fill('#input-task', 'error'); - await page.click('#submit-btn-two'); - expect(await page.textContent('h1')).toBe('500'); - expect(await page.textContent('#message')).toBe( - 'This is your custom error page saying: "Unexpected error (500 Internal Error)"' + + await page.fill('input', 'expected error'); + await page.getByText('set message').click(); + + await page.getByText('This is your custom error page saying: "oops"').waitFor(); + }); + + test('form redirects', async ({ page }) => { + await page.goto('/remote/form'); + + await page.fill('input', 'redirect'); + await page.getByText('set message').click(); + + await page.waitForURL('/remote'); + }); + + test('form.buttonProps works', async ({ page, javaScriptEnabled }) => { + await page.goto('/remote/form'); + + await page.fill('[data-unscoped] input', 'backwards'); + await page.getByText('set reverse message').click(); + + if (javaScriptEnabled) { + await page.getByText('message.current: sdrawkcab').waitFor(); + await expect(page.getByText('await get_message():')).toHaveText( + 'await get_message(): sdrawkcab' + ); + } + + await expect(page.getByText('set_reverse_message.result')).toHaveText( + 'set_reverse_message.result: sdrawkcab' ); }); - test('form.for(...) scopes form submission', async ({ page, javaScriptEnabled }) => { + test('form scoping with for(...) works', async ({ page, javaScriptEnabled }) => { await page.goto('/remote/form'); - await page.click('#submit-btn-item-foo'); - await expect(page.locator('#form-result-foo')).toHaveText('foo'); - await expect(page.locator('#form-result-bar')).toHaveText(''); - await expect(page.locator('#form-result-1')).toHaveText(''); - - await page.click('#submit-btn-item-2-foo'); - await expect(page.locator('#form-result-2-foo')).toHaveText('foo2'); - await expect(page.locator('#form-result-foo')).toHaveText(javaScriptEnabled ? 'foo' : ''); - await expect(page.locator('#form-result-2')).toHaveText(''); + + await page.fill('[data-scoped] input', 'hello'); + await page.getByText('set scoped message').click(); + + if (javaScriptEnabled) { + await expect(page.getByText('scoped.pending:')).toHaveText('scoped.pending: 1'); + await page.getByText('resolve deferreds').click(); + await expect(page.getByText('scoped.pending:')).toHaveText('scoped.pending: 0'); + + await page.getByText('message.current: hello').waitFor(); + await expect(page.getByText('await get_message():')).toHaveText('await get_message(): hello'); + } + + await expect(page.getByText('scoped.result')).toHaveText('scoped.result: hello'); + await expect(page.locator('[data-scoped] input')).toHaveValue(''); + }); + + test('form enhance(...) works', async ({ page, javaScriptEnabled }) => { + await page.goto('/remote/form'); + + await page.fill('[data-enhanced] input', 'hello'); + + // Click on the span inside the button to test the event.target vs event.currentTarget issue (#14159) + await page.locator('[data-enhanced] span').click(); + + if (javaScriptEnabled) { + await expect(page.getByText('enhanced.pending:')).toHaveText('enhanced.pending: 1'); + + await page.getByText('message.current: hello (override)').waitFor(); + + await page.getByText('resolve deferreds').click(); + await expect(page.getByText('enhanced.pending:')).toHaveText('enhanced.pending: 0'); + await expect(page.getByText('await get_message():')).toHaveText('await get_message(): hello'); + } + + await expect(page.getByText('enhanced.result')).toHaveText('enhanced.result: hello'); + await expect(page.locator('[data-enhanced] input')).toHaveValue(''); + }); + + test('form preflight works', async ({ page, javaScriptEnabled }) => { + if (!javaScriptEnabled) return; + + await page.goto('/remote/form/preflight'); + + for (const enhanced of [true, false]) { + const input = page.locator(enhanced ? '[data-enhanced] input' : '[data-default] input'); + const button = page.getByText(enhanced ? 'set enhanced number' : 'set number'); + + await input.fill('21'); + await button.click(); + await page.getByText('too big').waitFor(); + + await input.fill('9'); + await button.click(); + await page.getByText('too small').waitFor(); + + await input.fill('15'); + await button.click(); + await expect(page.getByText('number.current')).toHaveText('number.current: 15'); + } + }); + + test('form validate works', async ({ page, javaScriptEnabled }) => { + if (!javaScriptEnabled) return; + + await page.goto('/remote/form/validate'); + + const foo = page.locator('input[name="foo"]'); + const bar = page.locator('input[name="bar"]'); + + await foo.fill('a'); + await expect(page.locator('form')).not.toContainText('Invalid type: Expected'); + + await bar.fill('g'); + await expect(page.locator('form')).toContainText( + 'Invalid type: Expected ("d" | "e") but received "g"' + ); + + await bar.fill('d'); + await expect(page.locator('form')).not.toContainText('Invalid type: Expected'); + }); + + test('form inputs excludes underscore-prefixed fields', async ({ page, javaScriptEnabled }) => { + if (javaScriptEnabled) return; + + await page.goto('/remote/form/underscore'); + + await page.fill('input[name="username"]', 'abcdefg'); + await page.fill('input[name="_password"]', 'pqrstuv'); + await page.locator('button').click(); + + await expect(page.locator('input[name="username"]')).toHaveValue('abcdefg'); + await expect(page.locator('input[name="_password"]')).toHaveValue(''); }); test('prerendered entries not called in prod', async ({ page, clicknav }) => { diff --git a/packages/kit/test/apps/options-2/package.json b/packages/kit/test/apps/options-2/package.json index 818c8a9dc163..83d0e4c62814 100644 --- a/packages/kit/test/apps/options-2/package.json +++ b/packages/kit/test/apps/options-2/package.json @@ -19,6 +19,7 @@ "svelte": "^5.38.5", "svelte-check": "^4.1.1", "typescript": "^5.5.4", + "valibot": "^1.1.0", "vite": "catalog:" }, "type": "module" diff --git a/packages/kit/test/apps/options-2/src/routes/remote/count.remote.js b/packages/kit/test/apps/options-2/src/routes/remote/count.remote.js index fc93f18dd9c2..52bd1f530250 100644 --- a/packages/kit/test/apps/options-2/src/routes/remote/count.remote.js +++ b/packages/kit/test/apps/options-2/src/routes/remote/count.remote.js @@ -1,5 +1,6 @@ import { building, dev } from '$app/environment'; import { command, form, prerender, query } from '$app/server'; +import * as v from 'valibot'; let count = 0; @@ -21,7 +22,6 @@ export const prerendered = prerender(() => { return 'yes'; }); -export const set_count_form = form(async (form_data) => { - const c = /** @type {string} */ (form_data.get('count')); - return (count = parseInt(c)); +export const set_count_form = form(v.object({ count: v.string() }), async (data) => { + return (count = parseInt(data.count)); }); diff --git a/packages/kit/test/types/remote.test.ts b/packages/kit/test/types/remote.test.ts index 567b75d29f3f..2dea52594198 100644 --- a/packages/kit/test/types/remote.test.ts +++ b/packages/kit/test/types/remote.test.ts @@ -159,8 +159,8 @@ command_tests(); function form_tests() { const q = query(() => ''); - const f = form((f) => { - f.get(''); + const f = form('unchecked', (data: { input: string }) => { + data.input; return { success: true }; }); diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index 097c03cf9b38..960d072276a7 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -1682,28 +1682,105 @@ declare module '@sveltejs/kit' { restore: (snapshot: T) => void; } + // If T is unknown or RemoteFormInput, the types below will recurse indefinitely and create giant unions that TS can't handle + type WillRecurseIndefinitely = unknown extends T + ? true + : RemoteFormInput extends 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 = + 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 } + : T extends object + ? { + [K in keyof T]: FlattenInput< + T[K], + Prefix extends '' ? K & string : `${Prefix}.${K & string}` + >; + }[keyof T] + : { [P in Prefix]: string }; + + type FlattenIssues = + 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[] } + : T extends object + ? { + [K in keyof T]: FlattenIssues< + T[K], + Prefix extends '' ? K & string : `${Prefix}.${K & string}` + >; + }[keyof T] + : { [P in Prefix]: RemoteFormIssue[] }; + + type FlattenKeys = + 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 } + : T extends object + ? { + [K in keyof T]: FlattenKeys< + T[K], + Prefix extends '' ? K & string : `${Prefix}.${K & string}` + >; + }[keyof T] + : { [P in Prefix]: string }; + + export interface RemoteFormInput { + [key: string]: FormDataEntryValue | FormDataEntryValue[] | RemoteFormInput | RemoteFormInput[]; + } + + export interface RemoteFormIssue { + name: string; + path: Array; + message: string; + } + /** * The return value of a remote `form` function. See [Remote functions](https://svelte.dev/docs/kit/remote-functions#form) for full documentation. */ - export type RemoteForm = { + export type RemoteForm = { + /** Attachment that sets up an event handler that intercepts the form submission on the client to prevent a full page reload */ + [attachment: symbol]: (node: HTMLFormElement) => void; method: 'POST'; /** The URL to send the form to. */ action: string; - /** Event handler that intercepts the form submission on the client to prevent a full page reload */ - onsubmit: (event: SubmitEvent) => void; /** Use the `enhance` method to influence what happens when the form is submitted. */ enhance( callback: (opts: { form: HTMLFormElement; - data: FormData; + data: Input; submit: () => Promise & { updates: (...queries: Array | RemoteQueryOverride>) => Promise; }; - }) => void + }) => void | Promise ): { method: 'POST'; action: string; - onsubmit: (event: SubmitEvent) => void; + [attachment: symbol]: (node: HTMLFormElement) => void; }; /** * Create an instance of the form for the given key. @@ -1719,11 +1796,27 @@ declare module '@sveltejs/kit' { * {/each} * ``` */ - for(key: string | number | boolean): Omit, 'for'>; + 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 */ + validate(options?: { includeUntouched?: boolean }): Promise; /** The result of the form submission */ - get result(): Result | undefined; + get result(): Output | undefined; /** The number of pending submissions */ get pending(): number; + /** The submitted values */ + input: null | UnionToIntersection>; + /** Validation issues */ + issues: null | UnionToIntersection>; /** Spread this onto a `