├── .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 |
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 | [](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 |
--------------------------------------------------------------------------------