diff --git a/packages/react/src/runtime/use-form.ts b/packages/react/src/runtime/use-form.ts index 92d118d7e..d9e79b607 100644 --- a/packages/react/src/runtime/use-form.ts +++ b/packages/react/src/runtime/use-form.ts @@ -17,30 +17,21 @@ import { parseFormResetArgs, submitAction, type FormResetOptions, + type FormState, } from 'void/pages-client'; import type { ActionUrl, ResolveActionBody, ActionFormOptions } from 'void/routes'; import { RouterContext } from './context.ts'; type SubmitPayload = FormData | { method: string }; -type UntypedForm = { - data: Record; - setData(field: string, value: unknown): void; - errors: Record; - error: VoidActionError | null; - pending: boolean; - hasChanges: boolean; - wasSuccessful: boolean; - recentlySuccessful: boolean; - post(formData: FormData): void; - put(formData: FormData): void; - patch(formData: FormData): void; - delete(formData: FormData): void; - reset(options: FormResetOptions, ...fields: ReadonlyArray): void; - reset(...fields: ReadonlyArray): void; - setError(field: string, message: string): void; - clearErrors(...fields: ReadonlyArray): void; - clearError(): void; -}; +// Submit methods are native form actions that take FormData. +type FormReturn = FormState & + Readonly<{ + setData: (field: K, value: TData[K]) => void; + post(formData: FormData): void; + put(formData: FormData): void; + patch(formData: FormData): void; + delete(formData: FormData): void; + }>; type FormOptions = { params?: Record; @@ -54,37 +45,13 @@ export function useForm( url: U, defaults: ResolveActionBody, options?: ActionFormOptions & Pick, -): { - data: ResolveActionBody; - setData: & string>( - field: K, - value: ResolveActionBody[K], - ) => void; - errors: Partial & string, string>>; - error: VoidActionError | null; - pending: boolean; - hasChanges: boolean; - wasSuccessful: boolean; - recentlySuccessful: boolean; - post(formData: FormData): void; - put(formData: FormData): void; - patch(formData: FormData): void; - delete(formData: FormData): void; - reset( - options: FormResetOptions, - ...fields: ReadonlyArray & string> - ): void; - reset(...fields: ReadonlyArray & string>): void; - setError(field: keyof ResolveActionBody & string, message: string): void; - clearErrors(...fields: ReadonlyArray & string>): void; - clearError(): void; -}; +): FormReturn>; export function useForm( url: string, defaults: Record, options?: FormOptions, -): UntypedForm { +): FormReturn> { const router = useContext(RouterContext); const [data, setDataState] = useState>(() => cloneFormValue(defaults)); const [defaultsState, setDefaultsState] = useState>(() => diff --git a/packages/react/src/runtime/use-island-form.ts b/packages/react/src/runtime/use-island-form.ts index a1505ed07..bc8d8cd9b 100644 --- a/packages/react/src/runtime/use-island-form.ts +++ b/packages/react/src/runtime/use-island-form.ts @@ -1,23 +1,13 @@ import { useState, useRef, useCallback, useMemo } from 'react'; -import { isEqualFormValue, submitIslandAction, type VoidActionError } from 'void/pages-client'; +import { + isEqualFormValue, + submitIslandAction, + type IslandFormState, + type VoidActionError, +} from 'void/pages-client'; -export type IslandFormReturn> = Readonly<{ - data: T; - setData: (field: K, value: T[K]) => void; - errors: Record; - error: VoidActionError | null; - pending: boolean; - hasChanges: boolean; - wasSuccessful: boolean; - recentlySuccessful: boolean; - reset: (...fields: ReadonlyArray) => void; - clearErrors: (...fields: ReadonlyArray) => void; - clearError: () => void; - post: (url: string) => Promise; - put: (url: string) => Promise; - patch: (url: string) => Promise; - delete: (url: string) => Promise; -}>; +export type IslandFormReturn> = IslandFormState & + Readonly<{ setData: (field: K, value: T[K]) => void }>; /** * Form hook for island pages. Uses fetch + page reload instead of the diff --git a/packages/react/test/unit/form-errors.typecheck.ts b/packages/react/test/unit/form-errors.typecheck.ts index 0ae7c609b..5111d80b2 100644 --- a/packages/react/test/unit/form-errors.typecheck.ts +++ b/packages/react/test/unit/form-errors.typecheck.ts @@ -1,3 +1,4 @@ +import type { FormModel } from 'void/pages-client'; import { useForm } from '../../src/runtime/use-form.ts'; declare module 'void/routes' { @@ -13,4 +14,28 @@ export function checkFieldErrors(): void { form.setError('missing', 'Invalid field'); // @ts-expect-error Error messages must be strings. form.setError('email', 123); + // @ts-expect-error Form state is read-only; update fields with `setData`. + form.data = { email: '' }; + // @ts-expect-error Form state is read-only. + form.errors = {}; + // @ts-expect-error Form state is read-only. + form.error = null; + // @ts-expect-error Form state is read-only. + form.pending = true; + // @ts-expect-error Form state is read-only. + form.hasChanges = true; + // @ts-expect-error Form state is read-only. + form.wasSuccessful = true; + // @ts-expect-error Form state is read-only. + form.recentlySuccessful = true; + // @ts-expect-error Errors change through setError and clearErrors. + form.errors.email = 'Invalid'; + // @ts-expect-error Fields change through setData. + form.data.email = ''; + // @ts-expect-error Form methods are read-only. + form.clearError = () => {}; + // @ts-expect-error Submit methods are form actions that take FormData. + form.post(); + const model: FormModel<[FormData]> = form; + void model; } diff --git a/packages/react/test/unit/island-form.typecheck.ts b/packages/react/test/unit/island-form.typecheck.ts new file mode 100644 index 000000000..14a4cdf48 --- /dev/null +++ b/packages/react/test/unit/island-form.typecheck.ts @@ -0,0 +1,37 @@ +import { useIslandForm } from '../../src/runtime/use-island-form.ts'; + +export function checkIslandForm(): void { + const form = useIslandForm({ title: '', count: 0 }); + const title: string = form.data.title; + const count: number = form.data.count; + void title; + void count; + form.reset('title'); + // @ts-expect-error Reset fields must exist in the defaults. + form.reset('missing'); + // @ts-expect-error Island form state is read-only. + form.pending = true; + // @ts-expect-error Island form state is read-only. + form.data = { title: '', count: 0 }; + // @ts-expect-error Island form state is read-only. + form.errors = {}; + // @ts-expect-error Form methods are read-only. + form.clearError = () => {}; + // Island errors are keyed by whatever names the action returns. + const nestedError: string | undefined = form.errors['items.0.name']; + void nestedError; + const titleError: string | undefined = form.errors.title; + void titleError; + // @ts-expect-error A field may have no error. + void form.errors.title.length; + // @ts-expect-error Island errors are read-only. + form.errors.title = 'Invalid'; + // @ts-expect-error Fields change through setData. + form.data.title = ''; + void form.post('/notes'); + form.setData('title', 'Hello'); + // @ts-expect-error Field values keep their default types. + form.setData('count', '1'); + // @ts-expect-error Only Vue and Svelte islands expose submit(). + void form.submit('/notes', 'PATCH'); +} diff --git a/packages/solid/src/runtime/use-form.ts b/packages/solid/src/runtime/use-form.ts index 1f8f15840..38e649520 100644 --- a/packages/solid/src/runtime/use-form.ts +++ b/packages/solid/src/runtime/use-form.ts @@ -10,70 +10,33 @@ import { parseFormResetArgs, submitAction, type FormResetOptions, - type VisitOptions, + type FormState, + type FormSubmitOptions, } from 'void/pages-client'; import type { ActionUrl, ResolveActionBody, ActionFormOptions } from 'void/routes'; import { RouterContext, ErrorsContext } from './context.ts'; -type FormSubmitOptions = Omit & { - data?: Record; -}; -type UntypedForm = { - data: Record; - setData(field: string, value: unknown): void; - errors: Record; - error: VoidActionError | null; - pending: boolean; - hasChanges: boolean; - wasSuccessful: boolean; - recentlySuccessful: boolean; - post(opts?: FormSubmitOptions): Promise; - put(opts?: FormSubmitOptions): Promise; - patch(opts?: FormSubmitOptions): Promise; - delete(opts?: FormSubmitOptions): Promise; - reset(options: FormResetOptions, ...fields: ReadonlyArray): void; - reset(...fields: ReadonlyArray): void; - setError(field: string, message: string): void; - clearErrors(...fields: ReadonlyArray): void; - clearError(): void; -}; +type FormReturn = FormState & + Readonly<{ + setData: (field: K, value: TData[K]) => void; + post(opts?: FormSubmitOptions): Promise; + put(opts?: FormSubmitOptions): Promise; + patch(opts?: FormSubmitOptions): Promise; + delete(opts?: FormSubmitOptions): Promise; + }>; // Typed overload — full inference from PageActionMap (requires codegen) export function useForm( url: U, defaults: ResolveActionBody, options?: ActionFormOptions, -): { - data: ResolveActionBody; - setData: & string>( - field: K, - value: ResolveActionBody[K], - ) => void; - errors: Partial & string, string>>; - error: VoidActionError | null; - pending: boolean; - hasChanges: boolean; - wasSuccessful: boolean; - recentlySuccessful: boolean; - post(opts?: FormSubmitOptions): Promise; - put(opts?: FormSubmitOptions): Promise; - patch(opts?: FormSubmitOptions): Promise; - delete(opts?: FormSubmitOptions): Promise; - reset( - options: FormResetOptions, - ...fields: ReadonlyArray & string> - ): void; - reset(...fields: ReadonlyArray & string>): void; - setError(field: keyof ResolveActionBody & string, message: string): void; - clearErrors(...fields: ReadonlyArray & string>): void; - clearError(): void; -}; +): FormReturn>; export function useForm( url: string, defaults: Record, options?: { params?: Record; resetOnSuccess?: boolean }, -): UntypedForm { +): FormReturn> { const router = useContext(RouterContext); // ErrorsContext provides the errors signal accessor — a reactive function // that returns the current global errors object. This allows useForm to diff --git a/packages/solid/src/runtime/use-island-form.ts b/packages/solid/src/runtime/use-island-form.ts index 7aa7cd59a..e0af9e309 100644 --- a/packages/solid/src/runtime/use-island-form.ts +++ b/packages/solid/src/runtime/use-island-form.ts @@ -1,24 +1,14 @@ import { createSignal, createMemo } from 'solid-js'; import { createStore, reconcile } from 'solid-js/store'; -import { isEqualFormValue, submitIslandAction, type VoidActionError } from 'void/pages-client'; +import { + isEqualFormValue, + submitIslandAction, + type IslandFormState, + type VoidActionError, +} from 'void/pages-client'; -export type IslandFormReturn> = Readonly<{ - data: T; - setData: (field: K, value: T[K]) => void; - errors: Record; - error: VoidActionError | null; - pending: boolean; - hasChanges: boolean; - wasSuccessful: boolean; - recentlySuccessful: boolean; - reset: (...fields: ReadonlyArray) => void; - clearErrors: (...fields: ReadonlyArray) => void; - clearError: () => void; - post: (url: string) => Promise; - put: (url: string) => Promise; - patch: (url: string) => Promise; - delete: (url: string) => Promise; -}>; +export type IslandFormReturn> = IslandFormState & + Readonly<{ setData: (field: K, value: T[K]) => void }>; /** * Form composable for island pages. Uses fetch + page reload instead of the diff --git a/packages/solid/test/unit/form-errors.typecheck.ts b/packages/solid/test/unit/form-errors.typecheck.ts index 394f03fab..5849df88e 100644 --- a/packages/solid/test/unit/form-errors.typecheck.ts +++ b/packages/solid/test/unit/form-errors.typecheck.ts @@ -1,3 +1,4 @@ +import type { FormModel } from 'void/pages-client'; import { useForm } from '../../src/runtime/use-form.ts'; declare module 'void/routes' { @@ -13,6 +14,30 @@ export function checkFieldErrors(): void { form.setError('missing', 'Invalid field'); // @ts-expect-error Error messages must be strings. form.setError('email', 123); + // @ts-expect-error Form state is read-only; update fields with `setData`. + form.data = { email: '' }; + // @ts-expect-error Form state is read-only. + form.errors = {}; + // @ts-expect-error Form state is read-only. + form.error = null; + // @ts-expect-error Form state is read-only. + form.pending = true; + // @ts-expect-error Form state is read-only. + form.hasChanges = true; + // @ts-expect-error Form state is read-only. + form.wasSuccessful = true; + // @ts-expect-error Form state is read-only. + form.recentlySuccessful = true; + // @ts-expect-error Errors change through setError and clearErrors. + form.errors.email = 'Invalid'; + // @ts-expect-error Fields change through setData. + form.data.email = ''; + // @ts-expect-error Form methods are read-only. + form.clearError = () => {}; + // @ts-expect-error The submit method sets the HTTP method. + void form.post({ method: 'GET' }); + const model: FormModel = form; + void model; // @ts-expect-error Submission callbacks are specific to the React adapter. useForm('/__form-errors/solid', { email: '' }, { onSuccess: () => {} }); // @ts-expect-error Submission callbacks are specific to the React adapter. diff --git a/packages/solid/test/unit/island-form.typecheck.ts b/packages/solid/test/unit/island-form.typecheck.ts new file mode 100644 index 000000000..14a4cdf48 --- /dev/null +++ b/packages/solid/test/unit/island-form.typecheck.ts @@ -0,0 +1,37 @@ +import { useIslandForm } from '../../src/runtime/use-island-form.ts'; + +export function checkIslandForm(): void { + const form = useIslandForm({ title: '', count: 0 }); + const title: string = form.data.title; + const count: number = form.data.count; + void title; + void count; + form.reset('title'); + // @ts-expect-error Reset fields must exist in the defaults. + form.reset('missing'); + // @ts-expect-error Island form state is read-only. + form.pending = true; + // @ts-expect-error Island form state is read-only. + form.data = { title: '', count: 0 }; + // @ts-expect-error Island form state is read-only. + form.errors = {}; + // @ts-expect-error Form methods are read-only. + form.clearError = () => {}; + // Island errors are keyed by whatever names the action returns. + const nestedError: string | undefined = form.errors['items.0.name']; + void nestedError; + const titleError: string | undefined = form.errors.title; + void titleError; + // @ts-expect-error A field may have no error. + void form.errors.title.length; + // @ts-expect-error Island errors are read-only. + form.errors.title = 'Invalid'; + // @ts-expect-error Fields change through setData. + form.data.title = ''; + void form.post('/notes'); + form.setData('title', 'Hello'); + // @ts-expect-error Field values keep their default types. + form.setData('count', '1'); + // @ts-expect-error Only Vue and Svelte islands expose submit(). + void form.submit('/notes', 'PATCH'); +} diff --git a/packages/svelte/src/runtime/use-form.svelte.ts b/packages/svelte/src/runtime/use-form.svelte.ts index e832455ea..a968b2c32 100644 --- a/packages/svelte/src/runtime/use-form.svelte.ts +++ b/packages/svelte/src/runtime/use-form.svelte.ts @@ -9,65 +9,32 @@ import { parseFormResetArgs, submitAction, type FormResetOptions, - type VisitOptions, + type FormState, + type FormSubmitOptions, type VoidRouter, } from 'void/pages-client'; import type { ActionUrl, ResolveActionBody, ActionFormOptions } from 'void/routes'; -type FormSubmitOptions = Omit & { - data?: Record; -}; -type UntypedForm = { - readonly data: Record; - readonly errors: Record; - readonly error: VoidActionError | null; - readonly pending: boolean; - readonly hasChanges: boolean; - readonly wasSuccessful: boolean; - readonly recentlySuccessful: boolean; - post(opts?: FormSubmitOptions): Promise; - put(opts?: FormSubmitOptions): Promise; - patch(opts?: FormSubmitOptions): Promise; - delete(opts?: FormSubmitOptions): Promise; - reset(options: FormResetOptions, ...fields: ReadonlyArray): void; - reset(...fields: ReadonlyArray): void; - setError(field: string, message: string): void; - clearErrors(...fields: ReadonlyArray): void; - clearError(): void; -}; +type FormReturn = FormState & + Readonly<{ + post(opts?: FormSubmitOptions): Promise; + put(opts?: FormSubmitOptions): Promise; + patch(opts?: FormSubmitOptions): Promise; + delete(opts?: FormSubmitOptions): Promise; + }>; // Typed overload — full inference from PageActionMap (requires codegen) export function useForm( url: U, defaults: ResolveActionBody, options?: ActionFormOptions, -): { - readonly data: ResolveActionBody; - readonly errors: Partial & string, string>>; - readonly error: VoidActionError | null; - readonly pending: boolean; - readonly hasChanges: boolean; - readonly wasSuccessful: boolean; - readonly recentlySuccessful: boolean; - post(opts?: FormSubmitOptions): Promise; - put(opts?: FormSubmitOptions): Promise; - patch(opts?: FormSubmitOptions): Promise; - delete(opts?: FormSubmitOptions): Promise; - reset( - options: FormResetOptions, - ...fields: ReadonlyArray & string> - ): void; - reset(...fields: ReadonlyArray & string>): void; - setError(field: keyof ResolveActionBody & string, message: string): void; - clearErrors(...fields: ReadonlyArray & string>): void; - clearError(): void; -}; +): FormReturn>; export function useForm( url: string, defaults: Record, options?: { params?: Record; resetOnSuccess?: boolean }, -): UntypedForm { +): FormReturn> { const router = getContext('__void_router'); const initialDefaults = cloneFormValue(defaults); diff --git a/packages/svelte/src/runtime/use-island-form.svelte.ts b/packages/svelte/src/runtime/use-island-form.svelte.ts index 446f2d786..47078196f 100644 --- a/packages/svelte/src/runtime/use-island-form.svelte.ts +++ b/packages/svelte/src/runtime/use-island-form.svelte.ts @@ -1,29 +1,19 @@ -import { isEqualFormValue, submitIslandAction, type VoidActionError } from 'void/pages-client'; +import { + isEqualFormValue, + submitIslandAction, + type IslandFormState, + type VoidActionError, +} from 'void/pages-client'; -type UntypedIslandForm = { - readonly data: Record; - readonly errors: Record; - readonly error: VoidActionError | null; - readonly pending: boolean; - readonly hasChanges: boolean; - readonly wasSuccessful: boolean; - readonly recentlySuccessful: boolean; - submit(url: string, method: string): Promise; - post(url: string): Promise; - put(url: string): Promise; - patch(url: string): Promise; - delete(url: string): Promise; - reset(...fields: ReadonlyArray): void; - clearErrors(...fields: ReadonlyArray): void; - clearError(): void; -}; +export type IslandFormReturn> = IslandFormState & + Readonly<{ submit(url: string, method: string): Promise }>; /** * Form composable for island pages. Uses fetch + page reload instead of the * Void Router (which is not available in island mode). */ -export function useIslandForm(defaults: Record): UntypedIslandForm { - let data: Record = $state({ ...defaults }); +export function useIslandForm>(defaults: T): IslandFormReturn { + let data: T = $state({ ...defaults }); let errors: Record = $state({}); let error: VoidActionError | null = $state(null); let pending = $state(false); @@ -37,7 +27,7 @@ export function useIslandForm(defaults: Record): UntypedIslandF Object.keys(defaults_).some((key) => !isEqualFormValue(data[key], defaults_[key])), ); - function reset(...fields: ReadonlyArray) { + function reset(...fields: ReadonlyArray) { if (fields.length === 0) { data = { ...defaults_ }; } else { diff --git a/packages/svelte/test/unit/form-errors.typecheck.ts b/packages/svelte/test/unit/form-errors.typecheck.ts index 600fbb9d4..024ba82b0 100644 --- a/packages/svelte/test/unit/form-errors.typecheck.ts +++ b/packages/svelte/test/unit/form-errors.typecheck.ts @@ -1,3 +1,4 @@ +import type { FormModel } from 'void/pages-client'; import { useForm } from '../../src/runtime/use-form.svelte.ts'; declare module 'void/routes' { @@ -13,6 +14,30 @@ export function checkFieldErrors(): void { form.setError('missing', 'Invalid field'); // @ts-expect-error Error messages must be strings. form.setError('email', 123); + form.data.email = ''; + // @ts-expect-error Form state is read-only; edit fields through `data`. + form.data = { email: '' }; + // @ts-expect-error Form state is read-only. + form.errors = {}; + // @ts-expect-error Form state is read-only. + form.error = null; + // @ts-expect-error Form state is read-only. + form.pending = true; + // @ts-expect-error Form state is read-only. + form.hasChanges = true; + // @ts-expect-error Form state is read-only. + form.wasSuccessful = true; + // @ts-expect-error Form state is read-only. + form.recentlySuccessful = true; + form.errors.email = 'Invalid'; + // @ts-expect-error Svelte edits `data` in place, so there is no setData. + form.setData('email', ''); + // @ts-expect-error Form methods are read-only. + form.clearError = () => {}; + // @ts-expect-error The submit method sets the HTTP method. + void form.post({ method: 'GET' }); + const model: FormModel = form; + void model; // @ts-expect-error Submission callbacks are specific to the React adapter. useForm('/__form-errors/svelte', { email: '' }, { onSuccess: () => {} }); // @ts-expect-error Submission callbacks are specific to the React adapter. diff --git a/packages/svelte/test/unit/island-form.typecheck.ts b/packages/svelte/test/unit/island-form.typecheck.ts new file mode 100644 index 000000000..f12cbf5d2 --- /dev/null +++ b/packages/svelte/test/unit/island-form.typecheck.ts @@ -0,0 +1,33 @@ +import { useIslandForm } from '../../src/runtime/use-island-form.svelte.ts'; + +export function checkIslandForm(): void { + const form = useIslandForm({ title: '', count: 0 }); + const title: string = form.data.title; + const count: number = form.data.count; + void title; + void count; + form.reset('title'); + // @ts-expect-error Reset fields must exist in the defaults. + form.reset('missing'); + // @ts-expect-error Island form state is read-only. + form.pending = true; + // @ts-expect-error Island form state is read-only. + form.data = { title: '', count: 0 }; + // @ts-expect-error Island form state is read-only. + form.errors = {}; + // @ts-expect-error Form methods are read-only. + form.clearError = () => {}; + // Island errors are keyed by whatever names the action returns. + const nestedError: string | undefined = form.errors['items.0.name']; + void nestedError; + const titleError: string | undefined = form.errors.title; + void titleError; + // @ts-expect-error A field may have no error. + void form.errors.title.length; + form.errors.title = 'Invalid'; + void form.post('/notes'); + form.data.title = 'Hello'; + void form.submit('/notes', 'PATCH'); + // @ts-expect-error Svelte edits `data` in place, so there is no setData. + form.setData('title', 'Hello'); +} diff --git a/packages/void/src/pages/client.ts b/packages/void/src/pages/client.ts index 77311f53e..285cf80df 100644 --- a/packages/void/src/pages/client.ts +++ b/packages/void/src/pages/client.ts @@ -609,4 +609,7 @@ export { scheduleFormRetry, type FormModel, type FormMethod, + type FormState, + type FormSubmitOptions, + type IslandFormState, } from './form-presentation.ts'; diff --git a/packages/void/src/pages/form-presentation.ts b/packages/void/src/pages/form-presentation.ts index 881f66994..03e5e895c 100644 --- a/packages/void/src/pages/form-presentation.ts +++ b/packages/void/src/pages/form-presentation.ts @@ -1,12 +1,64 @@ import type { VoidActionError } from './action-result.ts'; +import type { FormResetOptions } from './form-reset.ts'; +import type { VisitOptions } from './navigation-engine.ts'; export type FormMethod = 'post' | 'put' | 'patch' | 'delete'; -export type FormModel = []> = { - readonly pending: boolean; - readonly error: VoidActionError | null; +export type FormModel = []> = Pick & { readonly errors: Readonly>; } & { [K in FormMethod]: (...args: Args) => void | Promise }; +/** Router options a `useForm()` submission accepts in adapters whose submit methods take options. */ +export type FormSubmitOptions = Omit & { + data?: Record; +}; +/** Submission state that `useForm()` and `useIslandForm()` report in every adapter. */ +export type FormStatus = { + readonly error: VoidActionError | null; + readonly pending: boolean; + readonly hasChanges: boolean; + readonly wasSuccessful: boolean; + readonly recentlySuccessful: boolean; +}; +/** Field-keyed validation messages; a field without an error is absent. */ +export type FormErrors = Partial>; +/** + * How an adapter's apps change `data` and `errors`, following its framework's idiom. + * `'in-place'`: apps edit them directly (Vue, Svelte). + * `'setters'`: they are read-only and change through adapter setters such as `setData` (React, Solid). + */ +type FormEditing = 'in-place' | 'setters'; +type FormFields = [TEditing] extends ['setters'] + ? { readonly data: Readonly; readonly errors: Readonly } + : { readonly data: TData; readonly errors: TErrors }; +/** + * The `useForm()` contract every adapter shares. Field names are typed from the action body. + * Adapters add their submit methods. + */ +export type FormState = FormStatus & + FormFields, TEditing> & + Readonly<{ + reset(options: FormResetOptions, ...fields: ReadonlyArray): void; + reset(...fields: ReadonlyArray): void; + setError(field: keyof TData & string, message: string): void; + clearErrors(...fields: ReadonlyArray): void; + clearError(): void; + }>; +/** + * The `useIslandForm()` contract every adapter shares. Errors are keyed by whatever names the + * action returns, not only the default fields. Submit methods take the action URL. + */ +export type IslandFormState = FormStatus & + FormFields>, TEditing> & + Readonly<{ + reset(...fields: ReadonlyArray): void; + clearErrors(...fields: ReadonlyArray): void; + clearError(): void; + post(url: string): Promise; + put(url: string): Promise; + patch(url: string): Promise; + delete(url: string): Promise; + }>; + export function formErrorMessage(form: Pick): string | null { return form.error?.message ?? (Object.values(form.errors).filter(Boolean).join('. ') || null); } diff --git a/packages/vue/src/runtime/use-form.ts b/packages/vue/src/runtime/use-form.ts index 643101ea7..25be0b9f5 100644 --- a/packages/vue/src/runtime/use-form.ts +++ b/packages/vue/src/runtime/use-form.ts @@ -8,65 +8,32 @@ import { parseFormResetArgs, submitAction, type FormResetOptions, - type VisitOptions, + type FormState, + type FormSubmitOptions, type VoidRouter, } from 'void/pages-client'; import type { ActionUrl, ResolveActionBody, ActionFormOptions } from 'void/routes'; import { computed, reactive, ref, shallowRef, inject, watch } from 'vue'; -type FormSubmitOptions = Omit & { - data?: Record; -}; -type UntypedForm = { - data: Record; - errors: Record; - error: VoidActionError | null; - pending: boolean; - hasChanges: boolean; - wasSuccessful: boolean; - recentlySuccessful: boolean; - post(opts?: FormSubmitOptions): Promise; - put(opts?: FormSubmitOptions): Promise; - patch(opts?: FormSubmitOptions): Promise; - delete(opts?: FormSubmitOptions): Promise; - reset(options: FormResetOptions, ...fields: ReadonlyArray): void; - reset(...fields: ReadonlyArray): void; - setError(field: string, message: string): void; - clearErrors(...fields: ReadonlyArray): void; - clearError(): void; -}; +type FormReturn = FormState & + Readonly<{ + post(opts?: FormSubmitOptions): Promise; + put(opts?: FormSubmitOptions): Promise; + patch(opts?: FormSubmitOptions): Promise; + delete(opts?: FormSubmitOptions): Promise; + }>; export function useForm( url: U, defaults: ResolveActionBody, options?: ActionFormOptions, -): { - data: ResolveActionBody; - errors: Partial & string, string>>; - error: VoidActionError | null; - pending: boolean; - hasChanges: boolean; - wasSuccessful: boolean; - recentlySuccessful: boolean; - post(opts?: FormSubmitOptions): Promise; - put(opts?: FormSubmitOptions): Promise; - patch(opts?: FormSubmitOptions): Promise; - delete(opts?: FormSubmitOptions): Promise; - reset( - options: FormResetOptions, - ...fields: ReadonlyArray & string> - ): void; - reset(...fields: ReadonlyArray & string>): void; - setError(field: keyof ResolveActionBody & string, message: string): void; - clearErrors(...fields: ReadonlyArray & string>): void; - clearError(): void; -}; +): FormReturn>; export function useForm( url: string, defaults: Record, options?: { params?: Record; resetOnSuccess?: boolean }, -): UntypedForm { +): FormReturn> { const initialDefaults = cloneFormValue(defaults); const router = inject('__void_router', null); const data = reactive(cloneFormValue(initialDefaults)) as Record; diff --git a/packages/vue/src/runtime/use-island-form.ts b/packages/vue/src/runtime/use-island-form.ts index 2238a6cb5..e083b22c1 100644 --- a/packages/vue/src/runtime/use-island-form.ts +++ b/packages/vue/src/runtime/use-island-form.ts @@ -1,30 +1,25 @@ -import { isEqualFormValue, submitIslandAction, type VoidActionError } from 'void/pages-client'; +import { + isEqualFormValue, + submitIslandAction, + type IslandFormState, + type VoidActionError, +} from 'void/pages-client'; import { reactive, ref, watch } from 'vue'; -export type IslandFormReturn> = Readonly<{ - data: T; - errors: Record; - error: VoidActionError | null; - pending: boolean; - hasChanges: boolean; - wasSuccessful: boolean; - recentlySuccessful: boolean; - submit(url: string, method: string): Promise; - post(url: string): Promise; - put(url: string): Promise; - patch(url: string): Promise; - delete(url: string): Promise; - reset(...fields: ReadonlyArray): void; - clearErrors(...fields: ReadonlyArray): void; - clearError(): void; -}>; +export type IslandFormReturn> = IslandFormState & + Readonly<{ submit(url: string, method: string): Promise }>; /** * Form composable for island pages. Uses fetch + page reload instead of the * Void Router (which is not available in island mode). */ -export function useIslandForm>(defaults: T): IslandFormReturn { - const data = reactive({ ...defaults }) as T; +export function useIslandForm>(defaults: T): IslandFormReturn; + +// Untyped implementation, so the returned object is checked against the island contract. +export function useIslandForm( + defaults: Record, +): IslandFormReturn> { + const data = reactive({ ...defaults }) as Record; const errors = reactive>({}); const error = ref(null); const pending = ref(false); @@ -45,7 +40,7 @@ export function useIslandForm>(defaults: T): I { deep: true }, ); - function reset(...fields: ReadonlyArray) { + function reset(...fields: ReadonlyArray) { if (fields.length === 0) { Object.assign(data, { ...defaults_ }); } else { @@ -123,5 +118,5 @@ export function useIslandForm>(defaults: T): I put: (url: string) => submit(url, 'PUT'), patch: (url: string) => submit(url, 'PATCH'), delete: (url: string) => submit(url, 'DELETE'), - }) as IslandFormReturn; + }); } diff --git a/packages/vue/test/unit/form-errors.typecheck.ts b/packages/vue/test/unit/form-errors.typecheck.ts index 8b2a3980e..ab336a67f 100644 --- a/packages/vue/test/unit/form-errors.typecheck.ts +++ b/packages/vue/test/unit/form-errors.typecheck.ts @@ -1,3 +1,4 @@ +import type { FormModel } from 'void/pages-client'; import { useForm } from '../../src/runtime/use-form.ts'; declare module 'void/routes' { @@ -13,4 +14,28 @@ export function checkFieldErrors(): void { form.setError('missing', 'Invalid field'); // @ts-expect-error Error messages must be strings. form.setError('email', 123); + form.data.email = ''; + // @ts-expect-error Form state is read-only; edit fields through `data`. + form.data = { email: '' }; + // @ts-expect-error Form state is read-only. + form.errors = {}; + // @ts-expect-error Form state is read-only. + form.error = null; + // @ts-expect-error Form state is read-only. + form.pending = true; + // @ts-expect-error Form state is read-only. + form.hasChanges = true; + // @ts-expect-error Form state is read-only. + form.wasSuccessful = true; + // @ts-expect-error Form state is read-only. + form.recentlySuccessful = true; + form.errors.email = 'Invalid'; + // @ts-expect-error Vue edits `data` in place, so there is no setData. + form.setData('email', ''); + // @ts-expect-error Form methods are read-only. + form.clearError = () => {}; + // @ts-expect-error The submit method sets the HTTP method. + void form.post({ method: 'GET' }); + const model: FormModel = form; + void model; } diff --git a/packages/vue/test/unit/island-form.typecheck.ts b/packages/vue/test/unit/island-form.typecheck.ts new file mode 100644 index 000000000..daffd401a --- /dev/null +++ b/packages/vue/test/unit/island-form.typecheck.ts @@ -0,0 +1,33 @@ +import { useIslandForm } from '../../src/runtime/use-island-form.ts'; + +export function checkIslandForm(): void { + const form = useIslandForm({ title: '', count: 0 }); + const title: string = form.data.title; + const count: number = form.data.count; + void title; + void count; + form.reset('title'); + // @ts-expect-error Reset fields must exist in the defaults. + form.reset('missing'); + // @ts-expect-error Island form state is read-only. + form.pending = true; + // @ts-expect-error Island form state is read-only. + form.data = { title: '', count: 0 }; + // @ts-expect-error Island form state is read-only. + form.errors = {}; + // @ts-expect-error Form methods are read-only. + form.clearError = () => {}; + // Island errors are keyed by whatever names the action returns. + const nestedError: string | undefined = form.errors['items.0.name']; + void nestedError; + const titleError: string | undefined = form.errors.title; + void titleError; + // @ts-expect-error A field may have no error. + void form.errors.title.length; + form.errors.title = 'Invalid'; + void form.post('/notes'); + form.data.title = 'Hello'; + void form.submit('/notes', 'PATCH'); + // @ts-expect-error Vue edits `data` in place, so there is no setData. + form.setData('title', 'Hello'); +}