From 07b6d17efd7e7b73361e832415438cb03d42e231 Mon Sep 17 00:00:00 2001 From: Edmund Hung Date: Sun, 15 Dec 2024 19:50:38 +0000 Subject: [PATCH 1/3] feat: auto field value update --- packages/conform-dom/form.ts | 317 +++++++++++++++++++-- packages/conform-react/context.tsx | 6 +- packages/conform-react/helpers.ts | 2 +- packages/conform-react/hooks.ts | 12 +- playground/app/routes/sync-form-state.tsx | 196 +++++++++++++ tests/integrations/sync-form-state.spec.ts | 180 ++++++++++++ 6 files changed, 688 insertions(+), 25 deletions(-) create mode 100644 playground/app/routes/sync-form-state.tsx create mode 100644 tests/integrations/sync-form-state.spec.ts diff --git a/packages/conform-dom/form.ts b/packages/conform-dom/form.ts index 66717aa0..b041b7ac 100644 --- a/packages/conform-dom/form.ts +++ b/packages/conform-dom/form.ts @@ -117,6 +117,7 @@ export type Constraint = { export type FormMeta = { formId: string; isValueUpdated: boolean; + lastIntent: Intent | null; submissionStatus?: 'error' | 'success'; defaultValue: Record; initialValue: Record; @@ -199,6 +200,7 @@ export type SubscriptionSubject = { } & { formId?: boolean; status?: boolean; + lastIntent?: boolean; }; export type SubscriptionScope = { @@ -231,6 +233,7 @@ export type FormContext< onBlur(event: Event): void; onUpdate(options: Partial>): void; observe(): () => void; + runSideEffect(intent: Intent): void; subscribe( callback: () => void, getSubject?: () => SubscriptionSubject | undefined, @@ -260,15 +263,16 @@ export type FormContext< function createFormMeta( options: FormOptions, - initialized?: boolean, + isResetting?: boolean, ): FormMeta { - const lastResult = !initialized ? options.lastResult : undefined; + const lastResult = !isResetting ? options.lastResult : undefined; const defaultValue = options.defaultValue ? (serialize(options.defaultValue) as Record) : {}; const initialValue = lastResult?.initialValue ?? defaultValue; const result: FormMeta = { formId: options.formId, + lastIntent: isResetting ? { type: 'reset', payload: {} } : null, isValueUpdated: false, submissionStatus: lastResult?.status, defaultValue, @@ -276,7 +280,7 @@ function createFormMeta( value: initialValue, constraint: options.constraint ?? {}, validated: lastResult?.state?.validated ?? {}, - key: !initialized + key: !isResetting ? getDefaultKey(defaultValue) : { '': generateId(), @@ -436,7 +440,6 @@ function updateValue( value: unknown, ): void { if (name === '') { - meta.initialValue = value as Record; meta.value = value as Record; meta.key = { ...getDefaultKey(value as Record), @@ -445,11 +448,9 @@ function updateValue( return; } - meta.initialValue = clone(meta.initialValue); meta.value = clone(meta.value); meta.key = clone(meta.key); - setValue(meta.initialValue, name, () => value); setValue(meta.value, name, () => value); if (isPlainObject(value) || Array.isArray(value)) { @@ -669,6 +670,7 @@ export function createFormContext< return { submissionStatus: next.submissionStatus, + lastIntent: next.lastIntent, defaultValue, initialValue, value, @@ -707,7 +709,7 @@ export function createFormContext< state = nextState; const cache: Record< - Exclude, + Exclude, Record > = { value: {}, @@ -726,6 +728,7 @@ export function createFormContext< (subject.formId && prevMeta.formId !== nextMeta.formId) || (subject.status && prevState.submissionStatus !== nextState.submissionStatus) || + (subject.lastIntent && prevMeta.lastIntent !== nextMeta.lastIntent) || shouldNotify( prevState.error, nextState.error, @@ -938,6 +941,7 @@ export function createFormContext< }, {}); const update: FormMeta = { ...meta, + lastIntent: result.intent ?? null, isValueUpdated: false, submissionStatus: result.status, value: result.initialValue, @@ -1038,7 +1042,22 @@ export function createFormContext< }); } + function getFieldElements(node: Node, form: HTMLFormElement): FieldElement[] { + if (isFieldElement(node) && node.form === form) { + return [node]; + } + + if (node instanceof HTMLElement) { + return Array.from( + node.querySelectorAll('input,select,textarea'), + ).filter((element) => element.form === form); + } + + return []; + } + function observe() { + const initializedElements = new Set(); const observer = new MutationObserver((mutations) => { const form = getFormElement(); @@ -1046,27 +1065,80 @@ export function createFormContext< return; } + let shouldUpdateFormValue = false; + for (const mutation of mutations) { - const nodes = - mutation.type === 'childList' - ? [...mutation.addedNodes, ...mutation.removedNodes] - : [mutation.target]; - - for (const node of nodes) { - const element = isFieldElement(node) - ? node - : node instanceof HTMLElement - ? node.querySelector('input,select,textarea') - : null; - - if (element?.form === form) { - updateFormValue(form); - return; + switch (mutation.type) { + case 'childList': { + for (const node of mutation.addedNodes) { + const elements = getFieldElements(node, form); + + for (const element of elements) { + const value = getValue(meta.initialValue, element.name); + const defaultValue = + typeof value === 'string' || + (Array.isArray(value) && + value.every((item) => typeof item === 'string')) + ? value + : undefined; + + if (!initializedElements.has(element)) { + updateField(element, { + value: defaultValue, + defaultValue, + constraint: meta.constraint[element.name], + }); + initializedElements.add(element); + } + + shouldUpdateFormValue = true; + } + } + for (const node of mutation.removedNodes) { + const elements = getFieldElements(node, form); + + if (elements.length > 0) { + shouldUpdateFormValue = true; + } + } + break; + } + default: { + const elements = getFieldElements(mutation.target, form); + + if (elements.length > 0) { + shouldUpdateFormValue = true; + } + break; } } } + + if (shouldUpdateFormValue) { + updateFormValue(form); + } }); + for (const element of getFormElement()?.elements ?? []) { + if (isFieldElement(element)) { + const value = getValue(meta.defaultValue, element.name); + const defaultValue = + typeof value === 'string' || + (Array.isArray(value) && + value.every((item) => typeof item === 'string')) + ? value + : undefined; + + updateField(element, { + value: defaultValue, + defaultValue, + constraint: meta.constraint[element.name], + }); + + initializedElements.add(element); + } + } + observer.observe(document, { subtree: true, childList: true, @@ -1079,6 +1151,67 @@ export function createFormContext< }; } + function runSideEffect(lastIntent: Intent) { + const formElement = getFormElement(); + + if (!formElement) { + return; + } + + switch (lastIntent.type) { + case 'update': { + const name = formatName( + lastIntent.payload.name, + lastIntent.payload.index, + ); + + for (const element of formElement.elements) { + if (isFieldElement(element)) { + const value = + element.name === name + ? lastIntent.payload.value + : flatten(lastIntent.payload.value, { prefix: name })[ + element.name + ]; + + updateField(element, { + value: + typeof value === 'string' || + (Array.isArray(value) && + value.every((item) => typeof item === 'string')) + ? value + : undefined, + }); + } + } + break; + } + case 'reset': { + const prefix = formatName( + lastIntent.payload.name, + lastIntent.payload.index, + ); + + for (const element of formElement.elements) { + if (isFieldElement(element) && isPrefix(element.name, prefix)) { + const value = getValue(meta.defaultValue, element.name); + + updateField(element, { + defaultValue: + typeof value === 'string' || + (Array.isArray(value) && + value.every((item) => typeof item === 'string')) + ? value + : undefined, + }); + resetField(element); + } + } + break; + } + } + } + return { getFormId() { return meta.formId; @@ -1094,9 +1227,149 @@ export function createFormContext< insert: createFormControl('insert'), remove: createFormControl('remove'), reorder: createFormControl('reorder'), + runSideEffect, subscribe, getState, getSerializedState, observe, }; } + +export function resetField( + element: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement, +): void { + if (element instanceof HTMLInputElement) { + switch (element.type) { + case 'checkbox': + case 'radio': + element.checked = element.defaultChecked; + break; + case 'file': + element.value = ''; + break; + default: + element.value = element.defaultValue; + break; + } + } else if (element instanceof HTMLSelectElement) { + for (const option of element.options) { + option.selected = option.defaultSelected; + } + } else { + element.value = element.defaultValue; + } +} + +export function updateField( + element: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement, + options: { + value?: string | string[]; + defaultValue?: string | string[]; + constraint?: { + required?: boolean; + minLength?: number; + maxLength?: number; + min?: string | number; + max?: string | number; + step?: string | number; + multiple?: boolean; + pattern?: string; + }; + }, +) { + const value = + typeof options.value === 'undefined' + ? null + : Array.isArray(options.value) + ? options.value + : [options.value]; + const defaultValue = + typeof options.defaultValue === 'undefined' + ? null + : Array.isArray(options.defaultValue) + ? options.defaultValue + : [options.defaultValue]; + + if (options.constraint) { + const { constraint } = options; + + if ( + typeof constraint.required !== 'undefined' && + // If the element is a part of the checkbox group, it is unclear whether all checkboxes are required or only one. + !( + element.type === 'checkbox' && + element.form?.elements.namedItem(element.name) instanceof RadioNodeList + ) + ) { + element.required = constraint.required; + } + + if (typeof constraint.multiple !== 'undefined' && 'multiple' in element) { + element.multiple = constraint.multiple; + } + + if (typeof constraint.minLength !== 'undefined' && 'minLength' in element) { + element.minLength = constraint.minLength; + } + + if (typeof constraint.maxLength !== 'undefined' && 'maxLength' in element) { + element.maxLength = constraint.maxLength; + } + if (typeof constraint.min !== 'undefined' && 'min' in element) { + element.min = `${constraint.min}`; + } + + if (typeof constraint.max !== 'undefined' && 'max' in element) { + element.max = `${constraint.max}`; + } + + if (typeof constraint.step !== 'undefined' && 'step' in element) { + element.step = `${constraint.step}`; + } + + if (typeof constraint.pattern !== 'undefined' && 'pattern' in element) { + element.pattern = constraint.pattern; + } + } + + if (element instanceof HTMLInputElement) { + switch (element.type) { + case 'checkbox': + case 'radio': + if (value) { + element.checked = value.includes(element.value); + } + if (defaultValue) { + element.defaultChecked = defaultValue.includes(element.value); + } + break; + case 'file': + // Do nothing for now + break; + default: + if (value) { + element.value = value[0] ?? ''; + } + if (defaultValue) { + element.defaultValue = defaultValue[0] ?? ''; + } + break; + } + } else if (element instanceof HTMLSelectElement) { + for (const option of element.options) { + if (value) { + option.selected = value.includes(option.value); + } + if (defaultValue) { + option.defaultSelected = defaultValue.includes(option.value); + } + } + } else { + if (value) { + element.value = value[0] ?? ''; + } + if (defaultValue) { + element.defaultValue = defaultValue[0] ?? ''; + } + } +} diff --git a/packages/conform-react/context.tsx b/packages/conform-react/context.tsx index 69ece9c4..76d9e89f 100644 --- a/packages/conform-react/context.tsx +++ b/packages/conform-react/context.tsx @@ -210,7 +210,11 @@ export function updateSubjectRef( scope?: keyof SubscriptionScope, name?: string, ): void { - if (subject === 'status' || subject === 'formId') { + if ( + subject === 'status' || + subject === 'formId' || + subject === 'lastIntent' + ) { ref.current[subject] = true; } else if (typeof scope !== 'undefined' && typeof name !== 'undefined') { ref.current[subject] = { diff --git a/packages/conform-react/helpers.ts b/packages/conform-react/helpers.ts index bebe699a..1f7734d9 100644 --- a/packages/conform-react/helpers.ts +++ b/packages/conform-react/helpers.ts @@ -214,7 +214,7 @@ export function getFormControlProps( options?: FormControlOptions, ): FormControlProps { return simplify({ - key: metadata.key, + key: undefined, required: metadata.required || undefined, ...getFieldsetProps(metadata, options), }); diff --git a/packages/conform-react/hooks.ts b/packages/conform-react/hooks.ts index 24161525..b35fc6ea 100644 --- a/packages/conform-react/hooks.ts +++ b/packages/conform-react/hooks.ts @@ -91,11 +91,21 @@ export function useForm< context.onUpdate({ ...formConfig, formId }); }); - const subjectRef = useSubjectRef(); + const subjectRef = useSubjectRef({ + lastIntent: true, + }); const stateSnapshot = useFormState(context, subjectRef); const noValidate = useNoValidate(options.defaultNoValidate); const form = getFormMetadata(context, subjectRef, stateSnapshot, noValidate); + useEffect(() => { + if (!stateSnapshot.lastIntent) { + return; + } + + context.runSideEffect(stateSnapshot.lastIntent); + }, [context, stateSnapshot.lastIntent]); + return [form, form.getFieldset()]; } diff --git a/playground/app/routes/sync-form-state.tsx b/playground/app/routes/sync-form-state.tsx new file mode 100644 index 00000000..7d1fb40e --- /dev/null +++ b/playground/app/routes/sync-form-state.tsx @@ -0,0 +1,196 @@ +import { getFormProps, useForm } from '@conform-to/react'; +import { parseWithZod } from '@conform-to/zod'; +import type { ActionFunctionArgs } from '@remix-run/node'; +import { json } from '@remix-run/node'; +import { Form, useActionData } from '@remix-run/react'; +import { useState } from 'react'; +import { z } from 'zod'; +import { Playground, Field } from '~/components'; + +const schema = z.object({ + input: z.object({ + text: z.string(), + files: z.instanceof(File).array(), + number: z.number(), + }), + textarea: z.string(), + select: z.string(), + multiSelect: z.array(z.string()), + checkbox: z.boolean(), + checkboxGroup: z.array(z.string()), + radioGroup: z.string(), +}); + +export async function action({ request }: ActionFunctionArgs) { + const formData = await request.formData(); + const submission = parseWithZod(formData, { schema }); + + return json({ + lastResult: submission.reply({ + resetForm: true, + }), + defaultValue: { + input: { + text: 'Default text', + number: 4, + files: [], + }, + textarea: 'You need to write something here', + select: 'red', + multiSelect: ['apple', 'banana', 'cherry'], + checkbox: false, + checkboxGroup: ['JS', 'CSS'], + radioGroup: 'Français', + }, + }); +} + +export default function Example() { + const actionData = useActionData(); + const [form, fields] = useForm({ + lastResult: actionData?.lastResult, + shouldValidate: 'onBlur', + onValidate: ({ formData }) => parseWithZod(formData, { schema }), + defaultValue: actionData?.defaultValue ?? { + input: { + text: 'Hello World', + number: 2, + files: [], + }, + textarea: 'Once upon a time', + select: 'green', + multiSelect: ['banana', 'cherry'], + checkbox: false, + checkboxGroup: ['HTML', 'CSS'], + radioGroup: 'Deutsch', + }, + constraint: { + 'input.text': { + required: true, + minLength: 5, + maxLength: 30, + pattern: '[a-zA-Z]+', + }, + 'input.number': { + min: 5, + max: 10, + step: 1, + }, + 'input.files': { + required: true, + multiple: true, + }, + textarea: { + required: true, + minLength: 10, + maxLength: 1000, + }, + select: { + required: true, + }, + multiSelect: { + multiple: true, + }, + checkbox: { + required: true, + }, + checkboxGroup: { + required: true, + }, + radioGroup: { + required: true, + }, + }, + }); + const inputFields = fields.input.getFieldset(); + const [showNumberField, setShowNumberField] = useState(true); + + return ( +
+ + + {}} /> + + + + + {showNumberField ? ( + + + + ) : null} + + + + +