Skip to content

Commit

Permalink
feat: validation cascade
Browse files Browse the repository at this point in the history
  • Loading branch information
logaretm committed Aug 20, 2024
1 parent ce77cc1 commit 0793729
Show file tree
Hide file tree
Showing 6 changed files with 122 additions and 41 deletions.
71 changes: 68 additions & 3 deletions packages/core/src/useFormGroup/useFormGroup.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ function createInputComponent(): Component {
const schema = attrs.schema as TypedSchema<any>;
const { errorMessage, inputProps } = useTextField({ name, label: name, schema });

return { errorMessage: errorMessage, inputProps, name };
return { errorMessage: errorMessage, inputProps, name, attrs };
},
template: `
<input v-bind="inputProps" :data-testid="name" />
<input v-bind="{...inputProps, ...attrs}" :data-testid="name" />
<span data-testid="err">{{ errorMessage }}</span>
`,
};
Expand Down Expand Up @@ -257,8 +257,10 @@ test('validation combines schema with form schema', async () => {

await flush();
expect(form.getErrors()).toHaveLength(2);

await fireEvent.update(screen.getByTestId('field'), 'test');
await fireEvent.blur(screen.getByTestId('other'));
await fireEvent.blur(screen.getByTestId('field'));

await flush();
expect(form.getErrors()).toHaveLength(1);
await fireEvent.update(screen.getByTestId('other'), 'test');
Expand All @@ -267,6 +269,69 @@ test('validation combines schema with form schema', async () => {
expect(form.getErrors()).toHaveLength(0);
});

test('validation cascades', async () => {
let form!: ReturnType<typeof useForm>;
const groups: ReturnType<typeof useFormGroup>[] = [];
const groupSchema: TypedSchema<{ field: string }> = {
async parse(value) {
return {
errors: value.field === 'valid' ? [] : [{ path: 'field', messages: ['error'] }],
};
},
};

const formSchema: TypedSchema<{ other: string }> = {
async parse(value) {
return {
errors: value.other === 'valid' ? [] : [{ path: 'other', messages: ['error'] }],
};
},
};

await render({
components: { TInput: createInputComponent(), TGroup: createGroupComponent(fg => groups.push(fg)) },
setup() {
form = useForm({
schema: formSchema,
}) as any;

return {
groupSchema,
};
},
template: `
<TGroup name="group" :schema="groupSchema">
<TInput name="field" :required="true" />
</TGroup>
<TInput name="other" :required="true" />
`,
});

await flush();
expect(form.getErrors()).toHaveLength(2);
expect(form.getErrors().flatMap(e => e.messages)).toEqual(['Constraints not satisfied', 'Constraints not satisfied']);

await fireEvent.update(screen.getByTestId('field'), 'test');
await fireEvent.blur(screen.getByTestId('field'));

await flush();
expect(form.getErrors()).toHaveLength(2);
expect(form.getErrors().flatMap(e => e.messages)).toEqual(['Constraints not satisfied', 'error']);
await fireEvent.update(screen.getByTestId('other'), 'test');
await fireEvent.blur(screen.getByTestId('other'));
await flush();
expect(form.getErrors()).toHaveLength(2);
expect(form.getErrors().flatMap(e => e.messages)).toEqual(['error', 'error']);

await fireEvent.update(screen.getByTestId('other'), 'valid');
await fireEvent.update(screen.getByTestId('field'), 'valid');
await fireEvent.blur(screen.getByTestId('other'));
await fireEvent.blur(screen.getByTestId('field'));
await flush();
expect(form.getErrors()).toHaveLength(0);
});

test('submission combines group data with form data', async () => {
const submitHandler = vi.fn();
const groupSchema: TypedSchema<{ first: string }> = {
Expand Down
21 changes: 6 additions & 15 deletions packages/core/src/useFormGroup/useFormGroup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { isEqual, normalizeProps, useUniqId, warn, withRefCapture } from '../uti
import { FormKey } from '../useForm';
import { useValidationProvider } from '../validation/useValidationProvider';
import { FormValidationMode } from '../useForm/formContext';
import { prefixPath as _prefixPath } from '../utils/path';

export interface FormGroupProps<TInput extends FormObject = FormObject, TOutput extends FormObject = TInput> {
name: string;
Expand Down Expand Up @@ -64,11 +65,11 @@ export function useFormGroup<TInput extends FormObject = FormObject, TOutput ext
type: 'GROUP',
});

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

Expand Down Expand Up @@ -129,7 +130,7 @@ export function useFormGroup<TInput extends FormObject = FormObject, TOutput ext
}

function prefixPath(path: string | undefined) {
return prefixGroupPath(getPath(), path);
return _prefixPath(getPath(), path);
}

const ctx: FormGroupContext = {
Expand All @@ -146,7 +147,7 @@ export function useFormGroup<TInput extends FormObject = FormObject, TOutput ext
validate().then(result => {
return {
...result,
errors: result.errors.map(e => ({ path: prefixPath(e.path) ?? '', messages: e.messages })),
errors: result.errors,
};
}),
);
Expand Down Expand Up @@ -194,13 +195,3 @@ function createInlineFormGroupComponent({ groupProps, labelProps }: Reactivify<I
};
};
}

function prefixGroupPath(prefix: string | undefined, path: string | undefined) {
if (!path) {
return path;
}

prefix = prefix ? `${prefix}.` : '';

return `${prefix}${path}`;
}
10 changes: 10 additions & 0 deletions packages/core/src/utils/path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,3 +220,13 @@ export function normalizePath(path: string): string {

return fullPath;
}

export function prefixPath(prefix: string | undefined, path: string | undefined) {
if (!path) {
return path;
}

prefix = prefix ? `${prefix}.` : '';

return `${prefix}${path}`;
}
38 changes: 21 additions & 17 deletions packages/core/src/validation/useInputValidity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useEventListener } from '../helpers/useEventListener';
import { FormKey } from '../useForm';
import { Maybe, ValidationResult } from '../types';
import { FormField } from '../useFormField';
import { cloneDeep, isInputElement, normalizeArrayable } from '../utils/common';
import { isInputElement, normalizeArrayable } from '../utils/common';
import { FormGroupKey } from '../useFormGroup';

interface InputValidityOptions {
Expand All @@ -15,28 +15,29 @@ interface InputValidityOptions {
export function useInputValidity(opts: InputValidityOptions) {
const form = inject(FormKey, null);
const formGroup = inject(FormGroupKey, null);
const { setErrors, errorMessage, schema, validate: validateField, getPath, getName, fieldValue } = opts.field;
const { setErrors, errorMessage, schema, validate: validateField, getPath } = opts.field;
const validityDetails = shallowRef<ValidityState>();
const validationMode = (formGroup || form)?.getValidationMode() ?? 'aggregate';
useMessageCustomValiditySync(errorMessage, opts.inputRef);

function validateNative(mutate?: boolean): ValidationResult {
const baseReturns: Omit<ValidationResult, 'errors' | 'isValid'> = {
type: 'FIELD',
path: (formGroup ? getName() : getPath()) || '',
output: cloneDeep(fieldValue.value),
path: getPath() || '',
};

if (!isInputElement(opts.inputRef?.value)) {
const inputEl = opts.inputRef?.value;
if (!isInputElement(inputEl)) {
return {
...baseReturns,
isValid: true,
errors: [{ messages: [], path: getPath() || '' }],
};
}

validityDetails.value = opts.inputRef?.value?.validity;
const messages = normalizeArrayable(opts.inputRef?.value?.validationMessage || ([] as string[])).filter(Boolean);
inputEl.setCustomValidity('');
validityDetails.value = inputEl.validity;
const messages = normalizeArrayable(inputEl.validationMessage || ([] as string[])).filter(Boolean);

if (mutate) {
setErrors(messages);
}
Expand All @@ -48,9 +49,14 @@ export function useInputValidity(opts: InputValidityOptions) {
};
}

function _updateValidity() {
if (validationMode === 'aggregate') {
return schema ? validateField(true) : validateNative(true);
async function _updateValidity() {
let result = validateNative(true);
if (schema && result.isValid) {
result = await validateField(true);
}

if (!result.isValid) {
return;
}

(formGroup || form)?.requestValidation();
Expand All @@ -66,18 +72,16 @@ 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.
(formGroup || form)?.onValidationDispatch(enqueue => {
if (schema) {
const result = validateNative(false);
if (schema && result.isValid) {
enqueue(validateField(false));
return;
}

if (validationMode === 'aggregate') {
enqueue(Promise.resolve(validateNative(false)));
return;
}
enqueue(Promise.resolve(result));
});

if (validationMode === 'aggregate') {
if (!schema) {
// It should self-mutate the field errors because this is fired by a native validation and not sourced by the form.
useEventListener(opts.inputRef, opts?.events || ['invalid'], () => validateNative(true));
}
Expand Down
22 changes: 16 additions & 6 deletions packages/core/src/validation/useValidationProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
import { batchAsync, cloneDeep, withLatestCall } from '../utils/common';
import { createEventDispatcher } from '../utils/events';
import { SCHEMA_BATCH_MS } from '../constants';
import { setInPath } from '../utils/path';
import { prefixPath, setInPath } from '../utils/path';

type AggregatorResult<TOutput extends FormObject> = FormValidationResult<TOutput> | GroupValidationResult<TOutput>;

Expand Down Expand Up @@ -56,7 +56,18 @@ export function useValidationProvider<
});
}

const { errors, output } = await schema.parse(getValues());
const { errors: parseErrors, output } = await schema.parse(getValues());
let errors = parseErrors;
const prefix = getPath?.();
if (prefix) {
errors = parseErrors.map(e => {
return {
messages: e.messages,
path: prefixPath(prefix, e.path) || '',
};
});
}

const allErrors = [...errors, ...fieldErrors];

return createValidationResult({
Expand All @@ -67,13 +78,11 @@ export function useValidationProvider<
}

function defineValidationRequest(mutator: (result: TResult) => void) {
const requestValidation = withLatestCall(batchAsync(validate, SCHEMA_BATCH_MS), result => {
return withLatestCall(batchAsync(validate, SCHEMA_BATCH_MS), result => {
mutator(result);

return result;
});

return requestValidation;
}

function createValidationResult(result: Omit<AggregatorResult<TOutput>, 'mode' | 'type'>): TResult {
Expand Down Expand Up @@ -111,8 +120,9 @@ export function useValidationProvider<
});

for (const result of sorted) {
const hasOutput = 'output' in result;
// Pathless fields will be dropped
if (!result.path) {
if (!result.path || !hasOutput) {
continue;
}

Expand Down
1 change: 1 addition & 0 deletions packages/schema-zod/src/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ describe('schema-zod', () => {
await fireEvent.update(screen.getByTestId('test'), 'test');
await fireEvent.click(screen.getByText('Submit'));
await flush();

expect(handler).toHaveBeenCalledOnce();
});

Expand Down

0 comments on commit 0793729

Please sign in to comment.