Skip to content

Commit

Permalink
feat: validate yup form schemas using object validation
Browse files Browse the repository at this point in the history
  • Loading branch information
logaretm committed Jul 18, 2020
1 parent 9a4279d commit bf216dd
Show file tree
Hide file tree
Showing 4 changed files with 135 additions and 33 deletions.
1 change: 1 addition & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export interface FormController {
values: ComputedRef<Record<string, any>>;
fields: ComputedRef<Record<string, any>>;
schema?: Record<string, GenericValidateFunction | string | Record<string, any>>;
validateSchema?: (shouldMutate?: boolean) => Promise<Record<string, ValidationResult>>;
}

export type MaybeReactive<T> = Ref<T> | ComputedRef<T> | T;
Expand Down
50 changes: 22 additions & 28 deletions packages/core/src/useField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,28 +26,34 @@ type RuleExpression = MaybeReactive<string | Record<string, any> | GenericValida
*/
export function useField(fieldName: MaybeReactive<string>, rules: RuleExpression, opts?: Partial<FieldOptions>) {
const { value, form, immediate, bails, disabled } = normalizeOptions(opts);
const { meta, errors, failedRules, onBlur, handleChange, reset, patch } = useValidationState(value);
// eslint-disable-next-line prefer-const
let schemaValidation: GenericValidateFunction | string | Record<string, any> | undefined;
const { meta, errors, onBlur, handleChange, reset, patch } = useValidationState(value);
const nonYupSchemaRules = extractRuleFromSchema(opts?.form?.schema, unwrap(fieldName));
const normalizedRules = computed(() => {
return normalizeRules(schemaValidation || unwrap(rules));
return normalizeRules(nonYupSchemaRules || unwrap(rules));
});

const runValidation = async (): Promise<ValidationResult> => {
meta.pending.value = true;
const result = await validate(value.value, normalizedRules.value, {
name: unwrap(fieldName),
values: form?.values.value ?? {},
bails,
});
if (!form || !form.validateSchema) {
const result = await validate(value.value, normalizedRules.value, {
name: unwrap(fieldName),
values: form?.values.value ?? {},
bails,
});

// Must be updated regardless if a mutation is needed or not
// FIXME: is this needed?
meta.valid.value = result.valid;
meta.invalid.value = !result.valid;
meta.pending.value = false;

return result;
}

// Must be updated regardless if a mutation is needed or not
// FIXME: is this needed?
meta.valid.value = result.valid;
meta.invalid.value = !result.valid;
const results = await form.validateSchema();
meta.pending.value = false;

return result;
return results[unwrap(fieldName)];
};

const runValidationWithMutation = () => runValidation().then(patch);
Expand All @@ -72,13 +78,13 @@ export function useField(fieldName: MaybeReactive<string>, rules: RuleExpression
meta,
errors,
errorMessage,
failedRules,
aria,
reset,
validate: runValidationWithMutation,
handleChange,
onBlur,
disabled,
setValidationState: patch,
};

watch(value, runValidationWithMutation, {
Expand All @@ -99,9 +105,6 @@ export function useField(fieldName: MaybeReactive<string>, rules: RuleExpression
// associate the field with the given form
form.register(field);

// set the rules if present in schema
schemaValidation = extractRuleFromSchema(form.schema, unwrap(fieldName));

// extract cross-field dependencies in a computed prop
const dependencies = computed(() => {
const rulesVal = normalizedRules.value;
Expand Down Expand Up @@ -282,15 +285,6 @@ function extractRuleFromSchema(schema: Record<string, any> | undefined, fieldNam
return undefined;
}

// a yup schema
if (schema.fields?.[fieldName]) {
return schema.fields?.[fieldName];
}

// there is a key on the schema object for this field
if (schema[fieldName]) {
return schema[fieldName];
}

return undefined;
return schema[fieldName];
}
66 changes: 65 additions & 1 deletion packages/core/src/useForm.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
import { computed, ref, Ref } from 'vue';
import type { useField } from './useField';
import { Flag, FormController, SubmissionHandler, GenericValidateFunction, SubmitEvent } from './types';
import {
Flag,
FormController,
SubmissionHandler,
GenericValidateFunction,
SubmitEvent,
ValidationResult,
} from './types';
import { unwrap } from './utils/refs';
import { isYupValidator } from './utils';

interface FormOptions {
validationSchema?: Record<string, GenericValidateFunction | string | Record<string, any>>;
Expand Down Expand Up @@ -46,9 +54,20 @@ export function useForm(opts?: FormOptions) {
fields: fieldsById,
values,
schema: opts?.validationSchema,
validateSchema: isYupValidator(opts?.validationSchema)
? (shouldMutate = false) => {
return validateYupSchema(controller, shouldMutate);
}
: undefined,
};

const validate = async () => {
if (controller.validateSchema) {
return controller.validateSchema(true).then(results => {
return Object.keys(results).every(r => results[r].valid);
});
}

const results = await Promise.all(
activeFields.value.map((f: any) => {
return f.validate();
Expand Down Expand Up @@ -138,3 +157,48 @@ function useFormMeta(fields: Ref<any[]>) {
return acc;
}, {} as Record<Flag, Ref<boolean>>);
}

async function validateYupSchema(
form: FormController,
shouldMutate = false
): Promise<Record<string, ValidationResult>> {
const errors: any[] = await (form.schema as any)
.validate(form.values.value, { abortEarly: false })
.then(() => [])
.catch((err: any) => {
// Yup errors have a name prop one them.
// https://github.com/jquense/yup#validationerrorerrors-string--arraystring-value-any-path-string
if (err.name !== 'ValidationError') {
throw err;
}

// list of aggregated errors
return err.inner || [];
});

const fields = form.fields.value;
const errorsByPath = errors.reduce((acc, err) => {
acc[err.path] = err;

return acc;
}, {});

// Aggregates the validation result
const aggregatedResult = Object.keys(fields).reduce((result: Record<string, ValidationResult>, fieldId) => {
const field = fields[fieldId];
const messages = (errorsByPath[fieldId] || { errors: [] }).errors;
const fieldResult = {
errors: messages,
valid: !messages.length,
};

result[fieldId] = fieldResult;
if (shouldMutate || field.meta.validated) {
field.setValidationState(fieldResult);
}

return result;
}, {});

return aggregatedResult;
}
51 changes: 47 additions & 4 deletions packages/core/tests/Form.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,15 +279,15 @@ describe('<Form />', () => {
wrapper.$el.querySelector('button').click();
await flushPromises();

expect(emailError.textContent).toBe('this is a required field');
expect(passwordError.textContent).toBe('this is a required field');
expect(emailError.textContent).toBe('email is a required field');
expect(passwordError.textContent).toBe('password is a required field');

setValue(email, 'hello@');
setValue(password, '1234');
await flushPromises();

expect(emailError.textContent).toBe('this must be a valid email');
expect(passwordError.textContent).toBe('this must be at least 8 characters');
expect(emailError.textContent).toBe('email must be a valid email');
expect(passwordError.textContent).toBe('password must be at least 8 characters');

setValue(email, 'hello@email.com');
setValue(password, '12346789');
Expand Down Expand Up @@ -331,4 +331,47 @@ describe('<Form />', () => {
expect(first.textContent).toBe(REQUIRED_MESSAGE);
expect(second.textContent).toBe(REQUIRED_MESSAGE);
});

test('cross field validation with yup schema', async () => {
const wrapper = mountWithHoc({
setup() {
const schema = yup.object().shape({
password: yup.string().required(),
confirmation: yup.string().oneOf([yup.ref('password')], 'passwords must match'),
});

return {
schema,
};
},
template: `
<VForm @submit="submit" as="form" :validationSchema="schema" v-slot="{ errors }">
<Field id="password" name="password" as="input" />
<span id="field">{{ errors.password }}</span>
<Field id="confirmation" name="confirmation" as="input" />
<span id="confirmationError">{{ errors.confirmation }}</span>
<button>Validate</button>
</VForm>
`,
});

const password = wrapper.$el.querySelector('#password');
const confirmation = wrapper.$el.querySelector('#confirmation');
const confirmationError = wrapper.$el.querySelector('#confirmationError');

wrapper.$el.querySelector('button').click();
await flushPromises();

setValue(password, 'hello@');
setValue(confirmation, '1234');
await flushPromises();
expect(confirmationError.textContent).toBe('passwords must match');

setValue(password, '1234');
setValue(confirmation, '1234');
await flushPromises();
expect(confirmationError.textContent).toBe('');
});
});

0 comments on commit bf216dd

Please sign in to comment.