├── .eslintignore ├── .eslintrc ├── .github └── workflows │ └── test.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .travis.yml ├── LICENSE ├── README.en-us.md ├── README.md ├── build.json ├── build.plugin.js ├── commitlint.config.js ├── cypress.config.ts ├── cypress └── support │ ├── component-index.html │ ├── component.ts │ └── types.d.ts ├── demo ├── autounmout.md ├── basic.md ├── custom.md ├── dynamic.md ├── mix.md ├── normalize.md ├── onchange.md ├── redux.md ├── same-name.md ├── seterror.md ├── topath.md ├── useField.md ├── validator.md ├── validatorPromise.md ├── valuename.md └── watch.md ├── ice.config.js ├── package-lock.json ├── package.json ├── src ├── index.ts ├── types.ts └── utils.ts ├── test ├── index.spec.tsx ├── options.spec.tsx ├── rules.spec.tsx ├── tsconfig.json └── utils.spec.tsx ├── tsconfig.build.json ├── tsconfig.json ├── tsdoc.json └── vite.config.ts /.eslintignore: -------------------------------------------------------------------------------- 1 | demo 2 | es 3 | lib 4 | build 5 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es6": true, 4 | "node": true 5 | }, 6 | "parser": "@typescript-eslint/parser", 7 | "parserOptions": { 8 | "project": "./tsconfig.json" 9 | }, 10 | "extends": [ 11 | "@alifd/eslint-config-next", 12 | "plugin:@typescript-eslint/recommended", 13 | "prettier" 14 | ], 15 | "plugins": ["@typescript-eslint", "eslint-plugin-tsdoc"], 16 | "overrides": [ 17 | { 18 | "files": ["./test/**/*.ts", "./test/**/*.tsx"], 19 | "extends": ["plugin:cypress/recommended"], 20 | "plugins": ["cypress"] 21 | } 22 | ], 23 | "rules": { 24 | "tsdoc/syntax": "error", 25 | "valid-jsdoc": "off", 26 | "max-statements": "off", 27 | "max-len": "off", 28 | "import/prefer-default-export": "off", 29 | "no-unused-vars": "off", 30 | "@typescript-eslint/no-unused-vars": ["warn", {"ignoreRestSiblings": true}], 31 | "no-use-before-define": "off", 32 | "react/no-multi-comp": "off", 33 | "react/jsx-filename-extension": ["error", { "extensions": [".tsx", ".jsx"] }], 34 | "@typescript-eslint/no-use-before-define": "error", 35 | "@typescript-eslint/no-explicit-any": "warn", 36 | "@typescript-eslint/no-this-alias": "warn", 37 | "@typescript-eslint/consistent-type-exports": "warn", 38 | "@typescript-eslint/consistent-type-imports": "warn" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | 11 | - uses: actions/setup-node@v3 12 | with: 13 | node-version: 14 14 | cache: 'npm' 15 | 16 | - run: npm ci 17 | 18 | - run: npm run test 19 | 20 | - name: coverage 21 | run: bash <(curl -s https://codecov.io/bash) 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules/ 5 | 6 | # production 7 | build/ 8 | dist/ 9 | tmp/ 10 | lib/ 11 | es/ 12 | 13 | # misc 14 | .idea/ 15 | .happypack 16 | .DS_Store 17 | *.swp 18 | *.dia~ 19 | 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | 24 | coverage/ -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | es 3 | lib 4 | build 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 4, 4 | "printWidth": 120, 5 | "semi": true, 6 | "singleQuote": true, 7 | "overrides": [ 8 | { 9 | "files": "package.json", 10 | "options": { 11 | "tabWidth": 2 12 | } 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: required 2 | 3 | language: node_js 4 | 5 | node_js: 6 | - 8 7 | 8 | jobs: 9 | include: 10 | - stage: test 11 | install: 12 | - npm install 13 | - npm install -g codecov 14 | before_script: 15 | - npm i -g npm 16 | script: 17 | - npm run eslint 18 | - npm test 19 | - codecov 20 | after_success: 21 | - bash <(curl -s https://codecov.io/bash) 22 | - stage: release 23 | script: skip 24 | deploy: 25 | provider: script 26 | skip_cleanup: true 27 | script: 28 | - npx semantic-release 29 | 30 | 31 | stages: 32 | - name: test 33 | if: ((branch = master) AND (type = push)) OR (type = pull_request) 34 | - name: release 35 | if: (branch = master) AND (type = push) 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018-present Alibaba Group Holding Limited, https://www.alibabagroup.com/ 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.en-us.md: -------------------------------------------------------------------------------- 1 | # Field 2 | 3 |

4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |

12 | 13 | - category: Components 14 | - family: DataEntry 15 | - chinese: 表单辅助工具 16 | - cols: 1 17 | - type: 表单 18 | 19 | --- 20 | ## Development Guide 21 | 22 | ### When to use 23 | 24 | Fields can be used to manage data when it comes to form data manipulation and validation. After being associated with a component, the form data can be automatically written back, read, and verified. 25 | 26 | ### Use caution 27 | 28 | - With Field `init` components, `value` `onChange` must be placed in init's third argument, otherwise it may be overridden by init. 29 | - `Form` has been deeply optimized with `Field` for `data acquisition` and `automatic verification prompt`. It is recommended to use `Field` in `Form`. Please check Form demo. 30 | - initValue The defaultValue of a similar component, which only takes effect when the component first renders (the ajax asynchronous invocation setting initValue may have missed the first render) 31 | - with `parseName=true` you could use `getValues` to get a struct value, but not work in `getValue` you still need pass complete key 32 | 33 | ### basic use 34 | 35 | ``` 36 | Class Demo extends React.Component { 37 |     Field = new Field(this); // instance creation 38 | 39 |     onClick = ()=>{ 40 |         Console.log(this.field.getValue('name')); 41 |     } 42 |     Render() { 43 |         Const init = this.field.init; 44 | 45 |         // Note: initValue will only be assigned when the component is first initialized. If you are using an asynchronous assignment, use setValue 46 |         Return
47 |              48 |              49 |         
50 |     } 51 | } 52 | ``` 53 | 54 | ### update data 55 | #### Event Updates 56 | 57 | ``` 58 | Class Demo extends React.Component { 59 |     Field = new Field(this); 60 | 61 |     onClick = ()=>{ 62 |         this.field.setValue('name', 'newvalue'); // Assignment will automatically trigger render 63 |     } 64 |     Render() { 65 |         Const init = this.field.init; 66 | 67 |         Return
68 |              69 |              70 |         
71 |     } 72 | } 73 | ``` 74 | 75 | #### props update 76 | 77 | ``` 78 | Class Demo extends React.Component { 79 |     Field = new Field(this); 80 | 81 |     // Set the data before the component is mounted (this can be replaced with initValue) 82 |     componentWillMount() { 83 |         this.field.setValue('name', 'init Name') 84 |     } 85 |     // Receive data from props 86 |     componentWillReceiveProps(nextProps) { 87 |         this.field.setValue('name', nextProps.name) 88 |     } 89 |     Render() { 90 |         Const init = this.field.init; 91 | 92 |         Return
93 |              94 |         
95 |     } 96 | } 97 | ``` 98 | 99 | #### ajax update 100 | ``` 101 | Class Demo extends React.Component { 102 |     Field = new Field(this); 103 | 104 |     onClick = ()=>{ 105 |         Ajax({ 106 |             Url:'/demo.json', 107 |             Success:(json)=>{ 108 |                 // Update of assignment in callback event 109 |                 this.field.setValue('name',json.name); 110 |             } 111 |         }); 112 |     } 113 |     Render() { 114 |         Const init = this.field.init; 115 | 116 |         Return
117 |              118 |              119 |         
120 |     } 121 | } 122 | ``` 123 | #### onChange update monitoring 124 | Two usages: 125 | 1. Unified management 126 | 127 | ``` 128 | Class Demo extends React.Component { 129 |     Field = new Field(this,{ 130 |         onChange:(name, value) => { 131 |           Switch(name) { 132 |             Case 'name1': 133 |               this.field.setValue('name2','value set by name1'); 134 |               Break; 135 |             Case 'name2': 136 |               this.field.setValue('name1','value set by name2'); 137 |               Break; 138 |           } 139 |         } 140 |     }); 141 |     Render() { 142 |         Const init = this.field.init; 143 | 144 |         Return
145 |            146 |            147 |         
148 |     } 149 | } 150 | ``` 151 | 152 | 2. Individual management 153 | 154 | ``` 155 | Class Demo extends React.Component { 156 |     Render() { 157 |         Const init = this.field.init; 158 | 159 |         Return
160 |           { 163 |                   this.field.setValue('name2','value set by name1'); 164 |                 } 165 |               } 166 |             })} /> 167 |           { 170 |                   this.field.setValue('name1','value set by name2'); 171 |                 } 172 |               } 173 |             })} /> 174 |         
175 |     } 176 | } 177 | ``` 178 | 179 | For details, please check Demo Demo `Associate Control` 180 | 181 | ## API 182 | ### initialization 183 | ``` 184 | Let myfield = new Field(this [,options]); 185 | ``` 186 | 187 | |Parameter | Description | Type | Optional |Default | 188 | |-----------|------------------------------------------|------------|-------|--------| 189 | | this | The incoming call to this class | React.Component | must be set | 190 | | options | Some event configuration, detailed parameters are as follows | Object | Not required | | 191 | 192 | `options` configuration item 193 | 194 | | Parameters | Description | Type |Default | 195 | |-----------|------------------------------------------|-----------|--------| 196 | | onChange | all component changes will arrive here [setValue won't trigger the function] | Function(name,value) | | 197 | |parseName | Whether to translate `name` in `init(name)` (getValues ​​will convert a string with `.` to an object) | Boolean | false| 198 | |forceUpdate | Only the components of PureComponent are recommended to open this forced refresh function, which will cause performance problems (500 components as an example: the render will cost 700ms when it is turned on, and 400ms if it is turned off) | Boolean |false| 199 | | autoUnmount | Automatically delete the Unmout element, if you want to keep the data can be set to false | Boolean |true| 200 | | autoValidate | Automatically validate while value changed | Boolean |true| 201 | | values | initial value| Object || 202 | | processErrorMessage | function to transform error objects on validation. Main usage is to add `key` prop to React component | Function(error) || 203 | | afterValidateRerender | function to perform any operations after components have rerendered to show validation results. | Function({errorGroup, options, instance}) - see [afterValidateRerender](#afterValidateRerender) for more information || 204 | 205 | 206 | #### API Interface 207 | The api interface provided by the object after `new` (eg `myfield.getValues()`) (The api function starting with `set` should not be manipulated in render, which may trigger an infinite loop) 208 | 209 | |Parameter | Description | Type | Optional |Default | 210 | |-----------|------------------------------------------|------------|-------|--------| 211 | |init | Initialize each component, [Detailed Parameters below] (#init))| Function(name:String, option:Object)| || 212 | | getValues ​​| Get the value of a group of input controls. If no parameters are passed, get the values ​​of all components | Function([names: String[]]) | 213 | | getValue | get the value of a single input control | Function(name: String) | 214 | | setValues ​​| Sets the value of a set of input controls (triggers render, follow the use of react time) | Function(obj: Object) | 215 | | setValue | Sets the value of a single input control (triggers render, follow the use of react time) | Function(name: String, value) | 216 | | validateCallback | Validate and retrieve the values ​​of a set of input fields and Error. | Function([names: String[]], [options: Object], callback: Function(errors, values)) | | | 217 | | validatePromise | Validate and retrieve the values ​​of a set of input fields and Error. Returns a promise| Function([names: String[]], [options: Object], (optional) callback: Function(errors, values)) | | | 218 | |getError | Get Error of a Single Input Control | Function(name: String) | | | 219 | |getErrors | Get Errors of a Group of Input Controls | Function([name: String]) | | | 220 | |setError | Set Error for a Single Input Control | Function(name: String, errors:String/Array[String]) | | | 221 | |setErrors | Set Errors of a Group of Input Controls | Function(obj: Object) | | | 222 | |reset | reset the value of a group of input controls, clear the checksum | Function([names: String[]])| || 223 | | resetToDefault | Resets the value of a group of input controls to the default | Function([names: String[]])| || 224 | |getState | Judge check status | Function(name: String)| 'error' 'success' 'loading' '' | '' | 225 | | getNames | Get the key of all components | Function()| || 226 | |remove | Delete the data of a certain control or a group of controls. After deletion, the validate/value associated with it will be cleared. | Function(name: String/String[])| 227 | | spliceArray | delete data of name like name.{index} | Function(keyMatch: String, index: Number)| | | 228 | | watch | watch field value changes | Function(names: String[], value: unknown, oldValue: unknown, triggerType: 'init' | 'change' | 'setValue' | 'unmount' | 'reset') | | | 229 | 230 | #### init 231 | ``` 232 | init(name, options, props) 233 | ``` 234 | 235 | |Parameter | Description | Type | Optional |Default | 236 | |-----------|------------------------------------------|------------|-------|--------| 237 | | name | Required unique input control symbol | String | 238 | | options.valueName | attribute name of the component value, such as Checkbox is `checked`, Input is `value` | String | | 'value' | 239 | | options.initValue | The initial value of the component (the component will be read only when rendering for the first time, and later modifying this value is invalid), similar to defaultValue | any | | | 240 | |options.trigger | Name of the event that triggered the data change | String | | 'onChange' | 241 | | options.rules | Checksum Rules | Array/Object | | | | 242 | | options.getValueFormatter | custom way to get value from `onChange` event, Detailed usage see demo `custom data get` | Function(value, ...args) parameter order and components are exactly the same The | | | | 243 | | options.getValueFormatter | custom way to set value. Detailed usage see demo `custom data get` | Function(values) | | | | 244 | |props | Component-defined events can be written here | Object | | | | 245 | | autoValidate | Automatically validate while value changed | Boolean |true| 246 | 247 | return 248 | 249 | ``` 250 | {id,value,onChange} 251 | ``` 252 | 253 | #### rules 254 | 255 | ``` 256 | { 257 | rules:[{ required: true }] 258 | } 259 | ``` 260 | 261 | multiple rule 262 | 263 | ``` 264 | { 265 | rules:[{required:true,trigger:'onBlur'},{pattern:/abcd/,message:'match abcd'},{validator:(rule, value, callback)=>{callback('got error')}}] 266 | } 267 | ``` 268 | 269 | |Parameter | Description | Type | Optional | 270 | |-----------|------------------------------------------|------------|-------|--------| 271 | | required | cannot be empty| Boolean | true | `undefined/null/"/[]` will trigger this rule) 272 | |pattern | check regular expression | regular | | | 273 | |minLength | Minimum string length / Minimum number of arrays | Number | | String/Number/Array | 274 | |maxLength | Maximum length of string / Maximum number of arrays | Number | | String/Number/Array | 275 | |length | string exact length / exact number of arrays | |number | | String/Number/Array | 276 | |min | Min | Number | | String/Number | 277 | |max | maximum | Number | | String/Number | 278 | | format | sum of common patterns | String | url, email, tel, number | String | 279 | | validator | Custom validation, (don't forget to execute `callback()` when validation is successful, otherwise validation will not return) | Function(rule,value,callback) | 280 | | trigger | Name of the event that triggered the check | String/Array | onChange/onBlur/... | onChange | 281 | | message | error message | String | | | 282 | 283 | ## Custom Component Access to Field Standards 284 | 285 | - Supports controlled mode (value+onChange) `Must` 286 |      - value control component data display 287 |      - onChange callback function when the component changes (the first parameter can be given to value) 288 | 289 | - One complete operation throws an onChange event 290 |      For example, there is a process that indicates the status of the progress, it is recommended to increase the API `onProcess`; if there is a Start indicates the startup state, it is recommended to increase the API `onStart` 291 | 292 | - Clear data when `value={undefined}`, field's reset function will send undefined data to all components 293 | 294 | ``` 295 | componentWillReceiveProps(nextProps) { 296 | if ('value' in nextProps ) { 297 | this.setState({ 298 | value: nextProps.value === undefined? []: nextProps.value // set value after clear 299 | }) 300 | } 301 | } 302 | ``` 303 | 304 | ## afterValidateRerender 305 | 306 | `afterValidateRerender` receives an object with the following properties 307 | 308 | |Parameter | Description | Type | 309 | |-----------|------------------------------------------|------------|-------|--------| 310 | | errorsGroup | Map of field elements with errors, indexed by name. Access the error by `errorsGroup[field_name].errors` | Object | 311 | | options | field's `options` property | Object | 312 | | instance | fields's `instance` property, which holds references to React components for the field items | Object | 313 | 314 | 315 | ## Known Issues 316 | 317 | - Why doesn't the callback function enter the `this.field.validate` manually? A: Is it safe to define the validator method to ensure that the `callback` can be executed on any branch? 318 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Field 2 | 3 |

4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |

12 | 13 | - category: Components 14 | - family: DataEntry 15 | - chinese: 表单辅助工具 16 | - cols: 1 17 | - type: 表单 18 | 19 | --- 20 | 21 | ## 开发指南 22 | 23 | ### 何时使用 24 | 25 | 涉及到表单数据操作、校验的地方都可以用 Field 来管理数据。和组件关联后可以自动对表单数据进行回写、读取、校验。 26 | 27 | ### 使用注意 28 | 29 | - 使用 Field `init` 过的组件, `value` `onChange` 必须放在 init 的第三个参数, 否则有可能被 init 覆盖。 30 | - `Form`已经和`Field` 在`数据获取`和`自动校验提示`方面做了深度优化,建议在`Form`中使用`Field`, 请查看 Form demo。 31 | - initValue 类似组件的 defaultValue 只有在组件第一次 render 的时候才生效(ajax 异步调用设置 initValue 可能已经错过了第一次 render) 32 | - autoUnmount 默认打开的,如果需要保留会 `自动卸载的组件` 数据请关闭此项 33 | - `parseName=true` 可以通过 `getValues` 获取到结构化的数据, 但是 getValue 还是必须传完整 key 值 34 | 35 | ### 基本使用 36 | 37 | ``` 38 | class Demo extends React.Component { 39 | field = new Field(this); // 实例创建 40 | 41 | onClick = ()=>{ 42 | console.log(this.field.getValue('name')); 43 | } 44 | render() { 45 | const init = this.field.init; 46 | 47 | // 注意:initValue只会在组件第一次初始化的时候被赋值,如果你是异步赋值请用setValue 48 | return
49 | 50 | 51 |
52 | } 53 | } 54 | ``` 55 | 56 | ### 更新数据 57 | 58 | #### 事件更新 59 | 60 | ``` 61 | class Demo extends React.Component { 62 | field = new Field(this); 63 | 64 | onClick = ()=>{ 65 | this.field.setValue('name', 'newvalue'); // 赋值会自动触发render 66 | } 67 | render() { 68 | const init = this.field.init; 69 | 70 | return
71 | 72 | 73 |
74 | } 75 | } 76 | ``` 77 | 78 | #### props 更新 79 | 80 | ``` 81 | class Demo extends React.Component { 82 | field = new Field(this); 83 | 84 | // 在组件挂载之前把数据设置进去(可以用initValue替代这种用法) 85 | componentWillMount() { 86 | this.field.setValue('name', 'init Name') 87 | } 88 | // 接收来自props的数据 89 | componentWillReceiveProps(nextProps) { 90 | this.field.setValue('name', nextProps.name) 91 | } 92 | render() { 93 | const init = this.field.init; 94 | 95 | return
96 | 97 |
98 | } 99 | } 100 | ``` 101 | 102 | #### ajax 更新 103 | 104 | ``` 105 | class Demo extends React.Component { 106 | field = new Field(this); 107 | 108 | onClick = ()=>{ 109 | Ajax({ 110 | url:'/demo.json', 111 | success:(json)=>{ 112 | // 回调事件中赋值更新 113 | this.field.setValue('name',json.name); 114 | } 115 | }); 116 | } 117 | render() { 118 | const init = this.field.init; 119 | 120 | return
121 | 122 | 123 |
124 | } 125 | } 126 | ``` 127 | 128 | #### onChange 更新监控 129 | 130 | 两种用法: 131 | 132 | 1. 统一管理 133 | 134 | ``` 135 | class Demo extends React.Component { 136 | field = new Field(this,{ 137 | onChange:(name, value) => { 138 | switch(name) { 139 | case 'name1': 140 | this.field.setValue('name2','value set by name1'); 141 | break; 142 | case 'name2': 143 | this.field.setValue('name1','value set by name2'); 144 | break; 145 | } 146 | } 147 | }); 148 | render() { 149 | const init = this.field.init; 150 | 151 | return
152 | 153 | 154 |
155 | } 156 | } 157 | ``` 158 | 159 | 2. 各自管理 160 | 161 | ``` 162 | class Demo extends React.Component { 163 | render() { 164 | const init = this.field.init; 165 | 166 | return
167 | { 170 | this.field.setValue('name2','value set by name1'); 171 | } 172 | } 173 | })} /> 174 | { 177 | this.field.setValue('name1','value set by name2'); 178 | } 179 | } 180 | })} /> 181 |
182 | } 183 | } 184 | ``` 185 | 186 | 详细请查看 demo 演示 `关联控制` 187 | 188 | ## API 189 | 190 | ### 初始化 191 | 192 | ``` 193 | let myfield = new Field(this [,options]); 194 | ``` 195 | 196 | | 参数 | 说明 | 类型 | 可选值 | 默认值 | 197 | | ------- | -------------------------- | --------------- | -------- | ------ | 198 | | this | 传入调用 class 的 this | React.Component | 必须设置 | | 199 | | options | 一些事件配置, 详细参数如下 | Object | 非必须 | | 200 | 201 | `options` 配置项 202 | 203 | | 参数 | 说明 | 类型 | 默认值 | 204 | | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | ------ | 205 | | onChange | 所有组件的 change 都会到达这里[setValue 不会触发该函数] | Function(name,value) | | 206 | | parseName | 是否翻译`init(name)`中的`name`(getValues 会把带`.`的字符串转换成对象) | Boolean | false | 207 | | forceUpdate | 仅建议 PureComponent 的组件打开此强制刷新功能,会带来性能问题(500 个组件为例:打开的时候 render 花费 700ms, 关闭时候 render 花费 400ms) | Boolean | false | 208 | | autoUnmount | 自动删除 Unmout 元素,如果想保留数据可以设置为 false | Boolean | true | 209 | | autoValidate | 是否修改数据的时候就自动触发校验, 设为 false 后只能通过 validate() 来触发校验 | Boolean | true | 210 | | values | 初始化数据 | Object | | 211 | 212 | #### API 接口 213 | 214 | `new`之后的对象提供的 api 接口 (例:`myfield.getValues()`)(`set` 开头的 api 函数不要在 render 里面操作, 可能会触发死循环) 215 | 216 | | 参数 | 说明 | 类型 | 可选值 | 默认值 | 217 | | ---------------- | ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------- | ------------------------------ | ------ | 218 | | init | 初始化每个组件,[详细参数如下](#init)) | Function(name:String, option:Object) | | | 219 | | getValues | 获取一组输入控件的值,如不传入参数,则获取全部组件的值 | Function([names: String[]]) | | | 220 | | getValue | 获取单个输入控件的值 | Function(name: String) | | | 221 | | setValues | 设置一组输入控件的值(会触发 render,请遵循 react 时机使用) | Function(obj: Object) | | | 222 | | setValue | 设置单个输入控件的值 (会触发 render,请遵循 react 时机使用) | Function(name: String, value) | | | 223 | | validate | 校验并获取一组输入域的值与 Error | Function([names: String[]], [options: Object], callback: Function(errors, values)) | | | 224 | | getError | 获取单个输入控件的 Error | Function(name: String) | | | 225 | | getErrors | 获取一组输入控件的 Error | Function([name: String]) | | | 226 | | setError | 设置单个输入控件的 Error | Function(name: String, errors:String/Array[String]) | | | 227 | | setErrors | 设置一组输入控件的 Error | Function(obj: Object) | | | 228 | | reset | 重置一组输入控件的值、清空校验 | Function([names: String[]]) | | | 229 | | resetToDefault | 重置一组输入控件的值为默认值 | Function([names: String[]]) | | | 230 | | getState | 判断校验状态 | Function(name: String) | 'error' 'success' 'loading' '' | '' | 231 | | getNames | 获取所有组件的 key | Function() | | | 232 | | remove | 删除某一个或者一组控件的数据,删除后与之相关的 validate/value 都会被清空 | Function(name: String/String[]) | | | 233 | | addArrayValue | 添加 name 是数组格式的数据, 并且自动处理其他 name 的数组错位问题 | Function(key: String, index: Number, value1, value2, ...) | | | 234 | | deleteArrayValue | 删除 name 是数组格式的数据, 并且自动处理其他 name 的数组错位问题 | Function(key: String, index: Number, howmany) | | | 235 | | watch | 监听字段值变化 | Function(names: String[], value: unknown, oldValue: unknown, triggerType: 'init' | 'change' | 'setValue' | 'unmount' | 'reset') | | | 236 | 237 | #### init 238 | 239 | ``` 240 | init(name, options, props) 241 | ``` 242 | 243 | | 参数 | 说明 | 类型 | 可选值 | 默认值 | 244 | | ------------------------- | ----------------------------------------------------------------------------------------- | ---------------------------------------------------------- | ------ | ---------- | 245 | | id | 自定义的表单域 id,如不提供则使用 name 替代 | String | | | 246 | | name | 必填输入控件唯一标志 | String | | | 247 | | options.valueName | 组件值的属性名称,如 Checkbox 的是 `checked`,Input 是 `value` | String | | 'value' | 248 | | options.initValue | 组件初始值(组件第一次 render 的时候才会读取,后面再修改此值无效),类似 defaultValue | any | | | 249 | | options.trigger | 触发数据变化的事件名称 | String | | 'onChange' | 250 | | options.rules | 校验规则 | Array/Object | | | | 251 | | options.getValueFormatter | 自定义从组件获取 `value` 的方式,详细用法查看 demo `自定义数据获取` | Function(value,...args) 参数顺序和组件的 onChange 完全一致 | | | | 252 | | options.setValueFormatter | 自定义转换 `value` 为组件需要的数据 ,详细用法查看 demo `自定义数据获取` | Function(value) | | | | 253 | | props | 组件自定义的事件可以写在这里 | Object | | | | 254 | | autoValidate | 是否修改数据的时候自动触发校验单个组件的校验, 设为 false 后只能通过 validate() 来触发校验 | Boolean | true | 255 | 256 | 返回值 257 | 258 | ``` 259 | {id,value,onChange} 260 | ``` 261 | 262 | #### rules 263 | 264 | ``` 265 | { 266 | rules:[{ required: true }] 267 | } 268 | ``` 269 | 270 | 多个 rule 271 | 272 | ``` 273 | { 274 | rules:[{required:true,trigger:'onBlur'},{pattern:/abcd/,message:'abcd不能缺'},{validator:(rule, value, callback)=>{callback('出错了')}}] 275 | } 276 | ``` 277 | 278 | | 参数 | 说明 | 类型 | 可选值 | 使用类型 | 279 | | --------- | --------------------------------------------------------------------- | ----------------------------- | ----------------------- | ------------------------------------ | 280 | | required | 不能为空 | Boolean | true | `undefined/null/“”/[]` 会触发此规则) | 281 | | pattern | 校验正则表达式 | 正则 | | | 282 | | minLength | 字符串最小长度 / 数组最小个数 | Number | | String/Number/Array | 283 | | maxLength | 字符串最大长度 / 数组最大个数 | Number | | String/Number/Array | 284 | | length | 字符串精确长度 / 数组精确个数 | Number | | String/Number/Array | 285 | | min | 最小值 | Number | | String/Number | 286 | | max | 最大值 | Number | | String/Number | 287 | | format | 对常用 pattern 的总结 | String | url、email、tel、number | String | 288 | | validator | 自定义校验,(校验成功的时候不要忘记执行 `callback()`,否则会校验不返回) | Function(rule,value,callback) | | | 289 | | trigger | 触发校验的事件名称 | String/Array | onChange/onBlur/... | onChange | 290 | | message | 出错时候信息 | String | | | 291 | 292 | ## 自定义组件接入 Field 标准 293 | 294 | - 支持受控模式(value+onChange) `必须` 295 | 296 | - value 控制组件数据展现 297 | - onChange 组件发生变化时候的回调函数(第一个参数可以给到 value) 298 | 299 | - 一次完整操作抛一次 onChange 事件 `建议` 300 | 比如有 Process 表示进展中的状态,建议增加 API `onProcess`;如果有 Start 表示启动状态,建议增加 API `onStart` 301 | 302 | - `value={undefined}`的时候清空数据, field 的 reset 函数会给所有组件下发 undefined 数据 `建议` 303 | 304 | ``` 305 | componentWillReceiveProps(nextProps) { 306 | if ('value' in nextProps ) { 307 | this.setState({ 308 | value: nextProps.value === undefined? []: nextProps.value // 设置组件的被清空后的数值 309 | }) 310 | } 311 | } 312 | ``` 313 | 314 | ## 已知问题 315 | 316 | - 为何手动调用`this.field.validate`的时候进不了回调函数? 答: 是不是自定义了 validator 方法,确保`callback`在任何分支下都能被执行到。 317 | -------------------------------------------------------------------------------- /build.json: -------------------------------------------------------------------------------- 1 | { 2 | "disableGenerateStyle": true, 3 | "plugins": [ 4 | "./build.plugin.js", 5 | "build-plugin-component", 6 | "build-plugin-fusion", 7 | [ 8 | "build-plugin-moment-locales", 9 | { 10 | "locales": ["zh-cn"] 11 | } 12 | ] 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /build.plugin.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | const modifyPkgHomePage = require('build-plugin-component/src/utils/modifyPkgHomePage'); 3 | 4 | module.exports = ({ context, onHook }) => { 5 | const { rootDir, pkg } = context; 6 | onHook('after.build.compile', async () => { 7 | // 提前中断 babel compile 任务,用 tsc 方式编译 8 | await modifyPkgHomePage(pkg, rootDir); 9 | process.exit(0); 10 | }); 11 | }; 12 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "rules": { 3 | "header-max-length": [2, "always", 72], 4 | "scope-case": [2, "always", "pascal-case"], 5 | "subject-empty": [2, "never"], 6 | "subject-full-stop": [2, "never", "."], 7 | "type-empty": [2, "never"], 8 | "type-case": [2, "always", "lower-case"], 9 | "type-enum": [2, "always", 10 | [ 11 | "typescript", 12 | "deprecated", 13 | "build", 14 | "chore", 15 | "ci", 16 | "docs", 17 | "feat", 18 | "fix", 19 | "perf", 20 | "refactor", 21 | "revert", 22 | "style", 23 | "test", 24 | "temp" 25 | ] 26 | ] 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'cypress'; 2 | 3 | export default defineConfig({ 4 | component: { 5 | devServer: { 6 | framework: 'react', 7 | bundler: 'vite', 8 | }, 9 | specPattern: ['test/**/*.spec.{ts,tsx}'], 10 | viewportWidth: 1000, 11 | viewportHeight: 600, 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /cypress/support/component-index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Components Test 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /cypress/support/component.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { mount } from 'cypress/react'; 4 | 5 | declare global { 6 | // eslint-disable-next-line @typescript-eslint/no-namespace 7 | namespace Cypress { 8 | interface Chainable { 9 | mount: typeof mount; 10 | } 11 | } 12 | } 13 | 14 | Cypress.Commands.add('mount', mount); 15 | -------------------------------------------------------------------------------- /cypress/support/types.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare namespace Chai { 4 | export interface Assert { 5 | deepEqual(actual: unknown, expected: unknown, message?: string): void; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /demo/autounmout.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 自动卸载 - auto unmount 3 | order: 6 4 | --- 5 | 6 | autoUnmount 默认为 true,当组件被 unmount 的时候会自动删除数据. autoUnmount 设置为 false 后,会一直保存数据. 7 | 8 | --- 9 | 10 | autoUnmount is true by default, and data will be deleted automatically. Field will keep data while autoUnmount is set to false. 11 | 12 | ```jsx 13 | import ReactDOM from 'react-dom'; 14 | import React from 'react'; 15 | import { Input, Button } from '@alifd/next'; 16 | import Field from '@alifd/field'; 17 | 18 | class Demo extends React.Component { 19 | state = { 20 | show: true, 21 | show2: true, 22 | }; 23 | field = new Field(this); 24 | field2 = new Field(this, { autoUnmount: false }); 25 | 26 | render() { 27 | return ( 28 |
29 | {this.state.show ? ( 30 | 35 | ) : null} 36 | 47 | 54 |
55 |
56 | {this.state.show2 ? ( 57 | 62 | ) : null} 63 | 74 | 81 |
82 | ); 83 | } 84 | } 85 | 86 | ReactDOM.render(, mountNode); 87 | ``` 88 | -------------------------------------------------------------------------------- /demo/basic.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 基本 - basic 3 | order: 0 4 | --- 5 | 6 | `getValue` `setValue` `reset` 的使用 7 | 8 | --- 9 | 10 | usage of `getValue` `setValue` `reset` 11 | 12 | ```jsx 13 | import ReactDOM from 'react-dom'; 14 | import React from 'react'; 15 | import { Input, Button } from '@alifd/next'; 16 | import Field from '@alifd/field'; 17 | 18 | class App extends React.Component { 19 | field = new Field(this, { values: { input: 0 } }); 20 | 21 | onGetValue() { 22 | console.log(this.field.getValue('input')); 23 | } 24 | 25 | render() { 26 | const { init, setValue, reset } = this.field; 27 | 28 | return ( 29 |
30 | 31 |
32 |
33 | 36 | 42 | 43 |
44 | ); 45 | } 46 | } 47 | 48 | ReactDOM.render(, mountNode); 49 | ``` 50 | 51 | ```css 52 | .demo .next-btn { 53 | margin-right: 5px; 54 | } 55 | ``` 56 | -------------------------------------------------------------------------------- /demo/custom.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 自定义组件 - custom 3 | order: 10 4 | --- 5 | 6 | 自己的组件如何接入Field。 7 | 8 | `最低标准`: 组件支持 `onChange` 读取组件数据。`达到效果`:Field 可以 getValue,但是 setValue 无效 9 | 10 | `完全支持`: 组件支持[受控](https://facebook.github.io/react/docs/forms.html#controlled-components), 也就是支持两个api:`value` `onChange`. value: 设置组件的数据; onChange: 在组件修改的时候在第一个数暴露数据 11 | 12 | --- 13 | 14 | `must`: has api of `onChange`, so you can use `getValue` but you can't `setValue` 15 | `perfect support`: has api of `value` `onChange`. value: set data for component; onChange: return first param for component 16 | 17 | ```jsx 18 | import ReactDOM from 'react-dom'; 19 | import React from 'react'; 20 | import { Button } from '@alifd/next'; 21 | import Field from '@alifd/field'; 22 | 23 | class Custom extends React.Component { 24 | constructor(props) { 25 | super(props); 26 | 27 | this.state = { 28 | value: typeof props.value === 'undefined' ? [] : props.value, 29 | }; 30 | } 31 | 32 | // update value 33 | componentWillReceiveProps(nextProps) { 34 | if ('value' in nextProps) { 35 | this.setState({ 36 | value: 37 | typeof nextProps.value === 'undefined' 38 | ? [] 39 | : nextProps.value, 40 | }); 41 | } 42 | } 43 | 44 | onAdd = () => { 45 | const value = this.state.value.concat([]); 46 | value.push('new'); 47 | 48 | this.setState({ 49 | value, 50 | }); 51 | this.props.onChange(value); 52 | }; 53 | 54 | render() { 55 | return ( 56 |
57 | {this.state.value.map((v, i) => { 58 | return ; 59 | })} 60 | 63 |
64 | ); 65 | } 66 | } 67 | 68 | class App extends React.Component { 69 | field = new Field(this); 70 | 71 | onGetValue() { 72 | console.log(this.field.getValue('custom')); 73 | } 74 | 75 | render() { 76 | const { init, setValue, reset } = this.field; 77 | 78 | return ( 79 |
80 | 81 | 82 |
83 |
84 | 85 | 88 | 94 | 95 |
96 | ); 97 | } 98 | } 99 | ReactDOM.render(, mountNode); 100 | ``` 101 | 102 | ```css 103 | .demo .next-btn { 104 | margin-right: 5px; 105 | } 106 | .custom { 107 | border: 1px dashed; 108 | padding: 4px; 109 | display: inline-block; 110 | } 111 | .custom span { 112 | border: 1px solid green; 113 | padding: 0px 5px; 114 | height: 24px; 115 | display: inline-block; 116 | margin-right: 2px; 117 | } 118 | ``` 119 | -------------------------------------------------------------------------------- /demo/dynamic.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 动态表格 - dynamic 3 | order: 7 4 | --- 5 | 6 | 通过 `deleteArrayValue/addArrayValue` 可以往数组格式的数据里面 删除/添加 数据, 并且自动订正其他 name 的 偏移问题 7 | 8 | --- 9 | 10 | by use `deleteArrayValue/addArrayValue` could delete and add array , and fix keys offset problem 11 | 12 | ```jsx 13 | import ReactDOM from 'react-dom'; 14 | import React from 'react'; 15 | import { Button, Input, Table } from '@alifd/next'; 16 | import Field from '@alifd/field'; 17 | 18 | const CustomInput = (props) => { 19 | return ; 20 | }; 21 | 22 | class Demo extends React.Component { 23 | constructor(props) { 24 | super(props); 25 | 26 | this.idx = 3; 27 | 28 | this.field = new Field(this, { 29 | parseName: true, 30 | values: { 31 | name: [0, 1, 2, 3].map((i) => { 32 | return { id: i, input: i, custom: i }; 33 | }), 34 | }, 35 | }); 36 | } 37 | 38 | getValues = () => { 39 | const values = this.field.getValues(); 40 | console.log(values); 41 | }; 42 | 43 | addItem(index) { 44 | ++this.idx; 45 | this.field.addArrayValue('name', index, { 46 | id: this.idx, 47 | input: this.idx, 48 | custom: this.idx, 49 | }); 50 | console.log(this.field.getNames()); 51 | } 52 | 53 | removeItem(index) { 54 | this.field.deleteArrayValue('name', index); 55 | console.log(this.field.getNames()); 56 | } 57 | 58 | input = (value, index) => ( 59 | 60 | ); 61 | customInput = (value, index) => ( 62 | 63 | ); 64 | op = (value, index) => { 65 | return ( 66 | 67 | 73 | 80 | 81 | ); 82 | }; 83 | 84 | render() { 85 | const dataSource = this.field.getValue('name'); 86 | return ( 87 |
88 | 89 | 90 | 95 | 100 | 105 |
106 |
107 |                     {JSON.stringify(dataSource, null, 2)}
108 |                 
109 |
110 | ); 111 | } 112 | } 113 | 114 | ReactDOM.render(, mountNode); 115 | ``` 116 | -------------------------------------------------------------------------------- /demo/mix.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 组合使用 - mix usage 3 | order: 8 4 | --- 5 | 6 | 多组件混合使用 7 | 8 | --- 9 | 10 | multi type of component 11 | 12 | ```jsx 13 | import ReactDOM from 'react-dom'; 14 | import React from 'react'; 15 | import { 16 | Button, 17 | Checkbox, 18 | Input, 19 | Radio, 20 | Select, 21 | Range, 22 | DatePicker, 23 | TimePicker, 24 | } from '@alifd/next'; 25 | import Field from '@alifd/field'; 26 | 27 | const CheckboxGroup = Checkbox.Group; 28 | const RadioGroup = Radio.Group; 29 | 30 | const list = [ 31 | { 32 | value: 'apple', 33 | label: 'apple', 34 | }, 35 | { 36 | value: 'pear', 37 | label: 'pear', 38 | }, 39 | { 40 | value: 'orange', 41 | label: 'orange', 42 | }, 43 | ]; 44 | const layout = { 45 | marginBottom: 10, 46 | width: 400, 47 | }; 48 | 49 | class App extends React.Component { 50 | field = new Field(this); 51 | 52 | render() { 53 | const { init, getValue } = this.field; 54 | 55 | return ( 56 |
57 |
58 | 59 | A 60 | B 61 | C 62 | D 63 | 64 |
65 | 66 | {getValue('radiogroup') !== 'd' ? ( 67 | 78 | ) : ( 79 | 80 | )} 81 |
82 | 83 | 90 | 91 |
92 | 96 |
97 |
98 | 99 |
100 |
101 | 102 |
103 |
104 | 105 |
106 | 114 | 126 | 133 |
134 | ); 135 | } 136 | } 137 | 138 | ReactDOM.render(, mountNode); 139 | ``` 140 | 141 | ```css 142 | .demo .next-btn { 143 | margin-right: 5px; 144 | } 145 | ``` 146 | -------------------------------------------------------------------------------- /demo/normalize.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 自定义返回值 - custom value 3 | order: 2 4 | --- 5 | 6 | 当组件返回的数据和最终期望提交的格式不一致的时候,可以使用 `getValueFormatter` 和 `setValueFormatter` 两个函数做转换。 7 | 8 | 比如 switch 组件期望上报 0/1, date-picker 组件期望上报 YYYY-MM-DD 这种字符串格式 9 | 10 | --- 11 | 12 | custom get `value` by api `getValueFormatter` 13 | custom set `value` by api `setValueFormatter` 14 | 15 | ```jsx 16 | import ReactDOM from 'react-dom'; 17 | import React from 'react'; 18 | import { Button, DatePicker, Switch } from '@alifd/next'; 19 | import Field from '@alifd/field'; 20 | import moment from 'moment'; 21 | 22 | class App extends React.Component { 23 | field = new Field(this); 24 | 25 | render() { 26 | const init = this.field.init; 27 | 28 | return ( 29 |
30 | { 33 | return value === true ? 1 : 0; 34 | }, 35 | setValueFormatter: (value, inputValues) => { 36 | return value === 1 ? true : false; 37 | }, 38 | })} 39 | /> 40 |
41 |
42 | { 45 | return value.format('YYYY-MM-DD'); 46 | }, 47 | setValueFormatter: (value, inputValues) => { 48 | return moment(value, 'YYYY-MM-DD'); 49 | }, 50 | })} 51 | /> 52 |
53 |
54 | 62 |
63 | ); 64 | } 65 | } 66 | 67 | ReactDOM.render(, mountNode); 68 | ``` 69 | -------------------------------------------------------------------------------- /demo/onchange.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 关联控制 - controlled 3 | order: 1 4 | --- 5 | 6 | 组件之间的关联控制. `onChange` 统一管理。 7 | 8 | --- 9 | 10 | manage value by `onChange` 11 | 12 | ```jsx 13 | import ReactDOM from 'react-dom'; 14 | import React from 'react'; 15 | import { Input, Select, Range } from '@alifd/next'; 16 | import Field from '@alifd/field'; 17 | 18 | class App extends React.Component { 19 | field = new Field(this, { 20 | onChange: (name, value) => { 21 | console.log(this.field.getValues()); 22 | 23 | switch (name) { 24 | case 'input': 25 | this.field.setValue('sync', `change to: ${value}`); 26 | break; 27 | case 'select': 28 | this.field.setValue('sync', `${value} is coming`); 29 | break; 30 | case 'range': 31 | this.field.setValue('sync', ` (${value.join(',')}) ready`); 32 | break; 33 | } 34 | }, 35 | }); 36 | 37 | render() { 38 | const { init, getValue } = this.field; 39 | const layout = { 40 | marginBottom: 10, 41 | width: 400, 42 | }; 43 | 44 | return ( 45 |
46 | 51 |
52 | 57 |
58 | 59 | 70 |
71 | 72 | {getValue('select') !== 'hugo' ? ( 73 | 83 | ) : null} 84 |
85 | 86 |
87 | 92 |
93 |
94 | ); 95 | } 96 | } 97 | 98 | ReactDOM.render(, mountNode); 99 | ``` 100 | -------------------------------------------------------------------------------- /demo/redux.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: redux 中使用 - with redux 3 | order: 5 4 | --- 5 | 6 | 在 redux 中使用, 在 `componentWillReceiveProps` 更新 7 | 8 | --- 9 | 10 | set value in `componentWillReceiveProps` 11 | 12 | ```jsx 13 | import ReactDOM from 'react-dom'; 14 | import React from 'react'; 15 | import { Input, Button } from '@alifd/next'; 16 | import Field from '@alifd/field'; 17 | import { combineReducers, createStore } from 'redux'; 18 | import { Provider, connect } from 'react-redux'; 19 | 20 | function formReducer(state = { email: 'frankqian@qq.com' }, action) { 21 | switch (action.type) { 22 | case 'save_fields': 23 | return { 24 | ...state, 25 | ...action.payload, 26 | }; 27 | default: 28 | return state; 29 | } 30 | } 31 | 32 | class Demo extends React.Component { 33 | componentWillReceiveProps(nextProps) { 34 | this.field.setValues({ 35 | email: nextProps.email, 36 | newlen: nextProps.email.length, 37 | }); 38 | } 39 | 40 | field = new Field(this, { 41 | onChange: (name, value) => { 42 | console.log('onChange', name, value); 43 | this.field.setValue('newlen', value.length); 44 | this.props.dispatch({ 45 | type: 'save_fields', 46 | payload: { 47 | [name]: value, 48 | }, 49 | }); 50 | }, 51 | }); 52 | 53 | setEmail() { 54 | this.props.dispatch({ 55 | type: 'save_fields', 56 | payload: { 57 | email: 'qq@gmail.com', 58 | }, 59 | }); 60 | } 61 | 62 | render() { 63 | const init = this.field.init; 64 | 65 | const newLen = init('newlen', { initValue: this.props.email.length }); 66 | 67 | return ( 68 |
69 | 84 | now length is:{newLen.value} 85 |

email: {this.props.email}

86 | 87 |
88 | ); 89 | } 90 | } 91 | 92 | const ReduxDemo = connect((state) => { 93 | return { 94 | email: state.formReducer.email, 95 | }; 96 | })(Demo); 97 | 98 | const store = createStore( 99 | combineReducers({ 100 | formReducer, 101 | }) 102 | ); 103 | 104 | ReactDOM.render( 105 | 106 |
107 | 108 |
109 |
, 110 | mountNode 111 | ); 112 | ``` 113 | -------------------------------------------------------------------------------- /demo/same-name.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 自动卸载同名组件 - same name 3 | order: 6 4 | debug: true 5 | --- 6 | 7 | 2 个组件相同 name,删除其中一个的时候数据要保留. 8 | 9 | --- 10 | 11 | 2 Component with same name, while delete one should keep the data. 12 | 13 | ```jsx 14 | import ReactDOM from 'react-dom'; 15 | import React from 'react'; 16 | import { Input, Button } from '@alifd/next'; 17 | import Field from '@alifd/field'; 18 | 19 | class Demo extends React.Component { 20 | state = { 21 | show: true, 22 | show2: true, 23 | }; 24 | field = new Field(this); 25 | 26 | render() { 27 | return ( 28 |
29 | {this.state.show ? ( 30 | 33 | ) : null} 34 | 41 |
42 |
43 | {this.state.show2 ? ( 44 | 47 | ) : null} 48 | 55 | 56 |
57 |
58 | 68 |
69 | ); 70 | } 71 | } 72 | 73 | ReactDOM.render(, mountNode); 74 | ``` 75 | -------------------------------------------------------------------------------- /demo/seterror.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 自定义错误 - custom errors 3 | order: 3 4 | --- 5 | 6 | 自己控制组件的errors 7 | 8 | --- 9 | 10 | set errors of component by yourself 11 | 12 | ```jsx 13 | import ReactDOM from 'react-dom'; 14 | import React from 'react'; 15 | import { Input, Button } from '@alifd/next'; 16 | import Field from '@alifd/field'; 17 | 18 | class App extends React.Component { 19 | field = new Field(this); 20 | 21 | validate = () => { 22 | console.log(this.field.getErrors()); 23 | this.field.validateCallback((error, values) => { 24 | alert(JSON.stringify(error)); 25 | }); 26 | }; 27 | 28 | render() { 29 | const { init, getError, setError, setErrors } = this.field; 30 | return ( 31 |
32 | 43 |
44 | {getError('input')} 45 | 46 |
47 | 54 | 55 | 62 | 63 | 70 | 71 |
72 |
73 | 74 |
75 | {getError('input2')} 76 |
77 | 78 | 88 | 89 | 90 |
91 | ); 92 | } 93 | } 94 | 95 | ReactDOM.render(, mountNode); 96 | ``` 97 | 98 | ```css 99 | .demo .next-btn { 100 | margin-right: 5px; 101 | } 102 | ``` 103 | -------------------------------------------------------------------------------- /demo/topath.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 结构化解析 - Parse Array or Object 3 | order: 11 4 | --- 5 | 6 | 把 `init('obj.b')` 的数据转换成 `obj={obj:{b:'value'}}`; 7 | 8 | 把 `init('arr.0')` 的数据转换成 `obj={arr:['']}`; 9 | 10 | --- 11 | 12 | from `init('obj.b')` to `obj={obj:{b:'value'}}`; 13 | 14 | from `init('arr.0')` to `obj={arr:['']}`; 15 | 16 | ```jsx 17 | import ReactDOM from 'react-dom'; 18 | import React from 'react'; 19 | import { Input, Button } from '@alifd/next'; 20 | import Field from '@alifd/field'; 21 | 22 | class App extends React.Component { 23 | field = new Field(this, { 24 | parseName: true, 25 | values: { 26 | objWithDefaults: { 27 | a: 1, 28 | b: 2, 29 | }, 30 | }, 31 | }); 32 | 33 | onGetValue() { 34 | console.log(this.field.getValues()); 35 | } 36 | 37 | onSetValue() { 38 | this.field.setValues({ 39 | obj: { 40 | b: 'b', 41 | c: 'c', 42 | }, 43 | arr: ['first', 'second'], 44 | objWithDefaults: { 45 | a: 100, 46 | b: 200, 47 | }, 48 | }); 49 | } 50 | 51 | render() { 52 | const { init, reset, resetToDefault } = this.field; 53 | 54 | return ( 55 |
56 |

Object transfer

57 | obj.b:   58 | obj.c: 59 |
60 |

Array transfer

61 | arr.0:   62 | arr.1: 63 |
64 |
65 |

Object with Defaults

66 | objWithDefaults.a: {' '} 67 |   objWithDefaults.b:{' '} 68 | 69 |
70 |
71 | result: 72 |
{JSON.stringify(this.field.getValues(), null, 2)}
73 |
74 |
75 | 78 | 79 | 80 | 81 |
82 | ); 83 | } 84 | } 85 | 86 | ReactDOM.render(, mountNode); 87 | ``` 88 | 89 | ```css 90 | .demo .next-btn { 91 | margin-right: 5px; 92 | } 93 | ``` 94 | -------------------------------------------------------------------------------- /demo/useField.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Hooks 3 | order: 12 4 | --- 5 | 6 | `getUseField` requires `useState` and `useMemo` implementation from React or Rax. Extend the `Field` Component and add a `useField` static method. 7 | 8 | `static useField(...args) { 9 | return this.getUseField(useState)(...args); 10 | }` 11 | 12 | ```jsx 13 | import ReactDOM from 'react-dom'; 14 | import React, { useState, useMemo } from 'react'; 15 | import { Input, Button } from '@alifd/next'; 16 | import Field from '@alifd/field'; 17 | 18 | class myField extends Field { 19 | static useField(...args) { 20 | return this.getUseField({ useState, useMemo })(...args); 21 | } 22 | } 23 | 24 | function NewApp() { 25 | const field = myField.useField(); 26 | 27 | const { init, setValue, reset } = field; 28 | 29 | function onGetValue() { 30 | console.log(field.getValue('input')); 31 | } 32 | 33 | function onSetValue() { 34 | field.setValue('input', 'xyz'); 35 | } 36 | 37 | return ( 38 |
39 | 40 | 41 | 42 |
43 |
44 |
45 | ); 46 | } 47 | 48 | ReactDOM.render(, mountNode); 49 | ``` 50 | -------------------------------------------------------------------------------- /demo/validator.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 校验 - validate 3 | order: 4 4 | --- 5 | 6 | 校验的错误信息需要用`getError`获取 7 | 8 | `注意`:Form 和 Field 做了深度结合,在 Form 中使用Field,错误信息不需`getError`获取会自动展现。 9 | 10 | --- 11 | 12 | you can easily use validate in `Form`, or you can use `getError` to set errors where you want to put 13 | 14 | ```jsx 15 | import ReactDOM from 'react-dom'; 16 | import React from 'react'; 17 | import { Input, Button, Checkbox } from '@alifd/next'; 18 | import Field from '@alifd/field'; 19 | 20 | const CheckboxGroup = Checkbox.Group; 21 | 22 | const list = [ 23 | { 24 | value: 'apple', 25 | label: 'apple', 26 | }, 27 | { 28 | value: 'pear', 29 | label: 'pear', 30 | }, 31 | { 32 | value: 'orange', 33 | label: 'orange', 34 | }, 35 | ]; 36 | 37 | class App extends React.Component { 38 | state = { 39 | checkboxStatus: true, 40 | }; 41 | field = new Field(this); 42 | 43 | isChecked(rule, value, callback) { 44 | if (!value) { 45 | return callback('consent agreement not checked '); 46 | } else { 47 | return callback(); 48 | } 49 | } 50 | 51 | userName(rule, value, callback) { 52 | if (value === 'frank') { 53 | setTimeout(() => callback('name existed'), 200); 54 | } else { 55 | setTimeout(() => callback(), 200); 56 | } 57 | } 58 | 59 | render() { 60 | const init = this.field.init; 61 | 62 | return ( 63 |
64 | 70 | {this.field.getError('input') ? ( 71 | 72 | {this.field.getError('input').join(',')} 73 | 74 | ) : ( 75 | '' 76 | )} 77 |
78 |
79 | 91 | {this.field.getError('input1') ? ( 92 | 93 | {this.field.getError('input1').join(',')} 94 | 95 | ) : ( 96 | '' 97 | )} 98 |
99 |
100 | 112 | {this.field.getState('username') === 'loading' 113 | ? 'validating...' 114 | : ''} 115 | {this.field.getError('username') ? ( 116 | 117 | {this.field.getError('username').join(',')} 118 | 119 | ) : ( 120 | '' 121 | )} 122 |
123 |
124 | agreement: 125 | 131 | {this.field.getError('checkbox') ? ( 132 | 133 | {this.field.getError('checkbox').join(',')} 134 | 135 | ) : ( 136 | '' 137 | )} 138 |
139 |
140 | 152 | {this.field.getError('textarea') ? ( 153 | 154 | {this.field.getError('textarea').join(',')} 155 | 156 | ) : ( 157 | '' 158 | )} 159 |
160 |
161 | {this.state.checkboxStatus ? ( 162 |
163 | Array validate: 164 | 177 | {this.field.getError('checkboxgroup') ? ( 178 | 179 | {this.field.getError('checkboxgroup').join(',')} 180 | 181 | ) : ( 182 | '' 183 | )} 184 |
185 | ) : null} 186 |
187 |
188 | 198 | 205 | 217 |
218 | ); 219 | } 220 | } 221 | 222 | ReactDOM.render(, mountNode); 223 | ``` 224 | 225 | ```css 226 | .demo .next-btn { 227 | margin-right: 5px; 228 | } 229 | ``` 230 | -------------------------------------------------------------------------------- /demo/validatorPromise.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 校验 - validatePromise 3 | order: 4 4 | --- 5 | 6 | 校验的错误信息需要用`getError`获取 7 | 8 | `注意`:Form 和 Field 做了深度结合,在 Form 中使用Field,错误信息不需`getError`获取会自动展现。 9 | 10 | --- 11 | 12 | you can easily use validate in `Form`, or you can use `getError` to set errors where you want to put 13 | 14 | ```jsx 15 | import ReactDOM from 'react-dom'; 16 | import React from 'react'; 17 | import { Input, Button, Checkbox } from '@alifd/next'; 18 | import Field from '@alifd/field'; 19 | 20 | const CheckboxGroup = Checkbox.Group; 21 | 22 | const list = [ 23 | { 24 | value: 'apple', 25 | label: 'apple', 26 | }, 27 | { 28 | value: 'pear', 29 | label: 'pear', 30 | }, 31 | { 32 | value: 'orange', 33 | label: 'orange', 34 | }, 35 | ]; 36 | 37 | class App extends React.Component { 38 | state = { 39 | checkboxStatus: true, 40 | }; 41 | field = new Field(this); 42 | 43 | isChecked(rule, value) { 44 | if (!value) { 45 | return Promise.reject('consent agreement not checked '); 46 | } else { 47 | return Promise.resolve(null); 48 | } 49 | } 50 | 51 | userName(rule, value) { 52 | if (value === 'frank') { 53 | return new Promise((resolve, reject) => { 54 | setTimeout(() => reject('name existed'), 200); 55 | }); 56 | } else { 57 | return new Promise((resolve, reject) => { 58 | setTimeout(() => reject(), 200); 59 | }); 60 | } 61 | } 62 | 63 | render() { 64 | const init = this.field.init; 65 | 66 | return ( 67 |
68 | 74 | {this.field.getError('input') ? ( 75 | 76 | {this.field.getError('input').join(',')} 77 | 78 | ) : ( 79 | '' 80 | )} 81 |
82 |
83 | 95 | {this.field.getError('input1') ? ( 96 | 97 | {this.field.getError('input1').join(',')} 98 | 99 | ) : ( 100 | '' 101 | )} 102 |
103 |
104 | 116 | {this.field.getState('username') === 'loading' 117 | ? 'validating...' 118 | : ''} 119 | {this.field.getError('username') ? ( 120 | 121 | {this.field.getError('username').join(',')} 122 | 123 | ) : ( 124 | '' 125 | )} 126 |
127 |
128 | agreement: 129 | 135 | {this.field.getError('checkbox') ? ( 136 | 137 | {this.field.getError('checkbox').join(',')} 138 | 139 | ) : ( 140 | '' 141 | )} 142 |
143 |
144 | 156 | {this.field.getError('textarea') ? ( 157 | 158 | {this.field.getError('textarea').join(',')} 159 | 160 | ) : ( 161 | '' 162 | )} 163 |
164 |
165 | {this.state.checkboxStatus ? ( 166 |
167 | Array validate: 168 | 181 | {this.field.getError('checkboxgroup') ? ( 182 | 183 | {this.field.getError('checkboxgroup').join(',')} 184 | 185 | ) : ( 186 | '' 187 | )} 188 |
189 | ) : null} 190 |
191 |
192 | 204 | 211 | 223 |
224 | ); 225 | } 226 | } 227 | 228 | ReactDOM.render(, mountNode); 229 | ``` 230 | 231 | ```css 232 | .demo .next-btn { 233 | margin-right: 5px; 234 | } 235 | ``` 236 | -------------------------------------------------------------------------------- /demo/valuename.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 自定义受控字段 - custom valueName 3 | order: 9 4 | --- 5 | 6 | valueName 的默认值为 value,如果为其他需要用 valueName 指定 7 | 8 | --- 9 | 10 | default valueName is `value` 11 | 12 | ```jsx 13 | import ReactDOM from 'react-dom'; 14 | import React from 'react'; 15 | import { Button, Checkbox, Radio, Switch } from '@alifd/next'; 16 | import Field from '@alifd/field'; 17 | 18 | class App extends React.Component { 19 | field = new Field(this); 20 | 21 | render() { 22 | const init = this.field.init; 23 | 24 | return ( 25 |
26 | 32 | {' '} 33 | checked 34 | 35 |
36 | 42 | defaultChecked 43 | 44 |
45 | 52 |
53 | 54 | 62 | 74 | 81 |
82 | ); 83 | } 84 | } 85 | 86 | ReactDOM.render(, mountNode); 87 | ``` 88 | 89 | ```css 90 | .demo .next-btn { 91 | margin-right: 5px; 92 | } 93 | ``` 94 | -------------------------------------------------------------------------------- /demo/watch.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 监听字段值变化 - watch value change 3 | order: 13 4 | --- 5 | 6 | 使用 `field.watch` 方法来监听字段值的变化 7 | 8 | --- 9 | 10 | Use `field.watch` to detect changes of the field value 11 | 12 | ```jsx 13 | import ReactDOM from 'react-dom'; 14 | import React from 'react'; 15 | import { Button, Input, Switch } from '@alifd/next'; 16 | import Field from '@alifd/field'; 17 | 18 | class App extends React.Component { 19 | constructor(props) { 20 | super(props); 21 | this.field = new Field(this); 22 | this.state = { 23 | showInput: true, 24 | }; 25 | this.field.watch( 26 | ['radio', 'input', 'switch'], 27 | (name, value, oldValue, triggerType) => { 28 | // console.log('[Detect change]', name, value, oldValue, triggerType); 29 | console.group('[Detect Change]'); 30 | console.log('name:', name); 31 | console.log('value:', oldValue, ' -> ', value); 32 | console.log('triggerType:', triggerType); 33 | console.groupEnd('[Detect Change]'); 34 | 35 | // 监听switch变化,联动控制input显隐 36 | if (name === 'switch') { 37 | this.setState({ 38 | showInput: value, 39 | }); 40 | } 41 | } 42 | ); 43 | } 44 | 45 | render() { 46 | const init = this.field.init; 47 | const { showInput } = this.state; 48 | 49 | return ( 50 |
51 | 58 |
59 | {showInput && ( 60 | 61 | )} 62 |
63 |
64 | 65 | 73 | 83 | 90 |
91 | ); 92 | } 93 | } 94 | 95 | ReactDOM.render(, mountNode); 96 | ``` 97 | 98 | ```css 99 | .demo .next-btn { 100 | margin-right: 5px; 101 | } 102 | ``` 103 | -------------------------------------------------------------------------------- /ice.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | injectBabel: 'runtime', 3 | publicPath: './', 4 | plugins: [ 5 | ['ice-plugin-fusion', {}], 6 | 'ice-plugin-component', 7 | ], 8 | chainWebpack: (config, { command }) => { 9 | // 内置 jsx 和 tsx 规则均会使用到 babel 配置 10 | ['jsx', 'tsx'].forEach((rule) => { 11 | config.module 12 | .rule(rule) 13 | .use('babel-loader') 14 | .tap((options) => { 15 | // 添加一条 babel plugin,同理可添加 presets 16 | options.plugins.push(require.resolve('babel-plugin-transform-jsx-list')); 17 | options.plugins.push(require.resolve('babel-plugin-transform-react-es6-displayname')); 18 | options.plugins.push(require.resolve('babel-plugin-transform-object-assign')); 19 | options.plugins.push(require.resolve('babel-plugin-transform-proto-to-assign')); 20 | 21 | return options; 22 | }); 23 | }); 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@alifd/field", 3 | "version": "2.0.4", 4 | "description": "Fields can be used to manage data when it comes to form data manipulation and validation. After being associated with a component, the form data can be automatically written back, read, and verified.", 5 | "files": [ 6 | "demo/", 7 | "es/", 8 | "lib/", 9 | "build/" 10 | ], 11 | "main": "lib/index.js", 12 | "module": "es/index.js", 13 | "stylePath": "style.js", 14 | "scripts": { 15 | "start": "build-scripts dev", 16 | "build": "npm run build:demo && npm run build:es && npm run build:lib", 17 | "build:demo": "build-scripts build", 18 | "build:es": "rm -rf es && tsc -p ./tsconfig.build.json --outDir es --module esnext", 19 | "build:lib": "rm -rf lib && tsc -p ./tsconfig.build.json --outDir lib --module commonjs", 20 | "prepublishOnly": "npm run build", 21 | "test": "cypress run --component -b chrome", 22 | "test:head": "cypress open --component -b chrome", 23 | "precommit": "lint-staged" 24 | }, 25 | "lint-staged": { 26 | "@(src|test)/**/*.@(ts|tsx)": [ 27 | "eslint" 28 | ], 29 | "**/*.@(js|ts|tsx|json)": [ 30 | "prettier --write", 31 | "git add" 32 | ] 33 | }, 34 | "husky": { 35 | "hooks": { 36 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" 37 | } 38 | }, 39 | "license": "MIT", 40 | "keywords": [ 41 | "react", 42 | "component" 43 | ], 44 | "dependencies": { 45 | "@alifd/validate": "^2.0.2", 46 | "tslib": "^2.6.2" 47 | }, 48 | "devDependencies": { 49 | "@alib/build-scripts": "^0.1.3", 50 | "@alifd/eslint-config-next": "^2.0.0", 51 | "@alifd/next": "^1.15.12", 52 | "@commitlint/cli": "^8.1.0", 53 | "@types/chai": "^4.3.11", 54 | "@types/react": "^16.14.56", 55 | "@types/react-dom": "^16.9.24", 56 | "@typescript-eslint/eslint-plugin": "^6.13.2", 57 | "@typescript-eslint/parser": "^6.13.2", 58 | "@vitejs/plugin-react": "^4.2.1", 59 | "build-plugin-component": "^1.0.0", 60 | "build-plugin-fusion": "^0.1.0", 61 | "build-plugin-moment-locales": "^0.1.0", 62 | "chai": "^4.4.1", 63 | "cypress": "^13.6.4", 64 | "enzyme": "^3.10.0", 65 | "enzyme-adapter-react-16": "^1.14.0", 66 | "es6-promise-polyfill": "^1.2.0", 67 | "eslint": "^8.56.0", 68 | "eslint-config-prettier": "^9.1.0", 69 | "eslint-plugin-cypress": "^2.15.1", 70 | "eslint-plugin-import": "^2.29.1", 71 | "eslint-plugin-markdown": "^3.0.1", 72 | "eslint-plugin-react": "^7.14.2", 73 | "eslint-plugin-tsdoc": "^0.2.17", 74 | "husky": "^3.0.0", 75 | "lint-staged": "^9.2.0", 76 | "mocha": "^6.1.4", 77 | "moment": "^2.24.0", 78 | "prettier": "^3.2.4", 79 | "prop-types": "^15.8.1", 80 | "react": "^16.3.0", 81 | "react-dom": "^16.8.6", 82 | "react-redux": "^7.1.0", 83 | "redux": "^4.0.4", 84 | "semantic-release": "^17.2.3", 85 | "sinon": "^16.1.3", 86 | "typescript": "^4.9.5", 87 | "vite": "^4.5.2" 88 | }, 89 | "componentConfig": { 90 | "name": "field", 91 | "title": "Field", 92 | "categories": [ 93 | "表单" 94 | ] 95 | }, 96 | "homepage": "https://unpkg.com/@alifd/field@2.0.4/build/index.html", 97 | "bugs": "https://github.com/alibaba-fusion/field/issues", 98 | "publishConfig": { 99 | "access": "public", 100 | "registry": "https://registry.npmjs.org" 101 | }, 102 | "repository": { 103 | "type": "git", 104 | "url": "https://github.com/alibaba-fusion/field.git" 105 | }, 106 | "release": { 107 | "plugins": [ 108 | [ 109 | "@semantic-release/commit-analyzer", 110 | { 111 | "releaseRules": [ 112 | { 113 | "type": "typescript", 114 | "release": "patch" 115 | }, 116 | { 117 | "type": "revert", 118 | "release": "patch" 119 | } 120 | ], 121 | "parserOpts": { 122 | "noteKeywords": [ 123 | "BREAKING CHANGE", 124 | "BREAKING CHANGES" 125 | ] 126 | } 127 | } 128 | ], 129 | "@semantic-release/release-notes-generator", 130 | "@semantic-release/npm", 131 | "@semantic-release/github" 132 | ] 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import type { Component, MutableRefObject } from 'react'; 2 | import Validate, { type NormalizedValidateError } from '@alifd/validate'; 3 | import { 4 | getValueFromEvent, 5 | getErrorStrs, 6 | getParams, 7 | hasIn, 8 | setIn, 9 | getIn, 10 | deleteIn, 11 | mapValidateRules, 12 | warning, 13 | cloneToRuleArr, 14 | isOverwritten, 15 | } from './utils'; 16 | import type { 17 | FieldOption, 18 | ComponentInstance, 19 | GetUseFieldOption, 20 | FieldMeta, 21 | RerenderFunction, 22 | WatchCallback, 23 | NormalizedFieldOption, 24 | InitOption, 25 | FieldValues, 26 | NormalizedFieldMeta, 27 | Rule, 28 | FieldState, 29 | ValidateCallback, 30 | ValidateResultFormatter, 31 | ValidateErrorGroup, 32 | RerenderType, 33 | WatchTriggerType, 34 | SetState, 35 | ValidatePromiseResults, 36 | InitResult, 37 | } from './types'; 38 | 39 | const initMeta = { 40 | state: '' as const, 41 | valueName: 'value', 42 | trigger: 'onChange', 43 | inputValues: [], 44 | }; 45 | 46 | class Field { 47 | static create(com: ComponentInstance, options: FieldOption = {}) { 48 | return new this(com, options); 49 | } 50 | 51 | static getUseField({ useState, useMemo }: GetUseFieldOption) { 52 | return (options: FieldOption = {}) => { 53 | const [, setState] = useState(); 54 | 55 | const field = useMemo(() => this.create({ setState }, options), [setState]); 56 | 57 | return field; 58 | }; 59 | } 60 | 61 | fieldsMeta: Record; 62 | cachedBind: Record>; 63 | instance: Record; 64 | instanceCount: Record; 65 | reRenders: Record; 66 | listeners: Record>; 67 | values: FieldValues; 68 | processErrorMessage?: FieldOption['processErrorMessage']; 69 | afterValidateRerender?: FieldOption['afterValidateRerender']; 70 | com: ComponentInstance; 71 | options: NormalizedFieldOption; 72 | 73 | constructor(com: ComponentInstance, options: FieldOption = {}) { 74 | if (!com) { 75 | warning('`this` is missing in `Field`, you should use like `new Field(this)`'); 76 | } 77 | 78 | this.com = com; 79 | this.fieldsMeta = {}; 80 | this.cachedBind = {}; 81 | this.instance = {}; 82 | this.instanceCount = {}; 83 | this.reRenders = {}; 84 | this.listeners = {}; 85 | // holds constructor values. Used for setting field defaults on init if no other value or initValue is passed. 86 | // Also used caching values when using `parseName: true` before a field is initialized 87 | this.values = Object.assign({}, options.values); 88 | 89 | this.processErrorMessage = options.processErrorMessage; 90 | this.afterValidateRerender = options.afterValidateRerender; 91 | 92 | this.options = Object.assign( 93 | { 94 | parseName: false, 95 | forceUpdate: false, 96 | first: false, 97 | onChange: () => {}, 98 | autoUnmount: true, 99 | autoValidate: true, 100 | }, 101 | options 102 | ); 103 | 104 | ( 105 | [ 106 | 'init', 107 | 'getValue', 108 | 'getValues', 109 | 'setValue', 110 | 'setValues', 111 | 'getError', 112 | 'getErrors', 113 | 'setError', 114 | 'setErrors', 115 | 'validateCallback', 116 | 'validatePromise', 117 | 'getState', 118 | 'reset', 119 | 'resetToDefault', 120 | 'remove', 121 | 'spliceArray', 122 | 'addArrayValue', 123 | 'deleteArrayValue', 124 | 'getNames', 125 | ] as const 126 | ).forEach((m) => { 127 | this[m] = this[m].bind(this); 128 | }); 129 | } 130 | 131 | /** 132 | * 设置配置信息 133 | * @param options - 配置 134 | */ 135 | setOptions(options: Partial) { 136 | Object.assign(this.options, options); 137 | } 138 | 139 | /** 140 | * 初始化一个字段项 141 | * @param name - 字段 key 142 | * @param option - 字段配置 143 | * @param rprops - 其它参数 144 | */ 145 | init< 146 | ValueType = any, 147 | ValueName extends string = 'value', 148 | Trigger extends string = 'onChange', 149 | OtherProps extends object = object, 150 | >(name: string, option: InitOption = {}, rprops?: OtherProps) { 151 | const { 152 | id, 153 | initValue, 154 | valueName = 'value', 155 | trigger = 'onChange', 156 | rules = [], 157 | props = {}, 158 | getValueFromEvent = null, 159 | getValueFormatter = getValueFromEvent, 160 | setValueFormatter, 161 | autoValidate = true, 162 | reRender, 163 | } = option; 164 | const { parseName } = this.options; 165 | 166 | if (getValueFromEvent) { 167 | warning('`getValueFromEvent` has been deprecated in `Field`, use `getValueFormatter` instead of it'); 168 | } 169 | 170 | const originalProps = Object.assign({}, props, rprops) as Record; 171 | const defaultValueName = `default${valueName[0].toUpperCase()}${valueName.slice(1)}`; 172 | let defaultValue; 173 | if (typeof initValue !== 'undefined') { 174 | defaultValue = initValue; 175 | } else if (typeof originalProps[defaultValueName] !== 'undefined') { 176 | // here use typeof, in case of defaultValue={0} 177 | defaultValue = originalProps[defaultValueName]; 178 | } 179 | 180 | // get field from this.fieldsMeta or new one 181 | const field = this._getInitMeta(name) as NormalizedFieldMeta; 182 | Object.assign(field, { 183 | valueName, 184 | initValue: defaultValue, 185 | disabled: 'disabled' in originalProps ? originalProps.disabled : false, 186 | getValueFormatter, 187 | setValueFormatter, 188 | rules: cloneToRuleArr(rules), 189 | ref: originalProps.ref, 190 | }); 191 | 192 | let oldValue = field.value; 193 | 194 | // Controlled Component, should always equal props.value 195 | if (valueName in originalProps) { 196 | const originalValue = originalProps[valueName]; 197 | 198 | // When rerendering set the values from props.value 199 | if (parseName) { 200 | // when parseName is true, field should not store value locally. To prevent sync issues 201 | if (!('value' in field)) { 202 | this._proxyFieldValue(field); 203 | } 204 | } else { 205 | this.values[name] = originalValue; 206 | } 207 | field.value = originalValue; 208 | } 209 | 210 | /** 211 | * first init field (value not in field) 212 | * should get field.value from this.values or defaultValue 213 | */ 214 | if (!('value' in field)) { 215 | if (parseName) { 216 | const cachedValue = getIn(this.values, name); 217 | if (typeof cachedValue !== 'undefined') { 218 | oldValue = cachedValue; 219 | } 220 | const initValue = typeof cachedValue !== 'undefined' ? cachedValue : defaultValue; 221 | // when parseName is true, field should not store value locally. To prevent sync issues 222 | this._proxyFieldValue(field); 223 | field.value = initValue; 224 | } else { 225 | const cachedValue = this.values[name]; 226 | if (typeof cachedValue !== 'undefined') { 227 | field.value = cachedValue; 228 | oldValue = cachedValue; 229 | } else if (typeof defaultValue !== 'undefined') { 230 | // should be same with parseName, but compatible with old versions 231 | field.value = defaultValue; 232 | this.values[name] = field.value; 233 | } 234 | } 235 | } 236 | 237 | // field value init end 238 | const newValue = field.value; 239 | this._triggerFieldChange(name, newValue, oldValue, 'init'); 240 | 241 | // Component props 242 | const inputProps = { 243 | 'data-meta': 'Field', 244 | id: id || name, 245 | ref: this._getCacheBind(name, `${name}__ref`, this._saveRef), 246 | [valueName]: setValueFormatter 247 | ? setValueFormatter(field.value as ValueType, field.inputValues) 248 | : field.value, 249 | }; 250 | 251 | let rulesMap: Record = {}; 252 | 253 | if (this.options.autoValidate && autoValidate !== false) { 254 | // trigger map in rules, 255 | rulesMap = mapValidateRules(field.rules, trigger); 256 | 257 | // step1 : validate hooks 258 | for (const action in rulesMap) { 259 | // skip default trigger, which will trigger in step2 260 | if (action === trigger) { 261 | continue; 262 | } 263 | 264 | const actionRule = rulesMap[action]; 265 | inputProps[action] = (...args: unknown[]) => { 266 | this._callNativePropsEvent(action, originalProps, ...args); 267 | this._validate(name, actionRule, action); 268 | }; 269 | } 270 | } 271 | 272 | // step2: onChange(trigger=onChange by default) hack 273 | inputProps[trigger] = (...args: unknown[]) => { 274 | const oldValue = this.getValue(name); 275 | this._updateFieldValue(name, ...args); 276 | const newValue = this.getValue(name); 277 | this._triggerFieldChange(name, newValue, oldValue, 'change'); 278 | 279 | // clear validate error 280 | this._resetError(name); 281 | 282 | this._callNativePropsEvent(trigger, originalProps, ...args); 283 | // call global onChange 284 | this.options.onChange(name, field.value); 285 | 286 | // validate while onChange 287 | const rule = rulesMap[trigger]; 288 | rule && this._validate(name, rule, trigger); 289 | 290 | this._reRender(name, trigger); 291 | }; 292 | 293 | // step3: save reRender function 294 | if (reRender && typeof reRender === 'function') { 295 | this.reRenders[name] = reRender; 296 | } 297 | 298 | delete originalProps[defaultValueName]; 299 | 300 | return Object.assign({}, originalProps, inputProps) as OtherProps & InitResult; 301 | } 302 | 303 | /** 304 | * 获取单个输入控件的值 305 | * @param name - 字段名 306 | * @returns 字段值 307 | */ 308 | getValue(name: string): T | undefined { 309 | if (this.options.parseName) { 310 | return getIn(this.values, name); 311 | } 312 | return this.values[name] as T; 313 | } 314 | 315 | /** 316 | * 获取一组输入控件的值 317 | * @param names - 字段名数组 318 | * @returns 不传入`names`参数,则获取全部字段的值 319 | */ 320 | getValues>(names?: string[]): T { 321 | const allValues: Record = {}; 322 | 323 | if (names && names.length) { 324 | names.forEach((name) => { 325 | allValues[name] = this.getValue(name); 326 | }); 327 | } else { 328 | Object.assign(allValues, this.values); 329 | } 330 | 331 | return allValues as T; 332 | } 333 | 334 | /** 335 | * 设置单个输入控件的值(默认会触发 render,请遵循 react 时机使用) 336 | * @param name - 字段名 337 | * @param value - 字段值 338 | * @param reRender - 设置完成后是否重新渲染,默认为 true 339 | * @param triggerChange - 是否触发 watch change,默认为 true 340 | */ 341 | setValue(name: string, value: T, reRender = true, triggerChange = true) { 342 | const oldValue = this.getValue(name); 343 | if (name in this.fieldsMeta) { 344 | this.fieldsMeta[name].value = value; 345 | } 346 | if (this.options.parseName) { 347 | this.values = setIn(this.values, name, value); 348 | } else { 349 | this.values[name] = value; 350 | } 351 | const newValue = this.getValue(name); 352 | if (triggerChange) { 353 | this._triggerFieldChange(name, newValue, oldValue, 'setValue'); 354 | } 355 | reRender && this._reRender(name, 'setValue'); 356 | } 357 | 358 | /** 359 | * 设置一组输入控件的值(默认会触发 render,请遵循 react 时机使用) 360 | * @param fieldsValue - 一组输入控件值对象 361 | * @param reRender - 设置完成后是否重新渲染,默认为 true 362 | */ 363 | setValues(fieldsValue: Record = {}, reRender = true) { 364 | if (!this.options.parseName) { 365 | Object.keys(fieldsValue).forEach((name) => { 366 | this.setValue(name, fieldsValue[name], false, true); 367 | }); 368 | } else { 369 | // NOTE: this is a shallow merge 370 | // Ex. we have two values a.b.c=1 ; a.b.d=2, and use setValues({a:{b:{c:3}}}) , then because of shallow merge a.b.d will be lost, we will get only {a:{b:{c:3}}} 371 | // fieldsMeta[name].value is proxy from this.values[name] when parseName is true, so there is no need to assign value to fieldMeta 372 | // shallow merge 373 | let newValues = Object.assign({}, this.values, fieldsValue); 374 | const fields = this.getNames(); 375 | const allOldFieldValues = this.getValues(fields); 376 | // record all old field values, exclude items overwritten by fieldsValue 377 | const oldFieldValues = fields 378 | .filter((name) => !isOverwritten(fieldsValue, name)) 379 | .map((name) => ({ name, value: this.fieldsMeta[name].value })); 380 | // assign lost field value to newValues 381 | oldFieldValues.forEach(({ name, value }) => { 382 | if (!hasIn(newValues, name)) { 383 | newValues = setIn(newValues, name, value); 384 | } 385 | }); 386 | // store the new values 387 | this.values = newValues; 388 | 389 | // trigger changes after update 390 | for (const name of fields) { 391 | this._triggerFieldChange(name, this.getValue(name), allOldFieldValues[name], 'setValue'); 392 | } 393 | } 394 | reRender && this._reRender(); 395 | } 396 | 397 | /** 398 | * 获取单个输入控件的 Error 399 | * @param name - 字段名 400 | * @returns 该字段的 Error 401 | */ 402 | getError(name: string) { 403 | const field = this._get(name); 404 | if (field && field.errors && field.errors.length) { 405 | return field.errors; 406 | } 407 | 408 | return null; 409 | } 410 | 411 | /** 412 | * 获取一组输入控件的 Error 413 | * @param names - 字段名列表 414 | * @returns 不传入`names`参数,则获取全部字段的 Error 415 | */ 416 | getErrors(names?: K[]) { 417 | const fields = names || this.getNames(); 418 | const allErrors = {} as Record; 419 | fields.forEach((f: K) => { 420 | allErrors[f] = this.getError(f); 421 | }); 422 | return allErrors; 423 | } 424 | 425 | /** 426 | * 设置单个输入控件的 Error 427 | * @param name - 字段名 428 | * @param errors - 错误信息 429 | */ 430 | setError(name: string, errors?: unknown) { 431 | const err: unknown[] = Array.isArray(errors) ? errors : errors ? [errors] : []; 432 | if (name in this.fieldsMeta) { 433 | this.fieldsMeta[name].errors = err; 434 | } else { 435 | this.fieldsMeta[name] = { 436 | errors: err, 437 | name, 438 | }; 439 | } 440 | 441 | if (this.fieldsMeta[name].errors && this.fieldsMeta[name].errors!.length > 0) { 442 | this.fieldsMeta[name].state = 'error'; 443 | } else { 444 | this.fieldsMeta[name].state = ''; 445 | } 446 | 447 | this._reRender(name, 'setError'); 448 | } 449 | 450 | /** 451 | * 设置一组输入控件的 Error 452 | */ 453 | setErrors(fieldsErrors: Record = {}) { 454 | Object.keys(fieldsErrors).forEach((name) => { 455 | this.setError(name, fieldsErrors[name]); 456 | }); 457 | } 458 | 459 | /** 460 | * 获取单个字段的校验状态 461 | * @param name - 字段名 462 | */ 463 | getState(name: string): FieldState { 464 | const field = this._get(name); 465 | 466 | if (field && field.state) { 467 | return field.state; 468 | } 469 | 470 | return ''; 471 | } 472 | 473 | /** 474 | * 校验全部字段 475 | * @param callback - 校验结果的回调函数 476 | */ 477 | validateCallback(callback?: ValidateCallback): void; 478 | /** 479 | * 校验指定字段 480 | * @param names - 字段名或字段名列表 481 | * @param callback - 校验结果回调函数 482 | */ 483 | validateCallback(names?: string | string[], callback?: ValidateCallback): void; 484 | /** 485 | * 校验 - Callback version 486 | */ 487 | validateCallback(ns?: string | string[] | ValidateCallback, cb?: ValidateCallback) { 488 | const { names, callback } = getParams(ns, cb); 489 | const fieldNames = names || this.getNames(); 490 | 491 | const descriptor: Record = {}; 492 | const values: FieldValues = {}; 493 | 494 | let hasRule = false; 495 | for (let i = 0; i < fieldNames.length; i++) { 496 | const name = fieldNames[i]; 497 | const field = this._get(name) as NormalizedFieldMeta; 498 | 499 | if (!field) { 500 | continue; 501 | } 502 | 503 | if (field.rules && field.rules.length) { 504 | descriptor[name] = field.rules; 505 | values[name] = this.getValue(name); 506 | hasRule = true; 507 | 508 | // clear error 509 | field.errors = []; 510 | field.state = ''; 511 | } 512 | } 513 | 514 | if (!hasRule) { 515 | const errors = this.formatGetErrors(fieldNames); 516 | callback && callback(errors, this.getValues(names ? fieldNames : [])); 517 | return; 518 | } 519 | 520 | const validate = new Validate(descriptor, { 521 | first: this.options.first, 522 | messages: this.options.messages, 523 | }); 524 | 525 | validate.validate(values, (errors) => { 526 | let errorsGroup: ValidateErrorGroup | null = null; 527 | if (errors && errors.length) { 528 | errorsGroup = {}; 529 | errors.forEach((e) => { 530 | const fieldName = e.field; 531 | if (!errorsGroup![fieldName]) { 532 | errorsGroup![fieldName] = { 533 | errors: [], 534 | }; 535 | } 536 | const fieldErrors = errorsGroup![fieldName].errors; 537 | fieldErrors.push(e.message); 538 | }); 539 | } 540 | if (errorsGroup) { 541 | // update error in every Field 542 | Object.keys(errorsGroup).forEach((i) => { 543 | const field = this._get(i); 544 | if (field) { 545 | field.errors = getErrorStrs(errorsGroup![i].errors, this.processErrorMessage); 546 | field.state = 'error'; 547 | } 548 | }); 549 | } 550 | 551 | const formattedGetErrors = this.formatGetErrors(fieldNames); 552 | 553 | if (formattedGetErrors) { 554 | errorsGroup = Object.assign({}, formattedGetErrors, errorsGroup); 555 | } 556 | 557 | // update to success which has no error 558 | for (let i = 0; i < fieldNames.length; i++) { 559 | const name = fieldNames[i]; 560 | const field = this._get(name); 561 | if (field && field.rules && !(errorsGroup && name in errorsGroup)) { 562 | field.state = 'success'; 563 | } 564 | } 565 | 566 | callback && callback(errorsGroup, this.getValues(names ? fieldNames : [])); 567 | this._reRender(names, 'validate'); 568 | 569 | this._triggerAfterValidateRerender(errorsGroup); 570 | }); 571 | } 572 | 573 | /** 574 | * Promise 方式校验全部字段 575 | */ 576 | async validatePromise(): Promise; 577 | /** 578 | * Promise 方式校验指定字段 579 | * @param names - 字段名或字段名列表 580 | */ 581 | async validatePromise(names?: string | string[]): Promise; 582 | /** 583 | * Promise 方式校验所有字段,并使用一个函数处理校验结果 584 | * @param formatter - 校验结果处理函数 585 | */ 586 | async validatePromise( 587 | formatter?: (results: ValidatePromiseResults) => FormatterResults | Promise 588 | ): Promise; 589 | /** 590 | * Promise 方式校验指定字段,并使用一个函数处理校验结果 591 | * @param names - 字段名或字段名列表 592 | * @param formatter - 校验结果处理函数 593 | */ 594 | async validatePromise( 595 | names?: string | string[], 596 | formatter?: (results: ValidatePromiseResults) => FormatterResults | Promise 597 | ): Promise; 598 | /** 599 | * 校验 - Promise version 600 | */ 601 | async validatePromise( 602 | ns?: string | string[] | ValidateResultFormatter, 603 | formatter?: ValidateResultFormatter 604 | ) { 605 | const { names, callback } = getParams(ns, formatter); 606 | const fieldNames = names || this.getNames(); 607 | 608 | const descriptor: Record = {}; 609 | const values: FieldValues = {}; 610 | 611 | let hasRule = false; 612 | for (let i = 0; i < fieldNames.length; i++) { 613 | const name = fieldNames[i]; 614 | const field = this._get(name) as NormalizedFieldMeta; 615 | 616 | if (!field) { 617 | continue; 618 | } 619 | 620 | if (field.rules && field.rules.length) { 621 | descriptor[name] = field.rules; 622 | values[name] = this.getValue(name); 623 | hasRule = true; 624 | 625 | // clear error 626 | field.errors = []; 627 | field.state = ''; 628 | } 629 | } 630 | 631 | if (!hasRule) { 632 | const errors = this.formatGetErrors(fieldNames); 633 | if (callback) { 634 | return callback({ 635 | errors, 636 | values: this.getValues(names ? fieldNames : []), 637 | }); 638 | } else { 639 | return { 640 | errors, 641 | values: this.getValues(names ? fieldNames : []), 642 | }; 643 | } 644 | } 645 | 646 | const validate = new Validate(descriptor, { 647 | first: this.options.first, 648 | messages: this.options.messages, 649 | }); 650 | 651 | const results = await validate.validatePromise(values); 652 | const errors = (results && results.errors) || []; 653 | 654 | const errorsGroup = this._getErrorsGroup({ errors, fieldNames }); 655 | 656 | let callbackResults: ValidatePromiseResults | FormatterResults = { 657 | errors: errorsGroup, 658 | values: this.getValues(names ? fieldNames : []), 659 | }; 660 | try { 661 | if (callback) { 662 | callbackResults = await callback(callbackResults); 663 | } 664 | } catch (error) { 665 | return error; 666 | } 667 | this._reRender(names, 'validate'); 668 | // afterValidateRerender 作为通用属性,在 callback 和 promise 两个版本的 validate 中保持相同行为 669 | this._triggerAfterValidateRerender(errorsGroup); 670 | return callbackResults; 671 | } 672 | 673 | /** 674 | * 重置一组输入控件的值,并清空校验信息 675 | * @param names - 要重置的字段名,不传递则重置全部字段 676 | */ 677 | reset(ns?: string | string[]) { 678 | this._reset(ns, false); 679 | } 680 | 681 | /** 682 | * 重置一组输入控件的值为默认值,并清空校验信息 683 | * @param names - 要重置的字段名,不传递则重置全部字段 684 | */ 685 | resetToDefault(ns?: string | string[]) { 686 | this._reset(ns, true); 687 | } 688 | 689 | /** 690 | * 获取所有字段名列表 691 | */ 692 | getNames() { 693 | const fieldsMeta = this.fieldsMeta; 694 | return Object.keys(fieldsMeta).filter(() => { 695 | return true; 696 | }); 697 | } 698 | 699 | /** 700 | * 删除某一个或者一组控件的数据,删除后与之相关的 validate/value 都会被清空 701 | * @param name - 要删除的字段名,不传递则删除全部字段 702 | */ 703 | remove(ns?: string | string[]) { 704 | if (typeof ns === 'string') { 705 | ns = [ns]; 706 | } 707 | if (!ns) { 708 | this.values = {}; 709 | } 710 | 711 | const names = ns || Object.keys(this.fieldsMeta); 712 | names.forEach((name) => { 713 | if (name in this.fieldsMeta) { 714 | delete this.fieldsMeta[name]; 715 | } 716 | if (this.options.parseName) { 717 | this.values = deleteIn(this.values, name); 718 | } else { 719 | delete this.values[name]; 720 | } 721 | }); 722 | } 723 | 724 | /** 725 | * 向指定数组字段内添加数据 726 | * @param name - 字段名 727 | * @param index - 开始添加的索引 728 | * @param argv - 新增的数据 729 | */ 730 | addArrayValue(name: string, index: number, ...argv: unknown[]) { 731 | return this._spliceArrayValue(name, index, 0, ...argv); 732 | } 733 | 734 | /** 735 | * 删除指定字段数组内的数据 736 | * @param name - 变量名 737 | * @param index - 开始删除的索引 738 | * @param howmany - 删除几个数据,默认为 1 739 | */ 740 | deleteArrayValue(name: string, index: number, howmany = 1) { 741 | return this._spliceArrayValue(name, index, howmany); 742 | } 743 | 744 | /** 745 | * splice in a Array [deprecated] 746 | * @deprecated Use `addArrayValue` or `deleteArrayValue` instead 747 | * @param keyMatch - like name.\{index\} 748 | * @param startIndex - index 749 | */ 750 | spliceArray(keyMatch: string, startIndex: number) { 751 | // @ts-expect-error FIXME 无效的 if 逻辑,恒定为 false 752 | if (keyMatch.match(/{index}$/) === -1) { 753 | warning('key should match /{index}$/'); 754 | return; 755 | } 756 | 757 | // regex to match field names in the same target array 758 | const reg = keyMatch.replace('{index}', '(\\d+)'); 759 | const keyReg = new RegExp(`^${reg}`); 760 | 761 | const listMap: Record> = {}; 762 | /** 763 | * keyMatch='key.\{index\}' 764 | * case 1: names=['key.0', 'key.1'], should delete 'key.1' 765 | * case 2: names=['key.0.name', 'key.0.email', 'key.1.name', 'key.1.email'], should delete 'key.1.name', 'key.1.email' 766 | */ 767 | const names = this.getNames(); 768 | const willChangeNames: string[] = []; 769 | names.forEach((n) => { 770 | // is name in the target array? 771 | const ret = keyReg.exec(n); 772 | if (ret) { 773 | const index = parseInt(ret[1]); 774 | 775 | if (index > startIndex) { 776 | const l = listMap[index]; 777 | const item = { 778 | from: n, 779 | to: `${keyMatch.replace('{index}', (index - 1).toString())}${n.replace(ret[0], '')}`, 780 | }; 781 | willChangeNames.push(item.from); 782 | if (names.includes(item.to)) { 783 | willChangeNames.push(item.to); 784 | } 785 | if (!l) { 786 | listMap[index] = [item]; 787 | } else { 788 | l.push(item); 789 | } 790 | } 791 | } 792 | }); 793 | const oldValues = this.getValues(willChangeNames); 794 | 795 | const idxList = Object.keys(listMap) 796 | .map((i) => { 797 | return { 798 | index: Number(i), 799 | list: listMap[i], 800 | }; 801 | }) 802 | // @ts-expect-error FIXME 返回 boolean 值并不能正确排序 803 | .sort((a, b) => a.index < b.index); 804 | 805 | // should be continuous array 806 | if (idxList.length > 0 && idxList[0].index === startIndex + 1) { 807 | idxList.forEach((l) => { 808 | const list = l.list; 809 | list.forEach((i) => { 810 | const v = this.getValue(i.from); // get index value 811 | this.setValue(i.to, v, false, false); // set value to index - 1 812 | }); 813 | }); 814 | 815 | const lastIdxList = idxList[idxList.length - 1]; 816 | lastIdxList.list.forEach((i) => { 817 | this.remove(i.from); 818 | }); 819 | 820 | let parentName = keyMatch.replace('.{index}', ''); 821 | parentName = parentName.replace('[{index}]', ''); 822 | const parent = this.getValue(parentName) as unknown[]; 823 | 824 | if (parent) { 825 | // if parseName=true then parent is an Array object but does not know an element was removed 826 | // this manually decrements the array length 827 | parent.length--; 828 | } 829 | } 830 | for (const name of willChangeNames) { 831 | this._triggerFieldChange(name, this.getValue(name), oldValues[name], 'setValue'); 832 | } 833 | } 834 | 835 | /** 836 | * 获取全部字段信息 837 | * @param name - 传递 falsy 值 838 | */ 839 | get(name?: undefined | ''): Record; 840 | /** 841 | * 获取指定字段信息 842 | * @param name - 字段名 843 | */ 844 | get(name: string): NormalizedFieldMeta | null; 845 | get(name?: string) { 846 | if (name) { 847 | return this._get(name); 848 | } else { 849 | return this.fieldsMeta; 850 | } 851 | } 852 | 853 | /** 854 | * 监听字段值变化 855 | * @param names - 监听的 name 列表 856 | * @param callback - 变化回调 857 | * @returns 解除监听回调 858 | */ 859 | watch(names: string[], callback: WatchCallback) { 860 | for (const name of names) { 861 | if (!this.listeners[name]) { 862 | this.listeners[name] = new Set(); 863 | } 864 | const set = this.listeners[name]; 865 | set.add(callback); 866 | } 867 | return () => { 868 | for (const name of names) { 869 | if (this.listeners[name]) { 870 | this.listeners[name].delete(callback); 871 | } 872 | } 873 | }; 874 | } 875 | 876 | _get(name: string) { 877 | return name in this.fieldsMeta ? this.fieldsMeta[name] : null; 878 | } 879 | 880 | _getInitMeta(name: string) { 881 | if (!(name in this.fieldsMeta)) { 882 | this.fieldsMeta[name] = Object.assign({ name }, initMeta); 883 | } 884 | 885 | return this.fieldsMeta[name]; 886 | } 887 | 888 | _getErrorsGroup({ errors, fieldNames }: { errors: NormalizedValidateError[]; fieldNames: string[] }) { 889 | let errorsGroup: ValidateErrorGroup | null = null; 890 | if (errors && errors.length) { 891 | errorsGroup = {}; 892 | errors.forEach((e) => { 893 | const fieldName = e.field; 894 | if (!errorsGroup![fieldName]) { 895 | errorsGroup![fieldName] = { 896 | errors: [], 897 | }; 898 | } 899 | const fieldErrors = errorsGroup![fieldName].errors; 900 | fieldErrors.push(e.message); 901 | }); 902 | } 903 | if (errorsGroup) { 904 | // update error in every Field 905 | Object.keys(errorsGroup).forEach((i) => { 906 | const field = this._get(i); 907 | if (field) { 908 | field.errors = getErrorStrs(errorsGroup![i].errors, this.processErrorMessage); 909 | field.state = 'error'; 910 | } 911 | }); 912 | } 913 | 914 | const formattedGetErrors = this.formatGetErrors(fieldNames); 915 | 916 | if (formattedGetErrors) { 917 | errorsGroup = Object.assign({}, formattedGetErrors, errorsGroup); 918 | } 919 | 920 | // update to success which has no error 921 | for (let i = 0; i < fieldNames.length; i++) { 922 | const name = fieldNames[i]; 923 | const field = this._get(name); 924 | if (field && field.rules && !(errorsGroup && name in errorsGroup)) { 925 | field.state = 'success'; 926 | } 927 | } 928 | 929 | return errorsGroup; 930 | } 931 | 932 | _reset(ns?: string | string[], backToDefault?: boolean) { 933 | if (typeof ns === 'string') { 934 | ns = [ns]; 935 | } 936 | let changed = false; 937 | 938 | const names = ns || Object.keys(this.fieldsMeta); 939 | 940 | const oldValues = this.getValues(names); 941 | if (!ns) { 942 | this.values = {}; 943 | } 944 | names.forEach((name) => { 945 | const field = this._get(name); 946 | if (field) { 947 | changed = true; 948 | 949 | field.value = backToDefault ? field.initValue : undefined; 950 | field.state = ''; 951 | 952 | delete field.errors; 953 | delete field.rules; 954 | delete field.rulesMap; 955 | 956 | if (this.options.parseName) { 957 | this.values = setIn(this.values, name, field.value); 958 | } else { 959 | this.values[name] = field.value; 960 | } 961 | } 962 | this._triggerFieldChange(name, this.getValue(name), oldValues[name], 'reset'); 963 | }); 964 | 965 | if (changed) { 966 | this._reRender(names, 'reset'); 967 | } 968 | } 969 | 970 | _resetError(name: string) { 971 | const field = this._get(name); 972 | if (field) { 973 | delete field.errors; //清空错误 974 | field.state = ''; 975 | } 976 | } 977 | 978 | _reRender(name?: string | string[], action?: RerenderType | string) { 979 | // 指定了字段列表且字段存在对应的自定义渲染函数 980 | if (name) { 981 | const names = Array.isArray(name) ? name : [name]; 982 | if (names.length && names.every((n) => this.reRenders[n])) { 983 | names.forEach((n) => { 984 | const reRender = this.reRenders[n]; 985 | reRender(action); 986 | }); 987 | return; 988 | } 989 | } 990 | 991 | if (this.com) { 992 | if (!this.options.forceUpdate && (this.com as { setState: SetState }).setState) { 993 | (this.com as { setState: SetState }).setState({}); 994 | } else if ((this.com as Component).forceUpdate) { 995 | (this.com as Component).forceUpdate(); //forceUpdate 对性能有较大的影响,成指数上升 996 | } 997 | } 998 | } 999 | 1000 | /** 1001 | * Get errors using `getErrors` and format to match the structure of errors returned in field.validate 1002 | */ 1003 | formatGetErrors(names?: string[]) { 1004 | const errors = this.getErrors(names); 1005 | let formattedErrors: ValidateErrorGroup | null = null; 1006 | for (const field in errors) { 1007 | if (errors.hasOwnProperty(field) && errors[field]) { 1008 | const errorsObj = errors[field]!; 1009 | if (!formattedErrors) { 1010 | formattedErrors = {}; 1011 | } 1012 | formattedErrors[field] = { errors: errorsObj }; 1013 | } 1014 | } 1015 | return formattedErrors; 1016 | } 1017 | 1018 | /** 1019 | * call native event from props.onXx 1020 | * eg: props.onChange props.onBlur props.onFocus 1021 | */ 1022 | _callNativePropsEvent(action: string, props: Record, ...args: unknown[]) { 1023 | action in props && 1024 | typeof props[action] === 'function' && 1025 | (props[action] as (...args: unknown[]) => unknown)(...args); 1026 | } 1027 | 1028 | _proxyFieldValue(field: NormalizedFieldMeta) { 1029 | const _this = this; 1030 | Object.defineProperty(field, 'value', { 1031 | configurable: true, 1032 | enumerable: true, 1033 | get() { 1034 | return getIn(_this.values, this.name); 1035 | }, 1036 | set(v) { 1037 | // 此处 this 解释同上 1038 | _this.values = setIn(_this.values, this.name, v); 1039 | return true; 1040 | }, 1041 | }); 1042 | } 1043 | 1044 | /** 1045 | * update field.value and validate 1046 | */ 1047 | _updateFieldValue(name: string, ...others: unknown[]) { 1048 | const e = others[0]; 1049 | const field = this._get(name); 1050 | 1051 | if (!field) { 1052 | return; 1053 | } 1054 | field.value = field.getValueFormatter ? field.getValueFormatter.apply(this, others) : getValueFromEvent(e); 1055 | field.inputValues = others; 1056 | 1057 | if (this.options.parseName) { 1058 | this.values = setIn(this.values, name, field.value); 1059 | } else { 1060 | this.values[name] = field.value; 1061 | } 1062 | } 1063 | 1064 | /** 1065 | * ref must always be the same function, or if not it will be triggerd every time. 1066 | */ 1067 | _getCacheBind( 1068 | name: string, 1069 | action: string, 1070 | fn: (name: string, ...args: Args) => Result 1071 | ): (...args: Args) => Result { 1072 | const cache = (this.cachedBind[name] = this.cachedBind[name] || {}); 1073 | if (!cache[action]) { 1074 | cache[action] = fn.bind(this, name); 1075 | } 1076 | return cache[action] as (...args: Args) => Result; 1077 | } 1078 | 1079 | _setCache(name: string, action: string, hander: unknown) { 1080 | const cache = (this.cachedBind[name] = this.cachedBind[name] || {}); 1081 | cache[action] = hander; 1082 | } 1083 | 1084 | _getCache(name: string, action: string): R | undefined { 1085 | const cache = this.cachedBind[name] || {}; 1086 | return cache[action] as R | undefined; 1087 | } 1088 | 1089 | _saveRef(name: string, component: unknown) { 1090 | const key = `${name}_field`; 1091 | const autoUnmount = this.options.autoUnmount; 1092 | 1093 | if (!component && autoUnmount) { 1094 | // more than one component, do nothing 1095 | this.instanceCount[name] && this.instanceCount[name]--; 1096 | if (this.instanceCount[name] > 0) { 1097 | return; 1098 | } 1099 | 1100 | // component with same name (eg: type ? :) 1101 | // while type changed, B will render before A unmount. so we should cached value for B 1102 | // step: render -> B mount -> 1. _saveRef(A, null) -> 2. _saveRef(B, ref) -> render 1103 | // 1. _saveRef(A, null) 1104 | const cache = this.fieldsMeta[name]; 1105 | if (cache) { 1106 | if (this.options.parseName) { 1107 | // 若 parseName 模式下,因为 value 为 getter、setter,所以将当前值记录到_value 内 1108 | cache._value = cache.value; 1109 | } 1110 | this._setCache(name, key, cache); 1111 | } 1112 | 1113 | // after destroy, delete data 1114 | delete this.instance[name]; 1115 | delete this.reRenders[name]; 1116 | const oldValue = this.getValue(name); 1117 | this.remove(name); 1118 | const newValue = this.getValue(name); 1119 | this._triggerFieldChange(name, newValue, oldValue, 'unmount'); 1120 | return; 1121 | } 1122 | 1123 | // 2. _saveRef(B, ref) (eg: same name but different compoent may be here) 1124 | if (autoUnmount && !this.fieldsMeta[name] && this._getCache(name, key)) { 1125 | const cache = this._getCache(name, key)!; 1126 | this.fieldsMeta[name] = cache; 1127 | // 若 parseName 模式,则使用_value 作为值设置到 values 内 1128 | this.setValue(name, this.options.parseName ? cache._value : cache.value, false, false); 1129 | this.options.parseName && '_value' in cache && delete cache._value; 1130 | } 1131 | 1132 | // only one time here 1133 | const field = this._get(name); 1134 | 1135 | if (field) { 1136 | //When the autoUnmount is false, the component uninstallation needs to clear the verification information to avoid blocking the validation. 1137 | if (!component && !autoUnmount) { 1138 | field.state = ''; 1139 | delete field.errors; 1140 | delete (field as FieldMeta).rules; 1141 | delete field.rulesMap; 1142 | } 1143 | const ref = field.ref; 1144 | if (ref) { 1145 | if (typeof ref === 'string') { 1146 | throw new Error(`can not set string ref for ${name}`); 1147 | } else if (typeof ref === 'function') { 1148 | ref(component); 1149 | } else if (typeof ref === 'object' && 'current' in ref) { 1150 | // while ref = React.createRef() ref={ current: null} 1151 | (ref as MutableRefObject).current = component; 1152 | } 1153 | } 1154 | 1155 | // mount 1156 | if (autoUnmount && component) { 1157 | let cnt = this.instanceCount[name]; 1158 | if (!cnt) { 1159 | cnt = 0; 1160 | } 1161 | 1162 | this.instanceCount[name] = cnt + 1; 1163 | } 1164 | 1165 | this.instance[name] = component; 1166 | } 1167 | } 1168 | 1169 | _validate(name: string, rule: Rule[], trigger: string) { 1170 | const field = this._get(name); 1171 | if (!field) { 1172 | return; 1173 | } 1174 | 1175 | const value = field.value; 1176 | 1177 | field.state = 'loading'; 1178 | let validate = this._getCache(name, trigger); 1179 | 1180 | if (validate && typeof validate.abort === 'function') { 1181 | validate.abort(); 1182 | } 1183 | validate = new Validate({ [name]: rule }, { messages: this.options.messages }); 1184 | 1185 | this._setCache(name, trigger, validate); 1186 | 1187 | validate.validate( 1188 | { 1189 | [name]: value, 1190 | }, 1191 | (errors) => { 1192 | let newErrors: string[], newState: FieldState; 1193 | if (errors && errors.length) { 1194 | newErrors = getErrorStrs(errors, this.processErrorMessage); 1195 | newState = 'error'; 1196 | } else { 1197 | newErrors = []; 1198 | newState = 'success'; 1199 | } 1200 | 1201 | let reRender = false; 1202 | // only status or errors changed, Rerender 1203 | if ( 1204 | newState !== field.state || 1205 | !field.errors || 1206 | newErrors.length !== field.errors.length || 1207 | newErrors.find((e, idx) => e !== field.errors![idx]) 1208 | ) { 1209 | reRender = true; 1210 | } 1211 | 1212 | field.errors = newErrors; 1213 | field.state = newState; 1214 | 1215 | reRender && this._reRender(name, 'validate'); 1216 | } 1217 | ); 1218 | } 1219 | 1220 | /** 1221 | * splice array 1222 | */ 1223 | _spliceArrayValue(key: string, index: number, howmany: number, ...argv: unknown[]) { 1224 | const argc = argv.length; 1225 | const offset = howmany - argc; // how the reset fieldMeta move 1226 | const startIndex = index + howmany; // 计算起点 1227 | 1228 | /** 1229 | * eg: call _spliceArrayValue('key', 1) to delete 'key.1': 1230 | * case 1: names=['key.0', 'key.1']; delete 'key.1'; 1231 | * case 2: names=['key.0', 'key.1', 'key.2']; key.1= key.2; delete key.2; 1232 | * case 3: names=['key.0.name', 'key.0.email', 'key.1.name', 'key.1.email'], should delete 'key.1.name', 'key.1.email' 1233 | * eg: call _spliceArrayValue('key', 1, item) to add 'key.1': 1234 | * case 1: names=['key.0']; add 'key.1' = item; 1235 | * case 2: names=['key.0', 'key.1']; key.2= key.1; delete key.1; add key.1 = item; 1236 | */ 1237 | const listMap: Record> = {}; // eg: {1:[{from: 'key.2.name', to: 'key.1.name'}, {from: 'key.2.email', to: 'key.1.email'}]} 1238 | const replacedReg = /\$/g; 1239 | // 替换特殊字符$ 1240 | const replacedKey = key.replace(replacedReg, '\\$&'); 1241 | const keyReg = new RegExp(`^(${replacedKey}.)(\\d+)`); 1242 | const replaceArgv: string[] = []; 1243 | const names = this.getNames(); 1244 | const willChangeNames: string[] = []; 1245 | 1246 | // logic of offset fix begin 1247 | names.forEach((n) => { 1248 | const ret = keyReg.exec(n); 1249 | if (ret) { 1250 | const idx = parseInt(ret[2]); // get index of 'key.0.name' 1251 | 1252 | if (idx >= startIndex) { 1253 | const l = listMap[idx]; 1254 | const item = { 1255 | from: n, 1256 | to: n.replace(keyReg, (match, p1) => `${p1}${idx - offset}`), 1257 | }; 1258 | willChangeNames.push(item.from); 1259 | if (names.includes(item.to)) { 1260 | willChangeNames.push(item.to); 1261 | } 1262 | if (!l) { 1263 | listMap[idx] = [item]; 1264 | } else { 1265 | l.push(item); 1266 | } 1267 | } 1268 | 1269 | // in case of offsetList.length = 0, eg: delete last element 1270 | if (offset > 0 && idx >= index && idx < index + howmany) { 1271 | replaceArgv.push(n); 1272 | } 1273 | } 1274 | }); 1275 | 1276 | const oldValues = this.getValues(willChangeNames); 1277 | 1278 | // sort with index eg: [{index:1, list: [{from: 'key.2.name', to: 'key.1.name'}]}, {index:2, list: [...]}] 1279 | const offsetList = Object.keys(listMap) 1280 | .map((i) => { 1281 | return { 1282 | index: Number(i), 1283 | list: listMap[i], 1284 | }; 1285 | }) 1286 | .sort((a, b) => (offset > 0 ? a.index - b.index : b.index - a.index)); 1287 | 1288 | offsetList.forEach((l) => { 1289 | const list = l.list; 1290 | list.forEach((i) => { 1291 | this.fieldsMeta[i.to] = this.fieldsMeta[i.from]; 1292 | // 移位后,同步调整 name 1293 | this.fieldsMeta[i.to].name = i.to; 1294 | }); 1295 | }); 1296 | 1297 | // delete copy data 1298 | if (offsetList.length > 0) { 1299 | const removeList = offsetList.slice(offsetList.length - (offset < 0 ? -offset : offset), offsetList.length); 1300 | removeList.forEach((item) => { 1301 | item.list.forEach((i) => { 1302 | delete this.fieldsMeta[i.from]; 1303 | }); 1304 | }); 1305 | } else { 1306 | // will get from this.values while rerender 1307 | replaceArgv.forEach((i) => { 1308 | delete this.fieldsMeta[i]; 1309 | }); 1310 | } 1311 | 1312 | const p = this.getValue(key) as unknown[]; 1313 | if (p) { 1314 | p.splice(index, howmany, ...argv); 1315 | } 1316 | 1317 | for (const name of willChangeNames) { 1318 | this._triggerFieldChange(name, this.getValue(name), oldValues[name], 'setValue'); 1319 | } 1320 | 1321 | this._reRender(); 1322 | } 1323 | 1324 | _triggerFieldChange(name: string, value: unknown, oldValue: unknown, triggerType: WatchTriggerType) { 1325 | // same value should not trigger change 1326 | if (Object.is(value, oldValue)) { 1327 | return; 1328 | } 1329 | const listenerSet = this.listeners[name]; 1330 | if (!listenerSet?.size) { 1331 | return; 1332 | } 1333 | for (const callback of listenerSet) { 1334 | callback(name, value, oldValue, triggerType); 1335 | } 1336 | } 1337 | 1338 | _triggerAfterValidateRerender(errorsGroup: ValidateErrorGroup | null) { 1339 | if (typeof this.afterValidateRerender === 'function') { 1340 | this.afterValidateRerender({ 1341 | errorsGroup, 1342 | options: this.options, 1343 | instance: this.instance, 1344 | }); 1345 | } 1346 | } 1347 | } 1348 | 1349 | export * from './types'; 1350 | 1351 | export default Field; 1352 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { MessagesConfig, PresetFormatter, Validator } from '@alifd/validate'; 2 | import type { Component, useState, useMemo, Dispatch, SetStateAction, Ref, RefCallback } from 'react'; 3 | 4 | export type { Validator }; 5 | 6 | export type FieldValues = Record; 7 | 8 | export type ValidateErrorGroup = Record; 9 | 10 | export interface FieldOption { 11 | /** 12 | * 所有组件的 change 都会到达这里 [setValue 不会触发该函数] 13 | */ 14 | onChange?: (name: string, value: any) => void; 15 | 16 | /** 17 | * 是否翻译 init(name) 中的 name(getValues 会把带。的字符串转换成对象) 18 | * @defaultValue false 19 | */ 20 | parseName?: boolean; 21 | 22 | /** 23 | * 仅建议 PureComponent 的组件打开此强制刷新功能,会带来性能问题 (500 个组件为例:打开的时候 render 花费 700ms, 关闭时候 render 花费 400ms) 24 | * @defaultValue false 25 | */ 26 | forceUpdate?: boolean; 27 | 28 | /** 29 | * 自动删除 (remove) Unmout 元素,如果想保留数据可以设置为 false 30 | * @defaultValue true 31 | */ 32 | autoUnmount?: boolean; 33 | 34 | /** 35 | * 是否修改数据的时候就自动触发校验,设为 false 后只能通过 validate() 来触发校验 36 | * @defaultValue true 37 | */ 38 | autoValidate?: boolean; 39 | 40 | /** 41 | * 初始化数据 42 | */ 43 | values?: FieldValues; 44 | 45 | /** 46 | * 校验时发现第一个错误就返回 47 | * @defaultValue false 48 | */ 49 | first?: boolean; 50 | 51 | /** 52 | * 定制默认错误校验信息模板 53 | */ 54 | messages?: MessagesConfig; 55 | 56 | /** 57 | * 处理错误信息 58 | * @param message - 错误信息字符串 59 | * @returns - 处理后的错误信息 60 | */ 61 | processErrorMessage?: (message: string) => string; 62 | 63 | /** 64 | * 校验结束并 rerender 之后的回调 65 | * @param results - 校验结果和一些元信息 66 | */ 67 | afterValidateRerender?: (results: { 68 | errorsGroup: ValidateErrorGroup | null; 69 | options: FieldOption; 70 | instance: Record; 71 | }) => void; 72 | } 73 | 74 | export type RequiredSome = Omit & Required>; 75 | 76 | export type NormalizedFieldOption = RequiredSome< 77 | FieldOption, 78 | 'parseName' | 'forceUpdate' | 'first' | 'onChange' | 'autoUnmount' | 'autoValidate' 79 | >; 80 | 81 | export type Rule = { 82 | /** 83 | * 是否必填 (不能和 pattern 同时使用) 84 | * @defaultValue true 85 | */ 86 | required?: boolean; 87 | 88 | /** 89 | * 出错时候信息 90 | */ 91 | message?: string; 92 | 93 | /** 94 | * 校验正则表达式 95 | */ 96 | pattern?: string | RegExp; 97 | /** 98 | * 字符串最小长度 / 数组最小个数 99 | */ 100 | minLength?: number; 101 | /** 102 | * 字符串最大长度 / 数组最大个数 103 | */ 104 | maxLength?: number; 105 | 106 | /** 107 | * 字符串精确长度 / 数组精确个数 108 | */ 109 | length?: number; 110 | 111 | /** 112 | * 最小值 113 | */ 114 | min?: number; 115 | 116 | /** 117 | * 最大值 118 | */ 119 | max?: number; 120 | /** 121 | * 对常用 pattern 的总结 122 | */ 123 | format?: PresetFormatter; 124 | 125 | /** 126 | * 自定义校验,(校验成功的时候不要忘记执行 callback(),否则会校验不返回) 127 | */ 128 | validator?: Validator; 129 | 130 | /** 131 | * 触发校验的事件名称 132 | */ 133 | trigger?: string | string[]; 134 | }; 135 | 136 | export type InitOption< 137 | ValueType = any, 138 | ValueName extends string = 'value', 139 | Trigger extends string = 'onChange', 140 | Props = object, 141 | > = { 142 | /** 143 | * 唯一标识 144 | */ 145 | id?: string; 146 | /** 147 | * 组件值的属性名称,如 Checkbox 的是 checked,Input 是 value 148 | * @defaultValue 'value' 149 | */ 150 | valueName?: ValueName; 151 | 152 | /** 153 | * 组件初始值 (组件第一次 render 的时候才会读取,后面再修改此值无效),类似 defaultValue 154 | */ 155 | initValue?: ValueType; 156 | 157 | /** 158 | * 触发数据变化的事件名称 159 | * @defaultValue 'onChange' 160 | */ 161 | trigger?: Trigger; 162 | 163 | /** 164 | * 校验规则 165 | */ 166 | rules?: Rule[] | Rule; 167 | 168 | /** 169 | * 自动校验 170 | * @defaultValue true 171 | */ 172 | autoValidate?: boolean; 173 | 174 | /** 175 | * 组件自定义的事件可以写在这里,其他会透传 (小包版本^0.3.0 支持,大包^0.7.0 支持) 176 | */ 177 | props?: Props; 178 | 179 | /** 180 | * 自定义从组件获取 `value` 的方式,参数顺序和组件的 onChange 完全一致的 181 | */ 182 | getValueFormatter?: (eventArgs: any) => ValueType; 183 | /** 184 | * @deprecated Use `getValueFormatter` instead 185 | */ 186 | getValueFromEvent?: (eventArgs: any) => ValueType; 187 | /** 188 | * 自定义转换 `value` 为组件需要的数据 189 | */ 190 | setValueFormatter?: (value: ValueType, ...restArgs: unknown[]) => any; 191 | 192 | /** 193 | * 自定义重新渲染函数 194 | */ 195 | reRender?: RerenderFunction; 196 | }; 197 | 198 | export type WatchTriggerType = 'init' | 'change' | 'setValue' | 'unmount' | 'reset'; 199 | 200 | export interface WatchCallback { 201 | (name: string, value: unknown, oldValue: unknown, triggerType: WatchTriggerType): void; 202 | } 203 | 204 | export interface GetUseFieldOption { 205 | useState: typeof useState; 206 | useMemo: typeof useMemo; 207 | } 208 | 209 | export type SetState = Dispatch>; 210 | 211 | export type ComponentInstance = Component | { setState: SetState } | unknown; 212 | 213 | /** 214 | * 字段校验状态 215 | * @example 216 | * - '': 初始状态 217 | * - 'loading': 正在校验 218 | * - 'success': 校验成功 219 | * - 'error': 校验错误 220 | */ 221 | export type FieldState = '' | 'loading' | 'success' | 'error'; 222 | 223 | export interface FieldMeta extends Pick { 224 | name?: string; 225 | value?: unknown; 226 | _value?: unknown; 227 | initValue?: unknown; 228 | disabled?: boolean; 229 | state?: FieldState; 230 | valueName?: string; 231 | trigger?: string; 232 | inputValues?: unknown[]; 233 | ref?: Ref; 234 | errors?: unknown[]; 235 | rulesMap?: never; 236 | } 237 | 238 | export interface NormalizedFieldMeta 239 | extends Omit, 'rules'> { 240 | rules: Rule[]; 241 | } 242 | 243 | export type UnknownFunction = (...args: unknown[]) => unknown; 244 | 245 | export type RerenderType = 'validate' | 'setValue' | 'setError' | 'reset'; 246 | 247 | export interface RerenderFunction { 248 | (type?: RerenderType): unknown; 249 | (type?: string): unknown; 250 | } 251 | 252 | export type ValidateCallback = (errors: ValidateErrorGroup | null, values: FieldValues) => unknown; 253 | 254 | export type ValidatePromiseResults = { 255 | errors: ValidateErrorGroup | null; 256 | values: FieldValues; 257 | }; 258 | 259 | // 沿用旧的名称 260 | export type ValidateResults = ValidatePromiseResults; 261 | 262 | export type ValidateResultFormatter = (results: ValidatePromiseResults) => R | Promise; 263 | 264 | export type DynamicKV = { 265 | [k in K]: V; 266 | }; 267 | 268 | export interface TriggerFunction { 269 | /** 270 | * @param eventOrValue - 事件对象或具体数据 271 | * @param rests - 其它参数 272 | */ 273 | (eventOrValue: any, ...rests: any[]): any; 274 | } 275 | 276 | export type InitResult = { 277 | 'data-meta': 'Field'; 278 | id: string; 279 | ref: RefCallback; 280 | } & { 281 | [k in Exclude]: ValueType; 282 | } & { 283 | [k in Exclude]: TriggerFunction; 284 | }; 285 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import type { ChangeEvent } from 'react'; 2 | import type { UnknownFunction } from './types'; 3 | 4 | export function splitNameToPath(name: ''): ''; 5 | export function splitNameToPath(name: string): string[]; 6 | export function splitNameToPath(name: unknown): ''; 7 | export function splitNameToPath(name: unknown) { 8 | return typeof name === 'string' && name ? name.replace(/\[/, '.').replace(/\]/, '').split('.') : ''; 9 | } 10 | 11 | export function hasIn(state: unknown, name: string | undefined | null): boolean { 12 | if (!state) { 13 | return false; 14 | } 15 | 16 | const path = splitNameToPath(name); 17 | const length = path.length; 18 | if (!length) { 19 | return false; 20 | } 21 | 22 | let result: unknown = state; 23 | for (let i = 0; i < length; ++i) { 24 | // parent is not object 25 | if (typeof result !== 'object' || result === null) { 26 | return false; 27 | } 28 | // has no property 29 | const thisName = path[i]; 30 | if (!(thisName in result)) { 31 | return false; 32 | } 33 | // pass on 34 | result = (result as Record)[thisName]; 35 | } 36 | 37 | return true; 38 | } 39 | 40 | export function getIn(state: unknown, name: string): V { 41 | if (!state) { 42 | return state as V; 43 | } 44 | 45 | const path = splitNameToPath(name); 46 | const length = path.length; 47 | if (!length) { 48 | return undefined as V; 49 | } 50 | 51 | let result: unknown = state; 52 | for (let i = 0; i < length; ++i) { 53 | // parent is not object 54 | if (typeof result !== 'object' || result === null) { 55 | return undefined as V; 56 | } 57 | result = (result as Record)[path[i]]; 58 | } 59 | 60 | return result as V; 61 | } 62 | 63 | function setInWithPath( 64 | state: unknown, 65 | value: unknown, 66 | path: string | Array, 67 | pathIndex: number 68 | ): R { 69 | if (pathIndex >= path.length) { 70 | return value as R; 71 | } 72 | 73 | const first = path[pathIndex]; 74 | const next = setInWithPath(state && (state as Record)[first], value, path, pathIndex + 1); 75 | 76 | if (!state) { 77 | const initialized: any = isNaN(first as number) ? {} : []; 78 | initialized[first] = next; 79 | return initialized as R; 80 | } 81 | 82 | if (Array.isArray(state)) { 83 | const copy = [...state]; 84 | copy[first as number] = next; 85 | return copy as R; 86 | } 87 | 88 | return Object.assign({}, state, { 89 | [first]: next, 90 | }) as R; 91 | } 92 | 93 | export function setIn(state: unknown, name: string, value: unknown): R { 94 | return setInWithPath( 95 | state, 96 | value, 97 | typeof name === 'string' ? name.replace(/\[/, '.').replace(/\]/, '').split('.') : '', 98 | 0 99 | ); 100 | } 101 | 102 | export function deleteIn(state: undefined | null, name: string): undefined; 103 | export function deleteIn | unknown[]>(state: S, name: string): S; 104 | export function deleteIn | unknown[] | undefined | null>(state: S, name: string) { 105 | if (!state) { 106 | return; 107 | } 108 | 109 | const path = typeof name === 'string' ? name.replace(/\[/, '.').replace(/\]/, '').split('.') : ''; 110 | const length = path.length; 111 | if (!length) { 112 | return state; 113 | } 114 | 115 | let result: any = state; 116 | for (let i = 0; i < length && !!result; ++i) { 117 | if (i === length - 1) { 118 | delete result[path[i]]; 119 | } else { 120 | result = result[path[i]]; 121 | } 122 | } 123 | 124 | return state; 125 | } 126 | export function getErrorStrs(): undefined; 127 | export function getErrorStrs( 128 | errors: Errors, 129 | processErrorMessage?: (message: unknown) => unknown 130 | ): Errors; 131 | export function getErrorStrs( 132 | errors: { message: MessageType }[], 133 | processErrorMessage?: ((message: MessageType) => R) | undefined 134 | ): typeof processErrorMessage extends undefined ? MessageType[] : R[]; 135 | export function getErrorStrs( 136 | errors: Error[], 137 | processErrorMessage?: ((message: Error) => R) | undefined 138 | ): typeof processErrorMessage extends undefined ? Error[] : R[]; 139 | export function getErrorStrs, Process extends (message: unknown) => unknown>( 140 | errors?: Error[] | undefined | null, 141 | processErrorMessage?: Process 142 | ) { 143 | if (errors) { 144 | return errors.map((e) => { 145 | const message = typeof e.message !== 'undefined' ? e.message : e; 146 | 147 | if (typeof processErrorMessage === 'function') { 148 | return processErrorMessage(message); 149 | } 150 | 151 | return message; 152 | }); 153 | } 154 | return errors; 155 | } 156 | 157 | export function getParams unknown>( 158 | ns: Cb, 159 | cb?: undefined 160 | ): { names: undefined; callback: Cb }; 161 | export function getParams( 162 | ns: Ns | Cb, 163 | cb: Cb 164 | ): { names: Ns extends undefined ? undefined : string[]; callback: Cb }; 165 | export function getParams unknown>(ns?: string | string[] | Cb, cb?: Cb) { 166 | let names: string[] | Cb | undefined = typeof ns === 'string' ? [ns] : ns; 167 | let callback = cb; 168 | if (cb === undefined && typeof names === 'function') { 169 | callback = names; 170 | names = undefined; 171 | } 172 | return { 173 | names, 174 | callback, 175 | }; 176 | } 177 | 178 | export function isOverwritten(values: unknown, name: unknown): typeof values extends object ? boolean : false; 179 | export function isOverwritten(values: unknown, name: unknown): typeof name extends string ? boolean : false; 180 | export function isOverwritten(values: unknown, name: unknown): boolean; 181 | /** 182 | * name 是否被覆写 183 | * e.g. \{ a: \{ b: 1 \} \} and 'a.b', should return true 184 | * e.g. \{ a: \{ b: 1 \} \} and 'a.b.c', should return true 185 | * e.g. \{ a: \{ b: 1 \} \} and 'a.b2', should return false 186 | * e.g. \{ a: \{ b: 1 \} \} and 'a2', should return false 187 | * e.g. \{ a: \{ b: [0, 1] \} \} and 'a.b[0]' return true 188 | * e.g. \{ a: \{ b: [0, 1] \} \} and 'a.b[5]' return true (miss index means overwritten in array) 189 | * @param values - 写入对象 190 | * @param name - 字段 key 191 | */ 192 | export function isOverwritten(values: unknown, name: unknown) { 193 | if (!values || typeof values !== 'object' || !name || typeof name !== 'string') { 194 | return false; 195 | } 196 | const paths = splitNameToPath(name); 197 | let obj = values as Record; 198 | for (const path of paths) { 199 | if (path in obj) { 200 | const pathValue = obj[path]; 201 | // 任意一层 path 值不是对象了,则代表被覆盖 202 | if (!pathValue || typeof pathValue !== 'object') { 203 | return true; 204 | } else { 205 | obj = pathValue as Record; 206 | } 207 | } else { 208 | // 数组的 index 已经移除,则代表被覆写 209 | if (Array.isArray(obj)) { 210 | return true; 211 | } 212 | return false; 213 | } 214 | } 215 | // 代表 name in values,则返回 true 216 | return true; 217 | } 218 | 219 | export function getValueFromEvent>(e: E): string | boolean; 220 | export function getValueFromEvent(e: E): E; 221 | /** 222 | * 从组件事件中获取数据 223 | * @param e - Event 或者 value 224 | * @returns 数据值 225 | */ 226 | export function getValueFromEvent(e: unknown) { 227 | // support custom element 228 | if (!e || !(e as ChangeEvent).target || !(e as ChangeEvent).preventDefault) { 229 | return e; 230 | } 231 | 232 | const { target } = e as ChangeEvent; 233 | 234 | if (target.type === 'checkbox') { 235 | return target.checked; 236 | } else if (target.type === 'radio') { 237 | //兼容原生 radioGroup 238 | if (target.value) { 239 | return target.value; 240 | } else { 241 | return target.checked; 242 | } 243 | } 244 | return target.value; 245 | } 246 | 247 | function validateMap( 248 | rulesMap: Record, 249 | rule: Rule, 250 | defaultTrigger: string 251 | ) { 252 | const nrule = Object.assign({}, rule); 253 | 254 | if (!nrule.trigger) { 255 | nrule.trigger = [defaultTrigger]; 256 | } 257 | 258 | if (typeof nrule.trigger === 'string') { 259 | nrule.trigger = [nrule.trigger]; 260 | } 261 | 262 | for (let i = 0; i < nrule.trigger.length; i++) { 263 | const trigger: string = nrule.trigger[i]; 264 | 265 | if (trigger in rulesMap) { 266 | rulesMap[trigger].push(nrule); 267 | } else { 268 | rulesMap[trigger] = [nrule]; 269 | } 270 | } 271 | 272 | delete nrule.trigger; 273 | } 274 | 275 | /** 276 | * 提取rule里面的trigger并且做映射 277 | * @param rules - 规则 278 | * @param defaultTrigger - 默认触发器 279 | */ 280 | export function mapValidateRules( 281 | rules: Rule[], 282 | defaultTrigger: string 283 | ) { 284 | const rulesMap: Record> = {}; 285 | 286 | rules.forEach((rule) => { 287 | validateMap(rulesMap, rule, defaultTrigger); 288 | }); 289 | 290 | return rulesMap; 291 | } 292 | 293 | let warn: (...args: unknown[]) => void = () => {}; 294 | 295 | if ( 296 | typeof process !== 'undefined' && 297 | process.env && 298 | process.env.NODE_ENV !== 'production' && 299 | typeof window !== 'undefined' && 300 | typeof document !== 'undefined' 301 | ) { 302 | warn = (...args) => { 303 | /* eslint-disable no-console */ 304 | if (typeof console !== 'undefined' && console.error) { 305 | console.error(...args); 306 | } 307 | /* eslint-enable no-console */ 308 | }; 309 | } 310 | 311 | export const warning = warn; 312 | 313 | export function cloneToRuleArr(rules?: undefined | null): []; 314 | export function cloneToRuleArr(rules: Rule): Rule extends unknown[] ? Rule : Rule[]; 315 | export function cloneToRuleArr(rules?: unknown | unknown[] | null) { 316 | if (!rules) { 317 | return []; 318 | } 319 | const rulesArr = Array.isArray(rules) ? rules : [rules]; 320 | // 后续会修改 rule 对象,这里做浅复制以避免对传入对象的修改 321 | return rulesArr.map((rule) => ({ ...rule })); 322 | } 323 | -------------------------------------------------------------------------------- /test/options.spec.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component, createRef } from 'react'; 2 | import { Input } from '@alifd/next'; 3 | import Field from '../src'; 4 | 5 | describe('options', () => { 6 | it('should support autoUnmount', () => { 7 | class Demo extends React.Component { 8 | state = { 9 | show: true, 10 | }; 11 | field = new Field(this); 12 | 13 | render() { 14 | const init = this.field.init; 15 | return ( 16 |
17 | 18 | {this.state.show ? : null} 19 |
20 | ); 21 | } 22 | } 23 | const ref = createRef(); 24 | cy.mount().then(() => { 25 | cy.wrap(ref.current).should('be.ok'); 26 | ref.current!.setState({ show: false }); 27 | cy.wrap(ref.current!.field.getValues()).should('not.have.property', 'input2'); 28 | }); 29 | }); 30 | 31 | it('should support autoUnmount with same name', () => { 32 | class Demo extends React.Component { 33 | state = { 34 | show: true, 35 | }; 36 | field = new Field(this); 37 | 38 | render() { 39 | const init = this.field.init; 40 | return ( 41 |
42 | {this.state.show ? ( 43 | 44 | ) : ( 45 | 46 | )} 47 |
48 | ); 49 | } 50 | } 51 | const ref = createRef(); 52 | cy.mount().then(() => { 53 | cy.wrap(ref.current).should('be.ok'); 54 | ref.current!.setState({ show: false }); 55 | cy.wrap(ref.current!.field.getValues()).should('have.property', 'input').and('eq', '1'); 56 | }); 57 | }); 58 | 59 | it('should support more than 1 Component with same name, delete one , can still getValue', () => { 60 | class Demo extends React.Component { 61 | state = { 62 | show: true, 63 | }; 64 | field = new Field(this); 65 | 66 | render() { 67 | const init = this.field.init; 68 | return ( 69 |
70 | {this.state.show && } 71 | 72 |
73 | ); 74 | } 75 | } 76 | const ref = createRef(); 77 | cy.mount().then(() => { 78 | cy.wrap(ref.current).should('be.ok'); 79 | ref.current!.setState({ show: false }); 80 | cy.wrap(ref.current!.field.getValues()).should('have.property', 'input').and('eq', '1'); 81 | }); 82 | }); 83 | 84 | it('same name field should cache value when use parseName=true and autoUnmount=true', () => { 85 | class Demo extends Component { 86 | state = { 87 | show: true, 88 | }; 89 | field = new Field(this, { 90 | autoUnmount: true, 91 | parseName: true, 92 | values: { 93 | name: 'aa', 94 | }, 95 | }); 96 | render() { 97 | const { show } = this.state; 98 | const { init } = this.field; 99 | return ( 100 |
101 |
{show && }
102 |
{!show && }
103 |
104 | ); 105 | } 106 | } 107 | 108 | const ref = createRef(); 109 | cy.mount().then(() => { 110 | cy.wrap(ref.current).should('be.ok'); 111 | const ins = ref.current!; 112 | // 首先判断 name 值是否符合预期 113 | cy.wrap(ins.field.getValue('name')).should('eq', 'aa'); 114 | // 调整 show,使两个 input 同时触发卸载和挂载 115 | ins.setState({ show: false }); 116 | // 判断 name 值是否保留 117 | cy.wrap(ins.field.getValue('name')).should('eq', 'aa'); 118 | // 复原 visible,使两个 input 同时触发挂载和卸载 119 | ins.setState({ show: true }); 120 | // 判断 name 值是否保留 121 | cy.wrap(ins.field.getValue('name')).should('eq', 'aa'); 122 | }); 123 | }); 124 | 125 | it('should support autoUnmount=false', () => { 126 | class Demo extends React.Component { 127 | state = { 128 | show: true, 129 | }; 130 | field = new Field(this, { autoUnmount: false }); 131 | 132 | render() { 133 | const { init } = this.field; 134 | return ( 135 |
136 | 137 | {this.state.show && } 138 |
139 | ); 140 | } 141 | } 142 | const ref = createRef(); 143 | cy.mount().then(() => { 144 | cy.wrap(ref.current).should('be.ok'); 145 | const ins = ref.current!; 146 | ins.setState({ show: false }); 147 | cy.wrap(ins.field.getValue('input2')).should('eq', 'test2'); 148 | }); 149 | }); 150 | 151 | describe('defaultValue', () => { 152 | it('should support `defaultValue`', () => { 153 | const inputValue = 'my value'; 154 | const field = new Field({}); 155 | field.init('input', { props: { defaultValue: inputValue } }); 156 | assert.equal(field.getValue('input'), inputValue); 157 | }); 158 | 159 | it('should support `defaultValue` with different value name and make camel case', () => { 160 | const inputValue = 'my value'; 161 | const field = new Field({}); 162 | field.init('input', { 163 | valueName: 'myValue', 164 | props: { defaultMyValue: inputValue }, 165 | }); 166 | assert.equal(field.getValue('input'), inputValue); 167 | }); 168 | 169 | it('should support `defaultValue` with falsy value', () => { 170 | const inputValue = 0; 171 | const field = new Field({}); 172 | field.init('input', { props: { defaultValue: inputValue } }); 173 | assert.equal(field.getValue('input'), inputValue); 174 | }); 175 | }); 176 | 177 | describe('values', () => { 178 | it('should set default field input values when given `values` in constructor', () => { 179 | const inputValue = 'my value'; 180 | const field = new Field( 181 | {}, 182 | { 183 | values: { 184 | input: inputValue, 185 | }, 186 | } 187 | ); 188 | assert.equal(field.getValue('input'), inputValue); 189 | }); 190 | 191 | it('should set default field input values when given falsy `values` in constructor', () => { 192 | const inputValue = 0; 193 | const field = new Field( 194 | {}, 195 | { 196 | values: { 197 | input: inputValue, 198 | }, 199 | } 200 | ); 201 | field.init('input'); 202 | assert.equal(field.getValue('input'), inputValue); 203 | }); 204 | 205 | it('should set default field input values when given `values` and `parseName` = true in constructor', () => { 206 | const inputValue = 'my value'; 207 | const field = new Field( 208 | {}, 209 | { 210 | parseName: true, 211 | values: { 212 | input: { 213 | child: inputValue, 214 | }, 215 | }, 216 | } 217 | ); 218 | field.init('input.child'); 219 | assert.equal(field.getValue('input.child'), inputValue); 220 | }); 221 | 222 | it('should allow access with `getValue` before init when given `values` in constructor', () => { 223 | const inputValue = 'my value'; 224 | const field = new Field( 225 | {}, 226 | { 227 | values: { 228 | input: inputValue, 229 | }, 230 | } 231 | ); 232 | assert.equal(field.getValue('input'), inputValue); 233 | }); 234 | 235 | it('should allow access to with `getValues` before init when given `values` in constructor', () => { 236 | const inputValue = 'my value'; 237 | const field = new Field( 238 | {}, 239 | { 240 | values: { 241 | input: inputValue, 242 | }, 243 | } 244 | ); 245 | assert.equal(field.getValues().input, inputValue); 246 | }); 247 | 248 | it('should use setValues instead of constructor values on field that has not been initialized', () => { 249 | const inputValue = 'my value'; 250 | const field = new Field( 251 | {}, 252 | { 253 | values: { 254 | input: inputValue, 255 | }, 256 | } 257 | ); 258 | field.setValue('input', 1); 259 | assert.equal(field.getValue('input'), 1); 260 | }); 261 | 262 | it('should reset `input` to undefined when given `values` in constructor and call `reset`', () => { 263 | const fieldDefault = 'field default value'; 264 | const field = new Field( 265 | {}, 266 | { 267 | values: { 268 | input: fieldDefault, 269 | }, 270 | } 271 | ); 272 | field.init('input'); 273 | field.reset(); 274 | assert.equal(field.getValue('input'), undefined); 275 | }); 276 | 277 | it('should reset `input` to constructor `undefined` after calling `resetToDefault`', () => { 278 | const fieldDefault = 'field default value'; 279 | const field = new Field( 280 | {}, 281 | { 282 | values: { 283 | input: fieldDefault, 284 | }, 285 | } 286 | ); 287 | field.init('input'); 288 | field.resetToDefault('input'); 289 | assert.equal(field.getValue('input'), undefined); 290 | }); 291 | 292 | it('should reset `input` to undefined when given `values` in constructor and call `reset`', () => { 293 | const fieldDefault = 'field default value'; 294 | const field = new Field( 295 | {}, 296 | { 297 | values: { 298 | input: fieldDefault, 299 | }, 300 | } 301 | ); 302 | field.init('input'); 303 | field.reset(); 304 | assert.equal(field.getValue('input'), undefined); 305 | }); 306 | 307 | it('should return `{}` for `getValues after all fields are removed', () => { 308 | const fieldDefault = 'field default value'; 309 | const field = new Field( 310 | {}, 311 | { 312 | values: { 313 | input: fieldDefault, 314 | }, 315 | } 316 | ); 317 | field.init('input'); 318 | field.remove('input'); 319 | assert.equal(Object.keys(field.getValues()).length, 0); 320 | }); 321 | 322 | it('should return `undefined` after `remove` then re-`init`', () => { 323 | const field = new Field({}, { values: { input: 4 } }); 324 | field.init('input'); 325 | field.remove('input'); 326 | field.init('input'); 327 | 328 | assert(field.getValue('input') === undefined); 329 | }); 330 | 331 | it('should set the value to constructor value even with initValue from init', () => { 332 | const inputValue = 0; 333 | const field = new Field( 334 | {}, 335 | { 336 | values: { 337 | input: inputValue, 338 | }, 339 | } 340 | ); 341 | field.init('input', { initValue: 1 }); 342 | assert.equal(field.getValue('input'), inputValue); 343 | }); 344 | }); 345 | 346 | describe('should support parseName', () => { 347 | it('getValues', () => { 348 | const field = new Field({}, { parseName: true }); 349 | field.init('user.name', { initValue: 'frankqian' }); 350 | field.init('user.pwd', { initValue: 12345 }); 351 | field.init('option[0]', { initValue: 'option1' }); 352 | field.init('option[1]', { initValue: 'option2' }); 353 | const values = field.getValues<{ 354 | user: { name: string; pwd: number }; 355 | option: string[]; 356 | }>(); 357 | 358 | assert(Object.keys(values).length === 2); 359 | assert(values.user.name === 'frankqian'); 360 | assert(values.user.pwd === 12345); 361 | assert(values.option[0] === 'option1'); 362 | 363 | assert(field.getValue('option[1]') === 'option2'); 364 | }); 365 | it('should get constructor value of `name` if `getValue` called before init', () => { 366 | const field = new Field( 367 | {}, 368 | { 369 | parseName: true, 370 | values: { a: { b: 1 } }, 371 | } 372 | ); 373 | assert(field.getValue('a.b') === 1); 374 | }); 375 | 376 | it('should return constructor value for `names` if `getValues` called before init', () => { 377 | const field = new Field( 378 | {}, 379 | { 380 | parseName: true, 381 | values: { a: 1, b: 2, c: 3 }, 382 | } 383 | ); 384 | const { a, b } = field.getValues(['a', 'b']); 385 | assert(a === 1); 386 | assert(b === 2); 387 | }); 388 | it('should return all of constructor value if `getValues` called with no names before init', () => { 389 | const field = new Field( 390 | {}, 391 | { 392 | parseName: true, 393 | values: { a: 1, b: 2, c: 3 }, 394 | } 395 | ); 396 | const { a, b, c } = field.getValues(); 397 | assert(a === 1); 398 | assert(b === 2); 399 | assert(c === 3); 400 | }); 401 | it('setValues', () => { 402 | const field = new Field({}, { parseName: true }); 403 | field.init('user.name', { initValue: 'frankqian' }); 404 | field.init('user.pwd', { initValue: 12345 }); 405 | field.init('option[0]', { initValue: 'option1' }); 406 | field.init('option[1]', { initValue: 'option2' }); 407 | 408 | let values = field.getValues<{ 409 | user: { name: string; pwd: number | string }; 410 | option: string[]; 411 | }>(); 412 | assert(values.user.name === 'frankqian'); 413 | assert(values.user.pwd === 12345); 414 | assert(values.option[0] === 'option1'); 415 | assert(values.option[1] === 'option2'); 416 | 417 | field.setValues({ 418 | user: { 419 | pwd: 'helloworld', 420 | }, 421 | option: ['test1', 'test2'], 422 | }); 423 | 424 | values = field.getValues(); 425 | 426 | assert(Object.keys(values).length === 2); 427 | 428 | assert(values.user.name === 'frankqian'); 429 | assert(values.user.pwd === 'helloworld'); 430 | assert(values.option[0] === 'test1'); 431 | }); 432 | 433 | it('should allow access with `getValue` before init when given `values` in constructor', () => { 434 | const fieldDefault = 'field default value'; 435 | const field = new Field( 436 | {}, 437 | { 438 | parseName: true, 439 | values: { 440 | input: { 441 | myValue: fieldDefault, 442 | }, 443 | }, 444 | } 445 | ); 446 | assert.equal(field.getValue('input.myValue'), fieldDefault); 447 | }); 448 | 449 | it('should allow access to with `getValues` before init when given `values` in constructor', () => { 450 | const fieldDefault = 'field default value'; 451 | const field = new Field( 452 | {}, 453 | { 454 | parseName: true, 455 | values: { 456 | input: { 457 | myValue: fieldDefault, 458 | }, 459 | }, 460 | } 461 | ); 462 | assert.equal(field.getValues<{ input: { myValue: string } }>().input.myValue, fieldDefault); 463 | }); 464 | 465 | it('should use setValue instead of constructor values on field that has not been initialized', () => { 466 | const fieldDefault = 'field default value'; 467 | const field = new Field( 468 | {}, 469 | { 470 | parseName: true, 471 | values: { 472 | input: { 473 | myValue: fieldDefault, 474 | }, 475 | }, 476 | } 477 | ); 478 | field.setValue('input.myValue', 1); 479 | assert.equal(field.getValue('input.myValue'), 1); 480 | }); 481 | 482 | it('should remove top level field after removed', () => { 483 | const fieldDefault = 'field default value'; 484 | const field = new Field( 485 | {}, 486 | { 487 | parseName: true, 488 | values: { 489 | input: { 490 | myValue: fieldDefault, 491 | }, 492 | }, 493 | } 494 | ); 495 | field.init('input.myValue'); 496 | field.remove('input.myValue'); 497 | assert.deepEqual(field.getValues(), { input: {} }); 498 | }); 499 | 500 | it('should return `{}` for `getValues after `remove()`', () => { 501 | const fieldDefault = 'field default value'; 502 | const field = new Field( 503 | {}, 504 | { 505 | parseName: true, 506 | values: { 507 | input: { 508 | myValue: fieldDefault, 509 | }, 510 | }, 511 | } 512 | ); 513 | field.init('input.myValue'); 514 | field.setValue('input.value2', fieldDefault); 515 | field.remove(); 516 | assert.equal(Object.keys(field.getValues()).length, 0); 517 | }); 518 | 519 | it('should return `undefined` after `remove` then re-`init`', () => { 520 | const fieldDefault = 'field default value'; 521 | const field = new Field( 522 | {}, 523 | { 524 | parseName: true, 525 | values: { 526 | input: { 527 | myValue: fieldDefault, 528 | }, 529 | }, 530 | } 531 | ); 532 | field.init('input.myValue'); 533 | field.remove('input.myValue'); 534 | field.init('input.myValue'); 535 | 536 | assert(field.getValue('input.myValue') === undefined); 537 | }); 538 | 539 | it('should return all setValues', () => { 540 | const fieldDefault = 'field default value'; 541 | const field = new Field( 542 | {}, 543 | { 544 | parseName: true, 545 | } 546 | ); 547 | field.setValues({ 548 | input: { 549 | myValue: fieldDefault, 550 | }, 551 | }); 552 | 553 | assert.deepEqual(field.getValues(), { 554 | input: { myValue: fieldDefault }, 555 | }); 556 | }); 557 | 558 | it('should return all setValues and initValues', () => { 559 | const fieldDefault = 'field default value'; 560 | const otherDefault = 'other default value'; 561 | const field = new Field( 562 | {}, 563 | { 564 | parseName: true, 565 | } 566 | ); 567 | field.setValues({ 568 | input: { 569 | myValue: fieldDefault, 570 | }, 571 | }); 572 | 573 | field.init('input.otherValue', { initValue: otherDefault }); 574 | 575 | assert.deepEqual(field.getValues(), { 576 | input: { 577 | myValue: fieldDefault, 578 | otherValue: otherDefault, 579 | }, 580 | }); 581 | }); 582 | describe('reset', () => { 583 | it('should reset all to undefined when call `reset`', () => { 584 | const fieldDefault = 'field default value'; 585 | const field = new Field( 586 | {}, 587 | { 588 | parseName: true, 589 | } 590 | ); 591 | field.setValue('input.myValue', fieldDefault); 592 | field.setValue('input.otherValue', fieldDefault); 593 | field.reset(); 594 | assert.equal(field.getValue('input.myValue'), undefined); 595 | assert.equal(field.getValue('input.otherValue'), undefined); 596 | }); 597 | 598 | it('should reset all to undefined when given `values` in constructor and call `reset`', () => { 599 | const fieldDefault = 'field default value'; 600 | const field = new Field( 601 | {}, 602 | { 603 | parseName: true, 604 | values: { 605 | input: { 606 | myValue: fieldDefault, 607 | otherValue: fieldDefault, 608 | }, 609 | }, 610 | } 611 | ); 612 | field.init('input.myValue'); 613 | field.init('input.otherValue'); 614 | field.reset(); 615 | assert.equal(field.getValue('input.myValue'), undefined); 616 | assert.equal(field.getValue('input.otherValue'), undefined); 617 | }); 618 | 619 | it('should reset only `input.myValue` to undefined when given `values` in constructor and pass `input.myValue` to `reset`', () => { 620 | const fieldDefault = 'field default value'; 621 | const field = new Field( 622 | {}, 623 | { 624 | parseName: true, 625 | values: { 626 | input: { 627 | myValue: fieldDefault, 628 | otherValue: fieldDefault, 629 | }, 630 | }, 631 | } 632 | ); 633 | field.init('input.myValue'); 634 | field.reset('input.myValue'); 635 | assert.equal(field.getValue('input.myValue'), undefined); 636 | assert.equal(field.getValue('input.otherValue'), fieldDefault); 637 | }); 638 | 639 | it('should reset all to undefined when call `resetToDefault` with no defaults', () => { 640 | const fieldDefault = 'field default value'; 641 | const field = new Field( 642 | {}, 643 | { 644 | parseName: true, 645 | } 646 | ); 647 | field.setValue('input.myValue', fieldDefault); 648 | field.setValue('input.otherValue', fieldDefault); 649 | field.resetToDefault(); 650 | assert.equal(field.getValue('input.myValue'), undefined); 651 | assert.equal(field.getValue('input.otherValue'), undefined); 652 | }); 653 | 654 | it('should reset all to undefined when given `values` in constructor and call `resetToDefault`', () => { 655 | const fieldDefault = 'field default value'; 656 | const secondValue = 'second'; 657 | const field = new Field( 658 | {}, 659 | { 660 | parseName: true, 661 | values: { 662 | input: { 663 | myValue: fieldDefault, 664 | otherValue: fieldDefault, 665 | }, 666 | }, 667 | } 668 | ); 669 | field.init('input.myValue'); 670 | field.init('input.otherValue'); 671 | field.setValue('input.myValue', secondValue); 672 | field.setValue('input.otherValue', secondValue); 673 | 674 | // simulation rerender 675 | field.init('input.myValue'); 676 | field.init('input.otherValue'); 677 | 678 | field.resetToDefault(); 679 | assert.equal(field.getValue('input.myValue'), undefined); 680 | assert.equal(field.getValue('input.otherValue'), undefined); 681 | }); 682 | 683 | it('should reset `input.myValue` which inited to undefined when given `values` in constructor and call `resetToDefault`', () => { 684 | const fieldDefault = 'field default value'; 685 | const secondValue = 'second'; 686 | const field = new Field( 687 | {}, 688 | { 689 | parseName: true, 690 | values: { 691 | input: { 692 | myValue: fieldDefault, 693 | otherValue: fieldDefault, 694 | }, 695 | }, 696 | } 697 | ); 698 | field.init('input.myValue'); 699 | field.setValue('input.myValue', secondValue); 700 | field.setValue('input.otherValue', secondValue); 701 | 702 | field.init('input.myValue'); 703 | 704 | field.resetToDefault('input.myValue'); 705 | assert.equal(field.getValue('input.myValue'), undefined); 706 | assert.equal(field.getValue('input.otherValue'), secondValue); 707 | }); 708 | }); 709 | 710 | it('should set the value to constructor value even with initValue from init', () => { 711 | const fieldDefault = 0; 712 | const initValue = 'other default value'; 713 | const field = new Field( 714 | {}, 715 | { 716 | parseName: true, 717 | values: { 718 | input: { 719 | myValue: fieldDefault, 720 | }, 721 | }, 722 | } 723 | ); 724 | 725 | field.init('input.myValue', { initValue }); 726 | 727 | assert.deepEqual(field.getValues(), { 728 | input: { 729 | myValue: fieldDefault, 730 | }, 731 | }); 732 | }); 733 | 734 | // Fix https://github.com/alibaba-fusion/next/issues/4525 735 | it('overwrite values by setValues', () => { 736 | const field = new Field( 737 | {}, 738 | { 739 | parseName: true, 740 | values: { 741 | one: [ 742 | [ 743 | { 744 | b: { name: 'zhangsan', age: 17 }, 745 | }, 746 | ], 747 | ], 748 | two: { code: '555' }, 749 | }, 750 | } 751 | ); 752 | const name = field.init('one.0.0.b.name'); 753 | const age = field.init('one.0.0.b.age'); 754 | const code = field.init('two.code'); 755 | assert.equal(name.value, 'zhangsan'); 756 | assert.equal(age.value, 17); 757 | assert.equal(code.value, '555'); 758 | 759 | field.setValues({ 760 | one: [ 761 | [ 762 | { 763 | b: null, 764 | }, 765 | ], 766 | ], 767 | two: '', 768 | }); 769 | assert.equal(field.init('one.0.0.b.name').value, undefined); 770 | assert.equal(field.init('one.0.0.b.age').value, undefined); 771 | assert.equal(field.init('two.code').value, undefined); 772 | }); 773 | }); 774 | 775 | describe('should support autoValidate=false', () => { 776 | it('options.autoValidate=true', () => { 777 | const field = new Field({}, { autoValidate: true }); 778 | const { value, ...inited } = field.init('input', { 779 | rules: [{ minLength: 10 }], 780 | }); 781 | 782 | cy.mount(); 783 | cy.get('input').type('test'); 784 | cy.then(() => { 785 | cy.wrap(field.getError('input')).should('not.be.null'); 786 | }); 787 | }); 788 | it('options.autoValidate=false', () => { 789 | const field = new Field({}, { autoValidate: false }); 790 | const { value, ...inited } = field.init('input', { 791 | rules: [{ minLength: 10 }], 792 | }); 793 | 794 | cy.mount(); 795 | cy.get('input').type('test'); 796 | cy.then(() => { 797 | cy.wrap(field.getError('input')).should('be.null'); 798 | field.validateCallback('input'); 799 | cy.wrap(field.getError('input')).should('not.be.null'); 800 | }); 801 | }); 802 | it('props.autoValidate=false', () => { 803 | const field = new Field({}); 804 | const { value, ...inited } = field.init('input', { 805 | autoValidate: false, 806 | rules: [{ minLength: 10 }], 807 | }); 808 | 809 | cy.mount(); 810 | cy.get('input').type('test'); 811 | cy.then(() => { 812 | cy.wrap(field.getError('input')).should('be.null'); 813 | field.validateCallback('input'); 814 | cy.wrap(field.getError('input')).should('not.be.null'); 815 | }); 816 | }); 817 | }); 818 | 819 | describe('processErrorMessage', () => { 820 | it('should pass error messages to `processErrorMessage` on validate', () => { 821 | const mySpy = cy.spy(); 822 | const field = new Field({}, { processErrorMessage: mySpy }); 823 | const inited = field.init('input', { 824 | initValue: 'test', 825 | rules: [{ minLength: 10, message: 'my error message' }], 826 | }); 827 | 828 | cy.mount().then(() => { 829 | field.validateCallback(); 830 | cy.wrap(mySpy).should('be.calledOnce'); 831 | cy.wrap(mySpy.firstCall.args[0]).should('eq', 'my error message'); 832 | }); 833 | }); 834 | }); 835 | 836 | describe('afterValidateRerender', () => { 837 | it('should pass error messages to `afterValidateRerender` on validate', () => { 838 | const mySpy = cy.spy(); 839 | const field = new Field({}, { afterValidateRerender: mySpy }); 840 | const inited = field.init('input', { 841 | initValue: 'test', 842 | rules: [{ minLength: 10, message: 'my error message' }], 843 | }); 844 | 845 | cy.mount().then(() => { 846 | field.validateCallback(); 847 | cy.wrap(mySpy).should('be.calledOnce'); 848 | cy.wrap(mySpy.firstCall.args[0].errorsGroup).should('deep.equal', { 849 | input: { errors: ['my error message'] }, 850 | }); 851 | cy.wrap(mySpy.firstCall.args[0].options).should('deep.equal', field.options); 852 | cy.wrap(mySpy.firstCall.args[0].instance).should( 853 | 'deep.equal', 854 | // @ts-expect-error read internal property "instance" for test 855 | field.instance 856 | ); 857 | }); 858 | }); 859 | }); 860 | 861 | describe('messages', () => { 862 | it('should support custom messages', () => { 863 | const mySpy = cy.spy(); 864 | const field = new Field( 865 | {}, 866 | { 867 | afterValidateRerender: mySpy, 868 | messages: { 869 | string: { 870 | minLength: 'custom error message', 871 | }, 872 | }, 873 | } 874 | ); 875 | const inited = field.init('input', { 876 | initValue: 'test', 877 | rules: [{ minLength: 10 }], 878 | }); 879 | 880 | cy.mount().then(() => { 881 | field.validateCallback(); 882 | cy.wrap(mySpy).should('be.calledOnce'); 883 | cy.wrap(mySpy.firstCall.args[0].errorsGroup).should('deep.equal', { 884 | input: { errors: ['custom error message'] }, 885 | }); 886 | }); 887 | }); 888 | 889 | it('should prefer user passed messages', () => { 890 | const mySpy = cy.spy(); 891 | const field = new Field( 892 | {}, 893 | { 894 | afterValidateRerender: mySpy, 895 | messages: { 896 | string: { 897 | minLength: 'custom error message', 898 | }, 899 | }, 900 | } 901 | ); 902 | const inited = field.init('input', { 903 | initValue: 'test', 904 | rules: [{ minLength: 10, message: 'my error message' }], 905 | }); 906 | 907 | cy.mount().then(() => { 908 | field.validateCallback(); 909 | cy.wrap(mySpy).should('be.calledOnce'); 910 | cy.wrap(mySpy.firstCall.args[0].errorsGroup).should('deep.equal', { 911 | input: { errors: ['my error message'] }, 912 | }); 913 | }); 914 | }); 915 | }); 916 | }); 917 | -------------------------------------------------------------------------------- /test/rules.spec.tsx: -------------------------------------------------------------------------------- 1 | import React, { createRef, forwardRef, useImperativeHandle } from 'react'; 2 | import { Input } from '@alifd/next'; 3 | import Field from '../src'; 4 | import { Validator } from '../src/types'; 5 | 6 | describe('rules', () => { 7 | it('required - validate', () => { 8 | const field = new Field({}); 9 | const { value, ...inited } = field.init('input', { 10 | rules: [ 11 | { 12 | required: true, 13 | message: 'cant be null', 14 | }, 15 | ], 16 | }); 17 | 18 | cy.mount(); 19 | cy.get('input').type('1'); 20 | cy.get('input').clear(); 21 | cy.then(() => { 22 | cy.wrap(field.getError('input')).should('deep.equal', ['cant be null']); 23 | }); 24 | 25 | const { value: _v, ...inited2 } = field.init('input', { rules: [] }); 26 | cy.mount(); 27 | const callback = cy.spy(); 28 | field.validateCallback(callback); 29 | 30 | cy.wrap(callback).should('be.calledOnce'); 31 | cy.wrap(callback).should('be.calledWithMatch', null); 32 | }); 33 | 34 | it('required - validatePromise - callback', () => { 35 | const field = new Field({}); 36 | const { value: _v, ...inited } = field.init('input', { 37 | rules: [ 38 | { 39 | required: true, 40 | message: 'cant be null', 41 | }, 42 | ], 43 | }); 44 | 45 | cy.mount(); 46 | cy.get('input').type('1'); 47 | cy.get('input').clear(); 48 | cy.then(() => { 49 | cy.wrap(field.getError('input')).should('deep.equal', ['cant be null']); 50 | }); 51 | 52 | const { value: _v2, ...inited2 } = field.init('input', { rules: [] }); 53 | cy.mount(); 54 | 55 | const callback = cy.spy(); 56 | field.validatePromise(callback); 57 | 58 | cy.wrap(callback).should('be.calledOnce'); 59 | }); 60 | 61 | it('required - validatePromise', () => { 62 | const field = new Field({}); 63 | const { value, ...inited } = field.init('input', { 64 | rules: [ 65 | { 66 | required: true, 67 | message: 'cant be null', 68 | }, 69 | ], 70 | }); 71 | 72 | cy.mount(); 73 | cy.get('input').type('a'); 74 | cy.get('input').clear(); 75 | cy.then(() => { 76 | cy.wrap(field.getError('input')).should('deep.equal', ['cant be null']); 77 | cy.wrap(field.validatePromise()).should('deep.equal', { 78 | errors: { input: { errors: ['cant be null'] } }, 79 | values: { input: '' }, 80 | }); 81 | }); 82 | }); 83 | 84 | it('triger', () => { 85 | const field = new Field({}); 86 | const { value, ...inited } = field.init('input', { 87 | rules: [ 88 | { 89 | required: true, 90 | trigger: 'onBlur', 91 | message: 'cant be null', 92 | }, 93 | ], 94 | }); 95 | 96 | cy.mount(); 97 | cy.get('input').trigger('blur'); 98 | cy.then(() => { 99 | cy.wrap(field.getError('input')).should('deep.equal', ['cant be null']); 100 | }); 101 | }); 102 | it('validator', () => { 103 | const field = new Field({}); 104 | const { value, ...inited } = field.init('input', { 105 | rules: [ 106 | { 107 | validator: (rule, value, callback) => { 108 | if (!value) { 109 | callback('不能为空!'); 110 | } else { 111 | callback(); 112 | } 113 | }, 114 | }, 115 | ], 116 | }); 117 | 118 | cy.mount(); 119 | cy.get('input').type('a'); 120 | cy.get('input').clear(); 121 | cy.then(() => { 122 | cy.wrap(field.getError('input')).should('deep.equal', ['不能为空!']); 123 | }); 124 | }); 125 | 126 | it('should reRender while validator callback after 200ms, fix #51', () => { 127 | class Demo extends React.Component { 128 | field = new Field(this); 129 | userExists: Validator = (_rule, value) => { 130 | return new Promise((resolve, reject) => { 131 | if (!value) { 132 | resolve(); 133 | } else { 134 | setTimeout(() => { 135 | if (value === 'frank') { 136 | reject([new Error('Sorry name existed')]); 137 | } else { 138 | resolve(); 139 | } 140 | }, 100); 141 | } 142 | }); 143 | }; 144 | 145 | render() { 146 | const { getError, init } = this.field; 147 | 148 | return ( 149 |
150 | 157 | 158 |
159 | ); 160 | } 161 | } 162 | 163 | cy.mount(); 164 | cy.clock(); 165 | cy.get('input').type('frank'); 166 | cy.tick(200); 167 | cy.get('label').should('have.text', 'Sorry name existed'); 168 | }); 169 | 170 | it('should rulesProps immutable', () => { 171 | const field = new Field({}); 172 | const initRules = { 173 | required: true, 174 | message: 'cant be null', 175 | }; 176 | const { value, ...inited } = field.init('input', { 177 | rules: initRules, 178 | }); 179 | 180 | cy.mount(); 181 | 182 | const callback = cy.spy(); 183 | field.validateCallback(callback); 184 | cy.wrap(initRules).should('not.have.property', 'validator'); 185 | }); 186 | 187 | it('Should not block validation when component is unmounted while autoUnmount=false.', () => { 188 | const useField = Field.getUseField({ 189 | useState: React.useState, 190 | useMemo: React.useMemo, 191 | }); 192 | type Ref = { 193 | field: Field; 194 | setVisible: React.Dispatch>; 195 | }; 196 | const Demo = forwardRef((_props, ref) => { 197 | const [visible, setVisible] = React.useState(true); 198 | const field = useField({ autoUnmount: false }); 199 | useImperativeHandle(ref, () => ({ setVisible, field })); 200 | if (!visible) { 201 | return null; 202 | } 203 | return ( 204 | 209 | ); 210 | }); 211 | 212 | const ref = createRef(); 213 | cy.mount().then(async () => { 214 | cy.wrap(ref.current).should('be.ok'); 215 | const { errors } = await ref.current!.field.validatePromise(); 216 | cy.wrap(errors).should('be.ok'); 217 | ref.current!.setVisible(false); 218 | const { errors: errors2 } = await ref.current!.field.validatePromise(); 219 | cy.wrap(errors2).should('not.be.ok'); 220 | }); 221 | }); 222 | }); 223 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "include": ["../cypress", "./**/*"], 4 | } 5 | -------------------------------------------------------------------------------- /test/utils.spec.tsx: -------------------------------------------------------------------------------- 1 | import React, { isValidElement, cloneElement, ReactElement } from 'react'; 2 | import { assert } from 'chai'; 3 | import { 4 | getErrorStrs, 5 | hasIn, 6 | getIn, 7 | setIn, 8 | deleteIn, 9 | cloneToRuleArr, 10 | splitNameToPath, 11 | isOverwritten, 12 | } from '../src/utils'; 13 | 14 | function processErrorMessage(element: ReactElement) { 15 | if (element && isValidElement(element)) { 16 | return cloneElement(element, { key: 'error' }); 17 | } 18 | return element; 19 | } 20 | 21 | describe('Field Utils', () => { 22 | describe('getErrorStrs', () => { 23 | it('should return `undefined` when given no errors', () => { 24 | assert(getErrorStrs() === undefined); 25 | }); 26 | 27 | it('should return array of strings when given error objects', () => { 28 | const errors = [{ message: 'error 1' }, { message: 'error 2' }]; 29 | assert.deepEqual(getErrorStrs(errors), ['error 1', 'error 2']); 30 | }); 31 | 32 | it('should return array of strings when given string errors', () => { 33 | const errors = ['error 1', 'error 2']; 34 | assert.deepEqual(getErrorStrs(errors), ['error 1', 'error 2']); 35 | }); 36 | 37 | it('should return array of React elements with `error` key when no key set using custom `processErrorMessage` function', () => { 38 | const errors = [{ message: message 1 }]; 39 | const result = getErrorStrs(errors, processErrorMessage); 40 | assert.equal(result[0].key, 'error'); 41 | }); 42 | 43 | it('should accept React Elements in `message` key using custom `processErrorMessage` function', () => { 44 | const errors = [{ message: message 1 }, { message: message 2 }]; 45 | const result = getErrorStrs(errors, processErrorMessage); 46 | assert.deepEqual(result[0].props.children, 'message 1'); 47 | assert.deepEqual(result[1].props.children, 'message 2'); 48 | }); 49 | 50 | it('should accept React Elements using custom `processErrorMessage` function', () => { 51 | const errors = [message 1, message 2]; 52 | const result = getErrorStrs(errors, processErrorMessage); 53 | assert.deepEqual(result[0].props.children, 'message 1'); 54 | assert.deepEqual(result[1].props.children, 'message 2'); 55 | }); 56 | }); 57 | 58 | describe('splitNameToPath', () => { 59 | it('basic usage', () => { 60 | assert.deepEqual(splitNameToPath('a'), ['a']); 61 | assert.deepEqual(splitNameToPath('a.b'), ['a', 'b']); 62 | }); 63 | it('with array', () => { 64 | assert.deepEqual(splitNameToPath('a[0]'), ['a', '0']); 65 | assert.deepEqual(splitNameToPath('a[0].b'), ['a', '0', 'b']); 66 | assert.deepEqual(splitNameToPath('a.b[0]'), ['a', 'b', '0']); 67 | }); 68 | it('should return blank string when is not valid string', () => { 69 | assert(splitNameToPath(undefined) === ''); 70 | assert(splitNameToPath(null) === ''); 71 | assert(splitNameToPath('') === ''); 72 | assert(splitNameToPath({}) === ''); 73 | assert(splitNameToPath([]) === ''); 74 | }); 75 | }); 76 | 77 | describe('hasIn', () => { 78 | it('should return false when state is nil', () => { 79 | assert(!hasIn(undefined, 'a.b')); 80 | assert(!hasIn(null, 'a.b')); 81 | }); 82 | it('should return false when name is blank or nil', () => { 83 | assert(!hasIn({ a: 1 }, '')); 84 | assert(!hasIn({ a: 1 }, undefined)); 85 | assert(!hasIn({ a: 1 }, null)); 86 | }); 87 | it('should return true when has property', () => { 88 | assert(hasIn({ a: { b: 1 } }, 'a.b')); 89 | }); 90 | it('should return false when has no property', () => { 91 | assert(!hasIn({ a: { b: 1 } }, 'a.b.c')); 92 | assert(!hasIn({ a: { b: 1 } }, 'a.c')); 93 | assert(!hasIn({ a: { b: 1 } }, 'a[0]')); 94 | }); 95 | }); 96 | 97 | describe('getIn', () => { 98 | it('should return state when state is falsy', () => { 99 | assert(getIn(undefined, 'a') === undefined); 100 | }); 101 | 102 | it('should return undefind when no name', () => { 103 | assert(getIn({ a: 1 }, '') === undefined); 104 | }); 105 | 106 | it('should return undefind when value for name', () => { 107 | assert(getIn({ a: 1 }, 'b') === undefined); 108 | }); 109 | 110 | it('should get top level element', () => { 111 | assert(getIn({ a: 1 }, 'a') === 1); 112 | }); 113 | 114 | it('should get deep level element', () => { 115 | assert(getIn({ a: { b: { c: 1 } } }, 'a.b.c') === 1); 116 | }); 117 | 118 | it('should get array element with dot notation', () => { 119 | assert(getIn({ a: [1, 2] }, 'a.1') === 2); 120 | }); 121 | 122 | it('should get array element with bracket notation', () => { 123 | assert(getIn({ a: [1, 2] }, 'a[1]') === 2); 124 | }); 125 | 126 | it('should get element that is deep array combination', () => { 127 | assert(getIn({ a: [1, { b: { c: 2 } }] }, 'a[1].b.c') === 2); 128 | }); 129 | 130 | it('should return undefined when name is not exists', () => { 131 | // a is not a object 132 | assert(getIn({ a: 1 }, 'a.a') === undefined); 133 | // has not property 134 | assert(getIn({ a: {} }, 'b.c') === undefined); 135 | // is array without the index 136 | assert(getIn({ a: [] }, 'a[0]') === undefined); 137 | }); 138 | }); 139 | 140 | describe('setIn', () => { 141 | it('should initialize state with object when it is falsy and path is NaN', () => { 142 | assert(setIn(undefined, 'a', 5).a === 5); 143 | }); 144 | 145 | it('should initialize state with array when it is falsy and path is NaN', () => { 146 | assert(setIn(undefined, '1', 5)[1] === 5); 147 | }); 148 | 149 | it('should initialize state with whole path', () => { 150 | assert(setIn(undefined, 'a.b.c', 5).a.b.c === 5); 151 | }); 152 | 153 | it('should not modify state when setting new value', () => { 154 | const state = { a: { b: { c: 1 } } }; 155 | setIn(state, 'a.b.c', 5); 156 | assert(state.a.b.c === 1); 157 | }); 158 | 159 | it('should duplicate state with new value', () => { 160 | const state = { a: { b: { c: 1 } } }; 161 | const newState = setIn(state, 'a.b.c', 5); 162 | assert(newState.a.b.c === 5); 163 | }); 164 | 165 | it('should handle array dot notation', () => { 166 | const state = { a: { b: [1, 2] } }; 167 | const newState = setIn(state, 'a.b.1', 5); 168 | assert(newState.a.b[1] === 5); 169 | }); 170 | 171 | it('should handle array bracket notation', () => { 172 | const state = { a: { b: [1, 2] } }; 173 | const newState = setIn(state, 'a.b[1]', 5); 174 | assert(newState.a.b[1] === 5); 175 | }); 176 | 177 | it('should add to existing nested object', () => { 178 | const state = { a: { b: 1 } }; 179 | const newState = setIn(state, 'a.c.d', 5); 180 | assert(newState.a.c.d === 5); 181 | }); 182 | 183 | it('should add to empty object', () => { 184 | const newState = setIn({}, 'a.b.c', 5); 185 | assert(newState.a.b.c === 5); 186 | }); 187 | }); 188 | 189 | describe('deleteIn', () => { 190 | it('should do nothing when name is not present', () => { 191 | assert.deepEqual(deleteIn({ a: { b: 1 } }, 'x'), { a: { b: 1 } }); 192 | }); 193 | 194 | it('should do nothing given empty object', () => { 195 | assert.deepEqual(deleteIn({}, 'x'), {}); 196 | }); 197 | 198 | it('should delete nested element, but leave object', () => { 199 | assert.deepEqual(deleteIn({ a: { b: 1 } }, 'a.b'), { a: {} }); 200 | }); 201 | 202 | it('should delete top level element, but leave object', () => { 203 | assert.deepEqual(deleteIn({ a: { b: 1 } }, 'a'), {}); 204 | }); 205 | 206 | it('should delete array element, but not change later indices', () => { 207 | assert.deepEqual(deleteIn({ a: { b: [1, 2, 3] } }, 'a.b.0'), { 208 | a: { b: [undefined, 2, 3] }, 209 | }); 210 | }); 211 | }); 212 | 213 | describe('cloneToRuleArr', () => { 214 | it('should return [] when rules is not empty', () => { 215 | assert.deepEqual(cloneToRuleArr(), []); 216 | assert.deepEqual(cloneToRuleArr(undefined), []); 217 | assert.deepEqual(cloneToRuleArr(null), []); 218 | }); 219 | 220 | it('should always return array', () => { 221 | assert.deepEqual(cloneToRuleArr({ required: true }), [{ required: true }]); 222 | assert.deepEqual(cloneToRuleArr([{ required: true }]), [{ required: true }]); 223 | }); 224 | 225 | it('should return cloned rule array', () => { 226 | const rule: Record = { required: true }; 227 | const cloned = cloneToRuleArr(rule); 228 | cloned[0].validate = () => {}; 229 | assert(!('validate' in rule)); 230 | }); 231 | }); 232 | 233 | describe('isOverwritten', () => { 234 | it('should return true while name is in values', () => { 235 | assert.equal(isOverwritten({ a: { b: 1 } }, 'a.b'), true); 236 | assert.equal(isOverwritten({ a: { b: {} } }, 'a.b'), true); 237 | assert.equal(isOverwritten({ a: { b: ['b0'] } }, 'a.b[0]'), true); 238 | }); 239 | it('should return true while miss the array index', () => { 240 | assert.equal(isOverwritten({ a: { b: ['b0', 'b1'] } }, 'a.b[5]'), true); 241 | assert.equal(isOverwritten({ a: { b: ['b0', 'b1'] } }, 'a.b.5'), true); 242 | }); 243 | it('should return false while name is not in values', () => { 244 | assert.equal(isOverwritten({ a: { b: 1 } }, 'a.c'), false); 245 | assert.equal(isOverwritten({ a: { b: 1 } }, 'g'), false); 246 | }); 247 | 248 | it('should return true while some paths of name is overwritten by values', () => { 249 | assert.equal(isOverwritten({ a: { b: 1 } }, 'a.b.c'), true); 250 | }); 251 | it('should return false while parameters is illegal', () => { 252 | assert.equal(isOverwritten(null, 'a.b'), false); 253 | assert.equal(isOverwritten(undefined, 'a.b'), false); 254 | assert.equal(isOverwritten(0, 'a.b'), false); 255 | assert.equal(isOverwritten('xxx', 'a.b'), false); 256 | assert.equal(isOverwritten({ a: { b: 1 } }, undefined), false); 257 | assert.equal(isOverwritten({ a: { b: 1 } }, null), false); 258 | assert.equal(isOverwritten({ a: { b: 1 } }, 0), false); 259 | assert.equal(isOverwritten({ a: { b: 1 } }, 1), false); 260 | assert.equal(isOverwritten({ a: { b: 1 } }, {}), false); 261 | }); 262 | }); 263 | }); 264 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "target": "ES5", 5 | "module": "ESNext", 6 | "importHelpers": true, 7 | "declaration": true 8 | }, 9 | "include": ["src"] 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 5 | "module": "ESNext", 6 | "moduleResolution": "Node", 7 | "experimentalDecorators": true, 8 | "allowJs": false, 9 | "declaration": true, 10 | "jsx": "react", 11 | "downlevelIteration": true, 12 | "noImplicitAny": true, 13 | "strictNullChecks": true, 14 | "skipLibCheck": true, 15 | "esModuleInterop": true, 16 | "allowSyntheticDefaultImports": true, 17 | "isolatedModules": false, 18 | "importHelpers": true, 19 | }, 20 | "include": ["src", "test"], 21 | } 22 | -------------------------------------------------------------------------------- /tsdoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json" 3 | } 4 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react'; 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [ 7 | react(), 8 | { 9 | name: 'my-polyfill', 10 | config() { 11 | return { 12 | optimizeDeps: { 13 | // include: ["@alifd/validate"] 14 | }, 15 | resolve: { 16 | alias: [ 17 | { 18 | find: /^moment$/, 19 | replacement: 'moment/moment.js', 20 | }, 21 | ], 22 | }, 23 | }; 24 | }, 25 | }, 26 | ], 27 | }); 28 | --------------------------------------------------------------------------------