diff --git a/docs/schema/utils.js b/docs/schema/utils.js index 2409c462..9f8985cb 100644 --- a/docs/schema/utils.js +++ b/docs/schema/utils.js @@ -3,9 +3,9 @@ import {Tooltip, Icon} from 'antd'; import TableSelectColumn from "../components/columns/select"; export const galleryValidation = { - validator: (content, reject) => { + validator: (content) => { if (content.length === 0) { - return reject("should at least have one photo"); + return "should at least have one photo"; } } }; diff --git a/packages/canner/src/hocs/types.js b/packages/canner/src/hocs/types.js index 70a2ad00..a0a08d8d 100644 --- a/packages/canner/src/hocs/types.js +++ b/packages/canner/src/hocs/types.js @@ -1,4 +1,4 @@ -// @flow +g// @flow import type {Action, ActionType} from '../action/types'; import type {Query} from '../query'; import type RefId from 'canner-ref-id'; @@ -17,7 +17,7 @@ export type Subscribe = (key: string, callback: (data: any) => void) => Subscrip export type Deploy = (key: string, id?: string) => Promise<*>; export type OnDeploy = (key: string, callback: Function) => any; export type RemoveOnDeploy = (key: string, callbackId: string) => void; -export type Validation = Object; +export type Validation = {schema?: Object, erorrMessage?: string, validator?: (value: any) => string | Promise | Promise | void} export type UIParams = Object; export type Relation = Object; export type RenderChildren = any => React.Node diff --git a/packages/canner/src/hocs/validation.js b/packages/canner/src/hocs/validation.js index 5627773c..baa3c1e6 100644 --- a/packages/canner/src/hocs/validation.js +++ b/packages/canner/src/hocs/validation.js @@ -3,7 +3,7 @@ import * as React from 'react'; import RefId from 'canner-ref-id'; import Ajv from 'ajv'; -import {isEmpty, isArray, isPlainObject, get} from 'lodash'; +import {isEmpty, isObject, isArray, isPlainObject, isFunction, toString, get} from 'lodash'; import type {HOCProps} from './types'; type State = { @@ -11,8 +11,67 @@ type State = { errorInfo: Array } + +const checkValidation = (validation) => { + return (isObject(validation) && !isEmpty(validation)) +} + +const checkSchema = (schema) => { + return (isObject(schema) && !isEmpty(schema) ) +} +const checkValidator = (validator) => { + return (isFunction(validator)) +} + +const promiseRequired = async (value) => { + const valid = Boolean(value) + return { + error: !valid, + errorInfo: !valid ? [{message: 'should be required'}] :[] + } +} + +const promiseSchemaValidation = (schema, errorMessage) => { + const ajv = new Ajv(); + const validate = ajv.compile(schema); + return async (value) => { + try { + const error = !validate(value); + const errorInfo = error ? [].concat( errorMessage ? {message: errorMessage} : validate.errors ) : []; + return { + error, + errorInfo + } + } + catch(err){ + return { + error: true, + errorInfo: [{message: toString(err)}] + } + } + + } +} +const promiseCustomizedValidator = (validator) => async (value) => { + try { + const errorMessage = await validator(value); + const error = Boolean(errorMessage); + const errorInfo = error ? [{message: errorMessage}] : [] + return { + error, + errorInfo + } + } + catch(err) { + return { + error: true, + errorInfo: [{message: toString(err)}] + } + } +} + export default function withValidation(Com: React.ComponentType<*>) { - return class ComponentWithValition extends React.Component { + return class ComponentWithValidation extends React.Component { key: string; id: ?string; callbackId: ?string; @@ -35,48 +94,70 @@ export default function withValidation(Com: React.ComponentType<*>) { this.removeOnDeploy(); } - validate = (result: any) => { - const {refId, validation = {}, required = false} = this.props; - // required - const paths = refId.getPathArr().slice(1); - const {value} = getValueAndPaths(result.data, paths); - const isRequiredValid = required ? Boolean(value) : true; - - // Ajv validation - const ajv = new Ajv(); - const validate = ajv.compile(validation); - - // custom validator - const {validator, errorMessage} = validation; - const reject = message => ({error: true, message}); - const validatorResult = validator && validator(value, reject); - - let customValid = !(validatorResult && validatorResult.error); - // if value is empty, should not validate with ajv - if (customValid && isRequiredValid && (!value || validate(value))) { - this.setState({ - error: false, - errorInfo: [] - }); - return result; + handleValidationResult = (results: any) => { + + let error = false; + let errorInfo = []; + + for(let index = 0; index < results.length; index++) { + error = error || results[index].error + errorInfo = errorInfo.concat(results[index].errorInfo); } - - - const errorInfo = [] - .concat(isRequiredValid ? [] : { - message: 'should be required' - }) - .concat(validate.errors ? (errorMessage ? {message: errorMessage} : validate.errors) : []) - .concat(customValid ? [] : validatorResult); this.setState({ - error: true, - errorInfo: errorInfo + error, + errorInfo }); + return { - ...result, - error: true, - errorInfo: errorInfo + error, + errorInfo + } + } + + validate = async (result: any) => { + const {refId, required = false, validation} = this.props; + const paths = refId.getPathArr().slice(1); + const {value} = getValueAndPaths(result.data, paths); + const promiseQueue = []; + try{ + // check whether value is required in first step + if(required) { + promiseQueue.push(promiseRequired(value)); + } + + // skip validation if object validation is undefined or empty + if(checkValidation(validation)) { + const {schema, errorMessage, validator} = validation; + if(value && checkSchema(schema)) { + promiseQueue.push(promiseSchemaValidation(schema, errorMessage)(value)); + } + if(validator) { + if(checkValidator(validator)) { + promiseQueue.push(promiseCustomizedValidator(validator)(value)); + } else { + throw 'Validator should be a function' + } + } + } + + const ValidationResult = await Promise.all(promiseQueue); + + return { + ...result, + ...this.handleValidationResult(ValidationResult) + } + } + catch(err){ + this.setState({ + error: true, + errorInfo: [].concat({message: toString(err)}) + }); + return { + ...result, + error: true, + errorInfo: [].concat({message: toString(err)}) + } } } diff --git a/packages/canner/test/hocs/validation.test.js b/packages/canner/test/hocs/validation.test.js index 3db47d01..582ed3f1 100644 --- a/packages/canner/test/hocs/validation.test.js +++ b/packages/canner/test/hocs/validation.test.js @@ -1,9 +1,10 @@ import * as React from 'react'; import Enzyme, { mount } from 'enzyme'; import Adapter from 'enzyme-adapter-react-16'; -import withValidationn from '../../src/hocs/validation'; +import withValidation from '../../src/hocs/validation'; import RefId from 'canner-ref-id'; +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); Enzyme.configure({ adapter: new Adapter() }); @@ -21,7 +22,7 @@ describe('withValidation', () => { onDeploy, removeOnDeploy } - WrapperComponent = withValidationn(MockComponent); + WrapperComponent = withValidation(MockComponent); }); it('should error state = false', () => { @@ -42,7 +43,7 @@ describe('withValidation', () => { expect(onDeploy).toBeCalledWith('posts', wrapper.instance().validate); }); - it('should pass validation', () => { + it('should pass validation', async () => { const result = { data: { 0: { url: 'https://'} @@ -50,16 +51,16 @@ describe('withValidation', () => { }; const wrapper = mount( (fn(result)))} required />); + await wrapper.instance().validate(result) expect(wrapper.state()).toEqual({ error: false, errorInfo: [] }) }); - it('should not pass required validation', () => { + it('should not pass required validation', async () => { const result = { data: { 0: { url: ''} @@ -67,9 +68,9 @@ describe('withValidation', () => { }; const wrapper = mount( (fn(result)))} required />); + await wrapper.instance().validate(result) expect(wrapper.state()).toEqual({ error: true, errorInfo: [{ @@ -78,7 +79,7 @@ describe('withValidation', () => { }) }); - it('should not pass ajv validation', () => { + it('should not pass ajv validation', async () => { const result = { data: { 0: { url: 'imgurl.com'} @@ -86,10 +87,10 @@ describe('withValidation', () => { }; const wrapper = mount( (fn(result)))} required - validation={{pattern: '^http://[.]+'}} + validation={{schema: {pattern: '^http://[.]+'}}} />); + await wrapper.instance().validate(result) expect(wrapper.state()).toMatchObject({ error: true, errorInfo: [{ @@ -98,7 +99,7 @@ describe('withValidation', () => { }) }); - it('should not pass ajv validation with custom error message', () => { + it('should not pass ajv validation with custom error message', async () => { const result = { data: { 0: { url: 'imgurl.com'} @@ -107,10 +108,10 @@ describe('withValidation', () => { const errorMessage = 'custom error'; const wrapper = mount( (fn(result)))} required - validation={{pattern: '^http://[.]+', errorMessage}} + validation={{schema: {pattern: '^http://[.]+'}, errorMessage}} />); + await wrapper.instance().validate(result) expect(wrapper.state()).toMatchObject({ error: true, errorInfo: [{ @@ -119,7 +120,7 @@ describe('withValidation', () => { }) }); - it('should pass ajv validation if empty', () => { + it('should pass ajv validation if empty', async () => { const result = { data: { 0: { url: ''} @@ -127,16 +128,17 @@ describe('withValidation', () => { }; const wrapper = mount( (fn(result)))} - validation={{pattern: '^http://[.]+'}} + validation={{schema: {pattern: '^http://[.]+'}}} />); + await wrapper.instance().validate(result) expect(wrapper.state()).toMatchObject({ error: false, errorInfo: [] }) }); - it('should use custom validation', () => { + // Synchronous functions + it('should use customized validator with error message', async () => { const result = { data: { 0: { url: ''} @@ -144,25 +146,245 @@ describe('withValidation', () => { }; const wrapper = mount( (fn(result)))} validation={ { - validator: (content, reject) => { + validator: (content) => { if (!content) { - return reject('should be required'); + return 'error message as return value'; } } } } />); + await wrapper.instance().validate(result) expect(wrapper.state()).toMatchObject({ error: true, errorInfo: [{ - message: 'should be required' + message: 'error message as return value' + }] + }) + }); + + it('should use customized validator with throwing error', async () => { + const result = { + data: { + 0: { url: ''} + } + }; + const wrapper = mount( { + throw 'Throw error' + } + } + } + />); + await wrapper.instance().validate(result) + expect(wrapper.state()).toMatchObject({ + error: true, + errorInfo: [{ + message: 'Throw error' + }] + }) + }); + + it('should use customized validator with void return', async () => { + const result = { + data: { + 0: { url: ''} + } + }; + const wrapper = mount( {} + } + } + />); + await wrapper.instance().validate(result) + expect(wrapper.state()).toMatchObject({ + error: false, + errorInfo: [] + }) + }); + + it('validator is not a function', async () => { + const result = { + data: { + 0: { url: ''} + } + }; + const wrapper = mount(); + await wrapper.instance().validate(result) + expect(wrapper.state()).toMatchObject({ + error: true, + errorInfo: [{ + message: 'Validator should be a function' + }] + }) + }); + + // Async-await functions + it('should use customized async validator with error message', async () => { + const result = { + data: { + 0: { url: ''} + } + }; + const wrapper = mount( { + await sleep(5) + if (!content) { + return 'error message as return value'; + } + } + } + } + />); + await wrapper.instance().validate(result) + expect(wrapper.state()).toMatchObject({ + error: true, + errorInfo: [{ + message: 'error message as return value' + }] + }) + }); + + it('should use customized async validator with throwing error', async () => { + const result = { + data: { + 0: { url: ''} + } + }; + const wrapper = mount( { + await sleep(5) + throw 'Throw error' + } + } + } + />); + await wrapper.instance().validate(result) + expect(wrapper.state()).toMatchObject({ + error: true, + errorInfo: [{ + message: 'Throw error' + }] + }) + }); + + it('should use customized async validator with void return', async () => { + const result = { + data: { + 0: { url: ''} + } + }; + const wrapper = mount( { await sleep(5) } + } + } + />); + await wrapper.instance().validate(result) + expect(wrapper.state()).toMatchObject({ + error: false, + errorInfo: [] + }) + }); + + // Function with promise operation + it('should use customized validator with a Promise', async () => { + const result = { + data: { + 0: { url: ''} + } + }; + const wrapper = mount( { + return new Promise(resolve => resolve('error message as resolved value')); + } + } + } + />); + await wrapper.instance().validate(result) + expect(wrapper.state()).toMatchObject({ + error: true, + errorInfo: [{ + message: 'error message as resolved value' + }] + }) + }); + + it('should use customized validator with a rejected Promise', async () => { + const result = { + data: { + 0: { url: ''} + } + }; + const wrapper = mount( { + return new Promise((resolve, reject) => { + reject(new Error('Rejected promise')); + }) + } + } + } + />); + await wrapper.instance().validate(result) + expect(wrapper.state()).toMatchObject({ + error: true, + errorInfo: [{ + message: 'Error: Rejected promise' }] }) }); + it('should use customized validator with a Promise', async () => { + const result = { + data: { + 0: { url: ''} + } + }; + const wrapper = mount( { return new Promise(resolve => resolve())} + } + } + />); + await wrapper.instance().validate(result) + expect(wrapper.state()).toMatchObject({ + error: false, + errorInfo: [] + }) + }); + + it('should removeOnDeploy not be called', () => { const wrapper = mount(