From dbddf1377a89f7b0d87c6a750fa7136860cf1b99 Mon Sep 17 00:00:00 2001 From: Edmund Hung Date: Sat, 3 Aug 2024 22:28:46 +0100 Subject: [PATCH] feat: auto field value update --- packages/conform-dom/form.ts | 32 ++++++------ packages/conform-react/helpers.ts | 3 +- packages/conform-react/hooks.ts | 68 +++++++++++++++++++++++++- packages/conform-react/integrations.ts | 28 ++--------- 4 files changed, 88 insertions(+), 43 deletions(-) diff --git a/packages/conform-dom/form.ts b/packages/conform-dom/form.ts index c0b57158..61e47baf 100644 --- a/packages/conform-dom/form.ts +++ b/packages/conform-dom/form.ts @@ -278,12 +278,7 @@ function createFormMeta( value: initialValue, constraint: options.constraint ?? {}, validated: lastResult?.state?.validated ?? {}, - key: !initialized - ? getDefaultKey(defaultValue) - : { - '': generateId(), - ...getDefaultKey(defaultValue), - }, + key: getDefaultKey(defaultValue), // The `lastResult` should comes from the server which we won't expect the error to be null // We can consider adding a warning if it happens error: (lastResult?.error as Record) ?? {}, @@ -300,15 +295,20 @@ function getDefaultKey( ): Record { return Object.entries(flatten(defaultValue, { prefix })).reduce< Record - >((result, [key, value]) => { - if (Array.isArray(value)) { - for (let i = 0; i < value.length; i++) { - result[formatName(key, i)] = generateId(); + >( + (result, [key, value]) => { + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + result[formatName(key, i)] = generateId(); + } } - } - return result; - }, {}); + return result; + }, + { + [prefix ?? '']: generateId(), + }, + ); } function setFieldsValidated( @@ -440,10 +440,8 @@ function updateValue( if (name === '') { meta.initialValue = value as Record; meta.value = value as Record; - meta.key = { - ...getDefaultKey(value as Record), - '': generateId(), - }; + meta.key = getDefaultKey(value as Record); + return; } diff --git a/packages/conform-react/helpers.ts b/packages/conform-react/helpers.ts index bebe699a..bfc668b9 100644 --- a/packages/conform-react/helpers.ts +++ b/packages/conform-react/helpers.ts @@ -1,7 +1,7 @@ import type { FormMetadata, FieldMetadata, Metadata, Pretty } from './context'; type FormControlProps = { - key: string | undefined; + key?: string; id: string; name: string; form: string; @@ -214,7 +214,6 @@ export function getFormControlProps( options?: FormControlOptions, ): FormControlProps { return simplify({ - key: metadata.key, required: metadata.required || undefined, ...getFieldsetProps(metadata, options), }); diff --git a/packages/conform-react/hooks.ts b/packages/conform-react/hooks.ts index 24161525..dcee2064 100644 --- a/packages/conform-react/hooks.ts +++ b/packages/conform-react/hooks.ts @@ -91,11 +91,77 @@ export function useForm< context.onUpdate({ ...formConfig, formId }); }); - const subjectRef = useSubjectRef(); + const subjectRef = useSubjectRef({ + key: { + // Subscribe to all key changes so it will re-render and + // update the field value as soon as the DOM is updated + prefix: [''], + }, + }); const stateSnapshot = useFormState(context, subjectRef); const noValidate = useNoValidate(options.defaultNoValidate); const form = getFormMetadata(context, subjectRef, stateSnapshot, noValidate); + useEffect(() => { + const formElement = document.forms.namedItem(formId); + + if (!formElement) { + return; + } + + const getAll = (value: unknown) => { + if (typeof value === 'string') { + return [value]; + } + + if ( + Array.isArray(value) && + value.every((item) => typeof item === 'string') + ) { + return value; + } + + return undefined; + }; + const get = (value: unknown) => getAll(value)?.[0]; + + for (const element of formElement.elements) { + if ( + element instanceof HTMLInputElement || + element instanceof HTMLTextAreaElement || + element instanceof HTMLSelectElement + ) { + const prev = element.dataset.conform; + const next = stateSnapshot.key[element.name]; + const defaultValue = stateSnapshot.initialValue[element.name]; + + if (prev === 'managed') { + // Skip fields managed by useInputControl() + continue; + } + + if (typeof prev === 'undefined' || prev !== next) { + element.dataset.conform = next; + + if ('options' in element) { + const value = getAll(defaultValue) ?? []; + + for (const option of element.options) { + option.selected = value.includes(option.value); + } + } else if ( + 'checked' in element && + (element.type === 'checkbox' || element.type === 'radio') + ) { + element.checked = get(defaultValue) === element.value; + } else { + element.value = get(defaultValue) ?? ''; + } + } + } + } + }, [formId, stateSnapshot]); + return [form, form.getFieldset()]; } diff --git a/packages/conform-react/integrations.ts b/packages/conform-react/integrations.ts index cb5c58b6..883222c9 100644 --- a/packages/conform-react/integrations.ts +++ b/packages/conform-react/integrations.ts @@ -19,8 +19,8 @@ export function getFieldElements( const elements = !field ? [] : field instanceof Element - ? [field] - : Array.from(field.values()); + ? [field] + : Array.from(field.values()); return elements.filter( ( @@ -71,7 +71,7 @@ export function createDummySelect( select.name = name; select.multiple = true; - select.dataset.conform = 'true'; + select.dataset.conform = 'managed'; // To make sure the input is hidden but still focusable select.setAttribute('aria-hidden', 'true'); @@ -98,7 +98,7 @@ export function createDummySelect( export function isDummySelect( element: HTMLElement, ): element is HTMLSelectElement { - return element.dataset.conform === 'true'; + return element.dataset.conform === 'managed'; } export function updateFieldValue( @@ -306,26 +306,8 @@ export function useControl< change(value); }; - const refCallback: RefCallback< - HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | undefined - > = (element) => { - register(element); - - if (!element) { - return; - } - - const prevKey = element.dataset.conform; - const nextKey = `${meta.key ?? ''}`; - - if (prevKey !== nextKey) { - element.dataset.conform = nextKey; - updateFieldValue(element, value ?? ''); - } - }; - return { - register: refCallback, + register, value, change: handleChange, focus,