mirror of
https://github.com/smartxworks/sunmao-ui.git
synced 2025-04-06 21:40:23 +08:00
Merge branch 'develop' into publish
* develop: (36 commits) fix(stateManager): add toRaw in maskEval move toRaw logic to maskEval to make sure component will not receive Proxy fix(editor): add a dependency array on a effect hook fix(editor-sdk): remove a wrong attribute fix(editor): fix the bug of chakra/checkbox with correct way feat: watch the expression string change chore: change the commit rules refactor: let the \`deepEval\` support eval the expression string fix(editor): fix a bug which will cause the mask of chakra-ui/checkbox render incorrectly fix(validator): ignore error if event trait parameter is expression fix(validator): don't count keys of array destructuring assignment in property refs fix(validator): don't count keys of object destructuring assignment in property refs fix(validator): fieldModel should not count object keys in property ref fix(validator): fix the bug that property reference is wrong in complex expression chore(validator): remove a log-line chore(validator): comment out a log-line fix(validator): if a object json schema does not have specific properties, treat it as any type fix(validator): validator will crash when it fails to find util method fix(validator): allow using question mask in expression fix(ImplWrapper): deep compare slotContext in memo fix(editor): import chakra arco widgets correctly ...
This commit is contained in:
commit
133c590343
@ -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'],
|
||||
};
|
||||
|
18
README.md
18
README.md
@ -18,21 +18,21 @@
|
||||
</a>
|
||||
</p>
|
||||
|
||||
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)
|
||||
|
@ -1 +1,6 @@
|
||||
module.exports = {extends: ['@commitlint/config-conventional']}
|
||||
module.exports = {
|
||||
extends: ['@commitlint/config-conventional'],
|
||||
rules: {
|
||||
'header-max-length': [2, 'always', 72],
|
||||
},
|
||||
};
|
||||
|
@ -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: {
|
||||
|
@ -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: {
|
||||
|
@ -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."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
@ -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."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
855
examples/form/formValidation.json
Normal file
855
examples/form/formValidation.json
Normal file
@ -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": []
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
@ -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 }}"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
@ -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."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
@ -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",
|
||||
|
@ -74,6 +74,14 @@ const rowClickStyle = css`
|
||||
|
||||
export const exampleProperties: Static<typeof TablePropsSpec> = {
|
||||
columns: [
|
||||
{
|
||||
title: 'Key',
|
||||
dataIndex: 'key',
|
||||
type: 'text',
|
||||
displayValue: '',
|
||||
filter: false,
|
||||
componentSlotIndex: 0,
|
||||
},
|
||||
{
|
||||
title: 'Name',
|
||||
dataIndex: 'name',
|
||||
@ -107,11 +115,12 @@ export const exampleProperties: Static<typeof TablePropsSpec> = {
|
||||
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<TableInstance | null>(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<number>(
|
||||
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<SortRule | null>(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 (
|
||||
<BaseTable
|
||||
ref={ref}
|
||||
rowKey={rowKey}
|
||||
className={css`
|
||||
${customStyle?.content}
|
||||
${rowClick ? rowClickStyle : ''}
|
||||
@ -503,6 +539,7 @@ export const Table = implementRuntimeComponent({
|
||||
});
|
||||
},
|
||||
onChange(selectedRowKeys, selectedRows) {
|
||||
currentChecked.current = selectedRowKeys;
|
||||
mergeState({
|
||||
selectedRowKeys: selectedRowKeys as string[],
|
||||
selectedRows,
|
||||
|
1
packages/arco-lib/src/constants/widgets.ts
Normal file
1
packages/arco-lib/src/constants/widgets.ts
Normal file
@ -0,0 +1 @@
|
||||
export const ARCO_V1_VERSION = 'arco/v1';
|
@ -301,6 +301,13 @@ export const TablePropsSpec = Type.Object({
|
||||
},
|
||||
weight: 0,
|
||||
}),
|
||||
rowKey: Type.Any({
|
||||
title: 'RowKey',
|
||||
category: Category.Columns,
|
||||
widget: 'arco/v1/primaryKey',
|
||||
description:
|
||||
'This optional is used to select a unique key for any given row from columns.',
|
||||
}),
|
||||
tableLayoutFixed: Type.Boolean({
|
||||
title: 'Layout Fixed',
|
||||
description:
|
||||
|
@ -1 +1,2 @@
|
||||
export { ArcoDesignLib } from './lib';
|
||||
export { widgets } from './widgets';
|
||||
|
49
packages/arco-lib/src/widgets/TablePrimaryKeyWidget.tsx
Normal file
49
packages/arco-lib/src/widgets/TablePrimaryKeyWidget.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
import { WidgetProps, implementWidget } from '@sunmao-ui/editor-sdk';
|
||||
import { ColumnSpec } from '../generated/types/Table';
|
||||
import { Static } from '@sinclair/typebox';
|
||||
import { Select } from '@arco-design/web-react';
|
||||
import { ARCO_V1_VERSION } from '../constants/widgets';
|
||||
|
||||
type TablePrimaryKeyWidget = 'arco/v1/primaryKey';
|
||||
|
||||
declare module '@sunmao-ui/editor-sdk' {
|
||||
interface WidgetOptionsMap {
|
||||
'arco/v1/primaryKey': Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
|
||||
export const TablePrimaryKeyWidget: React.FC<
|
||||
WidgetProps<TablePrimaryKeyWidget, string>
|
||||
> = props => {
|
||||
const { value, onChange, component } = props;
|
||||
const { properties } = component;
|
||||
const columns = properties.columns as Static<typeof ColumnSpec>[];
|
||||
|
||||
const keys = columns.map(c => c.dataIndex);
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={value}
|
||||
defaultValue="auto"
|
||||
onChange={value => {
|
||||
onChange(value);
|
||||
}}
|
||||
>
|
||||
{keys.map(key => {
|
||||
return (
|
||||
<Select.Option key={key} value={key}>
|
||||
{key}
|
||||
</Select.Option>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
||||
export default implementWidget<TablePrimaryKeyWidget>({
|
||||
version: ARCO_V1_VERSION,
|
||||
metadata: {
|
||||
name: 'primaryKey',
|
||||
},
|
||||
})(TablePrimaryKeyWidget);
|
3
packages/arco-lib/src/widgets/index.ts
Normal file
3
packages/arco-lib/src/widgets/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import TablePrimaryKeyWidget from './TablePrimaryKeyWidget';
|
||||
|
||||
export const widgets = [TablePrimaryKeyWidget];
|
@ -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<HTMLInputElement>(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<ReturnType<typeof getColorSchemePropertySpec>>;
|
||||
size?: Static<typeof SizePropertySpec>;
|
||||
@ -170,7 +178,7 @@ export default implementRuntimeComponent({
|
||||
className={css`
|
||||
${customStyle?.content}
|
||||
`}
|
||||
ref={elementRef}
|
||||
ref={ref}
|
||||
>
|
||||
<Text value={text} />
|
||||
</BaseCheckbox>
|
||||
|
@ -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<typeof ColumnsPropertySpec>;
|
||||
|
||||
evaledColumns[index].buttonConfig.handlers.forEach(evaledHandler => {
|
||||
services.apiService.send('uiMethod', {
|
||||
|
@ -60,3 +60,5 @@ export const sunmaoChakraUILib: SunmaoLib = {
|
||||
modules: [],
|
||||
utilMethods: [ChakraUIToastUtilMethodFactory],
|
||||
};
|
||||
|
||||
export { widgets } from './widgets';
|
||||
|
@ -147,7 +147,7 @@ export const ArrayTable: React.FC<ArrayTableProps> = props => {
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<Tr span>
|
||||
<Tr>
|
||||
<Td colSpan={(displayedKeys.length || 1) + 2} textAlign="center">
|
||||
No Data
|
||||
</Td>
|
||||
|
@ -155,7 +155,7 @@ const RowItem = (props: RowItemProps) => {
|
||||
</HStack>
|
||||
) : (
|
||||
(() => {
|
||||
const evaledResult = stateManager.maskedEval(value);
|
||||
const evaledResult = stateManager.deepEval(value);
|
||||
|
||||
return (
|
||||
<Box flex="2 2 66.66%" minWidth={0}>
|
||||
|
@ -170,7 +170,7 @@ export const ExpressionWidget: React.FC<WidgetProps<ExpressionWidgetType>> = pro
|
||||
try {
|
||||
const value = getParsedValue(code, type);
|
||||
const result = isExpression(value)
|
||||
? services.stateManager.maskedEval(value)
|
||||
? services.stateManager.deepEval(value)
|
||||
: value;
|
||||
|
||||
if (result instanceof ExpressionError) {
|
||||
|
@ -14,7 +14,7 @@ export interface EditorServices {
|
||||
};
|
||||
stateManager: {
|
||||
store: Record<string, any>;
|
||||
maskedEval: Function;
|
||||
deepEval: Function;
|
||||
};
|
||||
widgetManager: WidgetManager;
|
||||
}
|
||||
|
@ -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!');
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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 }}',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
@ -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<ComponentId | ModuleId, RefInfo> = {};
|
||||
@ -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;
|
||||
|
@ -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> = 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 => {
|
||||
|
@ -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> = 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> = props => {
|
||||
eventBus.send('operation', operation);
|
||||
};
|
||||
|
||||
const specObj = propertySpec === true ? {} : propertySpec;
|
||||
|
||||
return (
|
||||
<SpecWidget
|
||||
key={key}
|
||||
level={1}
|
||||
path={[key]}
|
||||
spec={{
|
||||
...propertySpec,
|
||||
title: propertySpec.title || key,
|
||||
}}
|
||||
spec={{ ...specObj, title: specObj.title || key }}
|
||||
value={value}
|
||||
services={services}
|
||||
component={component}
|
||||
|
@ -35,8 +35,7 @@ export const StructureTree: React.FC<Props> = props => {
|
||||
const [search, setSearch] = useState('');
|
||||
const { components, onSelectComponent, services } = props;
|
||||
const { editorStore } = services;
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const scrollWrapper = useRef<HTMLDivElement>(null);
|
||||
const onSelectOption = useCallback(
|
||||
({ item }: { item: Item }) => {
|
||||
onSelectComponent(item.value);
|
||||
@ -49,10 +48,19 @@ export const StructureTree: React.FC<Props> = 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> = props => {
|
||||
|
||||
return (
|
||||
<VStack
|
||||
ref={wrapperRef}
|
||||
height="full"
|
||||
paddingY="4"
|
||||
alignItems="start"
|
||||
@ -123,7 +130,14 @@ export const StructureTree: React.FC<Props> = props => {
|
||||
</AutoCompleteList>
|
||||
</AutoComplete>
|
||||
</VStack>
|
||||
<Box width="full" flex={1} minHeight={0} overflowY="auto" overflowX="hidden">
|
||||
<Box
|
||||
ref={scrollWrapper}
|
||||
width="full"
|
||||
flex={1}
|
||||
minHeight={0}
|
||||
overflowY="auto"
|
||||
overflowX="hidden"
|
||||
>
|
||||
{componentEles.length > 0 ? (
|
||||
componentEles
|
||||
) : (
|
||||
|
@ -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}`,
|
||||
];
|
||||
|
||||
|
@ -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);
|
||||
|
@ -77,7 +77,7 @@ class ExpressionValidatorRule implements PropertiesValidatorRule {
|
||||
|
||||
private checkObjHasPath(obj: Record<string, any>, 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;
|
||||
|
||||
|
@ -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<string, any> | 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}`,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
3
packages/editor/types/widgets.d.ts
vendored
3
packages/editor/types/widgets.d.ts
vendored
@ -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';
|
||||
}
|
||||
|
@ -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(<App options={ParentRerenderSchema} />);
|
||||
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] });
|
||||
|
@ -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',
|
||||
|
@ -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<Record<string, any>>(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<void>(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<void>(resolve => {
|
||||
const { result, stop } = stateManager.deepEvalAndWatch(
|
||||
'{{ text.value }}',
|
||||
newValue => {
|
||||
expect(newValue.result).toBe('bye');
|
||||
resolve();
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toBe('hello');
|
||||
|
||||
stateManager.store.text.value = 'bye';
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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 => {
|
||||
|
@ -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<ImplWrapperProps>(
|
||||
UnmountImplWrapper,
|
||||
@ -10,20 +11,19 @@ export const ImplWrapper = React.memo<ImplWrapperProps>(
|
||||
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)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
@ -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<T extends Record<string, any> = Record<string, any>>(
|
||||
obj: T
|
||||
): PropsAfterEvaled<{ obj: T }>['obj'] {
|
||||
): PropsAfterEvaled<T> {
|
||||
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<T>;
|
||||
}
|
||||
|
||||
// first eval the property, handlers, id of module
|
||||
|
@ -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';
|
||||
|
@ -29,6 +29,8 @@ type EvalOptions = {
|
||||
ignoreEvalError?: boolean;
|
||||
};
|
||||
|
||||
type EvaledResult<T> = T extends string ? unknown : PropsAfterEvaled<Exclude<T, string>>;
|
||||
|
||||
// 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<T extends object>(
|
||||
obj: T,
|
||||
fn: (params: {
|
||||
@ -157,58 +167,74 @@ export class StateManager {
|
||||
}) as PropsAfterEvaled<T>;
|
||||
}
|
||||
|
||||
deepEval<T extends Record<string, unknown> | any[]>(
|
||||
obj: T,
|
||||
deepEval<T extends Record<string, unknown> | any[] | string>(
|
||||
value: T,
|
||||
options: EvalOptions = {}
|
||||
): PropsAfterEvaled<T> {
|
||||
): EvaledResult<T> {
|
||||
// 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<T>;
|
||||
} else {
|
||||
return this._maskedEval(value, options) as EvaledResult<T>;
|
||||
}
|
||||
}
|
||||
|
||||
deepEvalAndWatch<T extends Record<string, unknown> | any[]>(
|
||||
obj: T,
|
||||
watcher: (params: { result: PropsAfterEvaled<T> }) => void,
|
||||
deepEvalAndWatch<T extends Record<string, unknown> | any[] | string>(
|
||||
value: T,
|
||||
watcher: (params: { result: EvaledResult<T> }) => void,
|
||||
options: EvalOptions = {}
|
||||
) {
|
||||
const stops: ReturnType<typeof watch>[] = [];
|
||||
|
||||
// just eval
|
||||
const evaluated = this.deepEval(obj, options);
|
||||
const evaluated = this.deepEval(value, options) as T extends string
|
||||
? unknown
|
||||
: PropsAfterEvaled<Exclude<T, string>>;
|
||||
|
||||
// watch change
|
||||
let resultCache: PropsAfterEvaled<T> = 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<Exclude<T, string>>;
|
||||
|
||||
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<T> });
|
||||
}
|
||||
);
|
||||
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<T> });
|
||||
}
|
||||
);
|
||||
stops.push(stop);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
result: evaluated,
|
||||
|
@ -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') {
|
||||
|
@ -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<typeof ResultSpec>;
|
||||
type ValidationRule = (text: string) => ValidationResult;
|
||||
type ParseValidateOption<
|
||||
T extends Record<string, TSchema>,
|
||||
OptionalKeys extends keyof T = ''
|
||||
> = {
|
||||
[K in keyof T as K extends OptionalKeys ? never : K]: Static<T[K]>;
|
||||
} & {
|
||||
[K in OptionalKeys]?: Static<T[K]>;
|
||||
};
|
||||
type UnionToIntersection<TUnion> = (
|
||||
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<typeof validateOptionMap['length'], 'minLength' | 'maxLength'>
|
||||
) {
|
||||
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<typeof validateOptionMap['include']>
|
||||
) {
|
||||
return includeList.includes(value);
|
||||
},
|
||||
exclude(
|
||||
value: string,
|
||||
{ excludeList }: ParseValidateOption<typeof validateOptionMap['exclude']>
|
||||
) {
|
||||
return !excludeList.includes(value);
|
||||
},
|
||||
regex(
|
||||
value: string,
|
||||
{ regex, flags }: ParseValidateOption<typeof validateOptionMap['regex'], 'flags'>
|
||||
) {
|
||||
return new RegExp(regex, flags).test(value);
|
||||
},
|
||||
number(
|
||||
value: string | number,
|
||||
{ min, max }: ParseValidateOption<typeof validateOptionMap['number'], 'min' | 'max'>
|
||||
) {
|
||||
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<AllFields>;
|
||||
|
||||
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<string, ValidationRule>();
|
||||
|
||||
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<string, ValidationResult> = {};
|
||||
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<string, Static<typeof ValidatorSpec>>);
|
||||
|
||||
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<string, Static<typeof ErrorSpec>[]>;
|
||||
}) {
|
||||
const validatedResult = Object.keys(errorsMap).reduce(
|
||||
(result: Static<typeof ValidatedResultSpec>, 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<typeof ErrorSpec> => error !== null);
|
||||
|
||||
return {
|
||||
name,
|
||||
isInvalid: errors.length !== 0,
|
||||
errors,
|
||||
};
|
||||
})
|
||||
.reduce((result: Static<typeof ValidatedResultSpec>, 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<string, []>, 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);
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
};
|
||||
});
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
@ -23,32 +23,50 @@ export function StringUnion<T extends string[]>(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:
|
||||
|
Loading…
x
Reference in New Issue
Block a user