diff --git a/package.json b/package.json index 0899ed1..fe8f4a1 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,8 @@ "testRegex": "(test/.*\\.test.js)$" }, "dependencies": { - "ajv": "^6.10.2" + "ajv": "^6.10.2", + "lodash": "^4.17.15" }, "devDependencies": { "@babel/cli": "^7.7.0", diff --git a/src/hooks/use-field-state.js b/src/hooks/use-field-state.js index 8cb722c..76a1c37 100644 --- a/src/hooks/use-field-state.js +++ b/src/hooks/use-field-state.js @@ -4,6 +4,7 @@ * Module dependencies. */ +import { get } from 'lodash'; import { useDebugValue } from 'react'; import { useFormState } from 'context/form-state-context'; @@ -15,9 +16,9 @@ export default function useFieldState(field: string) { const { fields } = useFormState(); const { errors, meta, values } = fields ?? {}; const fieldState = { - error: errors?.[field] ?? null, - meta: meta?.[field] ?? {}, - value: values?.[field] + error: get(errors, field, null), + meta: get(meta, field, {}), + value: get(values, field) }; useDebugValue(fieldState); diff --git a/src/hooks/use-form.js b/src/hooks/use-form.js index bdfef76..fc4494a 100644 --- a/src/hooks/use-form.js +++ b/src/hooks/use-form.js @@ -4,7 +4,7 @@ * Module dependencies. */ -import { identity } from 'lodash'; +import { get, identity, isEmpty, set } from 'lodash'; import { useCallback, useEffect, useMemo, useReducer } from 'react'; import baseValidate, { type FieldError, @@ -99,13 +99,13 @@ const valuesReducer = (state, action) => { case actionTypes.SET_FIELD_VALUE: return { ...state, - [payload.field]: payload.value + ...set({}, payload.field, payload.value) }; case actionTypes.REGISTER_FIELD: return { ...state, - [payload.field]: state[payload.field] + ...set({}, payload.field, get(state, payload.field)) }; case actionTypes.RESET: @@ -116,6 +116,28 @@ const valuesReducer = (state, action) => { } }; +/** + * Field meta keys. + */ + +const fieldMetaKeys = ['active', 'dirty', 'touched']; + +/** + * Get nested keys. + */ + +function getNestedKeys(state: Object, allKeys: Array): Array { + return Object.keys(state).reduce((acc, key) => { + if (fieldMetaKeys.includes(key)) { + return acc; + } + + acc.push(key); + + return getNestedKeys(state[key], acc); + }, allKeys); +} + /** * Meta reducer. */ @@ -127,61 +149,67 @@ const metaReducer = (state, action) => { case actionTypes.BLUR: return { ...state, - [payload.field]: { - ...state[payload.field], + ...set({}, payload.field, { + ...get(state, payload.field), active: false, touched: true - } + }) }; case actionTypes.SET_FIELD_VALUE: return { ...state, - [payload.field]: { - ...state[payload.field], + ...set({}, payload.field, { + ...get(state, payload.field), dirty: true - } + }) }; case actionTypes.FOCUS: return { ...state, - [payload.field]: { - ...state[payload.field], + ...set({}, payload.field, { + ...get(state, payload.field), active: true - } + }) }; case actionTypes.REGISTER_FIELD: return { ...state, - [payload.field]: { + ...set({}, payload.field, { + ...get(state, payload.field), active: false, dirty: false, - touched: false, - ...state[payload.field] - } + touched: false + }) }; - case actionTypes.SUBMIT_START: - return Object.keys(state).reduce((result, key) => ({ - ...result, - [key]: { - ...state?.[key], + case actionTypes.SUBMIT_START: { + const path = getNestedKeys(state, []).join('.'); + + return { + ...set({}, path, { + ...get(state, path), + active: false, + dirty: false, touched: true - } - }), {}); + }) + }; + } - case actionTypes.RESET: - return Object.keys(state).reduce((result, key) => ({ - ...result, - [key]: { - ...state?.[key], + case actionTypes.RESET: { + const path = getNestedKeys(state, []).join('.'); + + return { + ...set({}, path, { + ...get(state, path), active: false, dirty: false, touched: false - } - }), {}); + }) + }; + } default: return state; @@ -290,10 +318,40 @@ const formReducer = (validate: Object => FieldErrors, stateReducer: (state: Form }, isSubmitting, meta: { - active: fieldsMetaValues.some(({ active }) => active), - dirty: fieldsMetaValues.some(({ dirty }) => dirty), + active: fieldsMetaValues.some(element => { + const nestedKeys = getNestedKeys(element, []); + + if (!isEmpty(nestedKeys)) { + const path = nestedKeys.join('.'); + + return get(element, path).active; + } + + return element.active; + }), + dirty: fieldsMetaValues.some(element => { + const nestedKeys = getNestedKeys(element, []); + + if (!isEmpty(nestedKeys)) { + const path = nestedKeys.join('.'); + + return get(element, path).dirty; + } + + return element.dirty; + }), hasErrors: Object.entries(fieldsErrors).length > 0, - touched: fieldsMetaValues.some(({ touched }) => touched) + touched: fieldsMetaValues.some(element => { + const nestedKeys = getNestedKeys(element, []); + + if (!isEmpty(nestedKeys)) { + const path = nestedKeys.join('.'); + + return get(element, path).touched; + } + + return element.touched; + }) }, submitStatus }, action); diff --git a/src/utils/validate.js b/src/utils/validate.js index a6d8a14..bf3aaaa 100644 --- a/src/utils/validate.js +++ b/src/utils/validate.js @@ -4,7 +4,7 @@ * Module dependencies. */ -import { merge } from 'lodash'; +import { merge, set } from 'lodash'; import Ajv from 'ajv'; /** @@ -115,10 +115,12 @@ const getError = (error: ValidationError): FieldError => ({ */ export function parseValidationErrors(validationErrors: Array): FieldErrors { - return validationErrors.reduce((errors, error) => ({ - ...errors, - [getErrorPath(error)]: getError(error) - }), {}); + return validationErrors.reduce((errors, error) => { + return { + ...errors, + ...set({}, getErrorPath(error), getError(error)) + }; + }, {}); } /** diff --git a/test/src/hooks/use-field-state.test.js b/test/src/hooks/use-field-state.test.js index 181c88a..c5bcbb4 100644 --- a/test/src/hooks/use-field-state.test.js +++ b/test/src/hooks/use-field-state.test.js @@ -32,6 +32,30 @@ describe('useFieldState', () => { expect(result.current.value).toBe('bar'); }); + it('should return the nested field value', () => { + const state = { + fields: { + values: { + foo: { + bar: 'baz' + } + } + } + }; + + const Wrapper = ({ children }) => ( + + {children} + + ); + + const { result } = renderHook(() => useFieldState('foo.bar'), { + wrapper: Wrapper + }); + + expect(result.current.value).toBe('baz'); + }); + it('should return the field error', () => { const state = { fields: { @@ -52,6 +76,30 @@ describe('useFieldState', () => { expect(result.current.error).toBe('bar'); }); + it('should return the nested field error', () => { + const state = { + fields: { + errors: { + foo: { + bar: 'baz' + } + } + } + }; + + const Wrapper = ({ children }) => ( + + {children} + + ); + + const { result } = renderHook(() => useFieldState('foo.bar'), { + wrapper: Wrapper + }); + + expect(result.current.error).toBe('baz'); + }); + it('should return the field meta state', () => { const state = { fields: { @@ -71,4 +119,28 @@ describe('useFieldState', () => { expect(result.current.meta).toBe('bar'); }); + + it('should return the nested field meta state', () => { + const state = { + fields: { + meta: { + foo: { + bar: 'baz' + } + } + } + }; + + const Wrapper = ({ children }) => ( + + {children} + + ); + + const { result } = renderHook(() => useFieldState('foo.bar'), { + wrapper: Wrapper + }); + + expect(result.current.meta).toBe('baz'); + }); }); diff --git a/test/src/hooks/use-field.test.js b/test/src/hooks/use-field.test.js index 1d9b30c..ec7401d 100644 --- a/test/src/hooks/use-field.test.js +++ b/test/src/hooks/use-field.test.js @@ -45,6 +45,30 @@ describe('useField', () => { expect(result.current.value).toBe('bar'); }); + it('should return the nested field value', () => { + const state = { + fields: { + values: { + foo: { + bar: 'baz' + } + } + } + }; + + const Wrapper = ({ children }) => ( + + + {children} + + + ); + + const { result } = renderHook(() => useField('foo.bar'), { wrapper: Wrapper }); + + expect(result.current.value).toBe('baz'); + }); + it('should return the field error', () => { const state = { fields: { @@ -65,6 +89,32 @@ describe('useField', () => { expect(result.current.error).toBe('bar'); }); + it('should return the nested field error', () => { + const state = { + fields: { + errors: { + foo: { + bar: 'baz' + } + } + } + }; + + const Wrapper = ({ children }) => ( + + + {children} + + + ); + + const { result } = renderHook(() => useField('foo.bar'), { + wrapper: Wrapper + }); + + expect(result.current.error).toBe('baz'); + }); + it('should return the field meta state', () => { const state = { fields: { @@ -85,6 +135,30 @@ describe('useField', () => { expect(result.current.meta).toBe('bar'); }); + it('should return the nested field meta state', () => { + const state = { + fields: { + meta: { + foo: { + bar: 'baz' + } + } + } + }; + + const Wrapper = ({ children }) => ( + + + {children} + + + ); + + const { result } = renderHook(() => useField('foo.bar'), { wrapper: Wrapper }); + + expect(result.current.meta).toBe('baz'); + }); + it('should return an `onBlur` action that calls the `blurField` form action with the field name', () => { const Wrapper = ({ children }) => ( @@ -102,6 +176,25 @@ describe('useField', () => { expect(actions.blurField).toHaveBeenCalledWith('foo'); }); + it('should return an `onBlur` action that calls the `blurField` form action with the nested field name', () => { + const Wrapper = ({ children }) => ( + + + {children} + + + ); + + const { result } = renderHook(() => useField('foo.bar'), { + wrapper: Wrapper + }); + + result.current.onBlur(); + + expect(actions.blurField).toHaveBeenCalledTimes(1); + expect(actions.blurField).toHaveBeenCalledWith('foo.bar'); + }); + it('should return an `onFocus` action that calls the `focusField` form action with the field name', () => { const Wrapper = ({ children }) => ( @@ -119,6 +212,25 @@ describe('useField', () => { expect(actions.focusField).toHaveBeenCalledWith('foo'); }); + it('should return an `onFocus` action that calls the `focusField` form action with the nested field name', () => { + const Wrapper = ({ children }) => ( + + + {children} + + + ); + + const { result } = renderHook(() => useField('foo.bar'), { + wrapper: Wrapper + }); + + result.current.onFocus(); + + expect(actions.focusField).toHaveBeenCalledTimes(1); + expect(actions.focusField).toHaveBeenCalledWith('foo.bar'); + }); + it('should return an `onChange` action that calls the `setFieldValue` form action with the field name and the provided value', () => { const Wrapper = ({ children }) => ( @@ -135,4 +247,23 @@ describe('useField', () => { expect(actions.setFieldValue).toHaveBeenCalledTimes(1); expect(actions.setFieldValue).toHaveBeenCalledWith('foo', 'bar'); }); + + it('should return an `onChange` action that calls the `setFieldValue` form action with the nested field name and the provided value', () => { + const Wrapper = ({ children }) => ( + + + {children} + + + ); + + const { result } = renderHook(() => useField('foo.bar'), { + wrapper: Wrapper + }); + + result.current.onChange('baz'); + + expect(actions.setFieldValue).toHaveBeenCalledTimes(1); + expect(actions.setFieldValue).toHaveBeenCalledWith('foo.bar', 'baz'); + }); }); diff --git a/test/src/hooks/use-form.test.js b/test/src/hooks/use-form.test.js index 426ec89..8b58b57 100644 --- a/test/src/hooks/use-form.test.js +++ b/test/src/hooks/use-form.test.js @@ -24,6 +24,24 @@ describe('useForm hook', () => { }); }); + it('should set the initial values of nested objects', () => { + const { result } = renderHook(() => useForm({ + initialValues: { + foo: { + bar: 'baz' + } + }, + jsonSchema: { type: 'object' }, + onSubmit: () => {} + })); + + expect(result.current.state.fields.values).toEqual({ + foo: { + bar: 'baz' + } + }); + }); + it('should start as not active, not dirty, not touched and without errors', () => { const { result } = renderHook(() => useForm({ jsonSchema: { type: 'object' }, @@ -53,6 +71,25 @@ describe('useForm hook', () => { expect(onValuesChanged).toHaveBeenCalledWith({ foo: 'bar' }); }); + it('should call `onValuesChanged` when the form values change and we have nested objects', () => { + const onValuesChanged = jest.fn(); + const { result } = renderHook(() => useForm({ + jsonSchema: { type: 'object' }, + onSubmit: () => {}, + onValuesChanged + })); + + act(() => { + result.current.fieldActions.setFieldValue('foo.bar', 'baz'); + }); + + expect(onValuesChanged).toHaveBeenCalledWith({ + foo: { + bar: 'baz' + } + }); + }); + describe('blurField', () => { it('should set the field to inactive and touched', () => { const { result } = renderHook(() => useForm({ @@ -70,6 +107,22 @@ describe('useForm hook', () => { }); }); + it('should set the nested field to inactive and touched', () => { + const { result } = renderHook(() => useForm({ + jsonSchema: { type: 'object' }, + onSubmit: () => {} + })); + + act(() => { + result.current.fieldActions.blurField('foo.bar'); + }); + + expect(result.current.state.fields.meta.foo.bar).toEqual({ + active: false, + touched: true + }); + }); + it('should validate the field value', () => { const { result } = renderHook(() => useForm({ initialValues: { foo: 1 }, @@ -88,6 +141,36 @@ describe('useForm hook', () => { expect(result.current.state.fields.errors).toHaveProperty('foo'); }); + + it('should validate the nested field value', () => { + const { result } = renderHook(() => useForm({ + initialValues: { + foo: { + bar: 1 + } + }, + jsonSchema: { + properties: { + foo: { + properties: { + bar: { + type: 'string' + } + }, + type: 'object' + } + }, + type: 'object' + }, + onSubmit: () => {} + })); + + act(() => { + result.current.fieldActions.blurField('foo.bar'); + }); + + expect(result.current.state.fields.errors).toHaveProperty('foo.bar'); + }); }); describe('focusField', () => { @@ -106,6 +189,21 @@ describe('useForm hook', () => { }); }); + it('should set the nested field to active', () => { + const { result } = renderHook(() => useForm({ + jsonSchema: { type: 'object' }, + onSubmit: () => {} + })); + + act(() => { + result.current.fieldActions.focusField('foo.bar'); + }); + + expect(result.current.state.fields.meta.foo.bar).toEqual({ + active: true + }); + }); + it('should not validate the form', () => { const { result } = renderHook(() => useForm({ initialValues: { foo: 1 }, @@ -124,6 +222,36 @@ describe('useForm hook', () => { expect(result.current.state.fields.errors).toEqual({}); }); + + it('should not validate the form if we have nested objects', () => { + const { result } = renderHook(() => useForm({ + initialValues: { + foo: { + bar: 1 + } + }, + jsonSchema: { + properties: { + foo: { + properties: { + bar: { + type: 'string' + } + }, + type: 'object' + } + }, + type: 'object' + }, + onSubmit: () => {} + })); + + act(() => { + result.current.fieldActions.focusField('foo.bar'); + }); + + expect(result.current.state.fields.errors).toEqual({}); + }); }); describe('registerField', () => { @@ -144,6 +272,23 @@ describe('useForm hook', () => { expect(result.current.state.fields.meta).toHaveProperty('foo'); }); + it('should register the nested field', () => { + const { result } = renderHook(() => useForm({ + jsonSchema: { type: 'object' }, + onSubmit: () => {} + })); + + expect(result.current.state.fields.values).not.toHaveProperty('foo.bar'); + expect(result.current.state.fields.meta).not.toHaveProperty('foo.bar'); + + act(() => { + result.current.fieldActions.registerField('foo.bar'); + }); + + expect(result.current.state.fields.values).toHaveProperty('foo.bar'); + expect(result.current.state.fields.meta).toHaveProperty('foo.bar'); + }); + it('should keep initial values', () => { const { result } = renderHook(() => useForm({ initialValues: { foo: 'bar' }, @@ -158,6 +303,28 @@ describe('useForm hook', () => { expect(result.current.state.fields.values).toEqual({ foo: 'bar' }); }); + it('should keep initial values if we have nested objects', () => { + const { result } = renderHook(() => useForm({ + initialValues: { + foo: { + bar: 'baz' + } + }, + jsonSchema: { type: 'object' }, + onSubmit: () => {} + })); + + act(() => { + result.current.fieldActions.registerField('foo.bar'); + }); + + expect(result.current.state.fields.values).toEqual({ + foo: { + bar: 'baz' + } + }); + }); + it('should validate the form', () => { const { result } = renderHook(() => useForm({ initialValues: { foo: 1 }, @@ -176,6 +343,35 @@ describe('useForm hook', () => { expect(result.current.state.fields.errors).toHaveProperty('foo'); }); + + it('should validate the form if we have nested objects', () => { + const { result } = renderHook(() => useForm({ + initialValues: { + foo: { + bar: 1 + } + }, + jsonSchema: { + properties: { + foo: { + properties: { + bar: { + type: 'string' + } + } + } + }, + type: 'object' + }, + onSubmit: () => {} + })); + + act(() => { + result.current.fieldActions.registerField('foo.bar'); + }); + + expect(result.current.state.fields.errors).toHaveProperty('foo.bar'); + }); }); describe('reset', () => { @@ -187,6 +383,26 @@ describe('useForm hook', () => { act(() => { result.current.fieldActions.setFieldValue('foo', 'bar'); + }); + + act(() => { + result.current.formActions.reset(); + }); + + expect(result.current.state.fields.values).toEqual({}); + }); + + it('should clear the form values if we have nested objects', () => { + const { result } = renderHook(() => useForm({ + jsonSchema: { type: 'object' }, + onSubmit: () => {} + })); + + act(() => { + result.current.fieldActions.setFieldValue('foo.bar', 'baz'); + }); + + act(() => { result.current.formActions.reset(); }); @@ -201,13 +417,36 @@ describe('useForm hook', () => { })); act(() => { - result.current.fieldActions.setFieldValue('foo', 'baz'); + result.current.fieldActions.setFieldValue('foo', 'bar'); result.current.formActions.reset(); }); expect(result.current.state.fields.values).toEqual({ foo: 'bar' }); }); + it('should set the initial values if we have nested objects', () => { + const { result } = renderHook(() => useForm({ + initialValues: { + foo: { + bar: 'baz' + } + }, + jsonSchema: { type: 'object' }, + onSubmit: () => {} + })); + + act(() => { + result.current.fieldActions.setFieldValue('foo.bar', 'baz'); + result.current.formActions.reset(); + }); + + expect(result.current.state.fields.values).toEqual({ + foo: { + bar: 'baz' + } + }); + }); + it('should clear the form errors', () => { const { result } = renderHook(() => useForm({ jsonSchema: { @@ -227,6 +466,32 @@ describe('useForm hook', () => { expect(result.current.state.fields.errors).toEqual({}); }); + it('should clear the form errors if we have nested objects', () => { + const { result } = renderHook(() => useForm({ + jsonSchema: { + properties: { + foo: { + properties: { + bar: { + type: 'string' + } + }, + type: 'object' + } + }, + type: 'object' + }, + onSubmit: () => {} + })); + + act(() => { + result.current.fieldActions.setFieldValue('foo.bar', 1); + result.current.formActions.reset(); + }); + + expect(result.current.state.fields.errors).toEqual({}); + }); + it('should set all fields to inactive, untouched and pristine', () => { const { result } = renderHook(() => useForm({ jsonSchema: { type: 'object' }, @@ -248,6 +513,30 @@ describe('useForm hook', () => { } }); }); + + it('should set all nested fields to inactive, untouched and pristine', () => { + const { result } = renderHook(() => useForm({ + jsonSchema: { type: 'object' }, + onSubmit: () => {} + })); + + act(() => { + result.current.fieldActions.blurField('foo.bar'); + result.current.fieldActions.setFieldValue('foo.bar', 'baz'); + result.current.fieldActions.focusField('foo.bar'); + result.current.formActions.reset(); + }); + + expect(result.current.state.fields.meta).toEqual({ + foo: { + bar: { + active: false, + dirty: false, + touched: false + } + } + }); + }); }); describe('setFieldValue', () => { @@ -264,6 +553,23 @@ describe('useForm hook', () => { expect(result.current.state.fields.values).toEqual({ foo: 'bar' }); }); + it('should set the nested field value', () => { + const { result } = renderHook(() => useForm({ + jsonSchema: { type: 'object' }, + onSubmit: () => {} + })); + + act(() => { + result.current.fieldActions.setFieldValue('foo.bar', 'baz'); + }); + + expect(result.current.state.fields.values).toEqual({ + foo: { + bar: 'baz' + } + }); + }); + it('should set the field to dirty', () => { const { result } = renderHook(() => useForm({ jsonSchema: { type: 'object' }, @@ -279,6 +585,21 @@ describe('useForm hook', () => { })); }); + it('should set the nested field to dirty', () => { + const { result } = renderHook(() => useForm({ + jsonSchema: { type: 'object' }, + onSubmit: () => {} + })); + + act(() => { + result.current.fieldActions.setFieldValue('foo.bar', 'baz'); + }); + + expect(result.current.state.fields.meta.foo.bar).toEqual(expect.objectContaining({ + dirty: true + })); + }); + it('should validate the form', () => { const { result } = renderHook(() => useForm({ jsonSchema: { @@ -296,6 +617,31 @@ describe('useForm hook', () => { expect(result.current.state.fields.errors).toHaveProperty('foo'); }); + + it('should validate the form if we have nested objects', () => { + const { result } = renderHook(() => useForm({ + jsonSchema: { + properties: { + foo: { + properties: { + bar: { + type: 'string' + } + }, + type: 'object' + } + }, + type: 'object' + }, + onSubmit: () => {} + })); + + act(() => { + result.current.fieldActions.setFieldValue('foo.bar', 1); + }); + + expect(result.current.state.fields.errors).toHaveProperty('foo.bar'); + }); }); describe('submit', () => { @@ -319,13 +665,76 @@ describe('useForm hook', () => { }); }); + it('should call the `onSubmit` option with the form values and actions if we have nested objects', async () => { + const onSubmit = jest.fn(); + const { result, waitForNextUpdate } = renderHook(() => useForm({ + initialValues: { + foo: { + bar: 'baz' + } + }, + jsonSchema: { type: 'object' }, + onSubmit + })); + + act(() => { + result.current.formActions.submit(); + }); + + await waitForNextUpdate(); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit).toHaveBeenCalledWith({ + foo: { + bar: 'baz' + } + }, { + reset: expect.any(Function) + }); + }); + it('should not call `onSubmit` when the form has errors', () => { const onSubmit = jest.fn(); const { rerender, result } = renderHook(() => useForm({ initialValues: { foo: 1 }, jsonSchema: { properties: { - foo: { type: 'string' } + foo: { type: 'string' } + }, + type: 'object' + }, + onSubmit + })); + + act(() => { + result.current.fieldActions.blurField('foo'); + result.current.formActions.submit(); + }); + + // Force rerender to ensure effect is called. + rerender(); + + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('should not call `onSubmit` when the form has errors and we have nested objects', () => { + const onSubmit = jest.fn(); + const { rerender, result } = renderHook(() => useForm({ + initialValues: { + foo: { + bar: 1 + } + }, + jsonSchema: { + properties: { + foo: { + properties: { + bar: { + type: 'string' + } + }, + type: 'object' + } }, type: 'object' }, @@ -333,7 +742,7 @@ describe('useForm hook', () => { })); act(() => { - result.current.fieldActions.blurField('foo'); + result.current.fieldActions.blurField('foo.bar'); result.current.formActions.submit(); }); @@ -380,6 +789,27 @@ describe('useForm hook', () => { expect(result.current.state.fields.values).toEqual({ foo: 'bar' }); }); + it('should not reset the form values if we have nested objects', async () => { + const onSubmit = jest.fn(); + const { result, waitForNextUpdate } = renderHook(() => useForm({ + jsonSchema: { type: 'object' }, + onSubmit + })); + + act(() => { + result.current.fieldActions.setFieldValue('foo.bar', 'baz'); + result.current.formActions.submit(); + }); + + await waitForNextUpdate(); + + expect(result.current.state.fields.values).toEqual({ + foo: { + bar: 'baz' + } + }); + }); + it('should set all fields to touched', async () => { const { result, waitForNextUpdate } = renderHook(() => useForm({ jsonSchema: { type: 'object' }, @@ -388,7 +818,7 @@ describe('useForm hook', () => { act(() => { result.current.fieldActions.registerField('foo'); - result.current.formActions.submit(); + result.current.formActions.submit('foo'); }); await waitForNextUpdate(); @@ -400,6 +830,28 @@ describe('useForm hook', () => { }); }); + it('should set all nested fields to touched', async () => { + const { result, waitForNextUpdate } = renderHook(() => useForm({ + jsonSchema: { type: 'object' }, + onSubmit: () => {} + })); + + act(() => { + result.current.fieldActions.registerField('foo.bar'); + result.current.formActions.submit(); + }); + + await waitForNextUpdate(); + + expect(result.current.state.fields.meta).toEqual({ + foo: { + bar: expect.objectContaining({ + touched: true + }) + } + }); + }); + it('should validate the form', () => { const { result } = renderHook(() => useForm({ initialValues: { foo: 1 }, @@ -419,6 +871,36 @@ describe('useForm hook', () => { expect(result.current.state.fields.errors).toHaveProperty('foo'); }); + it('should validate the form if we have nested objects', () => { + const { result } = renderHook(() => useForm({ + initialValues: { + foo: { + bar: 1 + } + }, + jsonSchema: { + properties: { + foo: { + properties: { + bar: { + type: 'string' + } + }, + type: 'object' + } + }, + type: 'object' + }, + onSubmit: () => {} + })); + + act(() => { + result.current.formActions.submit(); + }); + + expect(result.current.state.fields.errors).toHaveProperty('foo.bar'); + }); + it('should reset the form if the passed `reset` action is called', async () => { const onSubmit = jest.fn((values, { reset }) => { reset(); @@ -441,6 +923,28 @@ describe('useForm hook', () => { expect(result.current.state.fields.values).toEqual({}); }); + it('should reset the form if the passed `reset` action is called and if we have nested objects', async () => { + const onSubmit = jest.fn((values, { reset }) => { + reset(); + + return Promise.resolve(); + }); + + const { result, waitForNextUpdate } = renderHook(() => useForm({ + jsonSchema: { type: 'object' }, + onSubmit + })); + + act(() => { + result.current.fieldActions.setFieldValue('foo.bar', 'baz'); + result.current.formActions.submit(); + }); + + await waitForNextUpdate(); + + expect(result.current.state.fields.values).toEqual({}); + }); + it('should change the foo value when set field value with any value', () => { const stateReducer = (state, action) => { const { type } = action; @@ -482,6 +986,67 @@ describe('useForm hook', () => { expect(result.current.state.fields.values.bar).toEqual('bar'); }); + it('should change the foo.bar value when set field value with any value', () => { + const stateReducer = (state, action) => { + const { type } = action; + + switch (type) { + case actionTypes.SET_FIELD_VALUE: + return merge({}, state, { + fields: { + values: { + foo: { + bar: 'baz' + } + } + } + }); + + default: + return state; + } + }; + + const { result } = renderHook(() => useForm({ + initialValues: { + foo: { + bar: 1 + } + }, + jsonSchema: { + properties: { + foo: { + properties: { + bar: { + type: 'string' + } + }, + type: 'object' + }, + qux: { + properties: { + quux: { + type: 'string' + } + }, + type: 'object' + } + }, + type: 'object' + }, + onSubmit: () => {}, + stateReducer + })); + + act(() => { + result.current.fieldActions.setFieldValue('foo.bar', 'foo.bar'); + result.current.fieldActions.setFieldValue('qux.quux', 'qux.quux'); + }); + + expect(result.current.state.fields.values.foo.bar).toEqual('baz'); + expect(result.current.state.fields.values.qux.quux).toEqual('qux.quux'); + }); + it('should validate the field with custom format', () => { const { result } = renderHook(() => useForm({ initialValues: { @@ -521,6 +1086,61 @@ describe('useForm hook', () => { }); }); + it('should validate the nested field with custom format', () => { + const { result } = renderHook(() => useForm({ + initialValues: { + bar: { + baz: '123' + }, + foo: { + qux: '123' + } + }, + jsonSchema: { + properties: { + bar: { + properties: { + baz: { + format: 'baz', + type: 'string' + } + }, + type: 'object' + }, + foo: { + properties: { + qux: { + format: 'qux', + type: 'string' + } + }, + type: 'object' + } + }, + type: 'object' + }, + onSubmit: () => {}, + validationOptions: { + formats: { + baz: value => !isNaN(Number(value)), + qux: () => false + } + } + })); + + act(() => { + result.current.formActions.submit(); + }); + + expect(result.current.state.fields.errors).toEqual({ + foo: { + qux: { + rule: 'format' + } + } + }); + }); + it('should validate with custom keywords', () => { const { result } = renderHook(() => useForm({ initialValues: { @@ -565,6 +1185,67 @@ describe('useForm hook', () => { } }); }); + + it('should validate with custom keywords if we have nested objects', () => { + const { result } = renderHook(() => useForm({ + initialValues: { + bar: { + baz: '123' + }, + foo: { + qux: '123' + } + }, + jsonSchema: { + properties: { + bar: { + properties: { + baz: { + isBaz: true, + type: 'string' + } + }, + type: 'object' + }, + foo: { + properties: { + qux: { + isQux: true, + type: 'string' + } + }, + type: 'object' + } + }, + type: 'object' + }, + onSubmit: () => {}, + validationOptions: { + keywords: { + isBaz: { + type: 'string', + validate: () => true + }, + isQux: { + type: 'string', + validate: () => false + } + } + } + })); + + act(() => { + result.current.formActions.submit(); + }); + + expect(result.current.state.fields.errors).toEqual({ + foo: { + qux: { + rule: 'isQux' + } + } + }); + }); }); describe('form meta', () => { @@ -581,6 +1262,19 @@ describe('useForm hook', () => { expect(result.current.state.meta.active).toBe(true); }); + it('should set the form as active when a nested field is active', () => { + const { result } = renderHook(() => useForm({ + jsonSchema: { type: 'object' }, + onSubmit: () => {} + })); + + act(() => { + result.current.fieldActions.focusField('foo.bar'); + }); + + expect(result.current.state.meta.active).toBe(true); + }); + it('should set the form as dirty when a field is dirty', () => { const { result } = renderHook(() => useForm({ jsonSchema: { type: 'object' }, @@ -594,6 +1288,19 @@ describe('useForm hook', () => { expect(result.current.state.meta.dirty).toBe(true); }); + it('should set the form as dirty when a nested field is dirty', () => { + const { result } = renderHook(() => useForm({ + jsonSchema: { type: 'object' }, + onSubmit: () => {} + })); + + act(() => { + result.current.fieldActions.setFieldValue('foo.bar', 'baz'); + }); + + expect(result.current.state.meta.dirty).toBe(true); + }); + it('should set the form as touched when a field is touched', () => { const { result } = renderHook(() => useForm({ jsonSchema: { type: 'object' }, @@ -606,6 +1313,19 @@ describe('useForm hook', () => { expect(result.current.state.meta.touched).toBe(true); }); + + it('should set the form as touched when a nested field is touched', () => { + const { result } = renderHook(() => useForm({ + jsonSchema: { type: 'object' }, + onSubmit: () => {} + })); + + act(() => { + result.current.fieldActions.blurField('foo.bar'); + }); + + expect(result.current.state.meta.touched).toBe(true); + }); }); describe('custom validate', () => { @@ -626,6 +1346,27 @@ describe('useForm hook', () => { expect(validate).toHaveBeenCalledWith(jsonSchema, { foo: 'bar' }, 'qux'); }); + it('should be called when a nested field value changes', () => { + const validate = jest.fn(() => ({})); + const jsonSchema = { type: 'object' }; + const { result } = renderHook(() => useForm({ + jsonSchema, + onSubmit: () => {}, + validate, + validationOptions: 'qux' + })); + + act(() => { + result.current.fieldActions.setFieldValue('foo.bar', 'baz'); + }); + + expect(validate).toHaveBeenCalledWith(jsonSchema, { + foo: { + bar: 'baz' + } + }, 'qux'); + }); + it('should set errors to an empty object when validate returns undefined', () => { const { result } = renderHook(() => useForm({ jsonSchema: { type: 'object' }, @@ -639,5 +1380,19 @@ describe('useForm hook', () => { expect(result.current.state.fields.errors).toEqual({}); }); + + it('should set errors to an empty object when validate returns undefined and we have nested objects', () => { + const { result } = renderHook(() => useForm({ + jsonSchema: { type: 'object' }, + onSubmit: () => {}, + validate: () => {} + })); + + act(() => { + result.current.fieldActions.setFieldValue('foo.bar', 'baz'); + }); + + expect(result.current.state.fields.errors).toEqual({}); + }); }); }); diff --git a/test/src/utils/validate.test.js b/test/src/utils/validate.test.js index 1b045fd..2cd0c57 100644 --- a/test/src/utils/validate.test.js +++ b/test/src/utils/validate.test.js @@ -38,6 +38,39 @@ describe('validate', () => { }); }); + it('should return errors with the `type` rule when a value does not match the type and we have nested objects', () => { + const result = validate({ + properties: { + foo: { + properties: { + bar: { + type: 'string' + } + }, + type: 'object' + }, + 'foo-baz': { type: 'string' } + }, + type: 'object' + }, { + foo: { + bar: 1 + }, + 'foo-baz': 1 + }); + + expect(result).toEqual({ + foo: { + bar: { + rule: 'type' + } + }, + 'foo-baz': { + rule: 'type' + } + }); + }); + it('should return errors with the `required` rule when a property is required', () => { const result = validate({ properties: { @@ -54,6 +87,34 @@ describe('validate', () => { }); }); + it('should return errors with the `required` rule when a property is required and we have nested objects', () => { + const result = validate({ + properties: { + foo: { + properties: { + bar: { + type: 'string' + } + }, + required: ['bar'], + type: 'object' + } + }, + required: ['foo'], + type: 'object' + }, { + foo: {} + }); + + expect(result).toEqual({ + foo: { + bar: { + rule: 'required' + } + } + }); + }); + it('should return errors with the `additionalProperties` rule when a property should not exist', () => { const result = validate({ additionalProperties: false, @@ -70,6 +131,31 @@ describe('validate', () => { }); }); + it('should return errors with the `additionalProperties` rule when a property should not exist and we have nested objects', () => { + const result = validate({ + properties: { + foo: { + additionalProperties: false, + properties: {}, + type: 'object' + } + }, + type: 'object' + }, { + foo: { + bar: 'baz' + } + }); + + expect(result).toEqual({ + foo: { + bar: { + rule: 'additionalProperties' + } + } + }); + }); + it('should return errors with the `maxLength` rule and `max` argument when a value is too large', () => { const result = validate({ properties: { @@ -92,6 +178,38 @@ describe('validate', () => { }); }); + it('should return errors with the `maxLength` rule and `max` argument when a nested value is too large', () => { + const result = validate({ + properties: { + foo: { + properties: { + bar: { + maxLength: 2, + type: 'string' + } + }, + required: ['bar'], + type: 'object' + } + }, + required: ['foo'], + type: 'object' + }, { + foo: { + bar: 'baz' + } + }); + + expect(result).toEqual({ + foo: { + bar: { + args: { max: 2 }, + rule: 'maxLength' + } + } + }); + }); + it('should return errors with the `minLength` rule and `min` argument when a value is too small', () => { const result = validate({ properties: { @@ -114,6 +232,38 @@ describe('validate', () => { }); }); + it('should return errors with the `minLength` rule and `min` argument when a nested value is too small', () => { + const result = validate({ + properties: { + foo: { + properties: { + bar: { + minLength: 4, + type: 'string' + } + }, + required: ['bar'], + type: 'object' + } + }, + required: ['foo'], + type: 'object' + }, { + foo: { + bar: 'baz' + } + }); + + expect(result).toEqual({ + foo: { + bar: { + args: { min: 4 }, + rule: 'minLength' + } + } + }); + }); + it('should return errors with the `maxItems` rule and `max` argument when there are too many items', () => { const result = validate({ properties: { @@ -134,6 +284,35 @@ describe('validate', () => { }); }); + it('should return errors with the `maxItems` rule and `max` argument when there are too many items and we have nested objects', () => { + const result = validate({ + properties: { + foo: { + properties: { + bar: { + maxItems: 1, + type: 'array' + } + }, + type: 'object' + } + } + }, { + foo: { + bar: [1, 2] + } + }); + + expect(result).toEqual({ + foo: { + bar: { + args: { max: 1 }, + rule: 'maxItems' + } + } + }); + }); + it('should return errors with the `minItems` rule and `min` argument when there are not enough items', () => { const result = validate({ properties: { @@ -154,6 +333,35 @@ describe('validate', () => { }); }); + it('should return errors with the `minItems` rule and `min` argument when there are not enough items and we have nested objects', () => { + const result = validate({ + properties: { + foo: { + properties: { + bar: { + minItems: 2, + type: 'array' + } + }, + type: 'object' + } + } + }, { + foo: { + bar: [1] + } + }); + + expect(result).toEqual({ + foo: { + bar: { + args: { min: 2 }, + rule: 'minItems' + } + } + }); + }); + it('should return errors with the `maxProperties` rule and `max` argument when there are too many properties', () => { const result = validate({ properties: { @@ -177,6 +385,37 @@ describe('validate', () => { }); }); + it('should return errors with the `maxProperties` rule and `max` argument when there are too many properties and we have nested objects', () => { + const result = validate({ + properties: { + foo: { + properties: { + quux: { + maxProperties: 1, + type: 'object' + } + } + } + } + }, { + foo: { + quux: { + bar: 'qux', + baz: 'biz' + } + } + }); + + expect(result).toEqual({ + foo: { + quux: { + args: { max: 1 }, + rule: 'maxProperties' + } + } + }); + }); + it('should return errors with the `minProperties` rule and `min` argument when there are not enough properties', () => { const result = validate({ properties: { @@ -199,6 +438,36 @@ describe('validate', () => { }); }); + it('should return errors with the `minProperties` rule and `min` argument when there are not enough properties and we have nested objects', () => { + const result = validate({ + properties: { + foo: { + properties: { + baz: { + minProperties: 2, + type: 'string' + } + } + } + } + }, { + foo: { + baz: { + bar: 'qux' + } + } + }); + + expect(result).toEqual({ + foo: { + baz: { + args: { min: 2 }, + rule: 'minProperties' + } + } + }); + }); + it('should return errors with the `maximum` rule and `max` argument when the value is too big', () => { const result = validate({ properties: { @@ -219,6 +488,34 @@ describe('validate', () => { }); }); + it('should return errors with the `maximum` rule and `max` argument when the nested value is too big', () => { + const result = validate({ + properties: { + foo: { + properties: { + bar: { + maximum: 1, + type: 'number' + } + } + } + } + }, { + foo: { + bar: 2 + } + }); + + expect(result).toEqual({ + foo: { + bar: { + args: { max: 1 }, + rule: 'maximum' + } + } + }); + }); + it('should return errors with the `minimum` rule and `min` argument when the value is too small', () => { const result = validate({ properties: { @@ -239,6 +536,34 @@ describe('validate', () => { }); }); + it('should return errors with the `minimum` rule and `min` argument when the nested value is too small', () => { + const result = validate({ + properties: { + foo: { + properties: { + bar: { + minimum: 2, + type: 'number' + } + } + } + } + }, { + foo: { + bar: 1 + } + }); + + expect(result).toEqual({ + foo: { + bar: { + args: { min: 2 }, + rule: 'minimum' + } + } + }); + }); + it('should validate with custom format', () => { const result = validate({ properties: { @@ -269,6 +594,52 @@ describe('validate', () => { }); }); + it('should validate with custom format and nested objects', () => { + const result = validate({ + properties: { + bar: { + properties: { + baz: { + format: 'baz', + type: 'string' + } + }, + type: 'object' + }, + foo: { + properties: { + qux: { + format: 'qux', + type: 'string' + } + }, + type: 'object' + } + }, + type: 'object' + }, { + bar: { + baz: '123' + }, + foo: { + qux: '123' + } + }, { + formats: { + baz: () => true, + qux: () => false + } + }); + + expect(result).toEqual({ + foo: { + qux: { + rule: 'format' + } + } + }); + }); + it('should validate with custom keywords', () => { const result = validate({ properties: { @@ -304,4 +675,56 @@ describe('validate', () => { } }); }); + + it('should validate with custom keywords and nested objects', () => { + const result = validate({ + properties: { + bar: { + properties: { + baz: { + isBaz: true, + type: 'string' + } + }, + type: 'object' + }, + foo: { + properties: { + qux: { + isQux: true, + type: 'string' + } + }, + type: 'object' + } + }, + type: 'object' + }, { + bar: { + baz: '123' + }, + foo: { + qux: '123' + } + }, { + keywords: { + isBaz: { + type: 'string', + validate: () => true + }, + isQux: { + type: 'string', + validate: () => false + } + } + }); + + expect(result).toEqual({ + foo: { + qux: { + rule: 'isQux' + } + } + }); + }); }); diff --git a/yarn.lock b/yarn.lock index 49f87d1..b02d9cd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4530,7 +4530,7 @@ lodash.zipobject@^4.0.0: resolved "https://registry.yarnpkg.com/lodash.zipobject/-/lodash.zipobject-4.1.3.tgz#b399f5aba8ff62a746f6979bf20b214f964dbef8" integrity sha1-s5n1q6j/YqdG9peb8gshT5ZNvvg= -lodash@^4.17.11, lodash@^4.17.12, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.4: +lodash@^4.17.11, lodash@^4.17.12, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.4: version "4.17.15" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548" integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==