diff --git a/examples/form/basic.json b/examples/form/basic.json index cbdf3a2a..b8011c33 100644 --- a/examples/form/basic.json +++ b/examples/form/basic.json @@ -139,10 +139,29 @@ { "type": "core/v1/validation", "properties": { - "value": "{{ phoneInput.value || \"\" }}", - "maxLength": 100, - "minLength": 0, - "rule": "phoneNumber" + "validators": [ + { + "name": "phone", + "value": "{{ phoneInput.value || \"\" }}", + "rules": [ + { + "type": "regex", + "regex": "^1[3456789]\\d{9}$", + "error": { + "message": "Please input the correct phone number." + } + }, + { + "type": "length", + "minLength": 0, + "maxLength": 100, + "error": { + "message": "Please input the length between 0 and 100." + } + } + ] + } + ] } } ] diff --git a/examples/form/formInDialog.json b/examples/form/formInDialog.json index ec686653..d9eaa1ad 100644 --- a/examples/form/formInDialog.json +++ b/examples/form/formInDialog.json @@ -314,9 +314,22 @@ { "type": "core/v1/validation", "properties": { - "value": "{{ nameInput.value || \"\" }}", - "maxLength": 10, - "minLength": 2 + "validators": [ + { + "name": "name", + "value": "{{ nameInput.value || \"\" }}", + "rules": [ + { + "type": "length", + "maxLength": 10, + "minLength": 2, + "error": { + "message": "Please input the length between 2 and 10." + } + } + ] + } + ] } } ] diff --git a/examples/form/formValidation.json b/examples/form/formValidation.json new file mode 100644 index 00000000..da07f238 --- /dev/null +++ b/examples/form/formValidation.json @@ -0,0 +1,855 @@ +{ + "app": { + "version": "sunmao/v1", + "kind": "Application", + "metadata": { + "name": "some App" + }, + "spec": { + "components": [ + { + "id": "name_form", + "type": "arco/v1/formControl", + "properties": { + "label": { + "format": "plain", + "raw": "name" + }, + "layout": "horizontal", + "required": true, + "hidden": false, + "extra": "", + "errorMsg": "{{name_form.validatedResult.name?.errors[0]?.message || '';}}", + "labelAlign": "left", + "colon": false, + "help": "", + "labelCol": { + "span": 3, + "offset": 0 + }, + "wrapperCol": { + "span": 21, + "offset": 0 + } + }, + "traits": [ + { + "type": "core/v1/validation", + "properties": { + "validators": [ + { + "name": "name", + "value": "{{name_input.value;}}", + "rules": [ + { + "type": "required", + "validate": null, + "error": { + "message": "Please input the name." + }, + "minLength": 0, + "maxLength": 0, + "list": [], + "min": 0, + "max": 0, + "regex": "", + "flags": "", + "customOptions": {} + }, + { + "type": "length", + "validate": null, + "error": { + "message": "The name is limited in length to between 1 and 10." + }, + "minLength": 1, + "maxLength": 10, + "list": [], + "min": 0, + "max": 0, + "regex": "", + "flags": "", + "customOptions": {} + } + ] + } + ] + } + } + ] + }, + { + "id": "name_input", + "type": "arco/v1/input", + "properties": { + "allowClear": false, + "disabled": false, + "readOnly": false, + "defaultValue": "", + "updateWhenDefaultValueChanges": false, + "placeholder": "please input the name.", + "error": "{{name_form.validatedResult.name.isInvalid}}", + "size": "default" + }, + "traits": [ + { + "type": "core/v1/slot", + "properties": { + "container": { + "id": "name_form", + "slot": "content" + }, + "ifCondition": true + } + }, + { + "type": "core/v1/event", + "properties": { + "handlers": [ + { + "type": "onChange", + "componentId": "name_form", + "method": { + "name": "validateFields", + "parameters": { + "names": "{{['name']}}" + } + } + }, + { + "type": "onBlur", + "componentId": "name_form", + "method": { + "name": "validateAllFields", + "parameters": {} + } + }, + { + "type": "onChange", + "componentId": "check_name", + "method": { + "name": "triggerFetch", + "parameters": {} + } + }, + { + "type": "onBlur", + "componentId": "check_name", + "method": { + "name": "triggerFetch", + "parameters": {} + } + } + ] + } + } + ] + }, + { + "id": "email_form", + "type": "arco/v1/formControl", + "properties": { + "label": { + "format": "plain", + "raw": "email" + }, + "layout": "horizontal", + "required": true, + "hidden": false, + "extra": "", + "errorMsg": "{{email_form.validatedResult.email?.errors[0]?.message;}}", + "labelAlign": "left", + "colon": false, + "help": "", + "labelCol": { + "span": 3, + "offset": 0 + }, + "wrapperCol": { + "span": 21, + "offset": 0 + } + }, + "traits": [ + { + "type": "core/v1/validation", + "properties": { + "validators": [ + { + "name": "email", + "value": "{{email_input.value;}}", + "rules": [ + { + "type": "required", + "validate": null, + "error": { + "message": "Please input the email." + }, + "minLength": 0, + "maxLength": 0, + "list": [], + "min": 0, + "max": 0, + "regex": "", + "flags": "", + "customOptions": {} + }, + { + "type": "email", + "validate": null, + "error": { + "message": "Please input the correct email." + }, + "minLength": 0, + "maxLength": 0, + "list": [], + "min": 0, + "max": 0, + "regex": "", + "flags": "", + "customOptions": {} + } + ] + } + ] + } + } + ] + }, + { + "id": "email_input", + "type": "arco/v1/input", + "properties": { + "allowClear": false, + "disabled": false, + "readOnly": false, + "defaultValue": "", + "updateWhenDefaultValueChanges": false, + "placeholder": "please input the email.", + "error": "{{email_form.validatedResult.email.isInvalid}}", + "size": "default" + }, + "traits": [ + { + "type": "core/v1/slot", + "properties": { + "container": { + "id": "email_form", + "slot": "content" + }, + "ifCondition": true + } + }, + { + "type": "core/v1/event", + "properties": { + "handlers": [ + { + "type": "onChange", + "componentId": "email_form", + "method": { + "name": "validateAllFields", + "parameters": {} + } + }, + { + "type": "onBlur", + "componentId": "email_form", + "method": { + "name": "validateAllFields", + "parameters": {} + } + } + ] + } + } + ] + }, + { + "id": "url_form", + "type": "arco/v1/formControl", + "properties": { + "label": { + "format": "plain", + "raw": "URL" + }, + "layout": "horizontal", + "required": true, + "hidden": false, + "extra": "", + "errorMsg": "{{url_form.validatedResult.url?.errors[0]?.message}}", + "labelAlign": "left", + "colon": false, + "help": "", + "labelCol": { + "span": 3, + "offset": 0 + }, + "wrapperCol": { + "span": 21, + "offset": 0 + } + }, + "traits": [ + { + "type": "core/v1/validation", + "properties": { + "validators": [ + { + "name": "url", + "value": "{{url_input.value}}", + "rules": [ + { + "type": "required", + "validate": null, + "error": { + "message": "Please input the URL. " + }, + "minLength": 0, + "maxLength": 0, + "list": [], + "min": 0, + "max": 0, + "regex": "", + "flags": "", + "customOptions": {} + }, + { + "type": "url", + "validate": null, + "error": { + "message": "Please input the correct URL." + }, + "minLength": 0, + "maxLength": 0, + "list": [], + "min": 0, + "max": 0, + "regex": "", + "flags": "", + "customOptions": {} + } + ] + } + ] + } + } + ] + }, + { + "id": "url_input", + "type": "arco/v1/input", + "properties": { + "allowClear": false, + "disabled": false, + "readOnly": false, + "defaultValue": "", + "updateWhenDefaultValueChanges": false, + "placeholder": "please input the URL.", + "error": "{{url_form.validatedResult.url.isInvalid}}", + "size": "default" + }, + "traits": [ + { + "type": "core/v1/slot", + "properties": { + "container": { + "id": "url_form", + "slot": "content" + }, + "ifCondition": true + } + }, + { + "type": "core/v1/event", + "properties": { + "handlers": [ + { + "type": "onChange", + "componentId": "url_form", + "method": { + "name": "validateAllFields", + "parameters": {} + } + }, + { + "type": "onBlur", + "componentId": "url_form", + "method": { + "name": "validateAllFields", + "parameters": {} + } + } + ] + } + } + ] + }, + { + "id": "ip_form", + "type": "arco/v1/formControl", + "properties": { + "label": { + "format": "plain", + "raw": "IP" + }, + "layout": "horizontal", + "required": true, + "hidden": false, + "extra": "", + "errorMsg": "{{ip_form.validatedResult.ip?.errors[0]?.message}}", + "labelAlign": "left", + "colon": false, + "help": "", + "labelCol": { + "span": 3, + "offset": 0 + }, + "wrapperCol": { + "span": 21, + "offset": 0 + } + }, + "traits": [ + { + "type": "core/v1/validation", + "properties": { + "validators": [ + { + "name": "ip", + "value": "{{ip_input.value}}", + "rules": [ + { + "type": "required", + "validate": null, + "error": { + "message": "Please input the IP." + }, + "minLength": 0, + "maxLength": 0, + "list": [], + "min": 0, + "max": 0, + "regex": "", + "flags": "", + "customOptions": {} + }, + { + "type": "ipv4", + "validate": null, + "error": { + "message": "Please input the correct IP." + }, + "minLength": 0, + "maxLength": 0, + "list": [], + "min": 0, + "max": 0, + "regex": "", + "flags": "", + "customOptions": {} + } + ] + } + ] + } + } + ] + }, + { + "id": "ip_input", + "type": "arco/v1/input", + "properties": { + "allowClear": false, + "disabled": false, + "readOnly": false, + "defaultValue": "", + "updateWhenDefaultValueChanges": false, + "placeholder": "please input the IP.", + "error": "{{ip_form.validatedResult.ip.isInvalid}}", + "size": "default" + }, + "traits": [ + { + "type": "core/v1/slot", + "properties": { + "container": { + "id": "ip_form", + "slot": "content" + }, + "ifCondition": true + } + }, + { + "type": "core/v1/event", + "properties": { + "handlers": [ + { + "type": "onChange", + "componentId": "ip_form", + "method": { + "name": "validateAllFields", + "parameters": {} + } + }, + { + "type": "onBlur", + "componentId": "ip_form", + "method": { + "name": "validateAllFields", + "parameters": {} + } + } + ] + } + } + ] + }, + { + "id": "phone_form", + "type": "arco/v1/formControl", + "properties": { + "label": { + "format": "plain", + "raw": "phone" + }, + "layout": "horizontal", + "required": true, + "hidden": false, + "extra": "", + "errorMsg": "{{phone_form.validatedResult.phone.errors[0]?.message}}", + "labelAlign": "left", + "colon": false, + "help": "", + "labelCol": { + "span": 3, + "offset": 0 + }, + "wrapperCol": { + "span": 21, + "offset": 0 + } + }, + "traits": [ + { + "type": "core/v1/validation", + "properties": { + "validators": [ + { + "name": "phone", + "value": "{{phone_input.value}}", + "rules": [ + { + "type": "required", + "validate": null, + "error": { + "message": "Please input the phone number." + }, + "minLength": 0, + "maxLength": 0, + "list": [], + "min": 0, + "max": 0, + "regex": "", + "flags": "", + "customOptions": {} + }, + { + "type": "regex", + "validate": null, + "error": { + "message": "Please input the correct phone number." + }, + "minLength": 0, + "maxLength": 0, + "list": [], + "min": 0, + "max": 0, + "regex": "^1[3456789]\\d{9}$", + "flags": "", + "customOptions": {} + } + ] + } + ] + } + } + ] + }, + { + "id": "phone_input", + "type": "arco/v1/input", + "properties": { + "allowClear": false, + "disabled": false, + "readOnly": false, + "defaultValue": "", + "updateWhenDefaultValueChanges": false, + "placeholder": "please input the phone number.", + "error": "{{phone_form.validatedResult.phone.isInvalid}}", + "size": "default" + }, + "traits": [ + { + "type": "core/v1/slot", + "properties": { + "container": { + "id": "phone_form", + "slot": "content" + }, + "ifCondition": true + } + }, + { + "type": "core/v1/event", + "properties": { + "handlers": [ + { + "type": "onChange", + "componentId": "phone_form", + "method": { + "name": "validateAllFields", + "parameters": {} + } + }, + { + "type": "onBlur", + "componentId": "phone_form", + "method": { + "name": "validateAllFields", + "parameters": {} + } + } + ] + } + } + ] + }, + { + "id": "city_form", + "type": "arco/v1/formControl", + "properties": { + "label": { + "format": "plain", + "raw": "city" + }, + "layout": "horizontal", + "required": false, + "hidden": false, + "extra": "", + "errorMsg": "{{city_form.validatedResult.city.errors[0]?.message}}", + "labelAlign": "left", + "colon": false, + "help": "", + "labelCol": { + "span": 3, + "offset": 0 + }, + "wrapperCol": { + "span": 21, + "offset": 0 + } + }, + "traits": [ + { + "type": "core/v1/validation", + "properties": { + "validators": [ + { + "name": "city", + "value": "{{city_select.value}}", + "rules": [ + { + "type": "include", + "validate": null, + "error": { + "message": "Please select \"Beijing\"." + }, + "minLength": 0, + "maxLength": 0, + "includeList": ["Beijing"], + "excludeList": [], + "min": 0, + "max": 0, + "regex": "", + "flags": "", + "customOptions": {} + } + ] + } + ] + } + } + ] + }, + { + "id": "city_select", + "type": "arco/v1/select", + "properties": { + "allowClear": false, + "multiple": false, + "allowCreate": false, + "bordered": true, + "defaultValue": "Beijing", + "disabled": false, + "labelInValue": false, + "loading": false, + "showSearch": false, + "unmountOnExit": false, + "options": [ + { + "value": "Beijing", + "text": "Beijing" + }, + { + "value": "London", + "text": "London" + }, + { + "value": "NewYork", + "text": "NewYork" + } + ], + "placeholder": "Select city", + "size": "default", + "error": false, + "updateWhenDefaultValueChanges": false + }, + "traits": [ + { + "type": "core/v1/slot", + "properties": { + "container": { + "id": "city_form", + "slot": "content" + }, + "ifCondition": true + } + }, + { + "type": "core/v1/event", + "properties": { + "handlers": [ + { + "type": "onChange", + "componentId": "city_form", + "method": { + "name": "validateAllFields", + "parameters": {} + } + } + ] + } + } + ] + }, + { + "id": "clear_button", + "type": "arco/v1/button", + "properties": { + "type": "default", + "status": "default", + "long": false, + "size": "default", + "disabled": false, + "loading": false, + "shape": "square", + "text": "clear" + }, + "traits": [ + { + "type": "core/v1/event", + "properties": { + "handlers": [ + { + "type": "onClick", + "componentId": "name_form", + "method": { + "name": "clearAllErrors", + "parameters": {} + } + }, + { + "type": "onClick", + "componentId": "email_form", + "method": { + "name": "clearAllErrors", + "parameters": {} + } + }, + { + "type": "onClick", + "componentId": "url_form", + "method": { + "name": "clearAllErrors", + "parameters": {} + } + }, + { + "type": "onClick", + "componentId": "ip_form", + "method": { + "name": "clearErrors", + "parameters": { + "names": "{{['ip']}}" + } + } + }, + { + "type": "onClick", + "componentId": "phone_form", + "method": { + "name": "clearErrors", + "parameters": { + "names": "{{['phone']}}" + } + } + }, + { + "type": "onClick", + "componentId": "city_form", + "method": { + "name": "clearAllErrors", + "parameters": {} + } + } + ] + } + } + ] + }, + { + "id": "check_name", + "type": "core/v1/dummy", + "properties": {}, + "traits": [ + { + "type": "core/v1/fetch", + "properties": { + "url": "", + "method": "get", + "lazy": false, + "disabled": false, + "headers": {}, + "body": {}, + "bodyType": "json", + "onComplete": [ + { + "componentId": "name_form", + "method": { + "name": "setErrors", + "parameters": { + "errorsMap": "{{\n{\n name: [...name_form.validatedResult.name.errors, { message: 'The name is exist.' }]\n}\n}}" + } + } + } + ], + "onError": [] + } + } + ] + } + ] + } + } +} diff --git a/examples/input-components/inputValidation.json b/examples/input-components/inputValidation.json index 21e72bc4..14149f71 100644 --- a/examples/input-components/inputValidation.json +++ b/examples/input-components/inputValidation.json @@ -21,10 +21,28 @@ { "type": "core/v1/validation", "properties": { - "value": "{{ emailInput.value || \"\" }}", - "maxLength": 20, - "minLength": 10, - "rule": "email" + "validators": [ + { + "name": "email", + "value": "{{ emailInput.value || \"\" }}", + "rules": [ + { + "type": "email", + "error": { + "message": "Please input the email." + } + }, + { + "type": "length", + "maxLength": 20, + "minLength": 10, + "error": { + "message": "Please input the length between 10 and 20." + } + } + ] + } + ] } } ] @@ -34,7 +52,7 @@ "type": "core/v1/text", "properties": { "value": { - "raw": "{{ emailInput.validResult.errorMsg }}", + "raw": "{{ emailInput.validatedResult.email.errors[0]?.message }}", "format": "plain" } }, @@ -54,10 +72,29 @@ { "type": "core/v1/validation", "properties": { - "value": "{{ phoneInput.value || \"\" }}", - "maxLength": 100, - "minLength": 0, - "rule": "phoneNumber" + "validators": [ + { + "name": "phone", + "value": "{{ phoneInput.value || \"\" }}", + "rules": [ + { + "type": "regex", + "regex": "^1[3456789]\\d{9}$", + "error": { + "message": "Please input the correct phone number." + } + }, + { + "type": "length", + "maxLength": 100, + "minLength": 0, + "error": { + "message": "Please input the length between 0 and 100." + } + } + ] + } + ] } } ] @@ -95,7 +132,7 @@ "parameters": "{{ `email:${ emailInput.value } phone:${ phoneInput.value }` }}" }, "wait": {}, - "disabled": "{{ emailInput.validResult.isInvalid || phoneInput.validResult.isInvalid }}" + "disabled": "{{ emailInput.validatedResult.email.isInvalid || phoneInput.validatedResult.phone.isInvalid }}" } ] } diff --git a/examples/table/tableWithForm.json b/examples/table/tableWithForm.json index c93cd7a0..78756fba 100644 --- a/examples/table/tableWithForm.json +++ b/examples/table/tableWithForm.json @@ -264,9 +264,22 @@ { "type": "core/v1/validation", "properties": { - "value": "{{ nameInput.value || \"\" }}", - "maxLength": 10, - "minLength": 2 + "validators": [ + { + "name": "name", + "value": "{{ nameInput.value || \"\" }}", + "rules": [ + { + "type": "length", + "maxLength": 10, + "minLength": 2, + "error": { + "message": "Please input the length between 2 and 10." + } + } + ] + } + ] } } ] diff --git a/packages/runtime/index.html b/packages/runtime/index.html index 731a90fb..4341016c 100644 --- a/packages/runtime/index.html +++ b/packages/runtime/index.html @@ -14,7 +14,9 @@ import { initSunmaoUI } from './src'; import { ChakraProvider } from '@chakra-ui/react'; import { sunmaoChakraUILib } from '@sunmao-ui/chakra-ui-lib'; + import { ArcoDesignLib } from '@sunmao-ui/arco-lib'; import examples from '@example.json'; + import '@arco-design/web-react/dist/css/arco.css'; const selectEl = document.querySelector('select'); for (const example of examples) { @@ -29,7 +31,9 @@ const rootEl = document.querySelector('#root'); const render = example => { ReactDOM.unmountComponentAtNode(rootEl); - const { App, registry } = initSunmaoUI({ libs: [sunmaoChakraUILib] }); + const { App, registry } = initSunmaoUI({ + libs: [sunmaoChakraUILib, ArcoDesignLib], + }); const { app, modules = [] } = example.value; window.registry = registry; modules.forEach(m => { diff --git a/packages/runtime/src/traits/core/Validation.tsx b/packages/runtime/src/traits/core/Validation.tsx index 2960c9f7..5e98854f 100644 --- a/packages/runtime/src/traits/core/Validation.tsx +++ b/packages/runtime/src/traits/core/Validation.tsx @@ -1,112 +1,384 @@ -import { Static, Type } from '@sinclair/typebox'; -import { isEqual } from 'lodash'; +import { Type, Static, TSchema } from '@sinclair/typebox'; import { implementRuntimeTrait } from '../../utils/buildKit'; -import { CORE_VERSION, CoreTraitName } from '@sunmao-ui/shared'; +import { CORE_VERSION, CoreTraitName, StringUnion } from '@sunmao-ui/shared'; -type ValidationResult = Static; -type ValidationRule = (text: string) => ValidationResult; +type ParseValidateOption< + T extends Record, + OptionalKeys extends keyof T = '' +> = { + [K in keyof T as K extends OptionalKeys ? never : K]: Static; +} & { + [K in OptionalKeys]?: Static; +}; +type UnionToIntersection = ( + TUnion extends unknown ? (params: TUnion) => unknown : never +) extends (params: infer Params) => unknown + ? Params + : never; -const ResultSpec = Type.Object({ - isInvalid: Type.Boolean(), - errorMsg: Type.String(), -}); +const validateOptionMap = { + length: { + minLength: Type.Number({ title: 'Min length' }), + maxLength: Type.Number({ title: 'Max length' }), + }, + include: { + includeList: Type.Array(Type.String(), { title: 'Include list' }), + }, + exclude: { + excludeList: Type.Array(Type.String(), { title: 'Exclude List' }), + }, + number: { + min: Type.Number({ title: 'Min' }), + max: Type.Number({ title: 'Max' }), + }, + regex: { + regex: Type.String({ title: 'Regex', description: 'The regular expression string.' }), + flags: Type.String({ + title: 'Flags', + description: 'The flags of the regular expression.', + }), + }, +}; +const validateFnMap = { + required(value: string) { + return value !== undefined && value !== null && value !== ''; + }, + length( + value: string, + { + minLength, + maxLength, + }: ParseValidateOption + ) { + if (minLength !== undefined && value.length < minLength) { + return false; + } -export const ValidationTraitStateSpec = Type.Object({ - validResult: ResultSpec, + if (maxLength !== undefined && value.length > maxLength) { + return false; + } + + return true; + }, + include( + value: string, + { includeList }: ParseValidateOption + ) { + return includeList.includes(value); + }, + exclude( + value: string, + { excludeList }: ParseValidateOption + ) { + return !excludeList.includes(value); + }, + regex( + value: string, + { regex, flags }: ParseValidateOption + ) { + return new RegExp(regex, flags).test(value); + }, + number( + value: string | number, + { min, max }: ParseValidateOption + ) { + const num = Number(value); + + if (min !== undefined && num < min) { + return false; + } + + if (max !== undefined && num > max) { + return false; + } + + return true; + }, + ipv4(value: string) { + return /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/.test( + value + ); + }, + email(value: string) { + return /^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/.test(value); + }, + url(value: string) { + return /^https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_+.~#?&/=]*)$/.test( + value + ); + }, +}; + +type AllFields = UnionToIntersection< + typeof validateOptionMap[keyof typeof validateOptionMap] +>; +type AllFieldsKeys = keyof UnionToIntersection; + +const ValidatorSpec = Type.Object({ + name: Type.String({ + title: 'Name', + description: 'The name is used for getting the validated result.', + }), + value: Type.Any({ title: 'Value', description: 'The value need to be validated.' }), + rules: Type.Array( + Type.Object( + { + type: StringUnion( + Object.keys(validateFnMap).concat(['custom']) as [ + keyof typeof validateFnMap, + 'custom' + ], + { + type: 'Type', + description: + 'The type of the rule. Setting it as `custom` to use custom validate function.', + } + ), + validate: + Type.Any({ + title: 'Validate', + description: + 'The validate function for the rule. Return `false` means it is invalid.', + conditions: [ + { + key: 'type', + value: 'custom', + }, + ], + }) || + Type.Function( + [Type.String(), Type.Record(Type.String(), Type.Any())], + Type.Boolean(), + { + conditions: [ + { + key: 'type', + value: 'custom', + }, + ], + } + ), + error: Type.Object( + { + message: Type.String({ + title: 'Message', + description: 'The message to display when the value is invalid.', + }), + }, + { title: 'Error' } + ), + ...(Object.keys(validateOptionMap) as [keyof typeof validateOptionMap]).reduce( + (result, key) => { + const option = validateOptionMap[key] as AllFields; + + (Object.keys(option) as [AllFieldsKeys]).forEach(optionKey => { + if (result[optionKey]) { + // if the different validate functions have the same parameter + throw Error( + "[Validation Trait]: The different validate function has the same parameter, please change the parameter's name." + ); + } else { + result[optionKey] = { + ...option[optionKey], + conditions: [{ key: 'type', value: key }], + } as any; + } + }); + + return result; + }, + {} as AllFields + ), + customOptions: Type.Record(Type.String(), Type.Any(), { + title: 'Custom options', + description: + 'The custom options would pass to the custom validate function as the second parameter.', + conditions: [ + { + key: 'type', + value: 'custom', + }, + ], + }), + }, + { + title: 'Rules', + } + ), + { + title: 'Rules', + widget: 'core/v1/array', + widgetOptions: { displayedKeys: ['type'] }, + } + ), }); export const ValidationTraitPropertiesSpec = Type.Object({ - value: Type.String(), - rule: Type.Optional(Type.String()), - maxLength: Type.Optional(Type.Integer()), - minLength: Type.Optional(Type.Integer()), + validators: Type.Array(ValidatorSpec, { + title: 'Validators', + widget: 'core/v1/array', + widgetOptions: { displayedKeys: ['name'] }, + }), +}); + +const ErrorSpec = Type.Object({ + message: Type.String(), +}); + +const ValidatedResultSpec = Type.Record( + Type.String(), + Type.Object({ + isInvalid: Type.Boolean(), + errors: Type.Array(ErrorSpec), + }) +); + +export const ValidationTraitStateSpec = Type.Object({ + validatedResult: ValidatedResultSpec, }); export default implementRuntimeTrait({ version: CORE_VERSION, metadata: { name: CoreTraitName.Validation, - description: 'validation trait', + description: 'A trait for the form validation.', }, spec: { properties: ValidationTraitPropertiesSpec, state: ValidationTraitStateSpec, - methods: [], + methods: [ + { + name: 'setErrors', + parameters: Type.Object({ + errorsMap: Type.Record(Type.String(), Type.Array(ErrorSpec)), + }), + }, + { + name: 'validateFields', + parameters: Type.Object({ + names: Type.Array(Type.String()), + }), + }, + { + name: 'validateAllFields', + parameters: Type.Object({}), + }, + { + name: 'clearErrors', + parameters: Type.Object({ names: Type.Array(Type.String()) }), + }, + { + name: 'clearAllErrors', + parameters: Type.Object({}), + }, + ], }, })(() => { - const rules = new Map(); - - function addValidationRule(name: string, rule: ValidationRule) { - rules.set(name, rule); - } - - addValidationRule('email', text => { - if (/^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/.test(text)) { - return { - isInvalid: false, - errorMsg: '', - }; - } else { - return { - isInvalid: true, - errorMsg: 'Please enter valid email.', - }; - } - }); - - addValidationRule('phoneNumber', text => { - if (/^1[3456789]\d{9}$/.test(text)) { - return { - isInvalid: false, - errorMsg: '', - }; - } else { - return { - isInvalid: true, - errorMsg: 'Please enter valid phone number.', - }; - } - }); - const ValidationResultCache: Record = {}; + const initialMap = new Map(); return props => { - const { value, minLength, maxLength, mergeState, componentId, rule } = props; + const { validators, componentId, subscribeMethods, mergeState } = props; + const validatorMap = validators.reduce((result, validator) => { + result[validator.name] = validator; - const result: ValidationResult = { - isInvalid: false, - errorMsg: '', - }; + return result; + }, {} as Record>); - if (maxLength !== undefined && value.length > maxLength) { - result.isInvalid = true; - result.errorMsg = `Can not be longer than ${maxLength}.`; - } else if (minLength !== undefined && value.length < minLength) { - result.isInvalid = true; - result.errorMsg = `Can not be shorter than ${minLength}.`; - } else { - const rulesArr = rule ? rule.split(',') : []; - for (const ruleName of rulesArr) { - const validateFunc = rules.get(ruleName); - if (validateFunc) { - const { isInvalid, errorMsg } = validateFunc(value); - if (isInvalid) { - result.isInvalid = true; - result.errorMsg = errorMsg; - break; - } - } - } + function setErrors({ + errorsMap, + }: { + errorsMap: Record[]>; + }) { + const validatedResult = Object.keys(errorsMap).reduce( + (result: Static, name) => { + result[name] = { + isInvalid: errorsMap[name].length !== 0, + errors: errorsMap[name], + }; + + return result; + }, + {} + ); + + mergeState({ + validatedResult, + }); + } + function validateFields({ names }: { names: string[] }) { + const validatedResult = names + .map(name => { + const validator = validatorMap[name]; + const { value, rules } = validator; + const errors = rules + .map(rule => { + const { type, error, validate, customOptions, ...options } = rule; + let isValid = true; + + if (type === 'custom') { + isValid = validate(value, customOptions); + } else { + isValid = validateFnMap[type](value, options); + } + + return isValid ? null : { message: error.message }; + }) + .filter((error): error is Static => error !== null); + + return { + name, + isInvalid: errors.length !== 0, + errors, + }; + }) + .reduce((result: Static, validatedResultItem) => { + result[validatedResultItem.name] = { + isInvalid: validatedResultItem.isInvalid, + errors: validatedResultItem.errors, + }; + + return result; + }, {}); + + mergeState({ validatedResult }); + } + function validateAllFields() { + validateFields({ names: validators.map(({ name }) => name) }); + } + function clearErrors({ names }: { names: string[] }) { + setErrors({ + errorsMap: names.reduce((result: Record, name) => { + result[name] = []; + + return result; + }, {}), + }); + } + function clearAllErrors() { + clearErrors({ names: validators.map(({ name }) => name) }); } - if (!isEqual(result, ValidationResultCache[componentId])) { - ValidationResultCache[componentId] = result; - mergeState({ - validResult: result, - }); + subscribeMethods({ + setErrors, + validateFields, + validateAllFields, + clearErrors, + clearAllErrors, + }); + + if (initialMap.has(componentId) === false) { + clearAllErrors(); + initialMap.set(componentId, true); } return { - props: null, + props: { + componentDidUnmount: [ + () => { + initialMap.delete(componentId); + }, + ], + }, }; }; });