├── .babelrc ├── .editorconfig ├── .eslintrc.js ├── .github └── workflows │ ├── push.yml │ └── release.yml ├── .gitignore ├── .travis.yml ├── CHANGE_LOG.txt ├── examples └── vanillajs.html ├── jest.config.json ├── package-lock.json ├── package.json ├── readme.md ├── rollup.config.js ├── src ├── index.test.ts └── index.ts ├── tsconfig.base.json ├── tsconfig.cjs.json ├── tsconfig.esm.json └── tsconfig.json /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "transform-es2015-modules-commonjs", 4 | "transform-class-properties" 5 | ], 6 | "presets": ["es2015"] 7 | } 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { browser: true, node: true }, 3 | root: true, 4 | parser: "@typescript-eslint/parser", 5 | plugins: [ 6 | "@typescript-eslint", 7 | "import", 8 | "simple-import-sort", 9 | "unused-imports", 10 | "ban", 11 | ], 12 | extends: [ 13 | "eslint:recommended", 14 | "plugin:@typescript-eslint/recommended", 15 | "prettier", 16 | ], 17 | }; 18 | -------------------------------------------------------------------------------- /.github/workflows/push.yml: -------------------------------------------------------------------------------- 1 | name: Chore 2 | on: [push] 3 | jobs: 4 | Chore: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - name: Check out repository code 8 | uses: actions/checkout@v2 9 | - name: install deps 10 | run: | 11 | npm install 12 | - name: lint 13 | run: | 14 | npm run lint 15 | - name: test 16 | run: | 17 | npm run test -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release validatex 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | - uses: actions/setup-node@v2 13 | with: 14 | node-version: 12 15 | - run: npm ci 16 | - run: npm run test 17 | 18 | publish-npm: 19 | needs: build 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v2 23 | - uses: actions/setup-node@v2 24 | with: 25 | node-version: 12 26 | registry-url: https://registry.npmjs.org/ 27 | - run: npm ci 28 | - run: npm run build 29 | - run: npm publish 30 | env: 31 | NODE_AUTH_TOKEN: ${{secrets.npm_token}} 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .brotasks 2 | node_modules/ 3 | .tern-port 4 | .tern-project 5 | npm-debug.log 6 | bower_components/ 7 | lib/ 8 | .DS_Store 9 | coverage/ 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "12" 4 | cache: 5 | directories: 6 | - "node_modules" 7 | install: 8 | - npm install 9 | script: 10 | - npm test 11 | -------------------------------------------------------------------------------- /CHANGE_LOG.txt: -------------------------------------------------------------------------------- 1 | -v4 2 | - brought back validatex 3 | - functional validators instead of class based validators 4 | -v3.1.0 5 | - validate method must throw ValidationError instead of returning an error 6 | - v3.0.0 7 | - Class based fields and form 8 | - dropped dependency to validatex 9 | - v2.3.0 10 | - bulk assign 11 | - data change projector 12 | - v2.2.0 [breaking changes] 13 | 14 | - validatex@0.3.x 15 | 16 | - it requires validators to return error message instead of throwing them 17 | - it requires validators to throw SkipValidation to short curcuit the validation 18 | - [visit validatex for more detail](https://github.com/ludbek/validatex#updates) 19 | 20 | -------------------------------------------------------------------------------- /examples/vanillajs.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |
7 | 8 | 9 |
10 | 11 | 12 |
13 | 14 | 15 |
16 | 17 | 18 |
19 | 20 | 21 |
22 |
23 | 24 | 25 |
26 |
27 | 28 |
29 | 30 | 88 | -------------------------------------------------------------------------------- /jest.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "rootDir": ".", 3 | "transform": { 4 | "^.+\\.tsx?$": "ts-jest" 5 | }, 6 | "testRegex": "src/.*\\.test\\.ts$", 7 | "moduleFileExtensions": ["ts", "tsx", "js", "jsx", "json", "node"], 8 | "coverageReporters": ["json-summary", "text", "lcov"], 9 | "globals": { 10 | "ts-jest": { 11 | "tsconfig": "tsconfig.json" 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "powerform", 3 | "version": "5.0.1-alpha1", 4 | "description": "A powerful form model.", 5 | "main": "./lib/index.js", 6 | "types": "./index.d.ts", 7 | "module": "./lib/index.mjs", 8 | "exports": { 9 | ".": { 10 | "require": "./lib/index.js", 11 | "import": "./lib/index.mjs", 12 | "types": "./index.d.ts" 13 | }, 14 | "./package.json": "./package.json" 15 | }, 16 | "scripts": { 17 | "test": "jest --coverage", 18 | "format": "prettier --write \"src/**/*.ts\" \"./*.json\"", 19 | "lint": "eslint --fix --ext .ts ./src", 20 | "clean": "rm -rf lib/* deno/lib/*", 21 | "build": "yarn run clean && npm run build:cjs && npm run build:esm", 22 | "build:esm": "rollup --config rollup.config.js", 23 | "build:cjs": "tsc --p tsconfig.cjs.json", 24 | "build:types": "tsc --p tsconfig.types.json" 25 | }, 26 | "files": [ 27 | "lib/**/*" 28 | ], 29 | "repository": { 30 | "type": "git", 31 | "url": "git+https://github.com/ludbek/powerform.git" 32 | }, 33 | "bugs": { 34 | "url": "https://github.com/ludbek/powerform/issues" 35 | }, 36 | "homepage": "https://github.com/ludbek/powerform#readme", 37 | "keywords": [ 38 | "form", 39 | "mithril", 40 | "react" 41 | ], 42 | "author": "ludbek", 43 | "license": "ISC", 44 | "devDependencies": { 45 | "@rollup/plugin-typescript": "^8.3.3", 46 | "@types/jest": "^28.1.6", 47 | "@typescript-eslint/eslint-plugin": "^5.31.0", 48 | "eslint": "^8.11.0", 49 | "eslint-config-prettier": "^8.5.0", 50 | "eslint-plugin-ban": "^1.6.0", 51 | "eslint-plugin-import": "^2.26.0", 52 | "eslint-plugin-simple-import-sort": "^7.0.0", 53 | "eslint-plugin-unused-imports": "^2.0.0", 54 | "jest": "^28.1.3", 55 | "lint-staged": "^12.3.7", 56 | "prettier": "^2.3.2", 57 | "rollup": "^2.70.1", 58 | "ts-jest": "^28.0.7", 59 | "tslint": "^6.1.3", 60 | "tslint-config-prettier": "^1.18.0", 61 | "typescript": "^4.7.4" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/ludbek/powerform.svg?branch=master)](https://travis-ci.org/ludbek/powerform) 2 | 3 | 4 | 5 | Logo by [Anand](https://www.behance.net/mukhiyaanad378) 6 | 7 | ## Introduction 8 | 9 | A tiny super portable form model which can be used in apps with or without frameworks like [React](https://github.com/facebook/react). 10 | 11 | ## Showcase 12 | 13 | [Vanilla JS](https://codesandbox.io/s/powerform-vanilla-js-ug2sx) 14 | 15 | [React](https://codesandbox.io/s/powerform-react-17gqu) 16 | 17 | [Mithril](https://codesandbox.io/s/powerform-mithril-pdrl1?file=/src/index.js) 18 | 19 | ## Breaking changes 20 | 21 | v4 introduces significant changes which are not backward compatible with v3. 22 | Please checkout the [change log](CHANGE_LOG.txt). 23 | 24 | ## Installation 25 | 26 | ### yarn 27 | 28 | `yarn add powerform` 29 | 30 | ### npm 31 | 32 | `npm install powerform` 33 | 34 | ## Quick walk-through 35 | 36 | ```javascript 37 | // es6 38 | import { powerform } from "powerform"; 39 | import { required, minLength, equalsTo } from "validatex"; 40 | 41 | const schema = { 42 | username: required(true), 43 | password: [required(true), minLength(8)], 44 | confirmPassword: [required(true), equalsTo("password")], 45 | }; 46 | 47 | const form = powerform(schema); 48 | 49 | // assign values to fields 50 | form.username.setData("ausername"); 51 | form.password.setData("apassword"); 52 | form.confirmPassword.setData("bpassword"); 53 | 54 | // per field validation 55 | console.log(form.username.validate()) > true; 56 | console.log(form.password.validate()) > true; 57 | console.log(form.confirmPassword.validate()) > false; 58 | console.log(form.confirmPassword.getError()) > "Passwords do not match."; 59 | 60 | // validate all the fields at once 61 | console.log(form.validate()) > false; 62 | console.log(form.getError()) > 63 | { 64 | username: undefined, 65 | password: undefined, 66 | confirmPassword: "Password and confirmation does not match.", 67 | }; 68 | ``` 69 | 70 | ## API 71 | 72 | ### Form 73 | 74 | #### powerform(schema, config?: object) 75 | 76 | Returns a form. 77 | 78 | ```javascript 79 | // reusing the schema from walkthrough 80 | const form = powerform(schema); 81 | ``` 82 | 83 | ##### Config schema 84 | 85 | ``` 86 | { 87 | data: object, 88 | onChange(data: object, form: Form): function, 89 | onError(error: object, form: Form): function, 90 | stopOnError: boolean 91 | } 92 | ``` 93 | 94 | ##### Set initial values of field 95 | 96 | Pass an object at `config.data` to set initial field values. 97 | 98 | ```javascript 99 | const config = { 100 | data: { 101 | username: "a username", 102 | password: "a password", 103 | }, 104 | }; 105 | const form = powerform(schema, config); 106 | console.log(form.username.getData()) > "a username"; 107 | console.log(form.password.getData()) > "a password"; 108 | ``` 109 | 110 | ##### Track changes in data and error 111 | 112 | Changes to values and errors of fields can be tracked through `config.onChange` callback. 113 | 114 | ```javascript 115 | const config = { 116 | onChange: (data, form) => { 117 | console.log(data); 118 | }, 119 | onError: (error, form) => { 120 | console.log(error); 121 | }, 122 | }; 123 | 124 | const form = powerform(schema, config); 125 | form.username.setData("a username") > 126 | // logs data 127 | { 128 | username: "a username", 129 | password: null, 130 | confirmPassword: null, 131 | }; 132 | form.password.validate() > 133 | // logs changes to error 134 | { 135 | username: null, 136 | password: "This field is required.", 137 | confirmPassword: null, 138 | }; 139 | ``` 140 | 141 | ##### Validate one field at a time 142 | 143 | It is possible to stop validation as soon as one of the fields fails. 144 | To enable this mode of validation set `config.stopOnError` to `true`. 145 | One can control the order at which fields are validated by supplying `index` to fields. 146 | 147 | ```javascript 148 | const loginSchema = { 149 | password: {validator: required(true), index: 2} 150 | username: {validator: required(true), index: 1} 151 | } 152 | const form = powerform(loginSchema, {stopOnError: true}) 153 | 154 | console.log(form.validate()) 155 | >> false 156 | 157 | console.log(form.getError()) 158 | >> {username: "This field is required."} 159 | 160 | ``` 161 | 162 | #### form.setData(data: object) 163 | 164 | Sets value of fields of a form. 165 | 166 | ```javascript 167 | const form = powerform(schema); 168 | let data = { 169 | username: "a username", 170 | password: "a password", 171 | }; 172 | form.setData(data); 173 | 174 | console.log(form.username.getData()) > "a username"; 175 | console.log(form.password.getData()) > "a password"; 176 | console.log(form.confirmPassword.getData()) > null; 177 | ``` 178 | 179 | #### form.getData() 180 | 181 | Returns key value pair of fields and their corresponding values. 182 | 183 | ```javascript 184 | const form = powerform(schema); 185 | let data = { 186 | username: "a username", 187 | password: "a password", 188 | }; 189 | form.setData(data); 190 | 191 | console.log(form.getData()) > 192 | { 193 | username: "a username", 194 | password: "a password", 195 | confirmPassword: null, 196 | }; 197 | ``` 198 | 199 | #### form.getUpdates() 200 | 201 | Returns key value pair of updated fields and their corresponding values. 202 | The data it returns can be used for patching a resource over API. 203 | 204 | ```javascript 205 | const userFormSchema = { 206 | name: required(true), 207 | address: required(true), 208 | username: required(true), 209 | }; 210 | 211 | const form = powerform(userFormSchema); 212 | let data = { 213 | name: "a name", 214 | address: "an address", 215 | }; 216 | form.setData(data); 217 | 218 | console.log(form.getUpdates()) > 219 | { 220 | name: "a name", 221 | address: "an address", 222 | }; 223 | ``` 224 | 225 | #### form.setError(errors: object) 226 | 227 | Sets error of fields in a form. 228 | 229 | ```javascript 230 | const form = powerform(schema); 231 | const errors = { 232 | username: "Invalid username.", 233 | password: "Password is too common.", 234 | }; 235 | form.setError(errors); 236 | 237 | console.log(form.username.getError()) > "Invalid username."; 238 | 239 | console.log(form.password.getError()) > "Password is too common."; 240 | 241 | console.log(form.confirmPassword.getError()) > null; 242 | ``` 243 | 244 | #### form.getError() 245 | 246 | Returns key value pair of fields and their corresponding errors. 247 | 248 | ```javascript 249 | const form = powerform(schema); 250 | form.password.setData("1234567"); 251 | form.confirmPassword.setData("12"); 252 | form.validate(); 253 | 254 | console.log(form.getError()) > 255 | { 256 | username: "This field is required.", 257 | password: "This field must be at least 8 characters long.", 258 | confirmPassword: "Passwords do not match.", 259 | }; 260 | ``` 261 | 262 | #### form.isDirty() 263 | 264 | Returns `true` if value of one of the fields in a form has been updated. 265 | Returns `false` if non of the fields has been updated. 266 | 267 | ```javascript 268 | const form = powerform(schema); 269 | 270 | console.log(form.isDirty()) > false; 271 | 272 | form.username.setData("a username"); 273 | console.log(f.isDirty()) > true; 274 | ``` 275 | 276 | #### form.makePristine() 277 | 278 | Sets initial value to current value in every fields. 279 | 280 | ```javascript 281 | const form = powerform(schema); 282 | form.username.setData("a username"); 283 | 284 | console.log(form.isDirty()) > true; 285 | 286 | form.makePristine(); 287 | console.log(form.isDirty()) > false; 288 | console.log(form.username.getData()) > "a username"; 289 | ``` 290 | 291 | #### form.reset() 292 | 293 | Resets all the fields of a form. 294 | 295 | ```javascript 296 | const form = powerform(schema); 297 | form.username.setData("a username"); 298 | form.password.setData("a password"); 299 | console.log(form.getData()) > 300 | { 301 | username: "a username", 302 | password: "a password", 303 | confirmPassword: null, 304 | }; 305 | 306 | form.reset(); 307 | console.log(form.getData()) > 308 | { 309 | username: null, 310 | password: null, 311 | confirmPassword: null, 312 | }; 313 | ``` 314 | 315 | #### form.isValid() 316 | 317 | Returns `true` if all fields of a form are valid. 318 | Returns `false` if one of the fields in a form is invalid. 319 | Unlike `form.validate()` it does not set the error. 320 | 321 | ```javascript 322 | const form = powerform(schema); 323 | form.password.setData("1234567"); 324 | 325 | console.log(form.isValid()) > false; 326 | 327 | console.log(form.getError()) > 328 | { 329 | username: null, 330 | password: null, 331 | confirmPassword: null, 332 | }; 333 | ``` 334 | 335 | ### Field 336 | 337 | Every keys in a schema that is passed to `powerform` is turned into a Field. We do not need to directly instanciate it. 338 | 339 | #### Field(config?: object| function | [function]) 340 | 341 | Creates and returns a field instance. 342 | 343 | ##### Config schema 344 | 345 | ``` 346 | { 347 | validator: function | [function], 348 | default?: any, 349 | debounce?: number, 350 | onChange(value: any, field: Field)?: function 351 | onError(error: any, field: Field)?: function 352 | } 353 | ``` 354 | 355 | ##### Set default value 356 | 357 | A field can have default value. 358 | 359 | ```javascript 360 | const form = powerform({ 361 | username: { validator: required(true), default: "orange" }, 362 | }); 363 | 364 | console.log(form.username.getData()) > "orange"; 365 | ``` 366 | 367 | ##### Trance changes in value and error 368 | 369 | Changes in value and error of a field can be tracked through `config.onChange` and `config.onError` callbacks. 370 | 371 | ```javascript 372 | function logData(data, field) { 373 | console.log('data: ', data) 374 | } 375 | 376 | function logError(data, field) { 377 | console.log('error: ', error) 378 | } 379 | 380 | const form = powerform({ 381 | username: { 382 | validator: required(true), 383 | default: 'orange', 384 | onChange: logData, 385 | onError: logError 386 | } 387 | }) 388 | form.username.validate() 389 | > "error: " "This field is required." 390 | 391 | form.username.setData('orange') 392 | > "data: " "orange" 393 | 394 | form.username.validate() 395 | > "error: " null 396 | ``` 397 | 398 | ##### Debounce change in value 399 | 400 | Changes in data can be debounced. 401 | 402 | ```javascript 403 | const form = powerform({ 404 | username: { 405 | validator: required(true), 406 | default: 'orange', 407 | onChange: logData, 408 | onError: logError 409 | } 410 | }) 411 | 412 | form.username.setData("banana") 413 | // after 1 second 414 | > "data: " "banana" 415 | ``` 416 | 417 | #### Field.setData(value: any) 418 | 419 | Sets field value. 420 | 421 | ```javascript 422 | const form = powerform({ 423 | name: required(true), 424 | }); 425 | form.name.setData("a name"); 426 | console.log(form.name.getData()) > "a name"; 427 | ``` 428 | 429 | #### Field.getData() 430 | 431 | Returns field value. 432 | 433 | #### Field.modify(newValue: any, oldValue: any) 434 | 435 | Modifies user's input value. 436 | Example usage - 437 | 438 | - capitalize user name as user types 439 | - insert space or dash as user types card number 440 | 441 | ```javascript 442 | const form = powerform({ 443 | name: { 444 | validator: required(true), 445 | modify(value) { 446 | if (!value) return null; 447 | return value.replace(/(?:^|\s)\S/g, (s) => s.toUpperCase()); 448 | }, 449 | }, 450 | }); 451 | 452 | form.name.setData("first last"); 453 | console.log(form.name.getData()) > "First Last"; 454 | ``` 455 | 456 | #### Field.clean(value: any) 457 | 458 | Cleans the value. 459 | `form.getData()` uses this method to get clean data. 460 | It is useful for situations where value in a view should be different to 461 | the value in stores. 462 | 463 | ```javascript 464 | const form = powerform({ 465 | card: { 466 | validator: required(true), 467 | modify(newVal, oldVal) { 468 | return newVal.length === 16 469 | ? newCard 470 | .split("-") 471 | .join("") 472 | .replace(/(\d{4})/g, "$1-") 473 | .replace(/(.)$/, "") 474 | : newCard 475 | .split("-") 476 | .join("") 477 | .replace(/(\d{4})/g, "$1-"); 478 | }, 479 | clean(value) { 480 | return card.split("-").join(""); 481 | }, 482 | }, 483 | }); 484 | 485 | form.card.setData("1111222233334444"); 486 | console.log(form.card.getData()) > "1111-2222-3333-4444"; 487 | console.log(form.getData()) > { card: "1111222233334444" }; 488 | ``` 489 | 490 | ### field.validate(value: any, allValues: object) 491 | 492 | #### field.isValid() 493 | 494 | Returns `true` or `false` based upon the validity. 495 | 496 | #### field.setError(error: string) 497 | 498 | Sets field error. 499 | 500 | #### field.getError() 501 | 502 | Returns field error. 503 | Call this method after validating the field. 504 | 505 | #### field.isDirty() 506 | 507 | Returns `true` if value of a field is changed else returns `false`. 508 | 509 | #### field.makePristine() 510 | 511 | Marks a field to be untouched. 512 | It sets current value as initial value. 513 | 514 | #### field.reset() 515 | 516 | It resets the field. 517 | Sets initial value as current value. 518 | 519 | #### field.setAndValidate(value: any) 520 | 521 | Sets and validates a field. It internally calls `Field.setData()` and `Field.validate()`. 522 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | // rollup.config.js 2 | import typescript from "@rollup/plugin-typescript"; 3 | 4 | export default [ 5 | { 6 | input: "src/index.ts", 7 | output: [ 8 | { 9 | file: "lib/index.mjs", 10 | format: "es", 11 | sourcemap: false, 12 | }, 13 | { 14 | file: "lib/index.umd.js", 15 | name: "Powerform", 16 | format: "umd", 17 | sourcemap: false, 18 | }, 19 | ], 20 | plugins: [ 21 | typescript({ 22 | tsconfig: "tsconfig.esm.json", 23 | sourceMap: false, 24 | }), 25 | ], 26 | }, 27 | ]; 28 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import { Form, Validator, Context, str, num } from "./index"; 2 | 3 | function nonEmpty(val: string): string | undefined { 4 | if (val === "") return "This field is required" 5 | return undefined 6 | } 7 | 8 | function equals(fieldName: string): Validator { 9 | return (val: T, ctx?: Context) => { 10 | if (ctx !== undefined) { 11 | if (val != ctx.all[fieldName]) { 12 | return `Must be equal to "${fieldName}"`; 13 | } 14 | return undefined; 15 | } 16 | return undefined; 17 | }; 18 | } 19 | 20 | function capitalize(val: string) { 21 | if (val === "") return val; 22 | return val.replace(/(?:^|\s)\S/g, (s) => s.toUpperCase()); 23 | } 24 | 25 | function signupForm() { 26 | return new Form({ 27 | username: str(nonEmpty), 28 | name: str(nonEmpty).addModifier(capitalize), 29 | password: str(nonEmpty), 30 | confirmPassword: str(nonEmpty, equals("password")), 31 | }); 32 | } 33 | 34 | describe("field.constructor()", () => { 35 | it("sets the decoder and validators", () => { 36 | function isApple(val: string) { 37 | if (val !== "apple") return "Expected an apple"; 38 | return undefined; 39 | } 40 | const fruitField = str(isApple); 41 | fruitField.setAndValidate("banana"); 42 | expect(fruitField.error).toEqual("Expected an apple"); 43 | expect(fruitField.value).toEqual("banana"); 44 | }); 45 | }); 46 | 47 | describe("field.setValue", () => { 48 | it("sets current value", () => { 49 | const value = "apple"; 50 | const field = str(); 51 | field.setValue(value); 52 | 53 | expect(field.value).toEqual(value); 54 | }); 55 | 56 | it("calls addModifier and sets value returned by it", () => { 57 | const field = str().addModifier(capitalize); 58 | field.setValue("red apple"); 59 | expect(field.value).toEqual("Red Apple"); 60 | }); 61 | 62 | it("calls onChange callback if exists", () => { 63 | const spy = jest.fn(); 64 | const fruit = str().onChange(spy); 65 | const value = "apple"; 66 | fruit.setValue(value); 67 | expect(spy.mock.calls[0][0]).toEqual(value); 68 | }); 69 | 70 | it("won't call onChange if value has not changed", () => { 71 | const spy = jest.fn(); 72 | const fruit = str().onChange(spy); 73 | const value = "apple"; 74 | fruit.setValue(value); 75 | expect(spy.mock.calls.length).toEqual(1); 76 | 77 | fruit.setValue(value); 78 | expect(spy.mock.calls.length).toEqual(1); 79 | }); 80 | 81 | it("won't call onChange callback if 'skipTrigger' is true", () => { 82 | const spy = jest.fn(); 83 | const fruit = str().onChange(spy); 84 | const value = "apple"; 85 | fruit.setValue(value, true); 86 | expect(spy.mock.calls.length).toEqual(0); 87 | }); 88 | }); 89 | 90 | describe("field.getValue()", () => { 91 | it("returns current value", () => { 92 | const value = "apple"; 93 | const fruit = str(); 94 | fruit.setValue(value); 95 | 96 | expect(fruit.value).toEqual(value); 97 | }); 98 | }); 99 | 100 | describe("field.validate()", () => { 101 | it("returns true on positive validation", () => { 102 | const { fields } = new Form({ 103 | fruit: str(), 104 | }); 105 | fields.fruit.setValue("apple"); 106 | 107 | expect(fields.fruit.validate()).toEqual(true); 108 | }); 109 | 110 | it("returns false on negative validation", () => { 111 | const { fields } = new Form({ 112 | fruit: str(), 113 | }); 114 | fields.fruit.setValue(1); 115 | 116 | expect(fields.fruit.validate()).toEqual(false); 117 | }); 118 | 119 | it("sets error", () => { 120 | const { fields } = new Form({ 121 | fruit: str(), 122 | }); 123 | fields.fruit.setValue(1); 124 | fields.fruit.validate(); 125 | 126 | expect(fields.fruit.error).toEqual("Expected a string, got 1"); 127 | }); 128 | 129 | it("can validate in relation to other form fields if exists", () => { 130 | const { fields } = new Form({ 131 | password: str(), 132 | confirmPassword: str(equals("password")), 133 | }); 134 | 135 | fields.password.setValue("apple"); 136 | fields.confirmPassword.setValue("banana"); 137 | fields.confirmPassword.validate(); 138 | expect(fields.confirmPassword.error).toEqual(`Must be equal to "password"`); 139 | }); 140 | }); 141 | 142 | describe("field.isValid()", () => { 143 | const schema = { fruit: str() }; 144 | 145 | it("returns true on positive validation", () => { 146 | const { fields } = new Form(schema); 147 | fields.fruit.setValue("apple"); 148 | 149 | expect(fields.fruit.isValid()).toEqual(true); 150 | }); 151 | 152 | it("returns false on negative validation", () => { 153 | const { fields } = new Form(schema); 154 | fields.fruit.setValue(1); 155 | 156 | expect(fields.fruit.isValid()).toEqual(false); 157 | }); 158 | 159 | it("wont set error", () => { 160 | const { fields } = new Form(schema); 161 | fields.fruit.setValue(1); 162 | 163 | expect(fields.fruit.isValid()).toEqual(false); 164 | expect(fields.fruit.error).toEqual(""); 165 | }); 166 | }); 167 | 168 | describe("field.setError()", () => { 169 | it("sets error", () => { 170 | const schema = { fruit: str() }; 171 | const { fields } = new Form(schema); 172 | const errMsg = "Nice error !!!"; 173 | fields.fruit.setError(errMsg); 174 | expect(fields.fruit.error).toEqual(errMsg); 175 | }); 176 | 177 | it("calls onError callback if exists", () => { 178 | const spy = jest.fn(); 179 | const schema = { 180 | fruit: str().onError(spy), 181 | }; 182 | const { fields } = new Form(schema); 183 | const errMsg = "Nice error !!!"; 184 | fields.fruit.setError(errMsg); 185 | expect(spy.mock.calls.length).toEqual(1); 186 | }); 187 | 188 | it("wont call onError callback if 'skipError' is true", () => { 189 | const spy = jest.fn(); 190 | const schema = { 191 | fruit: str().onError(spy), 192 | }; 193 | const { fields } = new Form(schema); 194 | const errMsg = "Nice error !!!"; 195 | fields.fruit.setError(errMsg, true); 196 | expect(spy.mock.calls.length).toEqual(0); 197 | }); 198 | }); 199 | 200 | describe("field.getError()", () => { 201 | it("returns error", () => { 202 | const { fields } = new Form({ fruit: str() }); 203 | const errMsg = "Nice error !!!"; 204 | fields.fruit.setError(errMsg); 205 | expect(fields.fruit.error).toEqual(errMsg); 206 | }); 207 | }); 208 | 209 | describe("field.isDirty()", () => { 210 | it("returns true for dirty field", () => { 211 | const { fields } = new Form({ fruit: str() }); 212 | fields.fruit.setValue("apple"); 213 | expect(fields.fruit.isDirty()).toEqual(true); 214 | }); 215 | 216 | it("returns false for non dirty field", () => { 217 | const { fields } = new Form({ fruit: str() }); 218 | expect(fields.fruit.isDirty()).toEqual(false); 219 | }); 220 | }); 221 | 222 | describe("field.makePristine()", () => { 223 | it("sets previousValue and initialValue to currentValue", () => { 224 | const { fields } = new Form({ fruit: str() }); 225 | fields.fruit.setValue("apple"); 226 | expect(fields.fruit.isDirty()).toEqual(true); 227 | 228 | fields.fruit.makePristine(); 229 | expect(fields.fruit.isDirty()).toEqual(false); 230 | }); 231 | 232 | it("empties error", () => { 233 | const { fields } = new Form({ fruit: str(nonEmpty) }); 234 | fields.fruit.validate(); 235 | expect(fields.fruit.error).toEqual("This field is required"); 236 | 237 | fields.fruit.makePristine(); 238 | expect(fields.fruit.error).toEqual(""); 239 | }); 240 | }); 241 | 242 | describe("field.reset()", () => { 243 | it("sets currentValue and previousValue to initialValue", () => { 244 | const { fields } = new Form({ fruit: str() }).initValue({ fruit: "apple" }); 245 | fields.fruit.setValue("banana"); 246 | expect(fields.fruit.value).toEqual("banana"); 247 | 248 | fields.fruit.reset(); 249 | expect(fields.fruit.value).toEqual("apple"); 250 | }); 251 | 252 | it("calls onChange callback", () => { 253 | const spy = jest.fn(); 254 | const { fields } = new Form({ 255 | fruit: str().onChange(spy), 256 | }); 257 | fields.fruit.setValue("banana"); 258 | expect(fields.fruit.value).toEqual("banana"); 259 | 260 | fields.fruit.reset(); 261 | expect(spy.mock.calls[1][0]).toEqual(""); 262 | }); 263 | 264 | it("empties error", () => { 265 | const { fields } = new Form({ fruit: str(nonEmpty) }); 266 | fields.fruit.validate(); 267 | expect(fields.fruit.error).toEqual("This field is required"); 268 | 269 | fields.fruit.reset(); 270 | expect(fields.fruit.error).toEqual(""); 271 | }); 272 | }); 273 | 274 | describe("field.setAndValidate()", () => { 275 | it("sets and validates field", () => { 276 | const { fields } = new Form({ fruit: str(nonEmpty) }); 277 | const error = fields.fruit.setAndValidate(""); 278 | expect(error).toEqual("This field is required"); 279 | }); 280 | }); 281 | 282 | describe("powerform", () => { 283 | it("returns form instance", () => { 284 | const form = signupForm(); 285 | expect(form instanceof Form).toEqual(true); 286 | }); 287 | 288 | it("attaches self to each field", () => { 289 | const form = signupForm(); 290 | const { fields } = form; 291 | expect(fields.username.form).toBe(form); 292 | expect(fields.password.form).toBe(form); 293 | expect(fields.confirmPassword.form).toBe(form); 294 | }); 295 | 296 | it("attaches field name to each field", () => { 297 | const form = signupForm(); 298 | const { fields } = form; 299 | expect(fields.username.fieldName).toEqual("username"); 300 | expect(fields.password.fieldName).toEqual("password"); 301 | expect(fields.confirmPassword.fieldName).toEqual("confirmPassword"); 302 | }); 303 | }); 304 | 305 | describe("form.validate", () => { 306 | it("returns true if all the fields are valid", () => { 307 | const form = signupForm(); 308 | const data = { 309 | username: "ausername", 310 | name: "a name", 311 | password: "apassword", 312 | confirmPassword: "apassword", 313 | }; 314 | form.setValue(data); 315 | expect(form.validate()).toEqual(true); 316 | }); 317 | 318 | it("returns false if any of the field is invalid", () => { 319 | const form = signupForm(); 320 | const data = { 321 | username: "ausername", 322 | name: "a name", 323 | password: "apassword", 324 | confirmPassword: "", 325 | }; 326 | form.setValue(data); 327 | expect(form.validate()).toEqual(false); 328 | }); 329 | 330 | it("sets error", () => { 331 | const form = signupForm(); 332 | form.validate(); 333 | expect(form.error).toEqual({ 334 | confirmPassword: "This field is required", 335 | name: "This field is required", 336 | password: "This field is required", 337 | username: "This field is required", 338 | }); 339 | }); 340 | 341 | it("calls onError callback", () => { 342 | const spy = jest.fn(); 343 | const form = signupForm().onError(spy); 344 | form.validate(); 345 | 346 | expect(spy.mock.calls.length).toEqual(1); 347 | }); 348 | 349 | it("respects config.stopOnError", () => { 350 | const schema = { 351 | username: str(nonEmpty), 352 | name: str(nonEmpty), 353 | password: str(nonEmpty), 354 | }; 355 | const config = { stopOnError: true }; 356 | const form = new Form(schema, config); 357 | const { fields } = form; 358 | fields.username.setValue("a username"); 359 | expect(form.validate()).toEqual(false); 360 | expect(fields.username.error).toEqual(""); 361 | expect(fields.name.error).toEqual("This field is required"); 362 | expect(fields.password.error).toEqual(""); 363 | }); 364 | }); 365 | 366 | describe("form.isValid", () => { 367 | it("returns true if all the fields are valid", () => { 368 | const form = signupForm(); 369 | const data = { 370 | username: "ausername", 371 | name: "a name", 372 | password: "apassword", 373 | confirmPassword: "apassword", 374 | }; 375 | form.setValue(data); 376 | expect(form.isValid()).toEqual(true); 377 | }); 378 | 379 | it("returns false if any of the field is invalid", () => { 380 | const form = signupForm(); 381 | const data = { 382 | username: "ausername", 383 | name: "a name", 384 | password: "apassword", 385 | confirmPassword: "", 386 | }; 387 | form.setValue(data); 388 | expect(form.isValid()).toEqual(false); 389 | }); 390 | 391 | it("won't set error", () => { 392 | const form = signupForm(); 393 | form.isValid(); 394 | expect(form.error).toEqual({ 395 | confirmPassword: "", 396 | name: "", 397 | password: "", 398 | username: "", 399 | }); 400 | }); 401 | 402 | it("won't call onError callback", () => { 403 | const spy = jest.fn(); 404 | const form = signupForm().onError(spy); 405 | form.isValid(); 406 | 407 | expect(spy.mock.calls.length).toEqual(0); 408 | }); 409 | }); 410 | 411 | describe("form.setData", () => { 412 | it("sets data of each field", () => { 413 | const form = new Form({ price: num() }); 414 | const data = { price: 1 }; 415 | form.setValue(data); 416 | 417 | expect(form.fields.price.value).toEqual(data.price); 418 | }); 419 | 420 | it("wont trigger update event from fields", () => { 421 | const spy = jest.fn(); 422 | const form = signupForm().onChange(spy); 423 | const data = { 424 | username: "ausername", 425 | name: "A Name", 426 | password: "apassword", 427 | confirmPassword: "apassword", 428 | }; 429 | form.setValue(data); 430 | 431 | expect(spy.mock.calls.length).toEqual(1); 432 | expect(spy.mock.calls[0][0]).toEqual(data); 433 | }); 434 | }); 435 | 436 | describe("form.getUpdates", () => { 437 | it("returns key value pair of updated fields and their value", () => { 438 | const form = signupForm(); 439 | form.fields.username.setValue("ausername"); 440 | form.fields.password.setValue("apassword"); 441 | 442 | const expected = { 443 | username: "ausername", 444 | password: "apassword", 445 | }; 446 | expect(form.getUpdates()).toEqual(expected); 447 | }); 448 | }); 449 | 450 | describe("form.setError", () => { 451 | it("sets error on each field", () => { 452 | const form = signupForm(); 453 | const errors = { 454 | name: "", 455 | username: "a error", 456 | password: "a error", 457 | confirmPassword: "", 458 | }; 459 | 460 | form.setError(errors); 461 | 462 | expect(form.fields.username.error).toEqual(errors.username); 463 | expect(form.fields.password.error).toEqual(errors.password); 464 | }); 465 | 466 | it("calls onError callback only once", () => { 467 | const spy = jest.fn(); 468 | const form = signupForm().onError(spy); 469 | const errors = { 470 | name: "", 471 | username: "a error", 472 | password: "a error", 473 | confirmPassword: "", 474 | }; 475 | form.setError(errors); 476 | 477 | expect(spy.mock.calls.length).toEqual(1); 478 | expect(spy.mock.calls[0]).toEqual([errors]); 479 | }); 480 | }); 481 | 482 | describe("form.getError", () => { 483 | it("returns errors from every fields", () => { 484 | const form = signupForm(); 485 | form.fields.username.setError("a error"); 486 | form.fields.password.setError("a error"); 487 | 488 | const expected = { 489 | username: "a error", 490 | name: "", 491 | password: "a error", 492 | confirmPassword: "", 493 | }; 494 | expect(form.error).toEqual(expected); 495 | }); 496 | }); 497 | 498 | describe("form.isDirty", () => { 499 | it("returns true if any field's data has changed", () => { 500 | const form = signupForm(); 501 | form.fields.username.setValue("ausername"); 502 | expect(form.isDirty()).toEqual(true); 503 | }); 504 | 505 | it("returns false if non of the field's data has changed", () => { 506 | const form = signupForm(); 507 | expect(form.isDirty()).toEqual(false); 508 | }); 509 | }); 510 | 511 | describe("form.makePristine", () => { 512 | it("makes all the fields prestine", () => { 513 | const form = signupForm(); 514 | const data = { 515 | name: "", 516 | username: "ausername", 517 | password: "apassword", 518 | confirmPassword: "password confirmation", 519 | }; 520 | form.setValue(data); 521 | expect(form.isDirty()).toEqual(true); 522 | form.makePristine(); 523 | expect(form.isDirty()).toEqual(false); 524 | }); 525 | 526 | it("empties all the error fields and calls onError callback only once", () => { 527 | const spy = jest.fn(); 528 | const form = signupForm().onError(spy); 529 | form.setValue({ 530 | name: "", 531 | password: "", 532 | username: "ausername", 533 | confirmPassword: "", 534 | }); 535 | form.validate(); // first call 536 | expect(form.isDirty()).toEqual(true); 537 | expect(form.error).toEqual({ 538 | confirmPassword: "This field is required", 539 | name: "This field is required", 540 | password: "This field is required", 541 | username: "", 542 | }); 543 | 544 | form.makePristine(); // second call 545 | expect(form.isDirty()).toEqual(false); 546 | expect(form.error).toEqual({ 547 | confirmPassword: "", 548 | name: "", 549 | password: "", 550 | username: "", 551 | }); 552 | expect(spy.mock.calls.length).toEqual(2); 553 | }); 554 | }); 555 | 556 | describe("form.reset", () => { 557 | it("resets all the fields and calls onChange callback only once", () => { 558 | const spy = jest.fn(); 559 | const form = signupForm().onChange(spy); 560 | const data = { 561 | username: "ausername", 562 | name: "a name", 563 | password: "apassword", 564 | confirmPassword: "password confirmation", 565 | }; 566 | form.setValue(data); // first trigger 567 | form.reset(); // second trigger 568 | 569 | const expected = { 570 | username: "", 571 | name: "", 572 | password: "", 573 | confirmPassword: "", 574 | }; 575 | expect(form.raw).toEqual(expected); 576 | expect(spy.mock.calls.length).toEqual(2); 577 | }); 578 | 579 | it("resets all the errors and calls onError callback only once", () => { 580 | const spy = jest.fn(); 581 | const form = signupForm().onError(spy); 582 | form.validate(); // 1st trigger 583 | form.reset(); // 2nd triggter 584 | 585 | const expected = { 586 | username: "", 587 | name: "", 588 | password: "", 589 | confirmPassword: "", 590 | }; 591 | expect(form.error).toEqual(expected); 592 | expect(spy.mock.calls.length).toEqual(2); 593 | }); 594 | }); 595 | 596 | describe("form.triggerOnChange", () => { 597 | it("calls callback with value", () => { 598 | const spy = jest.fn(); 599 | const form = signupForm().onChange(spy); 600 | const data = { 601 | username: "ausername", 602 | password: "", 603 | name: "", 604 | confirmPassword: "", 605 | }; 606 | form.setValue(data); 607 | form.triggerOnChange(); 608 | expect(spy.mock.calls.length).toEqual(2); 609 | expect(spy.mock.calls[1]).toEqual([data]); 610 | }); 611 | 612 | it("won't call onChange callback if 'getNotified' is false", () => { 613 | const spy = jest.fn(); 614 | const form = signupForm().onChange(spy); 615 | form.setValue({ 616 | username: "ausername", 617 | password: "", 618 | name: "", 619 | confirmPassword: "", 620 | }); 621 | form.toggleGetNotified(); 622 | form.triggerOnChange(); 623 | expect(spy.mock.calls.length).toEqual(1); 624 | }); 625 | }); 626 | 627 | describe("form.triggerOnError", () => { 628 | it("calls callback with value and form instance", () => { 629 | const spy = jest.fn(); 630 | const form = signupForm().onError(spy); 631 | const errors = { 632 | username: "an error", 633 | name: "", 634 | password: "", 635 | confirmPassword: "", 636 | }; 637 | form.setError(errors); 638 | form.triggerOnError(); 639 | expect(spy.mock.calls.length).toEqual(2); 640 | expect(spy.mock.calls[1]).toEqual([errors]); 641 | }); 642 | 643 | it("won't call onError callback if 'getNotified' is false", () => { 644 | const spy = jest.fn(); 645 | const form = signupForm().onError(spy); 646 | form.validate(); 647 | form.toggleGetNotified(); 648 | form.triggerOnError(); 649 | expect(spy.mock.calls.length).toEqual(1); 650 | }); 651 | }); 652 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | class DecodeError extends Error {} 2 | 3 | type Optional = T | undefined; 4 | function optional(decoder: Decoder) { 5 | return (val: string): [Optional, Error] => { 6 | if (val === "") return [undefined, ""]; 7 | return decoder(val); 8 | }; 9 | } 10 | 11 | type Error = string; 12 | type Decoder = (val: string) => [T, Error]; 13 | type ChangeHandler = (val: T) => void; 14 | type Modifer = (val: string, preVal: string) => string; 15 | type ErrorHandler = (error: string) => void; 16 | export class Field { 17 | changeHandler?: ChangeHandler; 18 | modifier?: Modifer; 19 | errorHandler?: ErrorHandler; 20 | fieldName = ""; 21 | form?: any; 22 | private _error = ""; 23 | 24 | // html input field value is always string no matter 25 | // what its type is, type is only for UI 26 | private initialValue = '""'; 27 | private previousValue = '""'; 28 | private currentValue = '""'; 29 | 30 | private validators: Validator>[]; 31 | 32 | constructor( 33 | private decoder: Decoder, 34 | ...validators: Validator>[] 35 | ) { 36 | this.validators = validators; 37 | } 38 | 39 | optional() { 40 | const optionalDecoder = optional(this.decoder); 41 | return new Field>( 42 | optionalDecoder, 43 | ...(this.validators as Validator>[]) 44 | ); 45 | } 46 | 47 | // sets initial values 48 | initValue(val: any) { 49 | val = typeof val === "string" ? val : JSON.stringify(val); 50 | this.setValue(val, true); 51 | this.makePristine(); 52 | return this; 53 | } 54 | 55 | addModifier(i: Modifer) { 56 | this.modifier = i; 57 | return this; 58 | } 59 | 60 | onError(i: ErrorHandler) { 61 | this.errorHandler = i; 62 | return this; 63 | } 64 | 65 | onChange(c: ChangeHandler) { 66 | this.changeHandler = c; 67 | return this; 68 | } 69 | 70 | triggerOnError() { 71 | const callback = this.errorHandler; 72 | callback && callback(this.error); 73 | 74 | if (this.form) this.form.triggerOnError(); 75 | } 76 | 77 | triggerOnChange() { 78 | const callback = this.changeHandler; 79 | callback && callback(this.raw); 80 | this.form && this.form.triggerOnChange(); 81 | } 82 | 83 | setValue(val: any, skipTrigger?: boolean) { 84 | const strVal = JSON.stringify(val); 85 | if (this.currentValue === strVal) return; 86 | this.previousValue = this.currentValue; 87 | // input handlers should deal with actual value 88 | // not a strigified version 89 | this.currentValue = JSON.stringify( 90 | this.modifier ? this.modifier(val, this.previousValue) : val 91 | ); 92 | 93 | if (skipTrigger) return; 94 | this.triggerOnChange(); 95 | } 96 | 97 | get raw(): T { 98 | return JSON.parse(this.currentValue); 99 | } 100 | 101 | get value(): T { 102 | const [val, err] = this.decoder(JSON.parse(this.currentValue)); 103 | if (err !== "") throw new DecodeError(`Invalid value at ${this.fieldName}`); 104 | return val; 105 | } 106 | 107 | _validate(): string | undefined { 108 | const [parsedVal, err] = this.decoder(JSON.parse(this.currentValue)); 109 | if (err !== "") { 110 | return err; 111 | } 112 | if (parsedVal === undefined) return; 113 | const [preValue] = this.decoder(this.previousValue); 114 | if (preValue === undefined) return; 115 | 116 | for (const v of this.validators) { 117 | const err = v(parsedVal as NoUndefined, { 118 | prevValue: preValue as NoUndefined, 119 | fieldName: this.fieldName, 120 | // optimise this step 121 | all: this.form ? this.form.raw : {}, 122 | }); 123 | if (err != undefined) { 124 | return err; 125 | } 126 | } 127 | return undefined; 128 | } 129 | 130 | validate(): boolean { 131 | const err = this._validate(); 132 | if (err === undefined) { 133 | this.setError(""); 134 | return true; 135 | } 136 | 137 | this.setError(err); 138 | return false; 139 | } 140 | 141 | isValid(): boolean { 142 | const err = this._validate(); 143 | return !err; 144 | } 145 | 146 | setError(error: string, skipTrigger?: boolean) { 147 | if (this._error === error) return; 148 | this._error = error; 149 | 150 | if (skipTrigger) return; 151 | this.triggerOnError(); 152 | } 153 | 154 | get error(): string { 155 | return this._error; 156 | } 157 | 158 | isDirty() { 159 | return this.previousValue !== this.currentValue; 160 | } 161 | 162 | makePristine() { 163 | this.initialValue = this.previousValue = this.currentValue; 164 | this.setError(""); 165 | } 166 | 167 | reset() { 168 | this.setValue(JSON.parse(this.initialValue)); 169 | this.makePristine(); 170 | } 171 | 172 | setAndValidate(value: T) { 173 | this.setValue(value); 174 | this.validate(); 175 | return this.error; 176 | } 177 | } 178 | 179 | type Schema = { 180 | [K in keyof T]: Field; 181 | }; 182 | type Values = { 183 | [K in keyof T]: T[K]; 184 | }; 185 | 186 | export const defaultConfig = { 187 | multipleErrors: false, 188 | stopOnError: false, 189 | }; 190 | type FormConfig = { 191 | multipleErrors?: boolean; 192 | stopOnError?: boolean; 193 | }; 194 | 195 | type FormErrorHandler = (errors: Errors) => void; 196 | type FormChangeHandler = (values: Values) => void; 197 | export class Form { 198 | getNotified = true; 199 | errorHandler?: FormErrorHandler; 200 | changeHandler?: FormChangeHandler; 201 | 202 | constructor( 203 | public fields: Schema, 204 | private config: FormConfig = defaultConfig 205 | ) { 206 | for (const fieldName in fields) { 207 | fields[fieldName].form = this; 208 | fields[fieldName].fieldName = fieldName; 209 | } 210 | } 211 | 212 | initValue(values: Values) { 213 | for (const fieldName in this.fields) { 214 | this.fields[fieldName].initValue(values[fieldName]); 215 | } 216 | return this; 217 | } 218 | 219 | onError(handler: FormErrorHandler) { 220 | this.errorHandler = handler; 221 | return this; 222 | } 223 | 224 | onChange(handler: FormChangeHandler) { 225 | this.changeHandler = handler; 226 | return this; 227 | } 228 | 229 | toggleGetNotified() { 230 | this.getNotified = !this.getNotified; 231 | } 232 | 233 | setValue(data: T, skipTrigger?: boolean) { 234 | this.toggleGetNotified(); 235 | let prop: keyof typeof data; 236 | for (prop in data) { 237 | this.fields[prop].setValue(data[prop], skipTrigger); 238 | } 239 | this.toggleGetNotified(); 240 | if (skipTrigger) return; 241 | this.triggerOnChange(); 242 | } 243 | 244 | triggerOnChange(): void { 245 | const callback = this.changeHandler; 246 | this.getNotified && callback && callback(this.raw); 247 | } 248 | 249 | triggerOnError(): void { 250 | const callback = this.errorHandler; 251 | this.getNotified && callback && callback(this.error); 252 | } 253 | 254 | get value(): T { 255 | const data = {} as Values; 256 | let fieldName: keyof Values; 257 | for (fieldName in this.fields) { 258 | data[fieldName] = this.fields[fieldName].value; 259 | } 260 | return data; 261 | } 262 | 263 | get raw(): Values { 264 | const data = {} as Values; 265 | let fieldName: keyof Values; 266 | for (fieldName in this.fields) { 267 | data[fieldName] = this.fields[fieldName].raw; 268 | } 269 | return data; 270 | } 271 | 272 | getUpdates(): T { 273 | const data = {} as T; 274 | let fieldName: keyof Values; 275 | for (fieldName in this.fields) { 276 | if (this.fields[fieldName].isDirty()) { 277 | data[fieldName] = this.fields[fieldName].value; 278 | } 279 | } 280 | return data; 281 | } 282 | 283 | setError(errors: Errors, skipTrigger?: boolean) { 284 | this.toggleGetNotified(); 285 | let prop: keyof typeof errors; 286 | for (prop in errors) { 287 | this.fields[prop].setError(errors[prop], skipTrigger); 288 | } 289 | this.toggleGetNotified(); 290 | 291 | if (skipTrigger) return; 292 | this.triggerOnError(); 293 | } 294 | 295 | get error(): Errors { 296 | const errors = {} as Errors; 297 | let fieldName: keyof Values; 298 | for (fieldName in this.fields) { 299 | errors[fieldName] = this.fields[fieldName].error; 300 | } 301 | return errors; 302 | } 303 | 304 | isDirty(): boolean { 305 | let fieldName: keyof Values; 306 | for (fieldName in this.fields) { 307 | if (this.fields[fieldName].isDirty()) return true; 308 | } 309 | return false; 310 | } 311 | 312 | makePristine() { 313 | this.toggleGetNotified(); 314 | let fieldName: keyof Values; 315 | for (fieldName in this.fields) { 316 | this.fields[fieldName].makePristine(); 317 | } 318 | this.toggleGetNotified(); 319 | this.triggerOnError(); 320 | } 321 | 322 | reset() { 323 | this.toggleGetNotified(); 324 | let fieldName: keyof Values; 325 | for (fieldName in this.fields) { 326 | this.fields[fieldName].reset(); 327 | } 328 | this.toggleGetNotified(); 329 | this.triggerOnError(); 330 | this.triggerOnChange(); 331 | } 332 | 333 | _validate(skipAttachError: boolean) { 334 | let status = true; 335 | this.toggleGetNotified(); 336 | 337 | let fieldName: keyof Values; 338 | for (fieldName in this.fields) { 339 | let validity: boolean; 340 | if (skipAttachError) { 341 | validity = this.fields[fieldName].isValid(); 342 | } else { 343 | validity = this.fields[fieldName].validate(); 344 | } 345 | if (!validity && this.config.stopOnError) { 346 | status = false; 347 | break; 348 | } 349 | status = validity && status; 350 | } 351 | 352 | this.toggleGetNotified(); 353 | return status; 354 | } 355 | 356 | validate() { 357 | const validity = this._validate(false); 358 | this.triggerOnError(); 359 | return validity; 360 | } 361 | 362 | isValid() { 363 | return this._validate(true); 364 | } 365 | } 366 | 367 | type Errors = { 368 | [K in keyof T]: string; 369 | }; 370 | 371 | export type Config = { 372 | onChange?: (data: T, form: Form) => void; 373 | onError?: (error: Errors, form: Form) => void; 374 | multipleErrors: boolean; 375 | stopOnError: boolean; 376 | }; 377 | 378 | export type Context = { 379 | prevValue: T; 380 | fieldName: string; 381 | all: Record; 382 | }; 383 | 384 | type NoUndefined = T extends undefined ? never : T; 385 | export type Validator = (val: T, ctx?: Context) => string | undefined; 386 | 387 | export function strDecoder(val: string): [string, Error] { 388 | if (typeof val !== "string") return ["", `Expected a string, got ${val}`]; 389 | return [val, ""]; 390 | } 391 | 392 | export function numDecoder(val: string): [number, Error] { 393 | if (val === "") return [NaN, "This field is required"]; 394 | try { 395 | return [JSON.parse(val), ""]; 396 | } catch (e) { 397 | return [NaN, `Expected a number, got ${val}`]; 398 | } 399 | } 400 | 401 | export function boolDecoder(val: string): [boolean, Error] { 402 | if (val === "") return [false, "This field is required"]; 403 | try { 404 | return [JSON.parse(val), ""]; 405 | } catch (e) { 406 | return [false, `Expected a boolean, got ${val}`]; 407 | } 408 | } 409 | 410 | export function str(...validators: Validator[]) { 411 | return new Field(strDecoder, ...validators); 412 | } 413 | 414 | export function num(...validators: Validator[]) { 415 | return new Field(numDecoder, ...validators); 416 | } 417 | 418 | export function bool(...validators: Validator[]) { 419 | return new Field(boolDecoder, ...validators); 420 | } 421 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["es5", "es6", "es7", "esnext", "dom"], 4 | "target": "es2018", 5 | "removeComments": false, 6 | "esModuleInterop": true, 7 | "moduleResolution": "node", 8 | "resolveJsonModule": true, 9 | "strict": true, 10 | "skipLibCheck": true, 11 | "strictPropertyInitialization": false, 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": true, 14 | "noImplicitReturns": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "downlevelIteration": true, 17 | "isolatedModules": true 18 | }, 19 | "include": ["src"], 20 | "exclude": ["node_modules", "**/*.test.ts"] 21 | } 22 | -------------------------------------------------------------------------------- /tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "outDir": "lib", 6 | "declaration": true, 7 | "declarationMap": false, 8 | "sourceMap": false 9 | }, 10 | "exclude": ["./src/**/__tests__"] 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.esm.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "compilerOptions": { 4 | "module": "es2015", 5 | "declaration": false, 6 | "declarationMap": false, 7 | "sourceMap": false 8 | }, 9 | "exclude": ["./src/**/__tests__"] 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json" 3 | } 4 | --------------------------------------------------------------------------------