From 3d103af9049194c9866b52b130ec29b70740e571 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 16 Aug 2024 01:27:26 +0300 Subject: [PATCH] feat: added support for output merging --- eslint.config.js | 1 + packages/core/src/types/forms.ts | 24 ++++- packages/core/src/useForm/useForm.ts | 8 +- packages/core/src/useForm/useFormActions.ts | 11 +-- .../core/src/useFormField/useFormField.ts | 21 +++- .../core/src/useFormGroup/useFormGroup.ts | 28 ++++-- .../core/src/validation/useInputValidity.ts | 7 +- .../src/validation/useValidationProvider.ts | 99 +++++++++++++++---- packages/playground/src/App.vue | 37 ++++--- 9 files changed, 174 insertions(+), 62 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index af97e02c..aa971ba5 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -18,6 +18,7 @@ export default tseslint.config( '@typescript-eslint/no-use-before-define': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/no-explicit-any': 'warn', + 'no-console': 'error', }, }, { diff --git a/packages/core/src/types/forms.ts b/packages/core/src/types/forms.ts index 52f9c60b..86cb6402 100644 --- a/packages/core/src/types/forms.ts +++ b/packages/core/src/types/forms.ts @@ -2,6 +2,7 @@ import { Schema, Simplify } from 'type-fest'; import { FormObject } from './common'; import { Path } from './paths'; import { TypedSchemaError } from './typedSchema'; +import { FormValidationMode } from '@core/useForm/formContext'; export type TouchedSchema = Simplify>; @@ -9,7 +10,28 @@ export type DisabledSchema = Partial = Partial, string[]>>; -export type ValidationResult = { +type BaseValidationResult = { isValid: boolean; errors: TypedSchemaError[]; }; + +export interface ValidationResult extends BaseValidationResult { + type: 'FIELD'; + output: TValue; + path: string; +} + +export interface GroupValidationResult extends BaseValidationResult { + type: 'GROUP'; + path: string; + output: TOutput; + mode: FormValidationMode; +} + +export interface FormValidationResult extends BaseValidationResult { + type: 'FORM'; + output: TOutput; + mode: FormValidationMode; +} + +export type AnyValidationResult = GroupValidationResult | ValidationResult; diff --git a/packages/core/src/useForm/useForm.ts b/packages/core/src/useForm/useForm.ts index 1ba4cd2a..1bcbdc46 100644 --- a/packages/core/src/useForm/useForm.ts +++ b/packages/core/src/useForm/useForm.ts @@ -10,10 +10,12 @@ import { Path, TypedSchema, ValidationResult, + FormValidationResult, + GroupValidationResult, } from '../types'; import { createFormContext, BaseFormContext } from './formContext'; import { FormTransactionManager, useFormTransactions } from './useFormTransactions'; -import { FormValidationResult, useFormActions } from './useFormActions'; +import { useFormActions } from './useFormActions'; import { useFormSnapshots } from './formSnapshot'; import { findLeaf } from '../utils/path'; @@ -29,7 +31,9 @@ export interface FormContext { requestValidation(): Promise>; onSubmitAttempt(cb: () => void): void; - onValidationDispatch(cb: (enqueue: (promise: Promise) => void) => void): void; + onValidationDispatch( + cb: (enqueue: (promise: Promise) => void) => void, + ): void; } export const FormKey: InjectionKey> = Symbol('Formwerk FormKey'); diff --git a/packages/core/src/useForm/useFormActions.ts b/packages/core/src/useForm/useFormActions.ts index 80f9f6bb..ffe42d2e 100644 --- a/packages/core/src/useForm/useFormActions.ts +++ b/packages/core/src/useForm/useFormActions.ts @@ -2,15 +2,15 @@ import { shallowRef } from 'vue'; import { DisabledSchema, FormObject, + FormValidationResult, MaybeAsync, Path, TouchedSchema, TypedSchema, TypedSchemaError, - ValidationResult, } from '../types'; import { createEventDispatcher } from '../utils/events'; -import { BaseFormContext, FormValidationMode, SetValueOptions } from './formContext'; +import { BaseFormContext, SetValueOptions } from './formContext'; import { unsetPath } from '../utils/path'; import { useValidationProvider } from '../validation/useValidationProvider'; @@ -25,11 +25,6 @@ export interface FormActionsOptions; } -export interface FormValidationResult extends ValidationResult { - output: TOutput; - mode: FormValidationMode; -} - export function useFormActions( form: BaseFormContext, { disabled, schema }: FormActionsOptions, @@ -40,7 +35,7 @@ export function useFormActions form.getValues() }); + } = useValidationProvider({ schema, getValues: () => form.getValues(), type: 'FORM' }); const requestValidation = defineValidationRequest(updateValidationStateFromResult); function handleSubmit(onSuccess: (values: TOutput) => MaybeAsync) { diff --git a/packages/core/src/useFormField/useFormField.ts b/packages/core/src/useFormField/useFormField.ts index c50a7384..91e962b4 100644 --- a/packages/core/src/useFormField/useFormField.ts +++ b/packages/core/src/useFormField/useFormField.ts @@ -75,21 +75,32 @@ export function useFormField(opts?: Partial): ValidationResult { + return { + type: 'FIELD', + path: getPath() || '', + ...result, + }; + } + + async function validate(mutate?: boolean): Promise { const schema = opts?.schema; if (!schema) { - return Promise.resolve({ isValid: true, errors: [] }); + return Promise.resolve( + createValidationResult({ isValid: true, errors: [], output: cloneDeep(fieldValue.value) }), + ); } - const { errors } = await schema.parse(fieldValue.value as TValue); + const { errors, output } = await schema.parse(fieldValue.value as TValue); if (mutate) { setErrors(errors.map(e => e.messages).flat()); } - return { + return createValidationResult({ isValid: errors.length === 0, + output, errors: errors.map(e => ({ messages: e.messages, path: getPath() || e.path })), - }; + }); } const field: FormField = { diff --git a/packages/core/src/useFormGroup/useFormGroup.ts b/packages/core/src/useFormGroup/useFormGroup.ts index e4930075..c8d9c9b6 100644 --- a/packages/core/src/useFormGroup/useFormGroup.ts +++ b/packages/core/src/useFormGroup/useFormGroup.ts @@ -14,11 +14,18 @@ import { } from 'vue'; import { useLabel } from '../a11y/useLabel'; import { FieldTypePrefixes } from '../constants'; -import { AriaLabelableProps, AriaLabelProps, FormObject, Reactivify, TypedSchema, ValidationResult } from '../types'; +import { + AriaLabelableProps, + AriaLabelProps, + FormObject, + GroupValidationResult, + Reactivify, + TypedSchema, + ValidationResult, +} from '../types'; import { isEqual, normalizeProps, useUniqId, warn, withRefCapture } from '../utils/common'; import { FormKey } from '../useForm'; import { useValidationProvider } from '../validation/useValidationProvider'; -import { FormValidationResult } from '../useForm/useFormActions'; export interface FormGroupProps { name: string; @@ -34,7 +41,7 @@ interface GroupProps extends AriaLabelableProps { interface FormGroupContext { prefixPath: (path: string | undefined) => string | undefined; onValidationDispatch(cb: (enqueue: (promise: Promise) => void) => void): void; - requestValidation(): Promise>; + requestValidation(): Promise>; } export const FormGroupKey: InjectionKey = Symbol('FormGroup'); @@ -45,11 +52,14 @@ export function useFormGroup toValue(props.name); const groupRef = elementRef || shallowRef(); const form = inject(FormKey, null); const { validate, onValidationDispatch, defineValidationRequest } = useValidationProvider({ - schema: props.schema, getValues, + getPath, + schema: props.schema, + type: 'GROUP', }); const requestValidation = defineValidationRequest(({ errors }) => { @@ -87,20 +97,20 @@ export function useFormGroup e.path.startsWith(path)); } const isValid = computed(() => getErrors().length === 0); - const isTouched = computed(() => form?.isFieldTouched(toValue(props.name)) ?? false); + const isTouched = computed(() => form?.isFieldTouched(getPath()) ?? false); const isDirty = computed(() => { - const path = toValue(props.name); + const path = getPath(); return !isEqual(getValues(), form?.getFieldOriginalValue(path) ?? {}); }); @@ -117,7 +127,7 @@ export function useFormGroup(); const validationMode = form?.getValidationMode() ?? 'native'; useMessageCustomValiditySync(errorMessage, opts.inputRef); @@ -28,6 +28,9 @@ export function useInputValidity(opts: InputValidityOptions) { } return { + type: 'FIELD', + path: getPath() || '', + output: cloneDeep(fieldValue.value), isValid: !messages.length, errors: [{ messages, path: getPath() || '' }], }; diff --git a/packages/core/src/validation/useValidationProvider.ts b/packages/core/src/validation/useValidationProvider.ts index 5686b275..ce36d47e 100644 --- a/packages/core/src/validation/useValidationProvider.ts +++ b/packages/core/src/validation/useValidationProvider.ts @@ -1,27 +1,44 @@ -import { FormObject, TypedSchema, ValidationResult } from '../types'; -import { FormValidationResult } from '../useForm/useFormActions'; +import { + AnyValidationResult, + FormObject, + FormValidationResult, + GroupValidationResult, + TypedSchema, + ValidationResult, +} from '../types'; import { batchAsync, cloneDeep, withLatestCall } from '../utils/common'; import { createEventDispatcher } from '../utils/events'; import { SCHEMA_BATCH_MS } from '../constants'; +import { setInPath } from '@core/utils/path'; -interface ValidationProviderOptions { +type AggregatorResult = FormValidationResult | GroupValidationResult; + +interface ValidationProviderOptions< + TInput extends FormObject, + TOutput extends FormObject, + TType extends AggregatorResult['type'], +> { + type: TType; schema?: TypedSchema; getValues: () => TInput; + getPath?: () => string; } -export function useValidationProvider({ - schema, - getValues, -}: ValidationProviderOptions) { +export function useValidationProvider< + TInput extends FormObject, + TOutput extends FormObject, + TType extends AggregatorResult['type'], + TResult extends AggregatorResult & { type: TType }, +>({ schema, getValues, type, getPath }: ValidationProviderOptions) { const [dispatchValidate, onValidationDispatch] = createEventDispatcher<(pending: Promise) => void>('validate'); /** * Validates but tries to not mutate anything if possible. */ - async function validate(): Promise> { - const validationQueue: Promise[] = []; - const enqueue = (promise: Promise) => validationQueue.push(promise); + async function validate(): Promise { + const validationQueue: Promise[] = []; + const enqueue = (promise: Promise) => validationQueue.push(promise); // This is meant to trigger a signal for all fields that can validate themselves to do so. // Native validation is sync so no need to wait for pending validators. // But field-level and group-level validations are async, so we need to wait for them. @@ -32,26 +49,24 @@ export function useValidationProvider) => void) { + function defineValidationRequest(mutator: (result: TResult) => void) { const requestValidation = withLatestCall(batchAsync(validate, SCHEMA_BATCH_MS), result => { mutator(result); @@ -61,6 +76,52 @@ export function useValidationProvider, 'mode' | 'type'>): TResult { + const base = { + output: result.output, + errors: result.errors, + isValid: result.isValid, + }; + + if (type === 'FORM') { + return { + type, + mode: schema ? 'schema' : 'native', + ...base, + } as TResult; + } + + return { + type: 'GROUP', + path: getPath?.() || '', + mode: schema ? 'schema' : 'native', + ...base, + } as TResult; + } + + function mergeOutputs(base: TOutput, results: (ValidationResult | GroupValidationResult)[]): TOutput { + const all = cloneDeep(base); + // Make sure we start with groups first since it may override indivdual fields + const sorted = [...results].sort((a, b) => { + if (a.type === b.type) { + return 0; + } + + return a.type === 'FIELD' ? -1 : 1; + }); + + for (const result of sorted) { + // Pathless fields will be dropped + if (!result.path) { + continue; + } + + setInPath(all, result.path, result.output); + } + + return all; + } + return { validate, onValidationDispatch, diff --git a/packages/playground/src/App.vue b/packages/playground/src/App.vue index 4fee262c..8783d373 100644 --- a/packages/playground/src/App.vue +++ b/packages/playground/src/App.vue @@ -3,17 +3,17 @@ - - + + -
- - - -
-
+
+ + + +
+
- {{values }} + {{ values }} @@ -26,13 +26,18 @@ import { useForm } from '@formwerk/core'; import { defineSchema } from '@formwerk/schema-zod'; import { z } from 'zod'; -const groupSchema = defineSchema(z.object({ - street: z.string().min(1), - city: z.string().min(1), - state: z.string().min(1), - - zip: z.preprocess((v) => Number(v), z.number().lte(1000).gte(100)), -})); +const groupSchema = defineSchema( + z.object({ + street: z + .string() + .min(1) + .transform(v => `${v} St.`), + city: z.string().min(1), + state: z.string().min(1), + + zip: z.preprocess(v => Number(v), z.number().lte(1000).gte(100)), + }), +); const { getErrors, values, handleSubmit } = useForm({ schema: defineSchema(