Skip to content

Commit

Permalink
feat: form-group level validation
Browse files Browse the repository at this point in the history
  • Loading branch information
logaretm committed Aug 15, 2024
1 parent ff97060 commit fe44e8d
Show file tree
Hide file tree
Showing 7 changed files with 160 additions and 71 deletions.
15 changes: 12 additions & 3 deletions packages/core/src/useForm/formContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export interface BaseFormContext<TForm extends FormObject = FormObject> {
setFieldErrors<TPath extends Path<TForm>>(path: TPath, message: Arrayable<string>): void;
getValidationMode(): FormValidationMode;
getErrors: () => TypedSchemaError[];
clearErrors: () => void;
clearErrors: (path?: string) => void;
hasErrors: () => boolean;
getValues: () => TForm;
setValues: (newValues: Partial<TForm>, opts?: SetValueOptions) => void;
Expand Down Expand Up @@ -204,8 +204,17 @@ export function createFormContext<TForm extends FormObject = FormObject, TOutput
merge(touched, newTouched);
}

function clearErrors() {
errors.value = {} as ErrorsSchema<TForm>;
function clearErrors(path?: string) {
if (!path) {
errors.value = {} as ErrorsSchema<TForm>;
return;
}

Object.keys(errors.value).forEach(key => {
if (key === path || key.startsWith(path)) {
delete errors.value[key as Path<TForm>];
}
});
}

function revertValues() {
Expand Down
51 changes: 7 additions & 44 deletions packages/core/src/useForm/useFormActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,10 @@ import {
TypedSchemaError,
ValidationResult,
} from '../types';
import { batchAsync, cloneDeep, withLatestCall } from '../utils/common';
import { createEventDispatcher } from '../utils/events';
import { BaseFormContext, FormValidationMode, SetValueOptions } from './formContext';
import { unsetPath } from '../utils/path';
import { SCHEMA_BATCH_MS } from '../constants';
import { useValidationProvider } from '../validation/useValidationProvider';

export interface ResetState<TForm extends FormObject> {
values: Partial<TForm>;
Expand All @@ -37,8 +36,12 @@ export function useFormActions<TForm extends FormObject = FormObject, TOutput ex
) {
const isSubmitting = shallowRef(false);
const [dispatchSubmit, onSubmitAttempt] = createEventDispatcher<void>('submit');
const [dispatchValidate, onValidationDispatch] =
createEventDispatcher<(pending: Promise<ValidationResult>) => void>('form-validate');
const {
validate: _validate,
onValidationDispatch,
defineValidationRequest,
} = useValidationProvider({ schema, getValues: () => form.getValues() });
const requestValidation = defineValidationRequest(updateValidationStateFromResult);

function handleSubmit<TReturns>(onSuccess: (values: TOutput) => MaybeAsync<TReturns>) {
return async function onSubmit(e: Event) {
Expand Down Expand Up @@ -71,40 +74,6 @@ export function useFormActions<TForm extends FormObject = FormObject, TOutput ex
};
}

/**
* Validates but tries to not mutate anything if possible.
*/
async function _validate(): Promise<FormValidationResult<TOutput>> {
const validationQueue: Promise<ValidationResult>[] = [];
const enqueue = (promise: Promise<ValidationResult>) => 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.
await dispatchValidate(enqueue);
const fieldErrors = (await Promise.all(validationQueue)).flatMap(r => r.errors).filter(e => e.messages.length);

// If we are using native validation, then we don't stop the state mutation
// Because it already has happened, since validations are sourced from the fields.
if (form.getValidationMode() === 'native' || !schema) {
return {
mode: 'native',
isValid: !fieldErrors.length,
errors: fieldErrors,
output: cloneDeep(form.getValues() as unknown as TOutput),
};
}

const { errors, output } = await schema.parse(form.getValues());
const allErrors = [...errors, ...fieldErrors];

return {
mode: 'schema',
isValid: !allErrors.length,
errors: allErrors,
output: cloneDeep(output ?? (form.getValues() as unknown as TOutput)),
};
}

function updateValidationStateFromResult(result: FormValidationResult<TOutput>) {
form.clearErrors();
applyErrors(result.errors);
Expand Down Expand Up @@ -146,12 +115,6 @@ export function useFormActions<TForm extends FormObject = FormObject, TOutput ex
return Promise.resolve();
}

const requestValidation = withLatestCall(batchAsync(_validate, SCHEMA_BATCH_MS), result => {
updateValidationStateFromResult(result);

return result;
});

return {
actions: {
handleSubmit,
Expand Down
51 changes: 43 additions & 8 deletions packages/core/src/useFormGroup/useFormGroup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@ import {
} from 'vue';
import { useLabel } from '../a11y/useLabel';
import { FieldTypePrefixes } from '../constants';
import { AriaLabelableProps, AriaLabelProps, FormObject, Reactivify, TypedSchema } from '../types';
import { AriaLabelableProps, AriaLabelProps, FormObject, Reactivify, TypedSchema, ValidationResult } from '../types';
import { isEqual, normalizeProps, useUniqId, warn, withRefCapture } from '../utils/common';
import { FormKey } from '@core/useForm';
import { FormKey } from '../useForm';
import { useValidationProvider } from '../validation/useValidationProvider';
import { FormValidationResult } from '../useForm/useFormActions';

export interface FormGroupProps<TInput extends FormObject = FormObject, TOutput extends FormObject = TInput> {
name: string;
Expand All @@ -29,20 +31,35 @@ interface GroupProps extends AriaLabelableProps {
role?: 'group';
}

interface FormGroupContext {
interface FormGroupContext<TOutput extends FormObject = FormObject> {
prefixPath: (path: string | undefined) => string | undefined;
onValidationDispatch(cb: (enqueue: (promise: Promise<ValidationResult>) => void) => void): void;
requestValidation(): Promise<FormValidationResult<TOutput>>;
}

export const FormGroupKey: InjectionKey<FormGroupContext> = Symbol('FormGroup');

export function useFormGroup<TInput extends FormObject = FormObject, TOutput extends FormObject = TInput>(
_props: Reactivify<FormGroupProps<TInput, TOutput>>,
_props: Reactivify<FormGroupProps<TInput, TOutput>, 'schema'>,
elementRef?: Ref<HTMLElement>,
) {
const id = useUniqId(FieldTypePrefixes.FormGroup);
const props = normalizeProps(_props);
const props = normalizeProps(_props, ['schema']);
const groupRef = elementRef || shallowRef<HTMLInputElement>();
const form = inject(FormKey, null);
const { validate, onValidationDispatch, defineValidationRequest } = useValidationProvider({
schema: props.schema,
getValues,
});

const requestValidation = defineValidationRequest(({ errors }) => {
// Clears Errors in that path before proceeding.
form?.clearErrors(toValue(props.name));
for (const entry of errors) {
form?.setFieldErrors(prefixPath(entry.path) ?? '', entry.messages);
}
});

if (!form) {
warn('Form groups must have a parent form. Please make sure to call `useForm` at a parent component.');
}
Expand Down Expand Up @@ -99,12 +116,29 @@ export function useFormGroup<TInput extends FormObject = FormObject, TOutput ext
return form?.isFieldTouched(path) ? msg : undefined;
}

function prefixPath(path: string | undefined) {
return prefixGroupPath(toValue(props.name), path);
}

const ctx: FormGroupContext = {
prefixPath(path: string | undefined) {
return prefixGroupPath(toValue(props.name), path);
},
prefixPath,
onValidationDispatch,
requestValidation,
};

// Whenever the form is validated, it is deferred to the form group to do that.
// Fields should not validate in response to their form triggering a validate and instead should follow the field group event
form?.onValidationDispatch(enqueue => {
enqueue(
validate().then(result => {
return {
...result,
errors: result.errors.map(e => ({ path: prefixPath(e.path) ?? '', messages: e.messages })),
};
}),
);
});

provide(FormGroupKey, ctx);

return {
Expand All @@ -119,6 +153,7 @@ export function useFormGroup<TInput extends FormObject = FormObject, TOutput ext
getValues,
getError,
displayError,
validate,
};
}

Expand Down
6 changes: 4 additions & 2 deletions packages/core/src/validation/useInputValidity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { FormKey } from '../useForm';
import { Maybe, ValidationResult } from '../types';
import { FormField } from '../useFormField';
import { normalizeArrayable } from '../utils/common';
import { FormGroupKey } from '../useFormGroup';

interface InputValidityOptions {
inputRef?: Ref<HTMLInputElement | HTMLTextAreaElement | undefined>;
Expand All @@ -13,6 +14,7 @@ interface InputValidityOptions {

export function useInputValidity(opts: InputValidityOptions) {
const form = inject(FormKey, null);
const formGroup = inject(FormGroupKey, null);
const { setErrors, errorMessage, schema, validate: validateField, getPath } = opts.field;
const validityDetails = shallowRef<ValidityState>();
const validationMode = form?.getValidationMode() ?? 'native';
Expand All @@ -36,7 +38,7 @@ export function useInputValidity(opts: InputValidityOptions) {
return schema ? validateField(true) : validateNative(true);
}

form?.requestValidation();
(formGroup || form)?.requestValidation();
}

async function updateValidity() {
Expand All @@ -48,7 +50,7 @@ export function useInputValidity(opts: InputValidityOptions) {

// It shouldn't mutate the field if the validation is sourced by the form.
// The form will handle the mutation later once it aggregates all the results.
form?.onValidationDispatch(enqueue => {
(formGroup || form)?.onValidationDispatch(enqueue => {
if (schema) {
enqueue(validateField(false));
return;
Expand Down
69 changes: 69 additions & 0 deletions packages/core/src/validation/useValidationProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { FormObject, TypedSchema, ValidationResult } from '../types';
import { FormValidationResult } from '../useForm/useFormActions';
import { batchAsync, cloneDeep, withLatestCall } from '../utils/common';
import { createEventDispatcher } from '../utils/events';
import { SCHEMA_BATCH_MS } from '../constants';

interface ValidationProviderOptions<TInput extends FormObject, TOutput extends FormObject = TInput> {
schema?: TypedSchema<TInput, TOutput>;
getValues: () => TInput;
}

export function useValidationProvider<TInput extends FormObject, TOutput extends FormObject = TInput>({
schema,
getValues,
}: ValidationProviderOptions<TInput, TOutput>) {
const [dispatchValidate, onValidationDispatch] =
createEventDispatcher<(pending: Promise<ValidationResult>) => void>('validate');

/**
* Validates but tries to not mutate anything if possible.
*/
async function validate(): Promise<FormValidationResult<TOutput>> {
const validationQueue: Promise<ValidationResult>[] = [];
const enqueue = (promise: Promise<ValidationResult>) => 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.
await dispatchValidate(enqueue);
const results = await Promise.all(validationQueue);
const fieldErrors = results.flatMap(r => r.errors).filter(e => e.messages.length);

// If we are using native validation, then we don't stop the state mutation
// Because it already has happened, since validations are sourced from the fields.
if (!schema) {
return {
mode: 'native',
isValid: !fieldErrors.length,
errors: fieldErrors,
output: cloneDeep(getValues() as unknown as TOutput),
};
}

const { errors, output } = await schema.parse(getValues());
const allErrors = [...errors, ...fieldErrors];

return {
mode: 'schema',
isValid: !allErrors.length,
errors: allErrors,
output: cloneDeep(output ?? (getValues() as unknown as TOutput)),
};
}

function defineValidationRequest(mutator: (result: FormValidationResult<TOutput>) => void) {
const requestValidation = withLatestCall(batchAsync(validate, SCHEMA_BATCH_MS), result => {
mutator(result);

return result;
});

return requestValidation;
}

return {
validate,
onValidationDispatch,
defineValidationRequest,
};
}
27 changes: 19 additions & 8 deletions packages/playground/src/App.vue
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
<template>
<div class="flex flex-col">
<FormGroup name="group1" label="Group 1">
<InputText label="Email" name="email" type="email" :schema="defineSchema(z.string().email())" />
<InputText label="Other" name="other" required />
</FormGroup>
<InputText label="Full Name" name="fullName" />

<FormGroup name="address" label="Shipping Address" :schema="groupSchema">
<InputText label="Address Line 1" name="street" />

<FormGroup name="group2" label="Group 2" class="mt-6">
<InputText label="Email" name="email" type="email" :schema="defineSchema(z.string().email())" />
<InputText label="Other" name="other" required />
</FormGroup>
<div class="grid grid-cols-3 gap-4">
<InputText label="City" name="city" />
<InputText label="State" name="state" />
<InputText label="Zip" name="zip" />
</div>
</FormGroup>

{{values }}

<button @click="onSubmit">Submit</button>
</div>
Expand All @@ -22,11 +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 { getErrors, values, handleSubmit } = useForm({
schema: defineSchema(
z.object({
other: z.string().min(3),
fullName: z.string().min(1),
}),
),
});
Expand Down
12 changes: 6 additions & 6 deletions packages/playground/src/components/FormGroup.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@

<slot :display-error="displayError" :get-error="getError" />

<pre class="bg-gray-600 text-white text-xs p-2.5 rounded-lg">Errors: {{getErrors()}}
</pre>
<!-- <pre class="bg-gray-600 text-white text-xs p-2.5 rounded-lg">Errors: {{getErrors()}}-->
<!-- </pre>-->

<div>values: {{ getValues()}}</div>
<div>touched: {{ isTouched }}</div>
<div>dirty: {{ isDirty }}</div>
<div>valid: {{ isValid }}</div>
<!-- <div>values: {{ getValues()}}</div>-->
<!-- <div>touched: {{ isTouched }}</div>-->
<!-- <div>dirty: {{ isDirty }}</div>-->
<!-- <div>valid: {{ isValid }}</div>-->
</fieldset>
</template>

Expand Down

0 comments on commit fe44e8d

Please sign in to comment.