diff --git a/docs/guide/pages-routing/actions-and-forms.md b/docs/guide/pages-routing/actions-and-forms.md index e8941d74f..8cebffb3a 100644 --- a/docs/guide/pages-routing/actions-and-forms.md +++ b/docs/guide/pages-routing/actions-and-forms.md @@ -159,9 +159,12 @@ The types are inferred from your action's `withValidator()` schema in the compan | `recentlySuccessful` | `true` for 2 seconds after a successful submission. Useful for flash messages. | | `reset(...fields)` | Reset form data to the current defaults (last submitted values after success). Field names autocomplete. Pass `{ initial: true }` first to reset to the values passed to `useForm`. | | `resetOnSuccess` (option) | When `true`, a successful submit resets both form data and defaults to the mount-time values. Useful for create forms that should clear after save. | +| `setError(field, message)` | Set a client-side validation error on a field. | | `clearErrors(...fields)` | Clear validation errors. Field names autocomplete. | | `clearError()` | Clear `error`. | +Use `setError()` to display errors from your own client-side validation. Setting an error preserves errors on other fields. Changing a field clears that field's validation error automatically (`setData` in React and Solid, or assigning to `data` in Vue and Svelte). + For a native React form, use `form.post`, `form.put`, `form.patch`, or `form.delete` as a native form action. For an awaitable mutation, use `action()`. In Vue, Svelte, and Solid, form submissions return `Promise`; catch thrown errors or use your framework's error handling. For dynamic routes, pass `params` in the options: diff --git a/docs/reference/api/pages.md b/docs/reference/api/pages.md index 8f5fb8e1e..3fb91c22a 100644 --- a/docs/reference/api/pages.md +++ b/docs/reference/api/pages.md @@ -49,7 +49,7 @@ Imported from `"@void/vue"`. | `useRouter()` | Returns the Void Router with current route state (`url`, `path`, `query`, `params`) and navigation methods: `visit`, `refresh`, awaitable `prefetch`, `flush`, `flushAll`. | | `useParams()` | Returns dynamic route params for the page currently being rendered. | | `useNavigation()` | Returns pending navigation state: `{ state, location, method }`, where `state` is `"idle"`, `"loading"`, or `"submitting"` and `location` is the pending destination. | -| `useForm(url, defaults, options?)` | Typed reactive form helper bound to a page action URL. Returns `{ data, post, put, patch, delete, pending, errors, error, hasChanges, wasSuccessful, recentlySuccessful, reset, clearErrors, clearError }`. | +| `useForm(url, defaults, options?)` | Typed reactive form helper bound to a page action URL. Returns `{ data, post, put, patch, delete, pending, errors, error, hasChanges, wasSuccessful, recentlySuccessful, reset, setError, clearErrors, clearError }`. | | `useIslandForm(defaults)` | Form helper for island components. Same form state as `useForm()`, plus `submit(url, method)`. `post(url)`, `put(url)`, `patch(url)`, and `delete(url)` take the action URL and submit with `fetch()`. | | `action(url, options?)` | Awaitable one-shot page action helper. Uses `POST` by default and accepts `{ data, method, params }`, where `method` can be `"PUT"`, `"PATCH"`, or `"DELETE"`. Returns an `ActionResult`. | | `useShared()` | Returns shared data injected by middleware via `c.set("shared", {...})`. | @@ -101,7 +101,7 @@ Imported from `"@void/react"`. | `useRouter()` | Returns the Void Router with current route state (`url`, `path`, `query`, `params`) and navigation methods: `visit`, `refresh`, awaitable `prefetch`, `flush`, `flushAll`. React navigations are scheduled as transitions. | | `useParams()` | Returns dynamic route params for the page currently being rendered. | | `useNavigation()` | Returns pending navigation state: `{ state, location, method }`, where `state` is `"idle"`, `"loading"`, or `"submitting"` and `location` is the pending destination. | -| `useForm(url, defaults, options?)` | Form helper hook. Returns `{ data, setData, post, put, patch, delete, pending, errors, error, hasChanges, wasSuccessful, recentlySuccessful, reset, clearErrors, clearError }`. | +| `useForm(url, defaults, options?)` | Form helper hook. Returns `{ data, setData, post, put, patch, delete, pending, errors, error, hasChanges, wasSuccessful, recentlySuccessful, reset, setError, clearErrors, clearError }`. | | `useIslandForm(defaults)` | Form helper hook for island components. Same form state as `useForm()`. `post(url)`, `put(url)`, `patch(url)`, and `delete(url)` take the action URL and submit with `fetch()`. | | `action(url, options?)` | Awaitable one-shot page action helper. Uses `POST` by default and accepts `{ data, method, params }`, where `method` can be `"PUT"`, `"PATCH"`, or `"DELETE"`. Returns an `ActionResult`. | | `useShared()` | Returns shared data injected by middleware via `c.set("shared", {...})`. | @@ -158,7 +158,7 @@ Imported from `"@void/svelte"`. | Export | Description | | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Link` | Svelte component for SPA navigation. Renders `` for GET, ` + + + + + + + ); +} diff --git a/playground/solid-pages/pages/form-errors.server.ts b/playground/solid-pages/pages/form-errors.server.ts new file mode 100644 index 000000000..142a725c8 --- /dev/null +++ b/playground/solid-pages/pages/form-errors.server.ts @@ -0,0 +1,12 @@ +import * as v from 'valibot'; +import { defineHandler } from 'void'; + +export const loader = defineHandler(() => ({})); +export const action = defineHandler.withValidator({ + body: v.object({ + email: v.pipe(v.string(), v.minLength(1, 'Email is required')), + name: v.pipe(v.string(), v.minLength(1, 'Name is required')), + profile: v.object({ name: v.string() }), + tags: v.array(v.string()), + }), +})(() => {}); diff --git a/playground/solid-pages/pages/form-errors.tsx b/playground/solid-pages/pages/form-errors.tsx new file mode 100644 index 000000000..ec39a7544 --- /dev/null +++ b/playground/solid-pages/pages/form-errors.tsx @@ -0,0 +1,66 @@ +import { Form, useForm } from '@void/solid'; + +export default function FormErrorsPage() { + const form = useForm('/form-errors', { email: '', name: '', profile: { name: '' }, tags: [] }); + + function setErrors() { + form.setError('email', 'Email invalid'); + form.setError('name', 'Name invalid'); + setNestedErrors(); + } + + function setNestedErrors() { + form.setError('profile', 'Profile invalid'); + form.setError('tags', 'Tags invalid'); + } + + return ( +
+ {JSON.stringify(form.errors)} + + + + + + + + + +
+ ); +} diff --git a/playground/svelte-pages/pages/form-errors.server.ts b/playground/svelte-pages/pages/form-errors.server.ts new file mode 100644 index 000000000..142a725c8 --- /dev/null +++ b/playground/svelte-pages/pages/form-errors.server.ts @@ -0,0 +1,12 @@ +import * as v from 'valibot'; +import { defineHandler } from 'void'; + +export const loader = defineHandler(() => ({})); +export const action = defineHandler.withValidator({ + body: v.object({ + email: v.pipe(v.string(), v.minLength(1, 'Email is required')), + name: v.pipe(v.string(), v.minLength(1, 'Name is required')), + profile: v.object({ name: v.string() }), + tags: v.array(v.string()), + }), +})(() => {}); diff --git a/playground/svelte-pages/pages/form-errors.svelte b/playground/svelte-pages/pages/form-errors.svelte new file mode 100644 index 000000000..f43766466 --- /dev/null +++ b/playground/svelte-pages/pages/form-errors.svelte @@ -0,0 +1,35 @@ + + +
+ {JSON.stringify(form.errors)} + + + + + + + + + +
diff --git a/playground/vue-pages/pages/form-errors.server.ts b/playground/vue-pages/pages/form-errors.server.ts new file mode 100644 index 000000000..142a725c8 --- /dev/null +++ b/playground/vue-pages/pages/form-errors.server.ts @@ -0,0 +1,12 @@ +import * as v from 'valibot'; +import { defineHandler } from 'void'; + +export const loader = defineHandler(() => ({})); +export const action = defineHandler.withValidator({ + body: v.object({ + email: v.pipe(v.string(), v.minLength(1, 'Email is required')), + name: v.pipe(v.string(), v.minLength(1, 'Name is required')), + profile: v.object({ name: v.string() }), + tags: v.array(v.string()), + }), +})(() => {}); diff --git a/playground/vue-pages/pages/form-errors.vue b/playground/vue-pages/pages/form-errors.vue new file mode 100644 index 000000000..f563978c9 --- /dev/null +++ b/playground/vue-pages/pages/form-errors.vue @@ -0,0 +1,37 @@ + + + diff --git a/test/pages-client-contract.ts b/test/pages-client-contract.ts index 3eeab9126..83c7f6aa1 100644 --- a/test/pages-client-contract.ts +++ b/test/pages-client-contract.ts @@ -1,5 +1,5 @@ import type { Page } from 'playwright'; -import { beforeAll, expect, it } from 'vite-plus/test'; +import { beforeAll, describe, expect, it } from 'vite-plus/test'; type TestWindow = Window & { __SPA_MARKER?: boolean; @@ -463,6 +463,93 @@ export function registerSharedClientTests({ } }); + describe('form field errors', () => { + async function openForm() { + const page = await newPage(); + await page.goto(BASE + '/form-errors'); + await waitForHydration(page); + return page; + } + + it('preserves new client validation errors and clears only edited fields', async ({ + expect, + }) => { + const page = await openForm(); + const readErrors = async () => JSON.parse((await page.getByTestId('errors').textContent())!); + try { + await page.getByRole('button', { name: 'Set errors', exact: true }).click(); + await expect.poll(readErrors).toEqual({ + email: 'Email invalid', + name: 'Name invalid', + profile: 'Profile invalid', + tags: 'Tags invalid', + }); + await page.getByLabel('Email', { exact: true }).fill('invalid'); + await expect.poll(readErrors).toEqual({ + email: 'Client email invalid', + name: 'Name invalid', + profile: 'Profile invalid', + tags: 'Tags invalid', + }); + await page.getByLabel('Name', { exact: true }).fill('Alice'); + await page.getByLabel('Email', { exact: true }).fill('alice@example.com'); + await expect.poll(readErrors).toEqual({ profile: 'Profile invalid', tags: 'Tags invalid' }); + await page.getByRole('button', { name: 'Clear all', exact: true }).click(); + await expect.poll(readErrors).toEqual({}); + } finally { + await page.context().close(); + } + }); + + it('clears errors after repeated nested object and array edits', async ({ expect }) => { + const page = await openForm(); + const readErrors = async () => JSON.parse((await page.getByTestId('errors').textContent())!); + try { + for (let edit = 0; edit < 3; edit++) { + await page.getByRole('button', { name: 'Set nested errors', exact: true }).click(); + await expect + .poll(readErrors) + .toEqual({ profile: 'Profile invalid', tags: 'Tags invalid' }); + await page.getByLabel('Profile', { exact: true }).fill('Name ' + edit); + await expect.poll(readErrors).toEqual({ tags: 'Tags invalid' }); + await page.getByRole('button', { name: 'Add tag', exact: true }).click(); + await expect.poll(readErrors).toEqual({}); + } + } finally { + await page.context().close(); + } + }); + + it('merges client and server errors without reviving cleared errors', async ({ expect }) => { + const page = await openForm(); + const readErrors = async () => JSON.parse((await page.getByTestId('errors').textContent())!); + try { + await page.getByRole('button', { name: 'Save', exact: true }).click(); + await expect + .poll(readErrors) + .toEqual({ email: 'Email is required', name: 'Name is required' }); + await page.getByRole('button', { name: 'Set nested errors', exact: true }).click(); + await expect.poll(readErrors).toEqual({ + email: 'Email is required', + name: 'Name is required', + profile: 'Profile invalid', + tags: 'Tags invalid', + }); + await page.getByRole('button', { name: 'Clear email', exact: true }).click(); + await page.getByLabel('Name', { exact: true }).fill('Alice'); + await expect.poll(readErrors).toEqual({ profile: 'Profile invalid', tags: 'Tags invalid' }); + await page.getByRole('button', { name: 'Clear all', exact: true }).click(); + await expect.poll(readErrors).toEqual({}); + await page.getByRole('button', { name: 'Save', exact: true }).click(); + await expect.poll(readErrors).toEqual({ email: 'Email is required' }); + await page.getByLabel('Email', { exact: true }).fill('alice@example.com'); + await expect.poll(readErrors).toEqual({}); + } finally { + await page.context().close(); + } + }); + }); + it('form validation errors display', async () => { const page = await newPage();