diff --git a/.changeset/blue-news-dream.md b/.changeset/blue-news-dream.md new file mode 100644 index 000000000000..9fb520e25077 --- /dev/null +++ b/.changeset/blue-news-dream.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': minor +--- + +feat: add array mutation methods to field arrays diff --git a/documentation/docs/20-core-concepts/60-remote-functions.md b/documentation/docs/20-core-concepts/60-remote-functions.md index 969b90d17121..01ba1ba96456 100644 --- a/documentation/docs/20-core-concepts/60-remote-functions.md +++ b/documentation/docs/20-core-concepts/60-remote-functions.md @@ -592,6 +592,30 @@ You can update a field (or a collection of fields) via the `set(...)` method: ``` +### Mutating field arrays + +Field arrays have the following extra methods to mutate the array: `.pop()`, `.push()`, `.shift()`, `.unshift()`, `.splice()`, and `.sort()`. Other array methods and properties can still be accessed through the `.value()` method. + +```svelte + +``` + ### Handling sensitive data 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. diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index 01e4f8cf9eb9..e07506be2216 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -1895,29 +1895,45 @@ type AsArgs = Type extends 'checkbox' /** * 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; -}; +export type RemoteFormField = RemoteFormFieldMethods & + RemoteFormArrayFieldMethods & { + /** + * 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; -}; +type RemoteFormArrayFieldMethods = + T extends Array + ? { + push(item: U): void; + pop(): U; + unshift(item: U): void; + shift(): U; + splice(start: number, deleteCount: number, ...insert: U[]): U[]; + sort(fn: (a: U, b: U) => number): U[]; + // you could add more, but the mutate fns are all we really need. + // the rest could be accessed like `.value().slice(...)` or `.value().at(-1)` + } + : {}; + +type RemoteFormFieldContainer = RemoteFormFieldMethods & + RemoteFormArrayFieldMethods & { + /** 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 = +export type RemoteFormFields = WillRecurseIndefinitely extends true ? RecursiveFormFields : NonNullable extends string | number | boolean | File diff --git a/packages/kit/src/runtime/form-utils.svelte.js b/packages/kit/src/runtime/form-utils.svelte.js index 4d0ff75d7318..7cbe614e6026 100644 --- a/packages/kit/src/runtime/form-utils.svelte.js +++ b/packages/kit/src/runtime/form-utils.svelte.js @@ -216,6 +216,82 @@ export function create_field_proxy(target, get_input, depend, set_input, get_iss ]); } + if (prop === 'push') { + const push_func = function (/** @type {any} */ newValue) { + set_input(path, [...get_value(), newValue]); + }; + return create_field_proxy(push_func, get_input, depend, set_input, get_issues, [ + ...path, + prop + ]); + } + + if (prop === 'unshift') { + const unshift_func = function (/** @type {any} */ newValue) { + set_input(path, [newValue, ...get_value()]); + }; + return create_field_proxy(unshift_func, get_input, depend, set_input, get_issues, [ + ...path, + prop + ]); + } + + if (prop === 'pop') { + const pop_func = function () { + const value = get_value(); + const output = value.pop(); + set_input(path, value); + return output; + }; + return create_field_proxy(pop_func, get_input, depend, set_input, get_issues, [ + ...path, + prop + ]); + } + + if (prop === 'shift') { + const shift_func = function () { + const value = get_value(); + const output = value.shift(); + set_input(path, value); + return output; + }; + return create_field_proxy(shift_func, get_input, depend, set_input, get_issues, [ + ...path, + prop + ]); + } + + if (prop === 'splice') { + const splice_func = function ( + /** @type {number} */ start, + /** @type {number} */ deleteCount, + /** @type {any[]} */ ...insert + ) { + const value = get_value(); + const output = value.splice(start, deleteCount, ...insert); + set_input(path, value); + return output; + }; + return create_field_proxy(splice_func, get_input, depend, set_input, get_issues, [ + ...path, + prop + ]); + } + + if (prop === 'sort') { + const sort_func = function (/** @type {function} */ fn) { + const value = get_value(); + const output = value.sort(fn); + set_input(path, value); + return output; + }; + return create_field_proxy(sort_func, get_input, depend, set_input, get_issues, [ + ...path, + prop + ]); + } + if (prop === 'value') { return create_field_proxy(get_value, get_input, depend, set_input, get_issues, [ ...path, diff --git a/packages/kit/test/apps/basics/src/routes/remote/form/arrays/+page.svelte b/packages/kit/test/apps/basics/src/routes/remote/form/arrays/+page.svelte new file mode 100644 index 000000000000..fff27c5db7c7 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/remote/form/arrays/+page.svelte @@ -0,0 +1,51 @@ + + +
+ {#each my_form.fields.strings.value() as _, i (i)} +
+ +
+ {/each} + + {#each my_form.fields.numbers.value() as _, i (i)} +
+ +
+ {/each} + + {#each my_form.fields.objects.value() as _, i (i)} +
+ + +
+ {/each} + + +
+ +

Full Form Value

+
{JSON.stringify(my_form.fields.value(), null, 2)}
diff --git a/packages/kit/test/apps/basics/src/routes/remote/form/arrays/form.remote.ts b/packages/kit/test/apps/basics/src/routes/remote/form/arrays/form.remote.ts new file mode 100644 index 000000000000..a9e40538529c --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/remote/form/arrays/form.remote.ts @@ -0,0 +1,18 @@ +import { form } from '$app/server'; +import * as v from 'valibot'; + +export const my_form = form( + v.object({ + strings: v.array(v.string()), + numbers: v.array(v.number()), + objects: v.array( + v.object({ + name: v.string(), + age: v.number() + }) + ) + }), + (input) => { + return { success: true, data: input }; + } +); diff --git a/packages/kit/test/apps/basics/test/test.js b/packages/kit/test/apps/basics/test/test.js index 1e64ded04982..c924c648a0d2 100644 --- a/packages/kit/test/apps/basics/test/test.js +++ b/packages/kit/test/apps/basics/test/test.js @@ -1,5 +1,5 @@ -import process from 'node:process'; import { expect } from '@playwright/test'; +import process from 'node:process'; import { test } from '../../../utils.js'; /** @typedef {import('@playwright/test').Response} Response */ @@ -1886,6 +1886,19 @@ test.describe('remote functions', () => { await expect(page.locator('input[name="_password"]')).toHaveValue(''); }); + test('form field arrays', async ({ page, javaScriptEnabled }) => { + if (!javaScriptEnabled) return; + + await page.goto('/remote/form/arrays'); + + const value = await page.locator('#full-value').textContent(); + expect(JSON.parse(value)).toEqual({ + strings: [''], + numbers: [0], + objects: [{ name: '', age: 0 }] + }); + }); + test('prerendered entries not called in prod', async ({ page, clicknav }) => { await page.goto('/remote/prerender'); await clicknav('[href="/remote/prerender/whole-page"]'); diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index d131766a3d12..80cdcdcb9489 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -1871,29 +1871,45 @@ declare module '@sveltejs/kit' { /** * 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; - }; + export type RemoteFormField = RemoteFormFieldMethods & + RemoteFormArrayFieldMethods & { + /** + * 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; - }; + type RemoteFormArrayFieldMethods = + T extends Array + ? { + push(item: U): void; + pop(): U; + unshift(item: U): void; + shift(): U; + splice(start: number, deleteCount: number, ...insert: U[]): U[]; + sort(fn: (a: U, b: U) => number): U[]; + // you could add more, but the mutate fns are all we really need. + // the rest could be accessed like `.value().slice(...)` or `.value().at(-1)` + } + : {}; + + type RemoteFormFieldContainer = RemoteFormFieldMethods & + RemoteFormArrayFieldMethods & { + /** 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 = + export type RemoteFormFields = WillRecurseIndefinitely extends true ? RecursiveFormFields : NonNullable extends string | number | boolean | File