From 1efc7a416b23ad9010fc7aab692fd95c761c626f Mon Sep 17 00:00:00 2001 From: Edmund Hung Date: Tue, 1 Oct 2024 22:22:59 +0100 Subject: [PATCH] feat: auto fields management --- packages/conform-dom/form.ts | 118 ++++++++++++++++++++---- packages/conform-react/helpers.ts | 5 +- packages/conform-react/hooks.ts | 12 ++- packages/conform-react/integrations.ts | 24 +---- playground/app/routes/form-control.tsx | 6 ++ tests/conform-react.spec.ts | 15 ++- tests/integrations/form-control.spec.ts | 4 + 7 files changed, 140 insertions(+), 44 deletions(-) diff --git a/packages/conform-dom/form.ts b/packages/conform-dom/form.ts index 66717aa0..272de62f 100644 --- a/packages/conform-dom/form.ts +++ b/packages/conform-dom/form.ts @@ -172,6 +172,14 @@ export type FormOptions = { */ shouldRevalidate?: 'onSubmit' | 'onBlur' | 'onInput'; + /** + * Define if the input could be updated by conform. + * Default to inputs that are configured using the `getInputProps`, `getSelectProps` or `getTextareaProps` helpers. + */ + shouldManageInput?: ( + element: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement, + ) => boolean; + /** * Define if conform should considered the field for dirty state. * e.g. Excluding form fields that are not managed by Conform, such as CSRF token @@ -230,6 +238,7 @@ export type FormContext< onInput(event: Event): void; onBlur(event: Event): void; onUpdate(options: Partial>): void; + updateFormElement(state: FormState): void; observe(): () => void; subscribe( callback: () => void, @@ -276,12 +285,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) ?? {}, @@ -298,15 +302,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( @@ -438,10 +447,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; } @@ -1038,6 +1045,81 @@ export function createFormContext< }); } + function updateFormElement(stateSnapshot: FormState) { + const formElement = getFormElement(); + + if (!formElement) { + return; + } + + // Default to manage inputs that is configured using the `getInputProps` helpers + // The data attribute is just an implementation detail and should not be used by the user + // We will likely make this a opt-out feature in v2 + const defaultShouldManageInput = ( + element: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement, + ) => typeof element.dataset.conform !== 'undefined'; + const shouldManageInput = + latestOptions.shouldManageInput ?? defaultShouldManageInput; + 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 + ) { + if ( + element.dataset.conform === 'managed' || + element.type === 'submit' || + element.type === 'reset' || + element.type === 'button' || + !shouldManageInput(element) + ) { + // Skip buttons and fields managed by useInputControl() + continue; + } + + const prev = element.dataset.conform; + const next = stateSnapshot.key[element.name]; + const defaultValue = stateSnapshot.initialValue[element.name]; + + 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 = + getAll(defaultValue)?.includes(element.value) ?? false; + } else { + element.value = get(defaultValue) ?? ''; + } + } + } + } + } + function observe() { const observer = new MutationObserver((mutations) => { const form = getFormElement(); @@ -1061,6 +1143,7 @@ export function createFormContext< if (element?.form === form) { updateFormValue(form); + updateFormElement(state); return; } } @@ -1094,6 +1177,7 @@ export function createFormContext< insert: createFormControl('insert'), remove: createFormControl('remove'), reorder: createFormControl('reorder'), + updateFormElement, subscribe, getState, getSerializedState, diff --git a/packages/conform-react/helpers.ts b/packages/conform-react/helpers.ts index bebe699a..910ddc43 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,8 @@ export function getFormControlProps( options?: FormControlOptions, ): FormControlProps { return simplify({ - key: metadata.key, + // let Conform updates the field value for us + 'data-conform': '', required: metadata.required || undefined, ...getFieldsetProps(metadata, options), }); diff --git a/packages/conform-react/hooks.ts b/packages/conform-react/hooks.ts index 24161525..353ead33 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({ + 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(() => { + context.updateFormElement(stateSnapshot); + }, [context, stateSnapshot]); + return [form, form.getFieldset()]; } diff --git a/packages/conform-react/integrations.ts b/packages/conform-react/integrations.ts index 1bdc9579..883222c9 100644 --- a/packages/conform-react/integrations.ts +++ b/packages/conform-react/integrations.ts @@ -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, diff --git a/playground/app/routes/form-control.tsx b/playground/app/routes/form-control.tsx index dad98b68..81fbfa85 100644 --- a/playground/app/routes/form-control.tsx +++ b/playground/app/routes/form-control.tsx @@ -128,6 +128,12 @@ export default function FormControl() { > Reset form + diff --git a/tests/conform-react.spec.ts b/tests/conform-react.spec.ts index dc26e244..963a37b7 100644 --- a/tests/conform-react.spec.ts +++ b/tests/conform-react.spec.ts @@ -35,7 +35,10 @@ function getProps(metadata: FieldMetadata) { describe('conform-react', () => { test('getInputProps', () => { const metadata = createFieldMetadata(); - const props = getProps(metadata); + const props = { + 'data-conform': '', + ...getProps(metadata), + }; expect( getInputProps( @@ -159,7 +162,10 @@ describe('conform-react', () => { test('getTextareaProps', () => { const metadata = createFieldMetadata(); - const props = getProps(metadata); + const props = { + 'data-conform': '', + ...getProps(metadata), + }; expect(getTextareaProps(metadata)).toEqual(props); expect( @@ -229,7 +235,10 @@ describe('conform-react', () => { test('getSelectProps', () => { const metadata = createFieldMetadata(); - const props = getProps(metadata); + const props = { + 'data-conform': '', + ...getProps(metadata), + }; expect(getSelectProps(metadata)).toEqual(props); expect( diff --git a/tests/integrations/form-control.spec.ts b/tests/integrations/form-control.spec.ts index 039b3ea5..54c00cc8 100644 --- a/tests/integrations/form-control.spec.ts +++ b/tests/integrations/form-control.spec.ts @@ -14,6 +14,7 @@ function getFieldset(form: Locator) { clearMessage: form.locator('button:text("Clear message")'), resetMessage: form.locator('button:text("Reset message")'), resetForm: form.locator('button:text("Reset form")'), + inputButton: form.locator('input[type="submit"]'), }; } @@ -21,6 +22,9 @@ async function runValidationScenario(page: Page) { const playground = getPlayground(page); const fieldset = getFieldset(playground.container); + // Conform should not overwrite the value of any input buttons + await expect(fieldset.inputButton).toHaveValue('Submit'); + await expect(playground.error).toHaveText(['', '', '']); await fieldset.validateMessage.click();