diff --git a/.cz-config.js b/.cz-config.js index 2140e2b4..97a63bf0 100644 --- a/.cz-config.js +++ b/.cz-config.js @@ -58,6 +58,7 @@ module.exports = { confirmCommit: 'Are you sure you want to proceed with the commit above?', }, + subjectLimit: 72, allowCustomScopes: false, allowBreakingChanges: ['feat', 'fix'], }; diff --git a/README.md b/README.md index 6811ce9d..c223a350 100644 --- a/README.md +++ b/README.md @@ -18,21 +18,21 @@

-Sunmao(榫卯 /suən mɑʊ/) is a front-end low-code framework. Through Sunmao, you can easily encapsulate any front-end UI components into low-code component libraries, so as to build your own low-code UI development platform, making front-end development as tight as Sunmao. +Sunmao(榫卯 /suən mɑʊ/) is a front-end low-code framework. Through Sunmao, you can easily encapsulate any front-end UI components into low-code component libraries to build your own low-code UI development platform, making front-end development as tight as Sunmao("mortise and tenon" in Chinese). [中文](./docs/zh/README.md) ## DEMO -Sunmao‘s website is developed with Sunmao, look here: [Sunmao website editor](https://sunmao-ui.com/dev.html) +The offcial website of Sunmao is developed by Sunmao, try it from here: [Sunmao website editor](https://sunmao-ui.com/dev.html) We also provide an open-to-use template: [Sunmao Starter Kit](https://github.com/webzard-io/sunmao-start) ## Why Sunmao? -### Reactive low-code framework +### Reactive rendering low-code framework -Sunmao chooses a reactive solution that is easy to understand and has excellent performance, making Sunmao intuitive and quick to use. +Sunmao chooses a reactive rendering solution that is easy to understand and has excellent performance, making Sunmao intuitive and quick to start. ### Powerful low-code GUI editor @@ -40,7 +40,7 @@ Sunmao has a built-in GUI editor, which almost includes all the capabilities tha ### Extremely Extensible -Both the UI component library itself and the low-code editor support custom extensions. Developers can register various components to cover application requirements and continue to use the existing visual design system. +Both the UI component library itself and the low-code editor support custom extensions. Developers can register various components to meet the needs of application and continue to use the existing visual design system. ### Type Safety @@ -50,13 +50,13 @@ For more details, read [Sunmao: A truly extensible low-code UI framework](./docs ## Tutorial -Sunmao users are divided into two roles, one is a developer and the other is a user. +Sunmao users are divided into two roles, one is developer and the other is user. -The responsibilities of developers are similar to those of common front-end developers. They are responsible for developing UI components and encapsulating common UI components to Sunmao components. Developers need to write code to implement the logic of the component. +The responsibilities of developers are similar to those of common front-end developers. They are responsible for developing UI components and encapsulating common UI components to Sunmao components. Developers need to write code to implement the logic of components. -The user's responsibility is to use the Sunmao components encapsulated by developers to build front-end applications in the Sunmao low-code editor. Users do not need front-end knowledge and programming skills. They can complete application construction only through UI interaction. +The user's responsibility is to use the Sunmao components encapsulated by developers to build front-end applications in the Sunmao low-code editor. Users do not need front-end knowledge and programming skills. They can finish building the application through UI interaction only. -We have prepared two tutorials for different roles. The user only needs to read the user's tutorial, but the developer has to read both. +We have prepared two tutorials for user and developer. The user only needs to read the user's tutorial, while the developer must read both. - [User's Tutorial](./docs/en/user.md) - [Developer's Tutorial](./docs/en/developer.md) diff --git a/commitlint.config.js b/commitlint.config.js index 28fe5c5b..6e4e2f1b 100644 --- a/commitlint.config.js +++ b/commitlint.config.js @@ -1 +1,6 @@ -module.exports = {extends: ['@commitlint/config-conventional']} +module.exports = { + extends: ['@commitlint/config-conventional'], + rules: { + 'header-max-length': [2, 'always', 72], + }, +}; diff --git a/docs/en/component.md b/docs/en/component.md index 19bc50cc..57ad4b59 100644 --- a/docs/en/component.md +++ b/docs/en/component.md @@ -29,7 +29,7 @@ First, let's look at this Input Component *Spec* example. displayName: "Input", exampleProperties: { placeholder: "Input here", - disabled: fasle, + disabled: false, }, }, spec: { diff --git a/docs/zh/component.md b/docs/zh/component.md index c015890b..aec988a2 100644 --- a/docs/zh/component.md +++ b/docs/zh/component.md @@ -29,7 +29,7 @@ Spec 本质上是一个 JSON,它的作用是描述组件的参数、行为等 displayName: "Input", exampleProperties: { placeholder: "Input here", - disabled: fasle, + disabled: false, }, }, spec: { @@ -135,7 +135,7 @@ const InputSpec = { displayName: 'Input', exampleProperties: { placeholder: 'Input here', - disabled: fasle, + disabled: false, }, }, spec: { 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/arco-lib/package.json b/packages/arco-lib/package.json index 372be297..b5f48a96 100644 --- a/packages/arco-lib/package.json +++ b/packages/arco-lib/package.json @@ -21,7 +21,7 @@ "scripts": { "dev": "vite", "typings": "tsc --emitDeclarationOnly", - "build": "tsup src/index.ts --format cjs,esm,iife --legacy-output --inject ./react-import.js --clean --no-splitting --sourcemap", + "build": "tsup src/index.ts src/widgets/index.ts --format cjs,esm,iife --legacy-output --inject ./react-import.js --clean --no-splitting --sourcemap", "serve": "vite preview", "lint": "eslint ./src --ext .ts --ext .tsx", "fix-lint": "eslint --fix ./src --ext .ts --ext .tsx", diff --git a/packages/arco-lib/src/components/Table/Table.tsx b/packages/arco-lib/src/components/Table/Table.tsx index 2c4f3ed2..134ad492 100644 --- a/packages/arco-lib/src/components/Table/Table.tsx +++ b/packages/arco-lib/src/components/Table/Table.tsx @@ -74,6 +74,14 @@ const rowClickStyle = css` export const exampleProperties: Static = { columns: [ + { + title: 'Key', + dataIndex: 'key', + type: 'text', + displayValue: '', + filter: false, + componentSlotIndex: 0, + }, { title: 'Name', dataIndex: 'name', @@ -107,11 +115,12 @@ export const exampleProperties: Static = { data: Array(13) .fill('') .map((_, index) => ({ - key: `key ${index}`, + key: index, name: `${Math.random() > 0.5 ? 'Kevin Sandra' : 'Naomi Cook'}${index}`, link: `link${Math.random() > 0.5 ? '-A' : '-B'}`, salary: Math.floor(Math.random() * 1000), })), + rowKey: 'key', checkCrossPage: true, pagination: { enablePagination: true, @@ -172,8 +181,15 @@ export const Table = implementRuntimeComponent({ } = props; const ref = useRef(null); - const { pagination, rowClick, useDefaultFilter, useDefaultSort, data, ...cProps } = - getComponentProps(props); + const { + pagination, + rowKey, + rowClick, + useDefaultFilter, + useDefaultSort, + data, + ...cProps + } = getComponentProps(props); const { pageSize, @@ -186,6 +202,7 @@ export const Table = implementRuntimeComponent({ } = pagination; const rowSelectionType = rowSelectionTypeMap[cProps.rowSelectionType]; + const currentChecked = useRef<(string | number)[]>([]); const [currentPage, setCurrentPage] = useStateValue( defaultCurrent ?? 1, @@ -196,6 +213,7 @@ export const Table = implementRuntimeComponent({ useEffect(() => { mergeState({ pageSize }); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const [sortRule, setSortRule] = useState(null); @@ -236,9 +254,29 @@ export const Table = implementRuntimeComponent({ // Otherwise it will automatically paginate on the front end based on the current page return sortedData?.slice(0, pageSize); } + return sortedData; }, [pageSize, sortedData, enablePagination, useCustomPagination]); + // reset state when data changed + useEffect(() => { + if (!currentPageData.length) { + mergeState({ + selectedRowKeys: [], + selectedRows: [], + sortRule: {}, + filterRule: undefined, + currentPage: undefined, + }); + } + + mergeState({ + selectedRows: currentPageData.filter(d => + currentChecked.current.includes(d[rowKey]) + ), + }); + }, [currentPageData, mergeState, rowKey]); + useEffect(() => { setColumns( cProps.columns!.map((column, i) => { @@ -310,13 +348,10 @@ export const Table = implementRuntimeComponent({ case 'button': const handleClick = () => { const rawColumns = component.properties.columns; - const evaledColumns = - typeof rawColumns === 'string' - ? (services.stateManager.maskedEval( - rawColumns, - evalOptions - ) as ColumnProperty[]) - : services.stateManager.deepEval(rawColumns, evalOptions); + const evaledColumns = services.stateManager.deepEval( + rawColumns, + evalOptions + ) as ColumnProperty[]; const evaledButtonConfig = evaledColumns[i].btnCfg; if (!evaledButtonConfig) return; @@ -466,6 +501,7 @@ export const Table = implementRuntimeComponent({ return ( ; + } +} + +export const TablePrimaryKeyWidget: React.FC< + WidgetProps +> = props => { + const { value, onChange, component } = props; + const { properties } = component; + const columns = properties.columns as Static[]; + + const keys = columns.map(c => c.dataIndex); + + return ( + + ); +}; + +export default implementWidget({ + version: ARCO_V1_VERSION, + metadata: { + name: 'primaryKey', + }, +})(TablePrimaryKeyWidget); diff --git a/packages/arco-lib/src/widgets/index.ts b/packages/arco-lib/src/widgets/index.ts new file mode 100644 index 00000000..5febe976 --- /dev/null +++ b/packages/arco-lib/src/widgets/index.ts @@ -0,0 +1,3 @@ +import TablePrimaryKeyWidget from './TablePrimaryKeyWidget'; + +export const widgets = [TablePrimaryKeyWidget]; diff --git a/packages/chakra-ui-lib/src/components/Checkbox.tsx b/packages/chakra-ui-lib/src/components/Checkbox.tsx index 1d90d5bb..f2e148cf 100644 --- a/packages/chakra-ui-lib/src/components/Checkbox.tsx +++ b/packages/chakra-ui-lib/src/components/Checkbox.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { Static, Type } from '@sinclair/typebox'; import { Checkbox as BaseCheckbox, useCheckboxGroupContext } from '@chakra-ui/react'; import { implementRuntimeComponent, Text, TextPropertySpec } from '@sunmao-ui/runtime'; @@ -116,7 +116,7 @@ export default implementRuntimeComponent({ colorScheme, mergeState, customStyle, - elementRef, + getElement, }) => { const groupContext = useCheckboxGroupContext(); let _defaultIsChecked = false; @@ -127,6 +127,8 @@ export default implementRuntimeComponent({ } const [checked, setChecked] = useState(_defaultIsChecked); + const ref = useRef(null); + useEffect(() => { mergeState({ text: text.raw }); }, [mergeState, text.raw]); @@ -143,6 +145,12 @@ export default implementRuntimeComponent({ setChecked(!!defaultIsChecked); }, [setChecked, defaultIsChecked]); + useEffect(() => { + if (getElement && ref.current) { + getElement(ref.current.parentElement as HTMLElement); + } + }, [getElement, ref]); + const args: { colorScheme?: Static>; size?: Static; @@ -170,7 +178,7 @@ export default implementRuntimeComponent({ className={css` ${customStyle?.content} `} - ref={elementRef} + ref={ref} > diff --git a/packages/chakra-ui-lib/src/components/Table/TableTd.tsx b/packages/chakra-ui-lib/src/components/Table/TableTd.tsx index a025e5e8..beb86bba 100644 --- a/packages/chakra-ui-lib/src/components/Table/TableTd.tsx +++ b/packages/chakra-ui-lib/src/components/Table/TableTd.tsx @@ -38,7 +38,7 @@ export const TableTd: React.FC<{ let buttonConfig = column.buttonConfig; if (column.displayValue) { - const result = services.stateManager.maskedEval(column.displayValue, evalOptions); + const result = services.stateManager.deepEval(column.displayValue, evalOptions); value = result instanceof ExpressionError ? '' : result; } @@ -66,12 +66,10 @@ export const TableTd: React.FC<{ case 'button': const onClick = () => { onClickItem(); - const evaledColumns = - typeof rawColumns === 'string' - ? (services.stateManager.maskedEval(rawColumns, evalOptions) as Static< - typeof ColumnsPropertySpec - >) - : services.stateManager.deepEval(rawColumns, evalOptions); + const evaledColumns = services.stateManager.deepEval( + rawColumns, + evalOptions + ) as Static; evaledColumns[index].buttonConfig.handlers.forEach(evaledHandler => { services.apiService.send('uiMethod', { diff --git a/packages/chakra-ui-lib/src/index.ts b/packages/chakra-ui-lib/src/index.ts index bd59cd7e..bfdce0eb 100644 --- a/packages/chakra-ui-lib/src/index.ts +++ b/packages/chakra-ui-lib/src/index.ts @@ -60,3 +60,5 @@ export const sunmaoChakraUILib: SunmaoLib = { modules: [], utilMethods: [ChakraUIToastUtilMethodFactory], }; + +export { widgets } from './widgets'; diff --git a/packages/editor-sdk/src/components/Form/ArrayTable.tsx b/packages/editor-sdk/src/components/Form/ArrayTable.tsx index bf1769b8..2e58e420 100644 --- a/packages/editor-sdk/src/components/Form/ArrayTable.tsx +++ b/packages/editor-sdk/src/components/Form/ArrayTable.tsx @@ -147,7 +147,7 @@ export const ArrayTable: React.FC = props => { /> )) ) : ( - + No Data diff --git a/packages/editor-sdk/src/components/Form/RecordEditor.tsx b/packages/editor-sdk/src/components/Form/RecordEditor.tsx index 6dde22ce..e5200282 100644 --- a/packages/editor-sdk/src/components/Form/RecordEditor.tsx +++ b/packages/editor-sdk/src/components/Form/RecordEditor.tsx @@ -155,7 +155,7 @@ const RowItem = (props: RowItemProps) => { ) : ( (() => { - const evaledResult = stateManager.maskedEval(value); + const evaledResult = stateManager.deepEval(value); return ( diff --git a/packages/editor-sdk/src/components/Widgets/ExpressionWidget.tsx b/packages/editor-sdk/src/components/Widgets/ExpressionWidget.tsx index 5db1c487..b901c4f0 100644 --- a/packages/editor-sdk/src/components/Widgets/ExpressionWidget.tsx +++ b/packages/editor-sdk/src/components/Widgets/ExpressionWidget.tsx @@ -170,7 +170,7 @@ export const ExpressionWidget: React.FC> = pro try { const value = getParsedValue(code, type); const result = isExpression(value) - ? services.stateManager.maskedEval(value) + ? services.stateManager.deepEval(value) : value; if (result instanceof ExpressionError) { diff --git a/packages/editor-sdk/src/types/editor.ts b/packages/editor-sdk/src/types/editor.ts index 5534f057..b2bdb6e5 100644 --- a/packages/editor-sdk/src/types/editor.ts +++ b/packages/editor-sdk/src/types/editor.ts @@ -14,7 +14,7 @@ export interface EditorServices { }; stateManager: { store: Record; - maskedEval: Function; + deepEval: Function; }; widgetManager: WidgetManager; } diff --git a/packages/editor/__tests__/model/fieldModel.spec.ts b/packages/editor/__tests__/model/fieldModel.spec.ts index 161ad68a..11bfee72 100644 --- a/packages/editor/__tests__/model/fieldModel.spec.ts +++ b/packages/editor/__tests__/model/fieldModel.spec.ts @@ -20,12 +20,29 @@ describe('Field test', () => { 'value', ]); expect(field.refComponentInfos['list' as ComponentId].refProperties).toEqual([ - '[0]', - '[0].text', + '0', + '0.text', ]); expect(field.rawValue).toEqual('{{input.value}} + {{list[0].text}}'); }); + it('allow using question mask in expression', () => { + const field = new FieldModel('{{api.fetch?.data }}'); + expect(field.isDynamic).toEqual(true); + expect(field.refComponentInfos['api' as ComponentId].refProperties).toEqual([ + 'fetch', + 'fetch.data', + ]); + }); + + it('stop member expression when meeting other expression ast node', () => { + const field = new FieldModel('{{ Array.from([]).fill() }}'); + expect(field.isDynamic).toEqual(true); + expect(field.refComponentInfos['Array' as ComponentId].refProperties).toEqual([ + 'from', + ]); + }); + it('parse inline variable in expression', () => { const field = new FieldModel('{{ [].length }}'); expect(field.isDynamic).toEqual(true); @@ -71,6 +88,30 @@ describe('Field test', () => { expect(field.refComponentInfos).toEqual({}); }); + it('should not count variables declared in iife in refs', () => { + const field = new FieldModel('{{(function() {const foo = "bar"})() }}'); + expect(field.isDynamic).toEqual(true); + expect(field.refComponentInfos).toEqual({}); + }); + + it('should not count object keys in refs', () => { + const field = new FieldModel('{{ {foo: 1, bar: 2, baz: 3, } }}'); + expect(field.isDynamic).toEqual(true); + expect(field.refComponentInfos).toEqual({}); + }); + + it('should not count keys of object destructuring assignment in refs', () => { + const field = new FieldModel('{{ ({foo: bar}) => bar }}'); + expect(field.isDynamic).toEqual(true); + expect(field.refComponentInfos).toEqual({}); + }); + + it('should not count keys of array destructuring assignment in refs', () => { + const field = new FieldModel('{{ ([bar]) => bar }}'); + expect(field.isDynamic).toEqual(true); + expect(field.refComponentInfos).toEqual({}); + }); + it('get value by path', () => { const field = new FieldModel({ foo: [{}, { bar: { baz: 'Hello, world!' } }] }); expect(field.getPropertyByPath('foo.1.bar.baz')?.getValue()).toEqual('Hello, world!'); diff --git a/packages/editor/__tests__/validator/component.spec.ts b/packages/editor/__tests__/validator/component.spec.ts index ab0002a3..b9394160 100644 --- a/packages/editor/__tests__/validator/component.spec.ts +++ b/packages/editor/__tests__/validator/component.spec.ts @@ -5,6 +5,7 @@ import { UseDependencyInExpressionSchema, LocalVariableInIIFEExpressionSchema, DynamicStateTraitAnyTypeSchema, + NestedObjectExpressionSchema, } from './mock'; import { SchemaValidator } from '../../src/validator'; import { registry } from '../services'; @@ -68,5 +69,11 @@ describe('Validate component', () => { ); expect(_result.length).toBe(0); }); + it('allow use nested object value in expression', () => { + const _result = schemaValidator.validate( + new AppModel(NestedObjectExpressionSchema, registry) + ); + expect(_result.length).toBe(0); + }); }); }); diff --git a/packages/editor/__tests__/validator/mock.ts b/packages/editor/__tests__/validator/mock.ts index 4b25eed5..2b62a848 100644 --- a/packages/editor/__tests__/validator/mock.ts +++ b/packages/editor/__tests__/validator/mock.ts @@ -414,3 +414,44 @@ export const DynamicStateTraitSchema: ComponentSchema[] = [ traits: [], }, ]; + +export const NestedObjectExpressionSchema: ComponentSchema[] = [ + { + id: 'api0', + type: 'core/v1/dummy', + properties: {}, + traits: [ + { + type: 'core/v1/fetch', + properties: { + url: '', + method: 'get', + lazy: false, + disabled: false, + headers: {}, + body: {}, + bodyType: 'json', + onComplete: [ + { + componentId: '', + method: { + name: '', + }, + }, + ], + onError: [ + { + componentId: '', + method: { + name: '', + parameters: {}, + }, + // should not warn api0.fetch.code + disabled: '{{ api0.fetch.code !== 401 }}', + }, + ], + }, + }, + ], + }, +]; diff --git a/packages/editor/src/AppModel/FieldModel.ts b/packages/editor/src/AppModel/FieldModel.ts index 3dcffda8..89cd71de 100644 --- a/packages/editor/src/AppModel/FieldModel.ts +++ b/packages/editor/src/AppModel/FieldModel.ts @@ -20,6 +20,16 @@ import escodegen from 'escodegen'; import { JSONSchema7 } from 'json-schema'; export type FunctionNode = ASTNode & { params: ASTNode[] }; +export type DeclaratorNode = ASTNode & { id: ASTNode }; +export type ObjectPatternNode = ASTNode & { properties: PropertyNode[] }; +export type ArrayPatternNode = ASTNode & { elements: ASTNode[] }; +export type PropertyNode = ASTNode & { value: ASTNode }; +export type LiteralNode = ASTNode & { raw: string }; +export type SequenceExpressionNode = ASTNode & { expressions: LiteralNode[] }; +export type ExpressionNode = ASTNode & { + object: ExpressionNode; + property: ExpressionNode | LiteralNode; +}; export class FieldModel implements IFieldModel { isDynamic = false; refComponentInfos: Record = {}; @@ -158,15 +168,14 @@ export class FieldModel implements IFieldModel { exps.forEach(exp => { let lastIdentifier: ComponentId = '' as ComponentId; const node = (acornLoose as typeof acorn).parse(exp, { ecmaVersion: 2020 }); - this.astNodes[exp] = node as ASTNode; - - // These are varirables of iife, they should be count in refs. - let localVariables: ASTNode[] = []; + // These are variables of iife or other identifiers, they can't be validated + // so they should not be added in refs + let whiteList: ASTNode[] = []; simpleWalk(node, { Function: functionNode => { - localVariables = [...localVariables, ...(functionNode as FunctionNode).params]; + whiteList = [...whiteList, ...(functionNode as FunctionNode).params]; }, Expression: expressionNode => { switch (expressionNode.type) { @@ -180,7 +189,7 @@ export class FieldModel implements IFieldModel { this.refComponentInfos[key].componentIdASTNodes.push( expressionNode as ASTNode ); - } else { + } else if (key) { this.refComponentInfos[key] = { componentIdASTNodes: [expressionNode as ASTNode], refProperties: [], @@ -190,27 +199,61 @@ export class FieldModel implements IFieldModel { break; case 'MemberExpression': - const str = exp.slice(expressionNode.start, expressionNode.end); - let path = str.replace(lastIdentifier, ''); - if (path.startsWith('.')) { - path = path.slice(1, path.length); + if (lastIdentifier) { + this.refComponentInfos[lastIdentifier]?.refProperties.push( + this.genPathFromMemberExpressionNode(expressionNode as ExpressionNode) + ); } - this.refComponentInfos[lastIdentifier]?.refProperties.push(path); + break; + case 'SequenceExpression': + const sequenceExpression = expressionNode as SequenceExpressionNode; + whiteList.push(sequenceExpression.expressions[1]); + break; + case 'Literal': + // do nothing, just stop it from going to default break; default: + // clear lastIdentifier when meet other astNode to break the MemberExpression chain + lastIdentifier = '' as ComponentId; } }, + ObjectPattern: objPatternNode => { + const propertyNodes = (objPatternNode as ObjectPatternNode).properties; + propertyNodes.forEach(property => { + whiteList.push(property.value); + }); + }, + ArrayPattern: arrayPatternNode => { + whiteList = [...whiteList, ...(arrayPatternNode as ArrayPatternNode).elements]; + }, + VariableDeclarator: declarator => { + whiteList.push((declarator as DeclaratorNode).id); + }, }); - - // remove localVariables from refs + // remove whiteList from refs for (const key in this.refComponentInfos) { - if (localVariables.some(({ name }) => key === name)) { + if (whiteList.some(({ name }) => key === name)) { delete this.refComponentInfos[key as any]; } } }); } + private genPathFromMemberExpressionNode(expNode: ExpressionNode) { + const path: string[] = []; + function travel(node: ExpressionNode) { + path.unshift( + node.property?.name || (node.property as LiteralNode)?.raw || node.name + ); + if (node.object) { + travel(node.object); + } + } + + travel(expNode); + return path.slice(1).join('.'); + } + private onReferenceIdChange({ oldId, newId }: AppModelEventType['idChange']) { if (!this.componentModel) { return; diff --git a/packages/editor/src/components/ComponentForm/GeneralTraitFormList/AddTraitButton.tsx b/packages/editor/src/components/ComponentForm/GeneralTraitFormList/AddTraitButton.tsx index 7ebd9fb8..33bd3f25 100644 --- a/packages/editor/src/components/ComponentForm/GeneralTraitFormList/AddTraitButton.tsx +++ b/packages/editor/src/components/ComponentForm/GeneralTraitFormList/AddTraitButton.tsx @@ -10,7 +10,7 @@ import { } from '@chakra-ui/react'; import { RegistryInterface } from '@sunmao-ui/runtime'; import React, { useMemo } from 'react'; -import { ignoreTraitsList } from '../../../constants'; +import { hideCreateTraitsList } from '../../../constants'; import { ComponentSchema } from '@sunmao-ui/core'; type Props = { @@ -30,7 +30,9 @@ export const AddTraitButton: React.FC = props => { [component] ); const traitTypes = useMemo(() => { - return registry.getAllTraitTypes().filter(type => !ignoreTraitsList.includes(type)); + return registry + .getAllTraitTypes() + .filter(type => !hideCreateTraitsList.includes(type)); }, [registry]); const menuItems = traitTypes.map(type => { diff --git a/packages/editor/src/components/ComponentForm/GeneralTraitFormList/GeneralTraitForm.tsx b/packages/editor/src/components/ComponentForm/GeneralTraitFormList/GeneralTraitForm.tsx index 3663cd0c..6d0bd7d4 100644 --- a/packages/editor/src/components/ComponentForm/GeneralTraitFormList/GeneralTraitForm.tsx +++ b/packages/editor/src/components/ComponentForm/GeneralTraitFormList/GeneralTraitForm.tsx @@ -3,7 +3,6 @@ import { ComponentSchema, TraitSchema } from '@sunmao-ui/core'; import { HStack, IconButton, VStack } from '@chakra-ui/react'; import { generateDefaultValueFromSpec } from '@sunmao-ui/shared'; import { CloseIcon } from '@chakra-ui/icons'; -import { TSchema } from '@sinclair/typebox'; import { formWrapperCSS } from '../style'; import { EditorServices } from '../../../types'; import { SpecWidget } from '@sunmao-ui/editor-sdk'; @@ -28,7 +27,10 @@ export const GeneralTraitForm: React.FC = props => { ); const fields = Object.keys(properties || []).map((key: string) => { const value = trait.properties[key]; - const propertySpec = (tImpl.spec.properties as TSchema).properties?.[key]; + const propertySpec = tImpl.spec.properties.properties?.[key]; + + if (!propertySpec) return undefined; + const onChange = (newValue: any) => { const operation = genOperation(registry, 'modifyTraitProperty', { componentId: component.id, @@ -41,15 +43,14 @@ export const GeneralTraitForm: React.FC = props => { eventBus.send('operation', operation); }; + const specObj = propertySpec === true ? {} : propertySpec; + return ( = props => { const [search, setSearch] = useState(''); const { components, onSelectComponent, services } = props; const { editorStore } = services; - const wrapperRef = useRef(null); - + const scrollWrapper = useRef(null); const onSelectOption = useCallback( ({ item }: { item: Item }) => { onSelectComponent(item.value); @@ -49,10 +48,19 @@ export const StructureTree: React.FC = props => { // wait the component tree to be expanded setTimeout(() => { const selectedElement: HTMLElement | undefined | null = - wrapperRef.current?.querySelector(`#tree-item-${selectedId}`); + scrollWrapper.current?.querySelector(`#tree-item-${selectedId}`); - if (selectedElement) { - scrollIntoView(selectedElement, { time: 0, align: { lockX: true } }); + const wrapperRect = scrollWrapper.current?.getBoundingClientRect(); + const eleRect = selectedElement?.getBoundingClientRect(); + if ( + selectedElement && + eleRect && + wrapperRect && + (eleRect.top < wrapperRect.top || + eleRect.top > wrapperRect.top + wrapperRect?.height) + ) { + // check selected element is outside of view + scrollIntoView(selectedElement, { time: 300, align: { lockX: true } }); } }); } @@ -90,7 +98,6 @@ export const StructureTree: React.FC = props => { return ( = props => { - + {componentEles.length > 0 ? ( componentEles ) : ( diff --git a/packages/editor/src/constants/index.ts b/packages/editor/src/constants/index.ts index 6bdca94d..7c4812f4 100644 --- a/packages/editor/src/constants/index.ts +++ b/packages/editor/src/constants/index.ts @@ -4,14 +4,16 @@ import { CORE_VERSION, CoreTraitName } from '@sunmao-ui/shared'; export const unremovableTraits = [`${CORE_VERSION}/${CoreTraitName.Slot}`]; -export const ignoreTraitsList = [ +export const hideCreateTraitsList = [ `${CORE_VERSION}/${CoreTraitName.Event}`, `${CORE_VERSION}/${CoreTraitName.Style}`, `${CORE_VERSION}/${CoreTraitName.Fetch}`, + `${CORE_VERSION}/${CoreTraitName.Slot}`, ]; export const hasSpecialFormTraitList = [ - ...ignoreTraitsList, + `${CORE_VERSION}/${CoreTraitName.Event}`, + `${CORE_VERSION}/${CoreTraitName.Style}`, `${CORE_VERSION}/${CoreTraitName.Fetch}`, ]; diff --git a/packages/editor/src/main.tsx b/packages/editor/src/main.tsx index fa22bfca..e4078d6a 100644 --- a/packages/editor/src/main.tsx +++ b/packages/editor/src/main.tsx @@ -1,9 +1,8 @@ import { StrictMode } from 'react'; import ReactDOM from 'react-dom'; import { RegistryInterface } from '@sunmao-ui/runtime'; -import { sunmaoChakraUILib } from '@sunmao-ui/chakra-ui-lib'; -import { widgets as chakraWidgets } from '@sunmao-ui/chakra-ui-lib/dist/esm/widgets/index'; -import { ArcoDesignLib } from '@sunmao-ui/arco-lib'; +import { sunmaoChakraUILib, widgets as chakraWidgets } from '@sunmao-ui/chakra-ui-lib'; +import { ArcoDesignLib, widgets as arcoWidgets } from '@sunmao-ui/arco-lib'; import { initSunmaoUIEditor } from './init'; import { LocalStorageManager } from './LocalStorageManager'; import '@sunmao-ui/arco-lib/dist/index.css'; @@ -17,7 +16,7 @@ type Options = Partial<{ const lsManager = new LocalStorageManager(); const { Editor, registry } = initSunmaoUIEditor({ - widgets: [...chakraWidgets], + widgets: [...chakraWidgets, ...arcoWidgets], storageHandler: { onSaveApp(app) { lsManager.saveAppInLS(app); diff --git a/packages/editor/src/validator/rules/PropertiesRules.ts b/packages/editor/src/validator/rules/PropertiesRules.ts index 69929d1d..d1e05350 100644 --- a/packages/editor/src/validator/rules/PropertiesRules.ts +++ b/packages/editor/src/validator/rules/PropertiesRules.ts @@ -77,7 +77,7 @@ class ExpressionValidatorRule implements PropertiesValidatorRule { private checkObjHasPath(obj: Record, path: string) { const arr = path.split('.'); - const curr = obj; + let curr = obj; for (const key of arr) { const value = curr[key]; if (value === undefined) { @@ -86,6 +86,7 @@ class ExpressionValidatorRule implements PropertiesValidatorRule { // if meet AnyTypePlaceholder, return true and skip return true; } + curr = value; } return true; } @@ -102,6 +103,7 @@ class ExpressionValidatorRule implements PropertiesValidatorRule { // validate expression properties.traverse((fieldModel, key) => { Object.keys(fieldModel.refComponentInfos).forEach((id: string) => { + if (!id) return; const targetComponent = appModel.getComponentById(id as ComponentId); const paths = fieldModel.refComponentInfos[id as ComponentId].refProperties; diff --git a/packages/editor/src/validator/rules/TraitRules.ts b/packages/editor/src/validator/rules/TraitRules.ts index d62cb7ad..b4180870 100644 --- a/packages/editor/src/validator/rules/TraitRules.ts +++ b/packages/editor/src/validator/rules/TraitRules.ts @@ -8,11 +8,28 @@ import { GLOBAL_UTIL_METHOD_ID } from '@sunmao-ui/runtime'; import { isExpression } from '../utils'; import { ComponentId, EventName } from '../../AppModel/IAppModel'; import { CORE_VERSION, CoreTraitName, EventHandlerSpec } from '@sunmao-ui/shared'; +import { get } from 'lodash'; +import { ErrorObject } from 'ajv'; class EventHandlerValidatorRule implements TraitValidatorRule { kind: 'trait' = 'trait'; traitMethods = ['setValue', 'resetValue', 'triggerFetch']; + private isErrorAnExpression( + error: ErrorObject, + parameters: Record | undefined + ) { + let path = ''; + const { instancePath, params } = error; + if (instancePath) { + path = instancePath.split('/').slice(1).join('.'); + } else { + path = params.missingProperty; + } + const field = get(parameters, path); + return isExpression(field); + } + validate({ appModel, trait, @@ -87,13 +104,16 @@ class EventHandlerValidatorRule implements TraitValidatorRule { const valid = validate(parameters); if (!valid) { validate.errors!.forEach(error => { - results.push({ - message: error.message || '', - componentId: component.id, - property: error.instancePath, - traitType: trait?.type, - traitIndex, - }); + if (!this.isErrorAnExpression(error, parameters)) { + results.push({ + message: error.message || '', + componentId: component.id, + property: error.instancePath, + traitType: trait?.type, + traitIndex, + }); + return results; + } }); } } else { @@ -125,22 +145,14 @@ class EventHandlerValidatorRule implements TraitValidatorRule { // check component method properties type if (method.parameters && !ajv.validate(method.parameters, parameters)) { ajv.errors!.forEach(error => { - if (error.keyword === 'type') { - const { instancePath } = error; - const path = instancePath.split('/')[1]; - const value = trait.rawProperties[path]; - - // if value is an expression, skip it - if (isExpression(value)) { - return; - } + if (!this.isErrorAnExpression(error, parameters)) { + results.push({ + message: error.message || '', + componentId: component.id, + traitType: trait.type, + property: `/handlers/${i}/method/parameters${error.instancePath}`, + }); } - results.push({ - message: error.message || '', - componentId: component.id, - traitType: trait.type, - property: `/handlers/${i}/method/parameters${error.instancePath}`, - }); }); } } diff --git a/packages/editor/types/widgets.d.ts b/packages/editor/types/widgets.d.ts index a86b1479..2db0f345 100644 --- a/packages/editor/types/widgets.d.ts +++ b/packages/editor/types/widgets.d.ts @@ -1,3 +1,6 @@ declare module '@sunmao-ui/chakra-ui-lib/dist/esm/widgets/index' { export * from '@sunmao-ui/chakra-ui-lib/lib/widgets/index'; } +declare module '@sunmao-ui/arco-lib/dist/esm/widgets/index' { + export * from '@sunmao-ui/arco-lib/lib/widgets/index'; +} diff --git a/packages/runtime/__tests__/ImplWrapper/ImplWrapper.spec.tsx b/packages/runtime/__tests__/ImplWrapper/ImplWrapper.spec.tsx index bf38700f..2add3789 100644 --- a/packages/runtime/__tests__/ImplWrapper/ImplWrapper.spec.tsx +++ b/packages/runtime/__tests__/ImplWrapper/ImplWrapper.spec.tsx @@ -1,6 +1,7 @@ import '@testing-library/jest-dom/extend-expect'; import { render, screen, waitFor, act } from '@testing-library/react'; import produce from 'immer'; +import React from 'react'; import { initSunmaoUI } from '../../src'; import { TestLib } from '../testLib'; import { destroyTimesMap, renderTimesMap, clearTesterMap } from '../testLib/Tester'; @@ -11,6 +12,7 @@ import { MergeStateSchema, AsyncMergeStateSchema, TabsWithSlotsSchema, + ParentRerenderSchema, } from './mockSchema'; // A pure single sunmao component will render twice when it mount. @@ -68,6 +70,28 @@ describe('hidden trait condition', () => { }); }); +describe('when parent rerender change', () => { + it('the children should not rerender', () => { + const { App, stateManager, apiService } = initSunmaoUI({ libs: [TestLib] }); + stateManager.noConsoleError = true; + const { unmount } = render(); + const childTester = screen.getByTestId('tester'); + expect(childTester).toHaveTextContent(SingleComponentRenderTimes); + act(() => { + apiService.send('uiMethod', { + componentId: 'input', + name: 'setValue', + parameters: 'foo', + }); + }); + expect(childTester).toHaveTextContent(SingleComponentRenderTimes); + expect(stateManager.store['input'].value).toBe('foo'); + + unmount(); + clearTesterMap(); + }); +}); + describe('when component merge state synchronously', () => { it('it will not cause extra render', () => { const { App, stateManager } = initSunmaoUI({ libs: [TestLib] }); diff --git a/packages/runtime/__tests__/ImplWrapper/mockSchema.ts b/packages/runtime/__tests__/ImplWrapper/mockSchema.ts index 47ba3644..442484c6 100644 --- a/packages/runtime/__tests__/ImplWrapper/mockSchema.ts +++ b/packages/runtime/__tests__/ImplWrapper/mockSchema.ts @@ -84,6 +84,55 @@ export const HiddenTraitSchema: Application = { }, }; +export const ParentRerenderSchema: Application = { + version: 'sunmao/v1', + kind: 'Application', + metadata: { + name: 'some App', + }, + spec: { + components: [ + { + id: 'input', + type: 'test/v1/input', + properties: { + defaultValue: '', + }, + traits: [], + }, + { + id: 'stack6', + type: 'core/v1/stack', + properties: { + spacing: 12, + direction: 'horizontal', + align: 'auto', + wrap: '{{!!input.value}}', + justify: 'flex-start', + }, + traits: [], + }, + { + id: 'tester', + type: 'test/v1/tester', + properties: {}, + traits: [ + { + type: 'core/v1/slot', + properties: { + container: { + id: 'stack6', + slot: 'content', + }, + ifCondition: true, + }, + }, + ], + }, + ], + }, +}; + export const MergeStateSchema: Application = { version: 'sunmao/v1', kind: 'Application', diff --git a/packages/runtime/__tests__/expression.spec.ts b/packages/runtime/__tests__/expression.spec.ts index 92d72fe6..c8be4539 100644 --- a/packages/runtime/__tests__/expression.spec.ts +++ b/packages/runtime/__tests__/expression.spec.ts @@ -1,3 +1,4 @@ +import { isProxy, reactive } from '@vue/reactivity'; import { StateManager, ExpressionError } from '../src/services/StateManager'; describe('evalExpression function', () => { @@ -24,67 +25,72 @@ describe('evalExpression function', () => { }, }; const stateManager = new StateManager(); + stateManager.store = reactive>(scope); stateManager.noConsoleError = true; it('can eval {{}} expression', () => { - const evalOptions = { evalListItem: false, scopeObject: scope }; + const evalOptions = { evalListItem: false }; - expect(stateManager.maskedEval('value', evalOptions)).toEqual('value'); - expect(stateManager.maskedEval('{{true}}', evalOptions)).toEqual(true); - expect(stateManager.maskedEval('{{ false }}', evalOptions)).toEqual(false); - expect(stateManager.maskedEval('{{[]}}', evalOptions)).toEqual([]); - expect(stateManager.maskedEval('{{ [] }}', evalOptions)).toEqual([]); - expect(stateManager.maskedEval('{{ [1,2,3] }}', evalOptions)).toEqual([1, 2, 3]); + expect(stateManager.deepEval('value', evalOptions)).toEqual('value'); + expect(stateManager.deepEval('{{true}}', evalOptions)).toEqual(true); + expect(stateManager.deepEval('{{ false }}', evalOptions)).toEqual(false); + expect(stateManager.deepEval('{{[]}}', evalOptions)).toEqual([]); + expect(stateManager.deepEval('{{ [] }}', evalOptions)).toEqual([]); + expect(stateManager.deepEval('{{ [1,2,3] }}', evalOptions)).toEqual([1, 2, 3]); - expect(stateManager.maskedEval('{{ {} }}', evalOptions)).toEqual({}); - expect(stateManager.maskedEval('{{ {id: 123} }}', evalOptions)).toEqual({ id: 123 }); - expect(stateManager.maskedEval('{{nothing}}', evalOptions)).toBeInstanceOf( + expect(stateManager.deepEval('{{ {} }}', evalOptions)).toEqual({}); + expect(stateManager.deepEval('{{ {id: 123} }}', evalOptions)).toEqual({ id: 123 }); + expect(stateManager.deepEval('{{nothing}}', evalOptions)).toBeInstanceOf( ExpressionError ); - expect(stateManager.maskedEval('{{input1.value}}', evalOptions)).toEqual('world'); - expect(stateManager.maskedEval('{{checkbox.value}}', evalOptions)).toEqual(true); - expect(stateManager.maskedEval('{{fetch.data}}', evalOptions)).toMatchObject([ + expect(stateManager.deepEval('{{input1.value}}', evalOptions)).toEqual('world'); + expect(stateManager.deepEval('{{checkbox.value}}', evalOptions)).toEqual(true); + expect(stateManager.deepEval('{{fetch.data}}', evalOptions)).toMatchObject([ { id: 1 }, { id: 2 }, ]); - expect(stateManager.maskedEval('{{{{}}}}', evalOptions)).toEqual(undefined); + expect(stateManager.deepEval('{{{{}}}}', evalOptions)).toEqual(undefined); expect( - stateManager.maskedEval('{{ value }}, {{ input1.value }}!', evalOptions) + stateManager.deepEval('{{ value }}, {{ input1.value }}!', evalOptions) ).toEqual('Hello, world!'); }); + it('will not return Proxy', () => { + const evalOptions = { evalListItem: false }; + const fetchData = stateManager.deepEval('{{fetch.data}}', evalOptions); + expect(isProxy(fetchData)).toBe(false); + }); + it('can eval $listItem expression', () => { expect( - stateManager.maskedEval('{{ $listItem.value }}', { + stateManager.deepEval('{{ $listItem.value }}', { evalListItem: false, - scopeObject: scope, }) ).toEqual('{{ $listItem.value }}'); expect( - stateManager.maskedEval('{{ $listItem.value }}', { + stateManager.deepEval('{{ $listItem.value }}', { evalListItem: true, - scopeObject: scope, }) ).toEqual('foo'); expect( - stateManager.maskedEval( + stateManager.deepEval( '{{ {{$listItem.value}}Input.value + {{$moduleId}}Fetch.value }}!', - { evalListItem: true, scopeObject: scope } + { evalListItem: true } ) ).toEqual('Yes, ok!'); }); it('can override scope', () => { expect( - stateManager.maskedEval('{{value}}', { + stateManager.deepEval('{{value}}', { scopeObject: { override: 'foo' }, overrideScope: true, }) ).toBeInstanceOf(ExpressionError); expect( - stateManager.maskedEval('{{override}}', { + stateManager.deepEval('{{override}}', { scopeObject: { override: 'foo' }, overrideScope: true, }) @@ -93,7 +99,7 @@ describe('evalExpression function', () => { it('can fallback to specific value when error', () => { expect( - stateManager.maskedEval('{{wrongExp}}', { + stateManager.deepEval('{{wrongExp}}', { fallbackWhenError: exp => exp, }) ).toEqual('{{wrongExp}}'); @@ -101,7 +107,7 @@ describe('evalExpression function', () => { it('can partially eval nest expression, even when some error happens', () => { expect( - stateManager.maskedEval('{{text}} {{{{ $moduleId }}__state0.value}}', { + stateManager.deepEval('{{text}} {{{{ $moduleId }}__state0.value}}', { scopeObject: { $moduleId: 'myModule', text: 'hello', @@ -110,4 +116,46 @@ describe('evalExpression function', () => { }) ).toEqual(`hello {{myModule__state0.value}}`); }); + + it('can watch the state change in the object value', () => { + const stateManager = new StateManager(); + + stateManager.noConsoleError = true; + stateManager.store.text = { value: 'hello' }; + + return new Promise(resolve => { + const { result } = stateManager.deepEvalAndWatch( + { text: '{{ text.value }}' }, + newValue => { + expect(newValue.result.text).toBe('bye'); + resolve(); + } + ); + + expect(result.text).toBe('hello'); + + stateManager.store.text.value = 'bye'; + }); + }); + + it('can watch the state change in the expression string', () => { + const stateManager = new StateManager(); + + stateManager.noConsoleError = true; + stateManager.store.text = { value: 'hello' }; + + return new Promise(resolve => { + const { result, stop } = stateManager.deepEvalAndWatch( + '{{ text.value }}', + newValue => { + expect(newValue.result).toBe('bye'); + resolve(); + } + ); + + expect(result).toBe('hello'); + + stateManager.store.text.value = 'bye'; + }); + }); }); 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/components/_internal/ImplWrapper/ImplWrapper.tsx b/packages/runtime/src/components/_internal/ImplWrapper/ImplWrapper.tsx index 7f2f7921..c667502a 100644 --- a/packages/runtime/src/components/_internal/ImplWrapper/ImplWrapper.tsx +++ b/packages/runtime/src/components/_internal/ImplWrapper/ImplWrapper.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { ImplWrapperProps } from '../../../types'; import { shallowCompare } from '@sunmao-ui/shared'; import { UnmountImplWrapper } from './UnmountImplWrapper'; +import { isEqual } from 'lodash'; export const ImplWrapper = React.memo( UnmountImplWrapper, @@ -10,20 +11,19 @@ export const ImplWrapper = React.memo( const nextChildren = nextProps.childrenMap[nextProps.component.id]?._grandChildren; const prevComponent = prevProps.component; const nextComponent = nextProps.component; - let isEqual = false; + let isComponentEqual = false; if (prevChildren && nextChildren) { - isEqual = shallowCompare(prevChildren, nextChildren); + isComponentEqual = shallowCompare(prevChildren, nextChildren); } else if (prevChildren === nextChildren) { - isEqual = true; + isComponentEqual = true; } - return ( - isEqual && + isComponentEqual && prevComponent === nextComponent && // TODO: keep ImplWrapper memorized and get slot props from store shallowCompare(prevProps.slotProps, nextProps.slotProps) && - shallowCompare(prevProps.slotContext, nextProps.slotContext) + isEqual(prevProps.slotContext, nextProps.slotContext) ); } ); diff --git a/packages/runtime/src/components/_internal/ModuleRenderer.tsx b/packages/runtime/src/components/_internal/ModuleRenderer.tsx index 18124584..ebb2725d 100644 --- a/packages/runtime/src/components/_internal/ModuleRenderer.tsx +++ b/packages/runtime/src/components/_internal/ModuleRenderer.tsx @@ -38,21 +38,17 @@ const ModuleRendererContent = React.forwardRef< Props & { moduleSpec: ImplementedRuntimeModule } >((props, ref) => { const { moduleSpec, properties, handlers, evalScope, services, app } = props; - const moduleId = services.stateManager.maskedEval(props.id, { + const moduleId = services.stateManager.deepEval(props.id, { evalListItem: true, scopeObject: evalScope, }) as string | ExpressionError; function evalObject = Record>( obj: T - ): PropsAfterEvaled<{ obj: T }>['obj'] { + ): PropsAfterEvaled { const evalOptions = { evalListItem: true, scopeObject: evalScope }; - return services.stateManager.mapValuesDeep({ obj }, ({ value }) => { - if (typeof value === 'string') { - return services.stateManager.maskedEval(value, evalOptions); - } - return value; - }).obj; + + return services.stateManager.deepEval(obj, evalOptions) as PropsAfterEvaled; } // first eval the property, handlers, id of module diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 72f15665..84cd0f1f 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -70,6 +70,30 @@ export type { StateManagerInterface } from './services/StateManager'; export { ModuleRenderer } from './components/_internal/ModuleRenderer'; export { ImplWrapper } from './components/_internal/ImplWrapper'; export { default as Text, TextPropertySpec } from './components/_internal/Text'; +export { + // constants + PRESET_PROPERTY_CATEGORY, + CORE_VERSION, + CoreComponentName, + CoreTraitName, + CoreWidgetName, + StyleWidgetName, + EXPRESSION, + LIST_ITEM_EXP, + LIST_ITEM_INDEX_EXP, + SLOT_PROPS_EXP, + GLOBAL_UTIL_METHOD_ID, + GLOBAL_MODULE_ID, + ExpressionKeywords, + AnyTypePlaceholder, + // specs + EventHandlerSpec, + EventCallBackHandlerSpec, + ModuleRenderSpec, + // utils + StringUnion, + generateDefaultValueFromSpec, +} from '@sunmao-ui/shared'; // TODO: check this export export { watch } from './utils/watchReactivity'; diff --git a/packages/runtime/src/services/StateManager.ts b/packages/runtime/src/services/StateManager.ts index e7160564..bc5a7d4a 100644 --- a/packages/runtime/src/services/StateManager.ts +++ b/packages/runtime/src/services/StateManager.ts @@ -29,6 +29,8 @@ type EvalOptions = { ignoreEvalError?: boolean; }; +type EvaledResult = T extends string ? unknown : PropsAfterEvaled>; + // TODO: use web worker const DefaultDependencies = { dayjs, @@ -94,7 +96,7 @@ export class StateManager { } }; - maskedEval(raw: string, options: EvalOptions = {}): unknown | ExpressionError { + private _maskedEval(raw: string, options: EvalOptions = {}): unknown | ExpressionError { const { evalListItem = false, fallbackWhenError } = options; let result: unknown[] = []; @@ -117,6 +119,7 @@ export class StateManager { result = expChunk.map(e => this.evalExp(e, options)); if (result.length === 1) { + if (isProxy(result[0])) return toRaw(result[0]); return result[0]; } return result.join(''); @@ -134,6 +137,13 @@ export class StateManager { } } + /** + * @deprecated please use the `deepEval` instead + */ + maskedEval(raw: string, options: EvalOptions = {}): unknown | ExpressionError { + return this.deepEval(raw, options); + } + mapValuesDeep( obj: T, fn: (params: { @@ -157,58 +167,74 @@ export class StateManager { }) as PropsAfterEvaled; } - deepEval | any[]>( - obj: T, + deepEval | any[] | string>( + value: T, options: EvalOptions = {} - ): PropsAfterEvaled { + ): EvaledResult { // just eval - const evaluated = this.mapValuesDeep(obj, ({ value }) => { - if (typeof value !== 'string') { - return value; - } - return this.maskedEval(value, options); - }); - - return evaluated; + if (typeof value !== 'string') { + return this.mapValuesDeep(value, ({ value }) => { + if (typeof value !== 'string') { + return value; + } + return this._maskedEval(value, options); + }) as EvaledResult; + } else { + return this._maskedEval(value, options) as EvaledResult; + } } - deepEvalAndWatch | any[]>( - obj: T, - watcher: (params: { result: PropsAfterEvaled }) => void, + deepEvalAndWatch | any[] | string>( + value: T, + watcher: (params: { result: EvaledResult }) => void, options: EvalOptions = {} ) { const stops: ReturnType[] = []; // just eval - const evaluated = this.deepEval(obj, options); + const evaluated = this.deepEval(value, options) as T extends string + ? unknown + : PropsAfterEvaled>; // watch change - let resultCache: PropsAfterEvaled = evaluated; - this.mapValuesDeep(obj, ({ value, path }) => { - const isDynamicExpression = - typeof value === 'string' && - parseExpression(value).some(exp => typeof exp !== 'string'); + if (value && typeof value === 'object') { + let resultCache = evaluated as PropsAfterEvaled>; - if (!isDynamicExpression) return; + this.mapValuesDeep(value, ({ value, path }) => { + const isDynamicExpression = + typeof value === 'string' && + parseExpression(value).some(exp => typeof exp !== 'string'); + if (!isDynamicExpression) return; + + const stop = watch( + () => { + const result = this._maskedEval(value as string, options); + + return result; + }, + newV => { + resultCache = produce(resultCache, draft => { + set(draft, path, newV); + }); + watcher({ result: resultCache as EvaledResult }); + } + ); + stops.push(stop); + }); + } else { const stop = watch( () => { - const result = this.maskedEval(value as string, options); + const result = this._maskedEval(value, options); return result; }, newV => { - if (isProxy(newV)) { - newV = toRaw(newV); - } - resultCache = produce(resultCache, draft => { - set(draft, path, newV); - }); - watcher({ result: resultCache }); + watcher({ result: newV as EvaledResult }); } ); stops.push(stop); - }); + } return { result: evaluated, diff --git a/packages/runtime/src/traits/core/Event.tsx b/packages/runtime/src/traits/core/Event.tsx index 04e08be3..a7db33e9 100644 --- a/packages/runtime/src/traits/core/Event.tsx +++ b/packages/runtime/src/traits/core/Event.tsx @@ -31,12 +31,9 @@ export const generateCallback = ( scopeObject: { $slot: slotProps }, evalListItem, }; - const evaledHandlers = - typeof rawHandlers === 'string' - ? (stateManager.maskedEval(rawHandlers, evalOptions) as Static< - typeof EventCallBackHandlerSpec - >[]) - : stateManager.deepEval(rawHandlers, evalOptions); + const evaledHandlers = stateManager.deepEval(rawHandlers, evalOptions) as Static< + typeof EventCallBackHandlerSpec + >[]; const evaledHandler = evaledHandlers[index]; if (evaledHandler.disabled && typeof evaledHandler.disabled === 'boolean') { 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); + }, + ], + }, }; }; }); diff --git a/packages/shared/__tests__/spec.spec.ts b/packages/shared/__tests__/spec.spec.ts index 16c56e67..3f57bc10 100644 --- a/packages/shared/__tests__/spec.spec.ts +++ b/packages/shared/__tests__/spec.spec.ts @@ -17,14 +17,9 @@ describe('generateDefaultValueFromSpec function', () => { const type = Type.Number(); expect(generateDefaultValueFromSpec(type)).toEqual(0); }); - // Type.Optional can only be judged by the modifier feature provided by the typebox, - // but this would break the consistency of the function, - // and it doesn't seem to make much sense to deal with non-object optional alone like Type.Optional(Type.String()) - // Therefore it is possible to determine whether an object's property is optional using spec.required, - // and if the property is within Type.Object is optional then it is not required. - it('can parse optional', () => { + it('can parse optional and the value is the default value of its type', () => { const type = Type.Optional(Type.Object({ str: Type.Optional(Type.String()) })); - expect(generateDefaultValueFromSpec(type)).toEqual({ str: undefined }); + expect(generateDefaultValueFromSpec(type)).toEqual({ str: '' }); }); it('can parse object', () => { const type = Type.Object({ @@ -98,5 +93,13 @@ describe('generateDefaultValueFromSpec function', () => { it('can parse any type', () => { expect(generateDefaultValueFromSpec({})).toEqual(''); expect(generateDefaultValueFromSpec({}, true)).toEqual(AnyTypePlaceholder); + expect( + ( + generateDefaultValueFromSpec( + Type.Object({ foo: Type.Object({ bar: Type.Any() }) }), + true + ) as any + ).foo.bar + ).toEqual(AnyTypePlaceholder); }); }); diff --git a/packages/shared/src/utils/spec.ts b/packages/shared/src/utils/spec.ts index ef496aa1..4b67a51f 100644 --- a/packages/shared/src/utils/spec.ts +++ b/packages/shared/src/utils/spec.ts @@ -23,32 +23,50 @@ export function StringUnion(values: [...T], options?: any) { ); } -function getArray(items: JSONSchema7Definition[]): JSONSchema7Type[] { +function getArray( + items: JSONSchema7Definition[], + returnPlaceholderForAny = false +): JSONSchema7Type[] { return items.map(item => - isJSONSchema(item) ? generateDefaultValueFromSpec(item) : null + isJSONSchema(item) + ? generateDefaultValueFromSpec(item, returnPlaceholderForAny) + : null ); } -function getObject(spec: JSONSchema7): JSONSchema7Object { +function getObject( + spec: JSONSchema7, + returnPlaceholderForAny = false +): JSONSchema7Object | string { const obj: JSONSchema7Object = {}; - const requiredKeys = spec.required; if (spec.allOf && spec.allOf.length > 0) { - return (getArray(spec.allOf) as JSONSchema7Object[]).reduce((prev, cur) => { - prev = Object.assign(prev, cur); - return prev; - }, obj); + return (getArray(spec.allOf, returnPlaceholderForAny) as JSONSchema7Object[]).reduce( + (prev, cur) => { + prev = Object.assign(prev, cur); + return prev; + }, + obj + ); } - requiredKeys && - requiredKeys.forEach(key => { - const subSpec = spec.properties?.[key]; - if (typeof subSpec === 'boolean') { - obj[key] = null; - } else if (subSpec) { - obj[key] = generateDefaultValueFromSpec(subSpec); - } - }); + // if not specific property, treat it as any type + if (!spec.properties) { + if (returnPlaceholderForAny) { + return AnyTypePlaceholder; + } + + return {}; + } + + for (const key in spec.properties) { + const subSpec = spec.properties?.[key]; + if (typeof subSpec === 'boolean') { + obj[key] = null; + } else if (subSpec) { + obj[key] = generateDefaultValueFromSpec(subSpec, returnPlaceholderForAny); + } + } return obj; } @@ -60,7 +78,7 @@ export function generateDefaultValueFromSpec( if ((spec.anyOf && spec.anyOf!.length > 0) || (spec.oneOf && spec.oneOf.length > 0)) { const subSpec = (spec.anyOf! || spec.oneOf)[0]; if (typeof subSpec === 'boolean') return null; - return generateDefaultValueFromSpec(subSpec); + return generateDefaultValueFromSpec(subSpec, returnPlaceholderForAny); } // It is any type @@ -79,7 +97,7 @@ export function generateDefaultValueFromSpec( const subSpec = { type: spec.type[0], } as JSONSchema7; - return generateDefaultValueFromSpec(subSpec); + return generateDefaultValueFromSpec(subSpec, returnPlaceholderForAny); } case spec.type === 'string': if (spec.enum && spec.enum.length > 0) { @@ -92,16 +110,16 @@ export function generateDefaultValueFromSpec( case spec.type === 'array': return spec.items ? Array.isArray(spec.items) - ? getArray(spec.items) + ? getArray(spec.items, returnPlaceholderForAny) : isJSONSchema(spec.items) - ? [generateDefaultValueFromSpec(spec.items)] + ? [generateDefaultValueFromSpec(spec.items, returnPlaceholderForAny)] : null : []; case spec.type === 'number': case spec.type === 'integer': return 0; case spec.type === 'object': - return getObject(spec); + return getObject(spec, returnPlaceholderForAny); case spec.type === 'null': return null; default: