From 78c9ce53749471e3f946826d3e5e67ff465092d3 Mon Sep 17 00:00:00 2001 From: Edmund Hung Date: Mon, 13 Nov 2023 00:04:15 +0100 Subject: [PATCH] refactor: cleanup and simplify data structure --- packages/conform-dom/form.ts | 81 ++++++++++---------- packages/conform-dom/formdata.ts | 10 +-- packages/conform-dom/index.ts | 3 +- packages/conform-dom/submission.ts | 4 +- packages/conform-react/context.tsx | 33 ++++----- packages/conform-react/helpers.ts | 86 +++++++++++----------- packages/conform-react/hooks.ts | 27 ++++--- packages/conform-react/integrations.ts | 4 +- playground/app/components.tsx | 4 +- playground/app/routes/input-attributes.tsx | 2 +- playground/app/routes/simple-list.tsx | 2 +- playground/app/routes/validitystate.tsx | 2 +- 12 files changed, 126 insertions(+), 132 deletions(-) diff --git a/packages/conform-dom/form.ts b/packages/conform-dom/form.ts index e645ac21..a99362f9 100644 --- a/packages/conform-dom/form.ts +++ b/packages/conform-dom/form.ts @@ -3,7 +3,7 @@ import { formatPaths, getFormData, getPaths, - isSubpath, + isPrefix, isPlainObject, setValue, } from './formdata'; @@ -61,11 +61,6 @@ export type Constraint = { pattern?: string; }; -export type FormMetadata = { - defaultValue: Record; - constraint: Record; -}; - export type FormState = { key: Record; validated: Record; @@ -73,15 +68,16 @@ export type FormState = { dirty: Record; }; -export interface FormContext { - metadata: FormMetadata; +export type FormContext = { + defaultValue: Record; initialValue: Record; value: Record; error: Record; + constraint: Record; state: FormState; -} +}; -export interface FormOptions { +export type FormOptions = { defaultValue?: DefaultValue; constraint?: Record; lastResult?: SubmissionResult; @@ -92,12 +88,12 @@ export interface FormOptions { submitter: HTMLInputElement | HTMLButtonElement | null; formData: FormData; }) => Submission; -} +}; export type SubscriptionSubject = { [key in | 'error' - | 'defaultValue' + | 'initialValue' | 'value' | 'key' | 'validated' @@ -106,11 +102,11 @@ export type SubscriptionSubject = { }; export type SubscriptionScope = { - parent?: string[]; + prefix?: string[]; name?: string[]; }; -export interface Form = any> { +export type Form = any> = { id: string; submit(event: SubmitEvent): { formData: FormData; @@ -130,7 +126,7 @@ export interface Form = any> { ): () => void; getContext(): FormContext; getSerializedState(): string; -} +}; export const VALIDATION_UNDEFINED = '__undefined__'; @@ -154,30 +150,33 @@ export function createForm = any>( } function initializeFormContext(): FormContext { - const metadata: FormMetadata = initializeMetadata(options); + const defaultValue = flatten(options.defaultValue); const value = options.lastResult?.initialValue ? flatten(options.lastResult.initialValue) - : metadata.defaultValue; + : defaultValue; const error = options.lastResult?.error ?? {}; return { - metadata, + constraint: options.constraint ?? {}, + defaultValue, initialValue: value, value, error, state: { validated: options.lastResult?.state?.validated ?? {}, key: createKeyProxy( - options.lastResult?.state?.key ?? getDefaultKey(metadata), + options.lastResult?.state?.key ?? getDefaultKey(defaultValue), ), valid: createValidProxy(error), - dirty: createDirtyProxy(metadata.defaultValue, value), + dirty: createDirtyProxy(defaultValue, value), }, }; } - function getDefaultKey(metadata: FormMetadata): Record { - return Object.entries(metadata.defaultValue).reduce>( + function getDefaultKey( + defaultValue: Record, + ): Record { + return Object.entries(defaultValue).reduce>( (result, [key, value]) => { if (Array.isArray(value)) { for (let i = 0; i < value.length; i++) { @@ -250,13 +249,6 @@ export function createForm = any>( ); } - function initializeMetadata(options: FormOptions): FormMetadata { - return { - defaultValue: flatten(options.defaultValue), - constraint: options.constraint ?? {}, - }; - } - function shouldNotify(config: { prev: Record; next: Record; @@ -265,10 +257,10 @@ export function createForm = any>( scope?: SubscriptionScope; }): boolean { if (config.scope) { - const parents = config.scope.parent ?? []; + const prefixes = config.scope.prefix ?? []; const names = config.scope.name ?? []; const list = - parents.length === 0 + prefixes.length === 0 ? names : Array.from( new Set([ @@ -279,9 +271,9 @@ export function createForm = any>( for (const name of list) { if ( - parents.length === 0 || + prefixes.length === 0 || names.includes(name) || - parents.some((parent) => isSubpath(name, parent)) + prefixes.some((prefix) => isPrefix(name, prefix)) ) { config.cache[name] ??= config.compareFn( config.prev[name], @@ -302,7 +294,7 @@ export function createForm = any>( const diff: Record> = { value: {}, error: {}, - defaultValue: {}, + initialValue: {}, key: {}, validated: {}, valid: {}, @@ -331,8 +323,8 @@ export function createForm = any>( next: next.initialValue, compareFn: (prev, next) => JSON.stringify(prev) !== JSON.stringify(next), - cache: diff.defaultValue, - scope: subject.defaultValue, + cache: diff.initialValue, + scope: subject.initialValue, }) || shouldNotify({ prev: prev.state.key, @@ -510,7 +502,7 @@ export function createForm = any>( value, state: { ...context.state, - dirty: createDirtyProxy(context.metadata.defaultValue, value), + dirty: createDirtyProxy(context.defaultValue, value), }, }); } else { @@ -543,18 +535,19 @@ export function createForm = any>( return; } - const metadata = initializeMetadata(latestOptions); + const defaultValue = flatten(latestOptions.defaultValue); updateContext({ - metadata, - initialValue: metadata.defaultValue, - value: metadata.defaultValue, + constraint: latestOptions.constraint ?? {}, + defaultValue, + initialValue: defaultValue, + value: defaultValue, error: {}, state: { validated: {}, - key: createKeyProxy(getDefaultKey(metadata)), + key: createKeyProxy(getDefaultKey(defaultValue)), valid: createValidProxy({}), - dirty: createDirtyProxy(metadata.defaultValue, metadata.defaultValue), + dirty: createDirtyProxy({}, {}), }, }); } @@ -622,7 +615,7 @@ export function createForm = any>( validated: result.state?.validated ?? {}, key, valid: createValidProxy(error), - dirty: createDirtyProxy(context.metadata.defaultValue, value), + dirty: createDirtyProxy(context.defaultValue, value), }, }); diff --git a/packages/conform-dom/formdata.ts b/packages/conform-dom/formdata.ts index dca2b73a..85ad0d68 100644 --- a/packages/conform-dom/formdata.ts +++ b/packages/conform-dom/formdata.ts @@ -73,15 +73,15 @@ export function formatPaths(paths: Array): string { } /** - * Check if a name is a subpath of a parent name + * Check if a name match the prefix paths */ -export function isSubpath(name: string, parent: string) { +export function isPrefix(name: string, prefix: string) { const paths = getPaths(name); - const parentPaths = getPaths(parent); + const prefixPaths = getPaths(prefix); return ( - paths.length >= parentPaths.length && - parentPaths.every((path, index) => paths[index] === path) + paths.length >= prefixPaths.length && + prefixPaths.every((path, index) => paths[index] === path) ); } diff --git a/packages/conform-dom/index.ts b/packages/conform-dom/index.ts index 3e688e2c..d515a25c 100644 --- a/packages/conform-dom/index.ts +++ b/packages/conform-dom/index.ts @@ -2,7 +2,6 @@ export { type UnionKeyof, type UnionKeyType, type Constraint, - type FormMetadata, type FormState, type FieldName, type DefaultValue, @@ -27,4 +26,4 @@ export { requestIntent, parse, } from './submission'; -export { getPaths, formatPaths, isSubpath } from './formdata'; +export { getPaths, formatPaths, isPrefix } from './formdata'; diff --git a/packages/conform-dom/submission.ts b/packages/conform-dom/submission.ts index 095455da..b2e13a0f 100644 --- a/packages/conform-dom/submission.ts +++ b/packages/conform-dom/submission.ts @@ -8,13 +8,13 @@ export type SubmissionState = { validated: Record; }; -export interface SubmissionContext { +export type SubmissionContext = { intent: string | null; initialValue: Record; value: Value | null; error: Record; state: SubmissionState; -} +}; export type Submission = | { diff --git a/packages/conform-react/context.tsx b/packages/conform-react/context.tsx index 42d70089..f7d6833e 100644 --- a/packages/conform-react/context.tsx +++ b/packages/conform-react/context.tsx @@ -8,7 +8,7 @@ import { type SubscriptionSubject, formatPaths, getPaths, - isSubpath, + isPrefix, STATE, } from '@conform-to/dom'; import { @@ -29,10 +29,10 @@ export type BaseMetadata = { id: string; errorId: string; descriptionId: string; - defaultValue: DefaultValue; + initialValue: DefaultValue; value: DefaultValue; - error: string[] | undefined; - allError: Record; + errors: string[] | undefined; + allErrors: Record; allValid: boolean; valid: boolean; dirty: boolean; @@ -142,7 +142,6 @@ export function getBaseMetadata( ): BaseMetadata { const name = options.name ?? ''; const id = name ? `${formId}-${name}` : formId; - const error = context.error[name]; const updateSubject = ( subject: keyof SubscriptionSubject, scope: keyof SubscriptionScope, @@ -160,8 +159,9 @@ export function getBaseMetadata( id, errorId: `${id}-error`, descriptionId: `${id}-description`, - defaultValue: context.initialValue[name] as DefaultValue, + initialValue: context.initialValue[name] as DefaultValue, value: context.value[name] as DefaultValue, + errors: context.error[name], get key() { return context.state.key[name]; }, @@ -179,14 +179,14 @@ export function getBaseMetadata( } for (const key of Object.keys(context.error)) { - if (isSubpath(key, name) && !context.state.valid[key]) { + if (isPrefix(key, name) && !context.state.valid[key]) { return false; } } return true; }, - get allError() { + get allErrors() { if (name === '') { return context.error; } @@ -194,31 +194,30 @@ export function getBaseMetadata( const result: Record = {}; for (const [key, errors] of Object.entries(context.error)) { - if (isSubpath(key, name)) { + if (isPrefix(key, name)) { result[key] = errors; } } return result; }, - error, }, { get(target, key, receiver) { switch (key) { case 'key': - case 'error': - case 'defaultValue': + case 'errors': + case 'initialValue': case 'value': case 'valid': case 'dirty': - updateSubject(key, 'name'); + updateSubject(key === 'errors' ? 'error' : key, 'name'); break; - case 'allError': - updateSubject('error', 'parent'); + case 'allErrors': + updateSubject('error', 'prefix'); break; case 'allValid': - updateSubject('valid', 'parent'); + updateSubject('valid', 'prefix'); break; } @@ -254,7 +253,7 @@ export function getFieldMetadata( case 'name': return name; case 'constraint': - return context.metadata.constraint[name]; + return context.constraint[name]; } return Reflect.get(target, key, receiver); diff --git a/packages/conform-react/helpers.ts b/packages/conform-react/helpers.ts index 9963b196..d38e4571 100644 --- a/packages/conform-react/helpers.ts +++ b/packages/conform-react/helpers.ts @@ -1,8 +1,8 @@ -import type { Form } from '@conform-to/dom'; import type { CSSProperties, HTMLInputTypeAttribute } from 'react'; -import type { FormMetadata, FieldMetadata, BaseMetadata } from './context'; +import type { FieldMetadata, BaseMetadata, Pretty } from './context'; +import type { FormConfig } from './hooks'; -interface FormControlProps { +type FormControlProps = { id: string; name: string; form: string; @@ -13,32 +13,38 @@ interface FormControlProps { 'aria-describedby'?: string; 'aria-invalid'?: boolean; 'aria-hidden'?: boolean; -} +}; -interface InputProps extends FormControlProps { - type?: Exclude; - minLength?: number; - maxLength?: number; - min?: string | number; - max?: string | number; - step?: string | number; - pattern?: string; - multiple?: boolean; - value?: string; - defaultChecked?: boolean; - defaultValue?: string; -} +type InputProps = Pretty< + FormControlProps & { + type?: Exclude; + minLength?: number; + maxLength?: number; + min?: string | number; + max?: string | number; + step?: string | number; + pattern?: string; + multiple?: boolean; + value?: string; + defaultChecked?: boolean; + defaultValue?: string; + } +>; -interface SelectProps extends FormControlProps { - defaultValue?: string | number | readonly string[] | undefined; - multiple?: boolean; -} +type SelectProps = Pretty< + FormControlProps & { + defaultValue?: string | number | readonly string[] | undefined; + multiple?: boolean; + } +>; -interface TextareaProps extends FormControlProps { - minLength?: number; - maxLength?: number; - defaultValue?: string; -} +type TextareaProps = Pretty< + FormControlProps & { + minLength?: number; + maxLength?: number; + defaultValue?: string; + } +>; type Primitive = string | number | boolean | Date | null | undefined; @@ -58,7 +64,7 @@ type ControlOptions = BaseOptions & { type FormOptions> = BaseOptions & { onSubmit?: ( event: React.FormEvent, - context: ReturnType['submit']>, + context: ReturnType['onSubmit']>, ) => void; onReset?: (event: React.FormEvent) => void; }; @@ -178,11 +184,11 @@ export function input( if (options.type === 'checkbox' || options.type === 'radio') { props.value = options.value ?? 'on'; props.defaultChecked = - typeof field.defaultValue === 'boolean' - ? field.defaultValue - : field.defaultValue === props.value; + typeof field.initialValue === 'boolean' + ? field.initialValue + : field.initialValue === props.value; } else if (options.type !== 'file') { - props.defaultValue = field.defaultValue?.toString(); + props.defaultValue = field.initialValue?.toString(); } return cleanup(props); @@ -193,7 +199,7 @@ export function select< >(metadata: FieldMetadata, options?: ControlOptions): SelectProps { return cleanup({ ...getFormControlProps(metadata, options), - defaultValue: metadata.defaultValue?.toString(), + defaultValue: metadata.initialValue?.toString(), multiple: metadata.constraint?.multiple, }); } @@ -204,20 +210,14 @@ export function textarea( ): TextareaProps { return cleanup({ ...getFormControlProps(metadata, options), - defaultValue: metadata.defaultValue?.toString(), + defaultValue: metadata.initialValue?.toString(), minLength: metadata.constraint?.minLength, maxLength: metadata.constraint?.maxLength, }); } export function form>( - metadata: FormMetadata & { - onSubmit: ( - event: React.FormEvent, - ) => ReturnType['submit']>; - onReset: (event: React.FormEvent) => void; - noValidate: boolean; - }, + metadata: FormConfig, options?: FormOptions, ) { const onSubmit = options?.onSubmit; @@ -279,9 +279,9 @@ export function collection< type: options.type, value, defaultChecked: - options.type === 'checkbox' && Array.isArray(metadata.defaultValue) - ? metadata.defaultValue.includes(value) - : metadata.defaultValue === value, + options.type === 'checkbox' && Array.isArray(metadata.initialValue) + ? metadata.initialValue.includes(value) + : metadata.initialValue === value, // The required attribute doesn't make sense for checkbox group // As it would require all checkboxes to be checked instead of at least one diff --git a/packages/conform-react/hooks.ts b/packages/conform-react/hooks.ts index 5a25e532..1dc37c68 100644 --- a/packages/conform-react/hooks.ts +++ b/packages/conform-react/hooks.ts @@ -40,6 +40,15 @@ export function useNoValidate(defaultNoValidate = true): boolean { return noValidate; } +export type FormConfig> = + FormMetadata & { + onSubmit: ( + event: React.FormEvent, + ) => ReturnType['submit']>; + onReset: (event: React.FormEvent) => void; + noValidate: boolean; + }; + export function useForm>(options: { /** * If the form id is provided, Id for label, @@ -98,13 +107,7 @@ export function useForm>(options: { formData: FormData; }) => Submission; }): { - form: FormMetadata & { - onSubmit: ( - event: React.FormEvent, - ) => ReturnType['submit']>; - onReset: (event: React.FormEvent) => void; - noValidate: boolean; - }; + form: FormConfig; context: Form; fields: Type extends Array ? { [Key in keyof Type]: FieldMetadata } @@ -277,18 +280,18 @@ export function useFieldList(options: { context?: Form; }): Array> { const subjectRef = useSubjectRef({ - defaultValue: { + initialValue: { name: [options.name], }, }); const context = useFormContext(options.formId, options.context, subjectRef); - const defaultValue = context.initialValue[options.name] ?? []; + const initialValue = context.initialValue[options.name] ?? []; - if (!Array.isArray(defaultValue)) { - throw new Error('The default value at the given name is not a list'); + if (!Array.isArray(initialValue)) { + throw new Error('The initial value at the given name is not a list'); } - return Array(defaultValue.length) + return Array(initialValue.length) .fill(0) .map((_, index) => getFieldMetadata(options.formId, context, { diff --git a/packages/conform-react/integrations.ts b/packages/conform-react/integrations.ts index f07c0f4c..761f32f2 100644 --- a/packages/conform-react/integrations.ts +++ b/packages/conform-react/integrations.ts @@ -14,13 +14,13 @@ import { const useSafeLayoutEffect = typeof document === 'undefined' ? useEffect : useLayoutEffect; -interface InputControl { +type InputControl = { change: ( eventOrValue: { target: { value: string } } | string | boolean, ) => void; focus: () => void; blur: () => void; -} +}; /** * Returns a ref object and a set of helpers that dispatch corresponding dom event. diff --git a/playground/app/components.tsx b/playground/app/components.tsx index 4c22ecbf..2ba7d700 100644 --- a/playground/app/components.tsx +++ b/playground/app/components.tsx @@ -115,10 +115,10 @@ export function Field({ label, inline, config, children }: FieldProps) { {children}
- {!config?.error?.length ? ( + {!config?.errors?.length ? (

) : ( - config.error.map((message) => ( + config.errors.map((message) => (

{message}

diff --git a/playground/app/routes/input-attributes.tsx b/playground/app/routes/input-attributes.tsx index 88f45171..0fc0b38a 100644 --- a/playground/app/routes/input-attributes.tsx +++ b/playground/app/routes/input-attributes.tsx @@ -116,7 +116,7 @@ export default function Example() { return (
- + - +
    {items.map((item, index) => (
  1. diff --git a/playground/app/routes/validitystate.tsx b/playground/app/routes/validitystate.tsx index bb888b87..00967366 100644 --- a/playground/app/routes/validitystate.tsx +++ b/playground/app/routes/validitystate.tsx @@ -113,7 +113,7 @@ export default function Example() { noValidate > - + {constraint.type === 'checkbox' || constraint.type === 'radio' ? (