From 03fe94e600058cfe669bf57b259316faab677fce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Maro=C5=A1i?= Date: Mon, 17 Aug 2020 14:16:18 +0200 Subject: [PATCH] feat(manager): synchronize field and form level validation --- .../src/tests/utils/manager-api.test.js | 145 ++++++++++++++++++ .../src/types/manager-api.d.ts | 8 +- .../src/utils/manager-api.ts | 33 +++- .../src/utils/use-subscription.ts | 2 +- 4 files changed, 183 insertions(+), 5 deletions(-) diff --git a/packages/form-state-manager/src/tests/utils/manager-api.test.js b/packages/form-state-manager/src/tests/utils/manager-api.test.js index c66432e90..4ec18780c 100644 --- a/packages/form-state-manager/src/tests/utils/manager-api.test.js +++ b/packages/form-state-manager/src/tests/utils/manager-api.test.js @@ -824,4 +824,149 @@ describe('managerApi', () => { }); }); }); + + describe('Combine form and field level validation', () => { + const formLevelValidate = (values) => (values.foo === 'foo' ? { foo: 'form-error' } : undefined); + const fieldLevelValidate = (value) => (value === 'bar' ? 'field-error' : undefined); + + it('should fail sync form level, but pass sync field level validation', () => { + const managerApi = createManagerApi({ validate: formLevelValidate }); + const { change, registerField, getFieldState } = managerApi(); + const getErrorState = () => { + let { valid, invalid, validating, errors } = managerApi(); + let { + meta: { error: fieldError, valid: fieldValid, validating: fieldValidating, invalid: fieldInvalid } + } = getFieldState('foo'); + + return { valid, invalid, validating, errors, fieldError, fieldValid, fieldValidating, fieldInvalid }; + }; + + registerField({ name: 'foo', validate: fieldLevelValidate, render: jest.fn() }); + + change('foo', 'foo'); + let expectedResult = getErrorState(); + expect(expectedResult).toEqual({ + valid: false, + invalid: true, + validating: false, + errors: { foo: 'form-error' }, + fieldError: 'form-error', + fieldValid: false, + fieldInvalid: true, + fieldValidating: false + }); + }); + + it('should initialy fail sync form level, but pass on second run validation', () => { + const managerApi = createManagerApi({ validate: formLevelValidate }); + const { change, registerField, getFieldState } = managerApi(); + const getErrorState = () => { + let { valid, invalid, validating, errors } = managerApi(); + let { + meta: { error: fieldError, valid: fieldValid, validating: fieldValidating, invalid: fieldInvalid } + } = getFieldState('foo'); + + return { valid, invalid, validating, errors, fieldError, fieldValid, fieldValidating, fieldInvalid }; + }; + + registerField({ name: 'foo', validate: fieldLevelValidate, render: jest.fn() }); + + change('foo', 'foo'); + + let expectedResult = getErrorState(); + expect(expectedResult).toEqual({ + valid: false, + invalid: true, + validating: false, + errors: { foo: 'form-error' }, + fieldError: 'form-error', + fieldValid: false, + fieldInvalid: true, + fieldValidating: false + }); + + change('foo', 'ok'); + + expectedResult = getErrorState(); + expect(expectedResult).toEqual({ + valid: true, + invalid: false, + validating: false, + errors: {}, + fieldError: undefined, + fieldValid: true, + fieldInvalid: false, + fieldValidating: false + }); + }); + + it('should pass sync form level, but fail sync field level validation', () => { + const managerApi = createManagerApi({ validate: formLevelValidate }); + const { change, registerField, getFieldState } = managerApi(); + const getErrorState = () => { + let { valid, invalid, validating, errors } = managerApi(); + let { + meta: { error: fieldError, valid: fieldValid, validating: fieldValidating, invalid: fieldInvalid } + } = getFieldState('foo'); + + return { valid, invalid, validating, errors, fieldError, fieldValid, fieldValidating, fieldInvalid }; + }; + + registerField({ name: 'foo', validate: fieldLevelValidate, render: jest.fn() }); + + change('foo', 'bar'); + let expectedResult = getErrorState(); + expect(expectedResult).toEqual({ + valid: false, + invalid: true, + validating: false, + errors: { foo: 'field-error' }, + fieldError: 'field-error', + fieldValid: false, + fieldInvalid: true, + fieldValidating: false + }); + }); + + it('should fail first sync field level validation, but pass on second round', () => { + const managerApi = createManagerApi({ validate: formLevelValidate }); + const { change, registerField, getFieldState } = managerApi(); + const getErrorState = () => { + let { valid, invalid, validating, errors } = managerApi(); + let { + meta: { error: fieldError, valid: fieldValid, validating: fieldValidating, invalid: fieldInvalid } + } = getFieldState('foo'); + + return { valid, invalid, validating, errors, fieldError, fieldValid, fieldValidating, fieldInvalid }; + }; + + registerField({ name: 'foo', validate: fieldLevelValidate, render: jest.fn() }); + + change('foo', 'bar'); + let expectedResult = getErrorState(); + expect(expectedResult).toEqual({ + valid: false, + invalid: true, + validating: false, + errors: { foo: 'field-error' }, + fieldError: 'field-error', + fieldValid: false, + fieldInvalid: true, + fieldValidating: false + }); + + change('foo', 'ok'); + expectedResult = getErrorState(); + expect(expectedResult).toEqual({ + valid: true, + invalid: false, + validating: false, + errors: {}, + fieldError: undefined, + fieldValid: true, + fieldInvalid: false, + fieldValidating: false + }); + }); + }); }); diff --git a/packages/form-state-manager/src/types/manager-api.d.ts b/packages/form-state-manager/src/types/manager-api.d.ts index 6c52fb99d..b8aeb6f6e 100644 --- a/packages/form-state-manager/src/types/manager-api.d.ts +++ b/packages/form-state-manager/src/types/manager-api.d.ts @@ -10,6 +10,12 @@ export interface FieldState { name: string; } +export interface ExtendedFieldState extends FieldState { + change: (value: any) => any; + blur: () => void; + focus: () => void; +} + export type UpdateFieldState = (name: string, mutateState: (prevState: FieldState) => FieldState) => void; export type Callback = () => void; @@ -20,7 +26,7 @@ export type UnregisterField = (field: Omit) => void; export type GetState = () => ManagerState; export type OnSubmit = (values: AnyObject) => void; export type GetFieldValue = (name: string) => any; -export type GetFieldState = (name: string) => AnyObject | undefined; +export type GetFieldState = (name: string) => ExtendedFieldState | undefined; export type Focus = (name: string) => void; export type Blur = (name: string) => void; export type UpdateValid = (valid: boolean) => void; diff --git a/packages/form-state-manager/src/utils/manager-api.ts b/packages/form-state-manager/src/utils/manager-api.ts index b1a9f2f74..d2a238dc2 100644 --- a/packages/form-state-manager/src/utils/manager-api.ts +++ b/packages/form-state-manager/src/utils/manager-api.ts @@ -8,7 +8,8 @@ import CreateManagerApi, { AsyncWatcherRecord, FieldState, Callback, - SubscriberConfig + SubscriberConfig, + ExtendedFieldState } from '../types/manager-api'; import AnyObject from '../types/any-object'; import FieldConfig from '../types/field-config'; @@ -215,6 +216,7 @@ const createManagerApi: CreateManagerApi = ({ onSubmit, clearOnUnmount, initiali function validateForm(validate: FormValidator) { const result = formLevelValidator(validate, state.values, managerApi); + const currentInvalidFields = Object.keys(state.errors); if (isPromise(result)) { const asyncResult = result as Promise; return asyncResult @@ -224,6 +226,7 @@ const createManagerApi: CreateManagerApi = ({ onSubmit, clearOnUnmount, initiali state.valid = true; state.invalid = false; state.error = undefined; + revalidateFields(currentInvalidFields); } }) .catch((errors) => { @@ -235,6 +238,9 @@ const createManagerApi: CreateManagerApi = ({ onSubmit, clearOnUnmount, initiali const syncError = result as FormLevelError | undefined; if (syncError) { + Object.keys(syncError).forEach((name) => { + handleFieldError(name, false, syncError[name]); + }); state.errors = syncError; state.valid = false; state.invalid = true; @@ -243,9 +249,19 @@ const createManagerApi: CreateManagerApi = ({ onSubmit, clearOnUnmount, initiali state.valid = true; state.invalid = false; state.error = undefined; + /** + * Fields have to be revalidated on field level to synchronize the form and field errors + */ + revalidateFields(currentInvalidFields); } } + function revalidateFields(fields: string[]) { + fields.forEach((name) => { + validateField(name, state.values[name]); + }); + } + function change(name: string, value?: any): void { state.values[name] = value; state.visited[name] = true; @@ -330,7 +346,7 @@ const createManagerApi: CreateManagerApi = ({ onSubmit, clearOnUnmount, initiali return state.values[name]; } - function getFieldState(name: string): AnyObject | undefined { + function getFieldState(name: string): ExtendedFieldState | undefined { if (state.fieldListeners[name]) { return { ...state.fieldListeners[name].state, @@ -358,7 +374,18 @@ const createManagerApi: CreateManagerApi = ({ onSubmit, clearOnUnmount, initiali } function updateError(name: string, error: string | undefined = undefined): void { - state.errors[name] = error; + if (error) { + state.errors[name] = error; + state.valid = false; + state.invalid = true; + } else { + delete state.errors[name]; + } + + if (Object.keys(state.errors).length === 0) { + state.valid = true; + state.invalid = false; + } } function registerAsyncValidator(validator: Promise) { diff --git a/packages/form-state-manager/src/utils/use-subscription.ts b/packages/form-state-manager/src/utils/use-subscription.ts index d0a7ae73e..392443687 100644 --- a/packages/form-state-manager/src/utils/use-subscription.ts +++ b/packages/form-state-manager/src/utils/use-subscription.ts @@ -110,7 +110,7 @@ const useSubscription = ({ } }; - return [formOptions().getFieldValue(name), onChange, () => focus(name), () => blur(name), state?.meta]; + return [formOptions().getFieldValue(name), onChange, () => focus(name), () => blur(name), state!.meta]; }; export default useSubscription;