diff --git a/README.md b/README.md index 80d41b99..838101a9 100644 --- a/README.md +++ b/README.md @@ -13,16 +13,13 @@ import { useForm, useFieldset } from '@conform-to/react'; export default function LoginForm() { const form = useForm({ - onValidate({ form }) { - return form.reportValidity(); - }, onSubmit(event, { submission }) { event.preventDefault(); console.log(submission); }, }); - const { email, password } = useFieldset(form.ref, form.config); + const { email, password } = useFieldset(form.ref); return (
diff --git a/examples/basics/README.md b/examples/basics/README.md index d00fa3e7..836577ac 100644 --- a/examples/basics/README.md +++ b/examples/basics/README.md @@ -76,9 +76,6 @@ import { useForm, useFieldset } from '@conform-to/react'; export default function LoginForm() { const form = useForm({ - onValidate({ form }) { - return form.reportValidity(); - } onSubmit(event) { event.preventDefault(); diff --git a/examples/basics/src/App.tsx b/examples/basics/src/App.tsx index 2a293119..66bfbad4 100644 --- a/examples/basics/src/App.tsx +++ b/examples/basics/src/App.tsx @@ -2,16 +2,13 @@ import { useForm, useFieldset } from '@conform-to/react'; export default function LoginForm() { const form = useForm({ - onValidate({ form }) { - return form.reportValidity(); - }, onSubmit(event, { submission }) { event.preventDefault(); console.log(submission); }, }); - const { email, password } = useFieldset(form.ref, form.config); + const { email, password } = useFieldset(form.ref); return ( diff --git a/examples/list/src/App.tsx b/examples/list/src/App.tsx index 43cd4d84..1b3055c0 100644 --- a/examples/list/src/App.tsx +++ b/examples/list/src/App.tsx @@ -15,10 +15,7 @@ interface Todo { export default function TodoForm() { const form = useForm({ initialReport: 'onBlur', - onValidate({ form }) { - return form.reportValidity(); - }, - async onSubmit(event, { submission }) { + onSubmit(event, { submission }) { event.preventDefault(); console.log(submission); diff --git a/examples/material-ui/src/App.tsx b/examples/material-ui/src/App.tsx index db403132..2ef69a87 100644 --- a/examples/material-ui/src/App.tsx +++ b/examples/material-ui/src/App.tsx @@ -10,9 +10,6 @@ interface Article { export default function ArticleForm() { const form = useForm
({ initialReport: 'onBlur', - onValidate({ form }) { - return form.reportValidity(); - }, onSubmit: (event, { submission }) => { event.preventDefault(); diff --git a/examples/nested/src/App.tsx b/examples/nested/src/App.tsx index 9b5599eb..2ae55f07 100644 --- a/examples/nested/src/App.tsx +++ b/examples/nested/src/App.tsx @@ -11,9 +11,6 @@ interface Payment { export default function PaymentForm() { const form = useForm({ - onValidate({ form }) { - return form.reportValidity(); - }, onSubmit(event, { submission }) { event.preventDefault(); diff --git a/examples/remix/app/routes/index.tsx b/examples/remix/app/routes/index.tsx index 2fe66ea5..0d6cd2f3 100644 --- a/examples/remix/app/routes/index.tsx +++ b/examples/remix/app/routes/index.tsx @@ -5,7 +5,7 @@ import { useFieldList, conform, parse, - reportValidity, + setFormError, } from '@conform-to/react'; import { getError } from '@conform-to/zod'; import type { ActionArgs } from '@remix-run/node'; @@ -28,9 +28,7 @@ export let action = async ({ request }: ActionArgs) => { const formData = await request.formData(); const submission = parse(formData); const result = todoSchema.safeParse(submission.value); - const error = !result.success - ? submission.error.concat(getError(result.error, submission.scope)) - : submission.error; + const error = submission.error.concat(getError(result)); switch (submission.type) { case 'validate': { @@ -59,14 +57,12 @@ export default function TodoForm() { state, onValidate({ form, submission }) { const result = todoSchema.safeParse(submission.value); - const error = !result.success - ? submission.error.concat(getError(result.error, submission.scope)) - : submission.error; - return reportValidity(form, { - ...submission, - error, - }); + if (!result.success) { + submission.error = submission.error.concat(getError(result.error)); + } + + setFormError(form, submission); }, onSubmit(event, { submission }) { switch (submission.type) { diff --git a/examples/server-validation/app/routes/index.tsx b/examples/server-validation/app/routes/index.tsx index 4fcca380..cd07aabc 100644 --- a/examples/server-validation/app/routes/index.tsx +++ b/examples/server-validation/app/routes/index.tsx @@ -1,11 +1,12 @@ -import type { FormState } from '@conform-to/react'; +import type { Submission } from '@conform-to/react'; import { conform, parse, useFieldset, useForm, hasError, - reportValidity, + shouldValidate, + setFormError, } from '@conform-to/react'; import { getError } from '@conform-to/zod'; import type { ActionArgs } from '@remix-run/node'; @@ -43,27 +44,20 @@ export let action = async ({ request }: ActionArgs) => { * (1) `submission.value`: Structured form value based on the name (path) * (2) `submission.error`: Error (if any) while parsing the FormData object, * (3) `submission.type` : Type of the submission. - * Set only when the user click on named button with pattern (`conform/${type}`), - * e.g. `validate` - * (4) `submission.scope`: Scope of the submission. Name of the fields that should be validated. - * e.g. The scope will be `name` only when the user is typing on the name field. + * The type would be `undefined` when user click on any normal submit button. + * It would be set only when the user click on named button with pattern (`conform/${type}`), + * e.g. Conform is clicking on a button with name `conform/validate` when validating, so the type would be `valdiate`. */ const submission = parse(formData); const result = await schema + // Async validation. e.g. checking uniqueness .refine( - async (employee) => { - // Zod does - if (!submission.scope.includes('email')) { - return true; - } - - // Async validation. e.g. checking uniqueness - return new Promise((resolve) => { + async (employee) => + new Promise((resolve) => { setTimeout(() => { resolve(employee.email === 'hey@conform.guide'); }, Math.random() * 100); - }); - }, + }), { message: 'Email is already used', path: ['email'], @@ -74,11 +68,8 @@ export let action = async ({ request }: ActionArgs) => { // Return the state to the client if the submission is made for validation purpose if (!result.success || submission.type === 'validate') { return json({ - scope: submission.scope, - value: submission.value, - error: submission.error.concat( - !result.success ? getError(result.error, submission.scope) : [], - ), + ...submission, + error: submission.error.concat(getError(result)), }); } @@ -87,9 +78,9 @@ export let action = async ({ request }: ActionArgs) => { return redirect('/'); }; -export default function TodoForm() { - // FormState returned from the server - const state = useActionData>(); +export default function EmployeeForm() { + // Last submission returned from the server + const state = useActionData>(); /** * The useForm hook now returns a `Form` object @@ -98,12 +89,17 @@ export default function TodoForm() { * (2) form.config: Fieldset config to be passed to the useFieldset hook. * [Optional] Needed only if the fields have default value / nojs support is needed) * (3) form.ref: Ref object of the form element. Same as `form.props.ref` - * (4) form.error: Form error. Set when an error with an empty string name is provided by the form state. + * (4) form.error: Form error. Set when an error with an empty string name is provided. */ const form = useForm({ + // Enable server validation mode + mode: 'server-validation', + + // Begin validating on blur + initialReport: 'onBlur', + // Just hook it up with the result from useActionData() state, - initialReport: 'onBlur', /** * The validate hook - `onValidate(context: FormContext): boolean` @@ -111,50 +107,33 @@ export default function TodoForm() { * * (1) Renamed from `validate` to `onValidate` * (2) Changed the function signature with a new context object, including `form`, `formData` and `submission` - * (3) It should now returns a boolean indicating if the server validation is needed * * If both `onValidate` and `onSubmit` are commented out, then it will validate the form completely by server validation */ onValidate({ form, submission }) { // Similar to server validation without the extra refine() const result = schema.safeParse(submission.value); - const error = submission.error.concat( - !result.success ? getError(result.error) : [], - ); - /** - * Since only `email` requires extra validation from the server. - * We skip reporting client error if the email is being validated while there is no error found from the client. - * e.g. Client validation would be enough if the email is invalid - */ - if (submission.scope.includes('email') && !hasError(error, 'email')) { - // Server validation is needed - return true; + if (!result.success) { + submission.error = submission.error.concat(getError(result.error)); + } + + if ( + shouldValidate(submission, 'email') && + !hasError(submission.error, 'email') + ) { + // Skip reporting client error + throw form; } /** - * The `reportValidity` helper does 2 things for you: - * (1) Set all error to the dom and trigger the `invalid` event through `form.reportValidity()` - * (2) Return whether the form is valid or not. If the form is invalid, stop it. + * Set the submission error to the dom */ - return reportValidity(form, { - ...submission, - error, - }); + setFormError(form, submission); }, - async onSubmit(event, { submission }) { - /** - * The `onSubmit` hook will be called only if `onValidate` returns true, - * or when `noValidate` / `formNoValidate` is configured - */ - switch (submission.type) { - case 'validate': { - if (submission.data !== 'email') { - // We need server validation only for the email field, stop the rest - event.preventDefault(); - } - break; - } + onSubmit(event, { submission }) { + if (submission.type === 'validate' && submission.metadata !== 'email') { + event.preventDefault(); } }, }); diff --git a/examples/validation/src/App.tsx b/examples/validation/src/App.tsx index 4e7121a6..4d2496ad 100644 --- a/examples/validation/src/App.tsx +++ b/examples/validation/src/App.tsx @@ -10,7 +10,7 @@ export default function SignupForm() { const form = useForm({ onValidate({ form, submission }) { for (const field of Array.from(form.elements)) { - if (isFieldElement(field) && submission.scope.includes(field.name)) { + if (isFieldElement(field)) { switch (field.name) { case 'email': if (field.validity.valueMissing) { diff --git a/examples/yup/src/App.tsx b/examples/yup/src/App.tsx index 05fd1438..18b6e007 100644 --- a/examples/yup/src/App.tsx +++ b/examples/yup/src/App.tsx @@ -1,4 +1,4 @@ -import { useFieldset, useForm, reportValidity } from '@conform-to/react'; +import { useFieldset, useForm, setFormError } from '@conform-to/react'; import { getError } from '@conform-to/yup'; import * as yup from 'yup'; @@ -26,9 +26,7 @@ export default function SignupForm() { }); } catch (error) { if (error instanceof yup.ValidationError) { - submission.error = submission.error.concat( - getError(error, submission.scope), - ); + submission.error = submission.error.concat(getError(error)); } else { submission.error = submission.error.concat([ ['', 'Validation failed'], @@ -36,7 +34,7 @@ export default function SignupForm() { } } - return reportValidity(form, submission); + setFormError(form, submission); }, onSubmit: async (event, { submission }) => { event.preventDefault(); diff --git a/examples/zod/src/App.tsx b/examples/zod/src/App.tsx index f89cb198..7823e003 100644 --- a/examples/zod/src/App.tsx +++ b/examples/zod/src/App.tsx @@ -1,4 +1,4 @@ -import { reportValidity, useFieldset, useForm } from '@conform-to/react'; +import { setFormError, useFieldset, useForm } from '@conform-to/react'; import { getError } from '@conform-to/zod'; import { z } from 'zod'; @@ -24,12 +24,11 @@ export default function SignupForm() { onValidate({ form, submission }) { const result = schema.safeParse(submission.value); - return reportValidity(form, { - ...submission, - error: !result.success - ? submission.error.concat(getError(result.error, submission.scope)) - : submission.error, - }); + if (!result.success) { + submission.error = submission.error.concat(getError(result.error)); + } + + setFormError(form, submission); }, onSubmit: async (event, { submission }) => { event.preventDefault(); diff --git a/packages/conform-dom/index.ts b/packages/conform-dom/index.ts index d88ddecb..b9bd4f93 100644 --- a/packages/conform-dom/index.ts +++ b/packages/conform-dom/index.ts @@ -36,22 +36,18 @@ export type FieldsetConstraint> = { [Key in keyof Schema]?: FieldConstraint; }; -export type FormState = { - scope: string[]; - value: FieldValue; - error: Array<[string, string]>; -}; - -export type Submission = FormState & - ( - | { - type?: undefined; - } - | { - type: string; - data: string; - } - ); +export type Submission = + | { + type?: undefined; + value: FieldValue; + error: Array<[string, string]>; + } + | { + type: string; + metadata: string; + value: FieldValue; + error: Array<[string, string]>; + }; export function isFieldElement(element: unknown): element is FieldElement { return ( @@ -112,6 +108,10 @@ export function getName(paths: Array): string { }, ''); } +export function shouldValidate(submission: Submission, name: string): boolean { + return submission.type !== 'validate' || submission.metadata === name; +} + export function hasError( error: Array<[string, string]>, name: string, @@ -123,21 +123,24 @@ export function hasError( ); } -export function reportValidity( +export function setFormError( form: HTMLFormElement, - state: FormState, -): boolean { - const firstErrorByName = Object.fromEntries([...state.error].reverse()); + submission: Submission, +): void { + const firstErrorByName = Object.fromEntries([...submission.error].reverse()); for (const element of form.elements) { - if (!isFieldElement(element) || !state.scope.includes(element.name)) { - continue; - } + if (isFieldElement(element)) { + const error = firstErrorByName[element.name]; - element.setCustomValidity(firstErrorByName[element.name] ?? ''); + if ( + typeof error !== 'undefined' || + shouldValidate(submission, element.name) + ) { + element.setCustomValidity(error ?? ''); + } + } } - - return form.reportValidity(); } export function setValue( @@ -207,10 +210,7 @@ export function getFormElement( return form; } -export function focusFirstInvalidField( - form: HTMLFormElement, - fields?: string[], -): void { +export function focusFirstInvalidField(form: HTMLFormElement): void { const currentFocus = document.activeElement; if ( @@ -227,8 +227,7 @@ export function focusFirstInvalidField( if ( !field.validity.valid && field.dataset.conformTouched && - field.tagName !== 'BUTTON' && - (!fields || fields.includes(field.name)) + field.tagName !== 'BUTTON' ) { field.focus(); break; @@ -253,7 +252,6 @@ export function parse>( let submission: Submission> = { value: {}, error: [], - scope: [''], }; try { @@ -274,26 +272,10 @@ export function parse>( submission = { ...submission, type: submissionType, - data: value, + metadata: value, }; } else { const paths = getPaths(name); - const scopes = paths.reduce((result, path) => { - if (result.length === 0) { - if (typeof path !== 'string') { - throw new Error(`Invalid name received: ${name}`); - } - - result.push(path); - } else { - const [lastName] = result.slice(-1); - result.push(getName([lastName, path])); - } - - return result; - }, []); - - submission.scope.push(...scopes); setValue(submission.value, paths, (prev) => { if (prev) { @@ -306,11 +288,6 @@ export function parse>( } switch (submission.type) { - case 'validate': - if (typeof submission.data !== 'undefined' && submission.data !== '') { - submission.scope = [submission.data]; - } - break; case 'list': submission = handleList(submission); break; @@ -322,9 +299,6 @@ export function parse>( ]); } - // Remove duplicates - submission.scope = Array.from(new Set(submission.scope)); - return submission as Submission; } @@ -406,7 +380,7 @@ export function handleList( return submission; } - const command = parseListCommand(submission.data); + const command = parseListCommand(submission.metadata); const paths = getPaths(command.scope); setValue(submission.value, paths, (list) => { @@ -417,8 +391,5 @@ export function handleList( return updateList(list, command); }); - return { - ...submission, - scope: [command.scope], - }; + return submission; } diff --git a/packages/conform-react/hooks.ts b/packages/conform-react/hooks.ts index eac8f65a..08350f4d 100644 --- a/packages/conform-react/hooks.ts +++ b/packages/conform-react/hooks.ts @@ -3,7 +3,6 @@ import { type FieldElement, type FieldValue, type FieldsetConstraint, - type FormState, type ListCommand, type Primitive, type Submission, @@ -17,8 +16,8 @@ import { parse, parseListCommand, requestSubmit, - reportValidity, requestValidate, + setFormError, updateList, } from '@conform-to/dom'; import { @@ -38,6 +37,11 @@ interface FormContext> { } export interface FormConfig> { + /** + * Validation mode. Default to `client-only`. + */ + mode?: 'client-only' | 'server-validation'; + /** * Define when the error should be reported initially. * Support "onSubmit", "onChange", "onBlur". @@ -54,7 +58,7 @@ export interface FormConfig> { /** * An object describing the state from the last submission */ - state?: FormState; + state?: Submission; /** * Enable native validation before hydation. @@ -73,7 +77,7 @@ export interface FormConfig> { /** * A function to be called when the form should be (re)validated. */ - onValidate?: (context: FormContext) => boolean; + onValidate?: (context: FormContext) => void; /** * The submit event handler of the form. It will be called @@ -120,15 +124,11 @@ export function useForm>( const [fieldsetConfig, setFieldsetConfig] = useState>( () => { const error = config.state?.error ?? []; - const scope = config.state?.scope; return { defaultValue: config.state?.value ?? config.defaultValue, initialError: error.filter( - ([name]) => - name !== '' && - getSubmissionType(name) === null && - (!scope || scope.includes(name)), + ([name]) => name !== '' && getSubmissionType(name) === null, ), }; }, @@ -152,8 +152,10 @@ export function useForm>( return; } - if (!reportValidity(form, config.state)) { - focusFirstInvalidField(form, config.state.scope); + setFormError(form, config.state); + + if (!form.reportValidity()) { + focusFirstInvalidField(form); } requestSubmit(form); @@ -308,23 +310,30 @@ export function useForm>( } } - if ( - typeof config.onValidate === 'function' && - !config.noValidate && - !submitter.formNoValidate - ) { - try { - if (!config.onValidate(context)) { + try { + if (!config.noValidate && !submitter.formNoValidate) { + config.onValidate?.(context); + + if (!form.reportValidity()) { focusFirstInvalidField(form); event.preventDefault(); } - } catch (e) { + } + } catch (e) { + if (e !== form) { console.warn(e); } } if (!event.defaultPrevented) { - config.onSubmit?.(event, context); + if ( + config.mode !== 'server-validation' && + submission.type === 'validate' + ) { + event.preventDefault(); + } else { + config.onSubmit?.(event, context); + } } }, }, @@ -443,58 +452,6 @@ export function useFieldset>( }); useEffect(() => { - /** - * Reset the error state of each field if its validity is changed. - * - * This is a workaround as no official way is provided to notify - * when the validity of the field is changed from `invalid` to `valid`. - */ - const resetError = (form: HTMLFormElement) => { - setError((prev) => { - let next = prev; - - const fieldsetName = configRef.current?.name ?? ''; - - for (const field of form.elements) { - if (isFieldElement(field) && field.name.startsWith(fieldsetName)) { - const [key, ...paths] = getPaths( - fieldsetName.length > 0 - ? field.name.slice(fieldsetName.length + 1) - : field.name, - ); - - if (typeof key === 'string' && paths.length === 0) { - const prevMessage = next?.[key] ?? ''; - const nextMessage = field.validationMessage; - - /** - * Techincally, checking prevMessage not being empty while nextMessage being empty - * is sufficient for our usecase. It checks if the message is changed instead to allow - * the hook to be useful independently. - */ - if (prevMessage !== '' && prevMessage !== nextMessage) { - next = { - ...next, - [key]: nextMessage, - }; - } - } - } - } - - return next; - }); - }; - const handleInput = (event: Event) => { - const form = getFormElement(ref.current); - const field = event.target; - - if (!form || !isFieldElement(field) || field.form !== form) { - return; - } - - resetError(form); - }; const invalidHandler = (event: Event) => { const form = getFormElement(ref.current); const field = event.target; @@ -542,8 +499,36 @@ export function useFieldset>( return; } - // This helps resetting error that fullfilled by the submitter - resetError(form); + /** + * Reset the error state of each field if its validity is changed. + * + * This is a workaround as no official way is provided to notify + * when the validity of the field is changed from `invalid` to `valid`. + */ + setError((prev) => { + let next = prev; + + const fieldsetName = configRef.current?.name ?? ''; + + for (const field of form.elements) { + if (isFieldElement(field) && field.name.startsWith(fieldsetName)) { + const key = fieldsetName + ? field.name.slice(fieldsetName.length + 1) + : field.name; + const prevMessage = next?.[key] ?? ''; + const nextMessage = field.validationMessage; + + if (prevMessage !== '' && nextMessage === '') { + next = { + ...next, + [key]: '', + }; + } + } + } + + return next; + }); }; const resetHandler = (event: Event) => { const form = getFormElement(ref.current); @@ -564,14 +549,12 @@ export function useFieldset>( setError({}); }; - document.addEventListener('input', handleInput); // The invalid event does not bubble and so listening on the capturing pharse is needed document.addEventListener('invalid', invalidHandler, true); document.addEventListener('submit', submitHandler); document.addEventListener('reset', resetHandler); return () => { - document.removeEventListener('input', handleInput); document.removeEventListener('invalid', invalidHandler, true); document.removeEventListener('submit', submitHandler); document.removeEventListener('reset', resetHandler); diff --git a/packages/conform-react/index.ts b/packages/conform-react/index.ts index 11012e73..a0c12caf 100644 --- a/packages/conform-react/index.ts +++ b/packages/conform-react/index.ts @@ -1,11 +1,11 @@ export { type FieldsetConstraint, - type FormState, type Submission, hasError, isFieldElement, parse, - reportValidity, + setFormError, + shouldValidate, } from '@conform-to/dom'; export * from './hooks'; export * as conform from './helpers'; diff --git a/packages/conform-yup/index.ts b/packages/conform-yup/index.ts index 7ef76654..819ac65a 100644 --- a/packages/conform-yup/index.ts +++ b/packages/conform-yup/index.ts @@ -84,15 +84,10 @@ export function getFieldsetConstraint( export function getError( error: yup.ValidationError | null, - scope?: string[], ): Array<[string, string]> { return ( error?.inner.reduce>((result, e) => { - const name = e.path ?? ''; - - if (!scope || scope.includes(name)) { - result.push([name, e.message]); - } + result.push([e.path ?? '', e.message]); return result; }, []) ?? [] diff --git a/packages/conform-zod/index.ts b/packages/conform-zod/index.ts index 01c37d30..583ce256 100644 --- a/packages/conform-zod/index.ts +++ b/packages/conform-zod/index.ts @@ -108,21 +108,25 @@ export function getFieldsetConstraint( return result; } -export function getError( - error: z.ZodError | null, - scope?: string[], +export function getError( + result: z.SafeParseReturnType | z.ZodError, ): Array<[string, string]> { - return ( - error?.errors.reduce>((result, e) => { - const name = getName(e.path); + const issues = + result instanceof z.ZodError + ? result.errors + : !result.success + ? result.error.errors + : null; - if (!scope || scope.includes(name)) { - result.push([name, e.message]); - } + if (!issues) { + return []; + } + + return issues.reduce>((result, e) => { + result.push([getName(e.path), e.message]); - return result; - }, []) ?? [] - ); + return result; + }, []); } export function ifNonEmptyString( diff --git a/playground/app/components.tsx b/playground/app/components.tsx index 5dd8aa15..2fbedd91 100644 --- a/playground/app/components.tsx +++ b/playground/app/components.tsx @@ -1,4 +1,4 @@ -import type { FormState } from '@conform-to/react'; +import type { Submission } from '@conform-to/react'; import type { ReactNode } from 'react'; import { useEffect, useState } from 'react'; @@ -6,7 +6,7 @@ interface PlaygroundProps { title: string; description?: string; form?: string; - state?: FormState>; + state?: Submission>; children: ReactNode; } @@ -17,10 +17,10 @@ export function Playground({ state, children, }: PlaygroundProps) { - const [status, setFormState] = useState(state ?? null); + const [submission, setSubmission] = useState(state ?? null); useEffect(() => { - setFormState(state ?? null); + setSubmission(state ?? null); }, [state]); return ( @@ -35,17 +35,17 @@ export function Playground({

{description}

- {status ? ( + {submission ? (
Submission
 0
+								submission.error.length > 0
 									? 'border-pink-600'
 									: 'border-emerald-500'
 							} pl-4 py-2 mt-4`}
 						>
-							{JSON.stringify(status, null, 2)}
+							{JSON.stringify(submission, null, 2)}
 						
) : null} @@ -59,7 +59,7 @@ export function Playground({