├── .nvmrc ├── src ├── index.ts ├── validators │ └── index.ts ├── core │ ├── context │ │ ├── index.ts │ │ └── form-provider.tsx │ ├── hooks │ │ ├── use-field │ │ │ ├── index.ts │ │ │ └── use-field.ts │ │ ├── use-form-errors │ │ │ ├── index.ts │ │ │ └── use-form-errors.ts │ │ ├── use-form-handle │ │ │ ├── index.ts │ │ │ └── use-form-handle.ts │ │ ├── use-form-values │ │ │ ├── index.ts │ │ │ └── use-form-values.ts │ │ ├── use-form-controller │ │ │ ├── index.ts │ │ │ ├── use-form-controller.tsx │ │ │ ├── formts-dispatch.ts │ │ │ └── formts-methods.ts │ │ └── index.ts │ ├── builders │ │ ├── schema │ │ │ ├── form-fields.ts │ │ │ ├── index.ts │ │ │ ├── form-schema-builder.ts │ │ │ └── create-schema.ts │ │ ├── validator │ │ │ ├── index.ts │ │ │ └── field-validator.ts │ │ └── index.ts │ ├── atoms │ │ ├── index.ts │ │ ├── form-errors-atom.ts │ │ ├── form-atoms.ts │ │ ├── field-deps-atoms.ts │ │ ├── form-handle-atom.ts │ │ └── field-state-atoms.ts │ ├── decoders │ │ ├── index.ts │ │ ├── bool.ts │ │ ├── array.ts │ │ ├── string.ts │ │ ├── date.ts │ │ ├── number.ts │ │ ├── choice.ts │ │ ├── object.ts │ │ ├── date.spec.ts │ │ ├── bool.spec.ts │ │ ├── string.spec.ts │ │ ├── choice.spec.ts │ │ ├── number.spec.ts │ │ ├── array.spec.ts │ │ └── object.spec.ts │ ├── types │ │ ├── field-error.ts │ │ ├── field-handle-schema.ts │ │ ├── form-controller.ts │ │ ├── form-schema.ts │ │ ├── formts-options.ts │ │ ├── formts-context.ts │ │ ├── type-mapper-util.ts │ │ ├── form-validator.ts │ │ ├── field-decoder.ts │ │ ├── formts-state.ts │ │ ├── form-handle.spec.ts │ │ ├── field-template.ts │ │ ├── form-handle.ts │ │ ├── field-decoder.spec.ts │ │ ├── formts-state.spec.ts │ │ ├── field-descriptor.ts │ │ ├── field-handle.ts │ │ ├── field-template.spec.ts │ │ ├── form-schema.spec.ts │ │ └── field-descriptor.spec.ts │ ├── helpers │ │ ├── index.ts │ │ ├── resolve-touched.ts │ │ ├── resolve-is-valid.ts │ │ ├── resolve-is-validating.ts │ │ ├── create-initial-values.ts │ │ ├── branch-values.ts │ │ ├── make-touched-values.ts │ │ ├── resolve-touched.spec.ts │ │ ├── make-validation-handlers.ts │ │ ├── make-validation-handlers.spec.ts │ │ ├── field-matcher.ts │ │ ├── decode-change-event.ts │ │ ├── resolve-is-valid.spec.ts │ │ ├── resolve-is-validating.spec.ts │ │ ├── create-initial-values.spec.ts │ │ ├── make-touched-values.spec.ts │ │ └── branch-values.spec.ts │ └── index.ts └── utils │ ├── index.ts │ ├── logger.ts │ ├── misc-utils.spec.ts │ ├── misc-utils.ts │ ├── use-subscription.ts │ ├── array.ts │ ├── utility-types.ts │ ├── lenses.ts │ ├── atoms.ts │ ├── object.ts │ └── task.ts ├── .prettierignore ├── scripts ├── postpack.sh ├── prepack.sh └── postbuild.sh ├── jest.config.js ├── .gitignore ├── docs ├── .nojekyll └── assets │ └── highlight.css ├── .commitlintrc.json ├── .prettierrc.json ├── .eslintrc ├── tsdx.config.js ├── LICENSE ├── tsconfig.json ├── .circleci └── config.yml └── package.json /.nvmrc: -------------------------------------------------------------------------------- 1 | v18.16.1 2 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./core"; 2 | -------------------------------------------------------------------------------- /src/validators/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./validators"; 2 | -------------------------------------------------------------------------------- /src/core/context/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./form-provider"; 2 | -------------------------------------------------------------------------------- /src/core/hooks/use-field/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./use-field"; 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | *.log 4 | .vscode 5 | .idea 6 | -------------------------------------------------------------------------------- /src/core/hooks/use-form-errors/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./use-form-errors"; 2 | -------------------------------------------------------------------------------- /src/core/hooks/use-form-handle/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./use-form-handle"; 2 | -------------------------------------------------------------------------------- /src/core/hooks/use-form-values/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./use-form-values"; 2 | -------------------------------------------------------------------------------- /src/core/hooks/use-form-controller/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./use-form-controller"; 2 | -------------------------------------------------------------------------------- /src/core/builders/schema/form-fields.ts: -------------------------------------------------------------------------------- 1 | export * as FormFields from "../../decoders"; 2 | -------------------------------------------------------------------------------- /scripts/postpack.sh: -------------------------------------------------------------------------------- 1 | # --- cleanup directories created by prepack.sh 2 | 3 | rm -rf ./__src 4 | -------------------------------------------------------------------------------- /src/core/builders/validator/index.ts: -------------------------------------------------------------------------------- 1 | export { FormValidatorBuilder } from "./form-validator-builder"; 2 | -------------------------------------------------------------------------------- /src/core/atoms/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./form-atoms"; 2 | export { FieldStateAtom } from "./field-state-atoms"; 3 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: "ts-jest", 3 | testEnvironment: "node", 4 | rootDir: "src", 5 | }; 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | node_modules_ 3 | dist 4 | __dist 5 | __src 6 | *.log 7 | .vscode 8 | .idea 9 | .DS_Store 10 | /validators 11 | -------------------------------------------------------------------------------- /src/core/builders/index.ts: -------------------------------------------------------------------------------- 1 | export { FormFields, FormSchemaBuilder } from "./schema"; 2 | export { FormValidatorBuilder } from "./validator"; 3 | -------------------------------------------------------------------------------- /src/core/builders/schema/index.ts: -------------------------------------------------------------------------------- 1 | export { FormFields } from "./form-fields"; 2 | export { FormSchemaBuilder } from "./form-schema-builder"; 3 | -------------------------------------------------------------------------------- /scripts/prepack.sh: -------------------------------------------------------------------------------- 1 | # --- copy src into __src to signal that it is not intended for imports 2 | 3 | rm -rf ./__src 4 | 5 | cp -r ./src ./__src 6 | 7 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./utility-types"; 2 | export * from "./misc-utils"; 3 | export * from "./object"; 4 | export * from "./logger"; 5 | -------------------------------------------------------------------------------- /docs/.nojekyll: -------------------------------------------------------------------------------- 1 | TypeDoc added this file to prevent GitHub Pages from using Jekyll. You can turn off this behavior by setting the `githubPages` option to false. -------------------------------------------------------------------------------- /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/commitlintrc", 3 | "extends": ["@commitlint/config-conventional"], 4 | "rules": { 5 | "header-max-length": [2, "always", 200] 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/core/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./use-field"; 2 | export * from "./use-form-controller"; 3 | export * from "./use-form-handle"; 4 | export * from "./use-form-values"; 5 | export * from "./use-form-errors"; 6 | -------------------------------------------------------------------------------- /src/core/decoders/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./array"; 2 | export * from "./bool"; 3 | export * from "./choice"; 4 | export * from "./date"; 5 | export * from "./number"; 6 | export * from "./object"; 7 | export * from "./string"; 8 | -------------------------------------------------------------------------------- /src/core/types/field-error.ts: -------------------------------------------------------------------------------- 1 | export type FieldError = { 2 | /** 3 | * ID string that enables connecting the error to a specific field. 4 | * Use `FieldMatcher` helper class to compare it against FieldDescriptor objects from your form schema. 5 | */ 6 | fieldId: string; 7 | error: Err; 8 | }; 9 | -------------------------------------------------------------------------------- /src/core/types/field-handle-schema.ts: -------------------------------------------------------------------------------- 1 | import { FieldHandle } from "./field-handle"; 2 | 3 | /** 4 | * Tree of field handles used to interact with all fields of the form 5 | */ 6 | export type FieldHandleSchema = { 7 | readonly [K in keyof Values]: FieldHandle; 8 | }; 9 | -------------------------------------------------------------------------------- /src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | const warn = (message?: unknown, ...params: unknown[]) => { 2 | // TODO: is that the way to check for DEV consumer side? 3 | if (process.env.NODE_ENV === "development") { 4 | // eslint-disable-next-line no-console 5 | console.warn(message, ...params); 6 | } 7 | }; 8 | 9 | export const logger = { warn }; 10 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/prettierrc", 3 | "printWidth": 80, 4 | "tabWidth": 2, 5 | "useTabs": false, 6 | "semi": true, 7 | "singleQuote": false, 8 | "jsxSingleQuote": false, 9 | "trailingComma": "es5", 10 | "bracketSpacing": true, 11 | "jsxBracketSameLine": false, 12 | "arrowParens": "avoid", 13 | "proseWrap": "always" 14 | } 15 | -------------------------------------------------------------------------------- /src/core/helpers/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./branch-values"; 2 | export * from "./create-initial-values"; 3 | export * from "./decode-change-event"; 4 | export * from "./field-matcher"; 5 | export * from "./make-touched-values"; 6 | export * from "./make-validation-handlers"; 7 | export * from "./resolve-is-valid"; 8 | export * from "./resolve-is-validating"; 9 | export * from "./resolve-touched"; 10 | -------------------------------------------------------------------------------- /src/core/types/form-controller.ts: -------------------------------------------------------------------------------- 1 | import { Nominal } from "../../utils"; 2 | 3 | import { InternalFormtsContext } from "./formts-context"; 4 | 5 | /** 6 | * Used to connect together hooks or FormProvider component with the actual form state 7 | */ 8 | export interface FormController extends Nominal<"FormControl"> {} 9 | 10 | export type _FormControllerImpl = { 11 | __ctx: InternalFormtsContext; 12 | }; 13 | -------------------------------------------------------------------------------- /scripts/postbuild.sh: -------------------------------------------------------------------------------- 1 | # --- 1. rename dist into __dist to signal that it is not intended for imports 2 | rm -rf ./__dist 3 | 4 | mv ./dist ./__dist 5 | 6 | # --- 2. prepare root validators dir to enable imports: 'import {} from "@virtuslab/formts/validators"' 7 | 8 | mkdir ./validators 9 | 10 | # 'dist/esm/index2' is a chunk created for 'src/validators/index.ts' 11 | echo 'export * from "../__dist/esm/index2";' > ./validators/index.js 12 | 13 | echo 'export * from "../__dist/validators";' > ./validators/index.d.ts -------------------------------------------------------------------------------- /src/utils/misc-utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { range } from "./misc-utils"; 2 | 3 | describe("range", () => { 4 | [ 5 | { start: 0, end: 5, result: [0, 1, 2, 3, 4, 5] }, 6 | { start: 5, end: 0, result: [5, 4, 3, 2, 1, 0] }, 7 | { start: -1, end: 1, result: [-1, 0, 1] }, 8 | { start: 0, end: 0, result: [0] }, 9 | { start: 10, end: 10, result: [10] }, 10 | { start: -10, end: -10, result: [-10] }, 11 | ].forEach(({ start, end, result }) => 12 | it(`range(${start}, ${end}) == ${result}`, () => { 13 | expect(range(start, end)).toEqual(result); 14 | }) 15 | ); 16 | }); 17 | -------------------------------------------------------------------------------- /src/utils/misc-utils.ts: -------------------------------------------------------------------------------- 1 | export const assertNever = (_it: never): never => { 2 | throw new Error("Illegal state"); 3 | }; 4 | 5 | /** 6 | * get array of consecutive integers in given range (inclusive) 7 | */ 8 | export const range = (start: number, end: number): number[] => { 9 | const step = end > start ? 1 : -1; 10 | const result = [start]; 11 | 12 | for (let i = start; i !== end; i = i + step) { 13 | result.push(i + step); 14 | } 15 | 16 | return result; 17 | }; 18 | 19 | export const isValidDate = (val: unknown): val is Date => 20 | val instanceof Date && !Number.isNaN(val.valueOf()); 21 | -------------------------------------------------------------------------------- /src/core/atoms/form-errors-atom.ts: -------------------------------------------------------------------------------- 1 | import { Atom } from "../../utils/atoms"; 2 | import { FormtsAtomState } from "../types/formts-state"; 3 | 4 | export type FormErrorsAtom = Atom.Readonly< 5 | Array<{ 6 | fieldId: string; 7 | error: Err; 8 | }> 9 | >; 10 | 11 | export const createFormErrorsAtom = ( 12 | state: FormtsAtomState 13 | ): FormErrorsAtom => 14 | Atom.fuse( 15 | errorsDict => 16 | Object.entries(errorsDict) 17 | .filter(([, error]) => error != null) 18 | .map(([fieldId, error]) => ({ fieldId, error: error! })), 19 | 20 | state.errors 21 | ); 22 | -------------------------------------------------------------------------------- /src/utils/use-subscription.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { Atom } from "./atoms"; 4 | 5 | type Observable = Atom | Atom.Readonly; 6 | 7 | /** 8 | * rerender component on changes to observable 9 | */ 10 | export const useSubscription = (observable: Observable) => { 11 | const update = useForceUpdate(); 12 | 13 | React.useEffect(() => { 14 | const sub = observable.subscribe(update); 15 | return () => observable.unsubscribe(sub); 16 | }, [observable, update]); 17 | }; 18 | 19 | const useForceUpdate = () => { 20 | const [, set] = React.useState({}); 21 | 22 | return React.useCallback(() => { 23 | set({}); 24 | }, [set]); 25 | }; 26 | -------------------------------------------------------------------------------- /src/utils/array.ts: -------------------------------------------------------------------------------- 1 | export const flatMap = (list: T[], map: (x: T) => P[]): P[] => { 2 | return list.reduce((acc, x) => acc.concat(map(x)), [] as P[]); 3 | }; 4 | 5 | export const uniqBy = ( 6 | list: T[], 7 | key: (x: T) => K 8 | ): T[] => { 9 | const usedKeys = {} as Record; 10 | 11 | return list.filter(x => { 12 | const xKey = key(x); 13 | if (!!usedKeys[xKey]) { 14 | return false; 15 | } else { 16 | usedKeys[xKey] = true; 17 | return true; 18 | } 19 | }); 20 | }; 21 | 22 | export const compact = (list: Array): T[] => 23 | list.filter(it => it != null) as T[]; 24 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": ["@typescript-eslint", "import"], 5 | "extends": ["prettier", "prettier/@typescript-eslint"], 6 | "rules": { 7 | "no-console": "warn", 8 | 9 | "import/no-default-export": "warn", 10 | "import/newline-after-import": "warn", 11 | "import/order": [ 12 | "warn", 13 | { 14 | "newlines-between": "always", 15 | "alphabetize": { "order": "asc" }, 16 | "groups": [ 17 | "builtin", 18 | "external", 19 | "internal", 20 | "parent", 21 | "sibling", 22 | "index" 23 | ] 24 | } 25 | ] 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/core/helpers/resolve-touched.ts: -------------------------------------------------------------------------------- 1 | import { values } from "../../utils"; 2 | import { TouchedValues } from "../types/formts-state"; 3 | 4 | export const resolveTouched = (it: unknown): boolean => { 5 | if (isBool(it)) { 6 | return it === true; 7 | } 8 | if (isArray(it)) { 9 | return it.some(resolveTouched); 10 | } 11 | if (isRecord(it)) { 12 | return values(it).some(resolveTouched); 13 | } 14 | return false; 15 | }; 16 | 17 | const isBool = (it: unknown): it is boolean => typeof it === "boolean"; 18 | 19 | const isArray = Array.isArray; 20 | 21 | const isRecord = (it: unknown): it is Record> => 22 | !!(it && typeof it === "object" && !Array.isArray(it)); 23 | -------------------------------------------------------------------------------- /src/core/types/form-schema.ts: -------------------------------------------------------------------------------- 1 | import { Nominal } from "../../utils"; 2 | 3 | import { GenericFieldDescriptor } from "./field-descriptor"; 4 | 5 | /** 6 | * Description of a form. 7 | * Used to interact with Formts API and point to specific form fields. 8 | * Created using FormSchemaBuilder class. 9 | */ 10 | export type FormSchema = Nominal<"FormSchema"> & 11 | { 12 | readonly [K in keyof Values]: GenericFieldDescriptor; 13 | }; 14 | 15 | /** 16 | * Helper type for inferring type of form values as received by FormHandle.submit function 17 | * 18 | * @example 19 | * ```ts 20 | * type Values = ExtractFormValues 21 | * ``` 22 | */ 23 | export type ExtractFormValues = Schema extends FormSchema 24 | ? V 25 | : never; 26 | -------------------------------------------------------------------------------- /src/core/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | FormFields, 3 | FormSchemaBuilder, 4 | FormValidatorBuilder, 5 | } from "./builders"; 6 | 7 | export { 8 | useFormController, 9 | useFormHandle, 10 | useFormValues, 11 | useFormErrors, 12 | useField, 13 | } from "./hooks"; 14 | 15 | export { FormProvider } from "./context"; 16 | 17 | export { FieldMatcher } from "./helpers"; 18 | 19 | export { FormtsOptions } from "./types/formts-options"; 20 | export { FieldDecoder } from "./types/field-decoder"; 21 | export { FieldDescriptor } from "./types/field-descriptor"; 22 | export { FieldHandle } from "./types/field-handle"; 23 | export { FormSchema, ExtractFormValues } from "./types/form-schema"; 24 | export { FormValidator, Validator } from "./types/form-validator"; 25 | export { FormController } from "./types/form-controller"; 26 | -------------------------------------------------------------------------------- /src/core/helpers/resolve-is-valid.ts: -------------------------------------------------------------------------------- 1 | import { entries } from "../../utils"; 2 | import { 3 | FieldDescriptor, 4 | isPrimitiveDescriptor, 5 | } from "../types/field-descriptor"; 6 | import { FieldErrors } from "../types/formts-state"; 7 | import { impl } from "../types/type-mapper-util"; 8 | 9 | export const resolveIsValid = ( 10 | errors: FieldErrors, 11 | field: FieldDescriptor 12 | ): boolean => { 13 | const path = impl(field).__path; 14 | 15 | if (errors[path] != null) { 16 | return false; 17 | } 18 | 19 | if (isPrimitiveDescriptor(field)) { 20 | return errors[path] == null; 21 | } 22 | 23 | return not( 24 | entries(errors).some( 25 | ([errorPath, error]) => error != null && errorPath.startsWith(path) 26 | ) 27 | ); 28 | }; 29 | 30 | const not = (bool: boolean) => !bool; 31 | -------------------------------------------------------------------------------- /src/core/helpers/resolve-is-validating.ts: -------------------------------------------------------------------------------- 1 | import { entries } from "../../utils"; 2 | import { 3 | FieldDescriptor, 4 | isPrimitiveDescriptor, 5 | } from "../types/field-descriptor"; 6 | import { FieldValidatingState } from "../types/formts-state"; 7 | import { impl } from "../types/type-mapper-util"; 8 | 9 | export const resolveIsValidating = ( 10 | validatingState: FieldValidatingState, 11 | field: FieldDescriptor 12 | ): boolean => { 13 | const path = impl(field).__path; 14 | 15 | if (validatingState[path] != null) { 16 | return true; 17 | } 18 | 19 | if (isPrimitiveDescriptor(field)) { 20 | return validatingState[path] != null; 21 | } 22 | 23 | return entries(validatingState).some( 24 | ([validatingFieldPath, validations]) => 25 | validations != null && validatingFieldPath.startsWith(path) 26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /src/core/helpers/create-initial-values.ts: -------------------------------------------------------------------------------- 1 | import { deepMerge, DeepPartial, entries } from "../../utils"; 2 | import { _FieldDescriptorImpl } from "../types/field-descriptor"; 3 | import { FormSchema } from "../types/form-schema"; 4 | import { InitialValues } from "../types/formts-state"; 5 | import { impl } from "../types/type-mapper-util"; 6 | 7 | export const createInitialValues = ( 8 | schema: FormSchema, 9 | initial?: InitialValues 10 | ): Values => { 11 | const initialStateFromDecoders = entries(schema).reduce( 12 | (shape, [key, descriptor]) => { 13 | shape[key] = impl(descriptor).__decoder.init() as Values[keyof Values]; 14 | return shape; 15 | }, 16 | {} as Values 17 | ); 18 | 19 | return initial 20 | ? deepMerge( 21 | initialStateFromDecoders, 22 | (initial as unknown) as DeepPartial 23 | ) 24 | : initialStateFromDecoders; 25 | }; 26 | -------------------------------------------------------------------------------- /src/core/types/formts-options.ts: -------------------------------------------------------------------------------- 1 | import { FormSchema } from "./form-schema"; 2 | import { FormValidator } from "./form-validator"; 3 | import { InitialValues } from "./formts-state"; 4 | 5 | export type FormtsOptions = { 6 | /** Definition of form fields created using `FormSchemaBuilder`. */ 7 | Schema: FormSchema; 8 | 9 | /** 10 | * Values used to override the defaults when filling the form 11 | * after the component is mounted or after form reset (optional). 12 | * The defaults depend on field type (defined in the Schema). 13 | * Snapshot of the initialValues will be taken when mounting the component 14 | * and further changes to it will be ignored. If you want to change 15 | * initialValues use FormHandle.reset method. 16 | */ 17 | initialValues?: InitialValues; 18 | 19 | /** Form validator created using `FormValidatorBuilder` (optional). */ 20 | validator?: FormValidator; 21 | }; 22 | -------------------------------------------------------------------------------- /tsdx.config.js: -------------------------------------------------------------------------------- 1 | // see: https://github.com/formium/tsdx#rollup 2 | 3 | // TODO: track https://github.com/formium/tsdx/issues/961 for possible better solution 4 | 5 | const path = require("path"); 6 | 7 | const relativePath = p => path.join(__dirname, p); 8 | 9 | module.exports = { 10 | rollup(config, options) { 11 | if (options.format === "esm") { 12 | // we use this to output separate chunk for /src/validators 13 | // see: https://stackoverflow.com/a/65173887 14 | return { 15 | ...config, 16 | input: [ 17 | relativePath("src/index.ts"), 18 | relativePath("src/validators/index.ts"), 19 | ], 20 | output: { 21 | ...config.output, 22 | file: undefined, 23 | dir: relativePath("dist/esm"), 24 | preserveModules: true, 25 | preserveModulesRoot: relativePath("src"), 26 | }, 27 | }; 28 | } else { 29 | return config; 30 | } 31 | }, 32 | }; 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 VirtusLab 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/core/helpers/branch-values.ts: -------------------------------------------------------------------------------- 1 | import { filter as OFilter } from "../../utils"; 2 | import { FieldDescriptor } from "../types/field-descriptor"; 3 | import { FieldErrors, FieldValidatingState } from "../types/formts-state"; 4 | 5 | import { FieldMatcher } from "./field-matcher"; 6 | 7 | export const constructBranchErrorsString = ( 8 | errors: FieldErrors, 9 | field: FieldDescriptor 10 | ): string => { 11 | const branchErrors = OFilter(errors, ({ key }) => 12 | isExactOrChildPath(field)(key) 13 | ); 14 | 15 | return JSON.stringify(branchErrors); 16 | }; 17 | 18 | export const constructBranchValidatingString = ( 19 | validating: FieldValidatingState, 20 | field: FieldDescriptor 21 | ): string => { 22 | const branchValidating = OFilter(validating, ({ key }) => 23 | isExactOrChildPath(field)(key) 24 | ); 25 | 26 | return JSON.stringify(branchValidating); 27 | }; 28 | 29 | export const isExactOrChildPath = (field: FieldDescriptor) => ( 30 | path: string 31 | ): boolean => { 32 | const fieldMatcher = new FieldMatcher(field); 33 | return fieldMatcher.matches(path) || fieldMatcher.isParentOf(path); 34 | }; 35 | -------------------------------------------------------------------------------- /src/core/atoms/form-atoms.ts: -------------------------------------------------------------------------------- 1 | import { FormSchema } from "../types/form-schema"; 2 | import { FormtsAtomState } from "../types/formts-state"; 3 | 4 | import { FieldDependenciesAtomCache } from "./field-deps-atoms"; 5 | import { FieldStateAtomCache } from "./field-state-atoms"; 6 | import { FormErrorsAtom, createFormErrorsAtom } from "./form-errors-atom"; 7 | import { FormHandleAtom, createFormHandleAtom } from "./form-handle-atom"; 8 | 9 | export class FormAtoms { 10 | public readonly formHandle: FormHandleAtom; 11 | public readonly formErrors: FormErrorsAtom; 12 | public readonly fieldStates: FieldStateAtomCache; 13 | public readonly fieldDependencies: FieldDependenciesAtomCache; 14 | 15 | constructor( 16 | private readonly state: FormtsAtomState, 17 | private readonly Schema: FormSchema 18 | ) { 19 | this.formHandle = createFormHandleAtom(this.state, this.Schema); 20 | this.formErrors = createFormErrorsAtom(this.state); 21 | this.fieldStates = new FieldStateAtomCache(this.state); 22 | this.fieldDependencies = new FieldDependenciesAtomCache(this.state); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/core/decoders/bool.ts: -------------------------------------------------------------------------------- 1 | import { FieldDecoder, _FieldDecoderImpl } from "../types/field-decoder"; 2 | import { opaque } from "../types/type-mapper-util"; 3 | 4 | /** 5 | * Define field of type `boolean`. 6 | * Default initial value will be `false`. 7 | * Accepts boolean values and string "true" | "false". 8 | * 9 | * @example 10 | * ``` 11 | * const Schema = new FormSchemaBuilder() 12 | * .fields({ 13 | * x: FormFields.bool() // x: boolean 14 | * }) 15 | * .build() 16 | * ``` 17 | */ 18 | export const bool = (): FieldDecoder => { 19 | const decoder: _FieldDecoderImpl = { 20 | fieldType: "bool", 21 | 22 | init: () => false, 23 | 24 | decode: value => { 25 | switch (typeof value) { 26 | case "boolean": 27 | return { ok: true, value }; 28 | 29 | case "string": { 30 | switch (value.toLowerCase().trim()) { 31 | case "true": 32 | return { ok: true, value: true }; 33 | case "false": 34 | return { ok: true, value: false }; 35 | default: 36 | return { ok: false }; 37 | } 38 | } 39 | 40 | default: 41 | return { ok: false }; 42 | } 43 | }, 44 | }; 45 | 46 | return opaque(decoder); 47 | }; 48 | -------------------------------------------------------------------------------- /src/core/helpers/make-touched-values.ts: -------------------------------------------------------------------------------- 1 | import { entries, isPlainObject } from "../../utils"; 2 | import { TouchedValues } from "../types/formts-state"; 3 | 4 | export const makeUntouchedValues = (value: T): TouchedValues => 5 | transformToBoolObject(value, false); 6 | 7 | export const makeTouchedValues = (value: T): TouchedValues => 8 | transformToBoolObject(value, true); 9 | 10 | const transformToBoolObject = ( 11 | value: T, 12 | bool: boolean 13 | ): TouchedValues => { 14 | if ( 15 | typeof value === "string" || 16 | typeof value === "number" || 17 | typeof value === "boolean" 18 | ) { 19 | // a bit of premature optimization 20 | return bool as TouchedValues; 21 | } 22 | 23 | if (Array.isArray(value)) { 24 | if (value.length === 0 && bool === true) { 25 | // special case to mark empty array as touched 26 | return bool as TouchedValues; 27 | } 28 | 29 | return (value.map(v => 30 | transformToBoolObject(v, bool) 31 | ) as unknown) as TouchedValues; 32 | } 33 | 34 | if (isPlainObject(value)) { 35 | return entries(value).reduce((obj, [key, value]) => { 36 | (obj as any)[key] = transformToBoolObject(value, bool); 37 | return obj; 38 | }, {} as TouchedValues); 39 | } 40 | 41 | return bool as TouchedValues; 42 | }; 43 | -------------------------------------------------------------------------------- /src/core/context/form-provider.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { FormController } from "../types/form-controller"; 4 | import { InternalFormtsContext } from "../types/formts-context"; 5 | import { impl } from "../types/type-mapper-util"; 6 | 7 | const Context = React.createContext< 8 | InternalFormtsContext | undefined 9 | >(undefined); 10 | 11 | type FormProviderProps = { 12 | controller: FormController; 13 | children: React.ReactNode; 14 | }; 15 | 16 | /** 17 | * Enables usage of formts hooks in nested components 18 | * 19 | * @example 20 | * ```tsx 21 | * const controller = useFormController({ ... }); 22 | * 23 | * 24 | * ... 25 | * 26 | * ``` 27 | */ 28 | export const FormProvider = ({ controller, children }: FormProviderProps) => ( 29 | {children} 30 | ); 31 | 32 | // internal use only 33 | export const useFormtsContext = ( 34 | controller: FormController | undefined 35 | ): InternalFormtsContext => { 36 | const ctx = React.useContext(Context); 37 | if (ctx != null) return ctx; 38 | 39 | if (!controller) { 40 | throw new Error("FormController not found!"); 41 | } 42 | 43 | return impl(controller).__ctx; 44 | }; 45 | -------------------------------------------------------------------------------- /src/core/decoders/array.ts: -------------------------------------------------------------------------------- 1 | import { FieldDecoder, _FieldDecoderImpl } from "../types/field-decoder"; 2 | import { impl, opaque } from "../types/type-mapper-util"; 3 | 4 | /** 5 | * Define array field with elements of type defined by provided `innerDecoder`. 6 | * Default initial value will be `[]` 7 | * Accepts empty arrays and arrays containing elements which are valid in respect to rules imposed by `innerDecoder`. 8 | * 9 | * @example 10 | * ``` 11 | * const Schema = new FormSchemaBuilder() 12 | * .fields({ 13 | * x: FormFields.array(FormFields.string()) // x: string[] 14 | * }) 15 | * .build() 16 | * ``` 17 | */ 18 | export const array = ( 19 | innerDecoder: FieldDecoder 20 | ): FieldDecoder> => { 21 | const decoder: _FieldDecoderImpl> = { 22 | fieldType: "array", 23 | inner: impl(innerDecoder), 24 | 25 | init: () => [], 26 | 27 | decode: value => { 28 | if (value && Array.isArray(value)) { 29 | const decodeResults = value.map(impl(innerDecoder).decode); 30 | if (decodeResults.every(result => result.ok)) { 31 | return { 32 | ok: true, 33 | value: decodeResults.map(result => result.value as E), 34 | }; 35 | } 36 | } 37 | return { ok: false }; 38 | }, 39 | }; 40 | 41 | return opaque(decoder); 42 | }; 43 | -------------------------------------------------------------------------------- /src/core/hooks/use-form-values/use-form-values.ts: -------------------------------------------------------------------------------- 1 | import { useSubscription } from "../../../utils/use-subscription"; 2 | import { useFormtsContext } from "../../context"; 3 | import { FormController } from "../../types/form-controller"; 4 | import { FormSchema } from "../../types/form-schema"; 5 | 6 | /** 7 | * Hook used to gain access to values of all form fields. 8 | * Causes the component to subscribe to changes of all field values. 9 | * 10 | * @param Schema - created using `FormSchemaBuilder`, needed for type inference. 11 | * @param controller - obtained by using `useFormController` hook, used to connect to form state. 12 | * Injected automatically via React Context when used inside `FormProvider` component. 13 | * 14 | * @returns object containing values of all fields. It's shape is determined by `FormSchema` 15 | * 16 | * @example 17 | * ```ts 18 | * const Schema = new FormSchemaBuilder()...; 19 | * 20 | * const MyForm: React.FC = () => { 21 | * const controller = useFormController({ Schema }) 22 | * const values = useFormValues(Schema, controller) 23 | * 24 | * ... 25 | * } 26 | * ``` 27 | */ 28 | export const useFormValues = ( 29 | _Schema: FormSchema, 30 | controller?: FormController 31 | ): Values => { 32 | const { state } = useFormtsContext(controller); 33 | useSubscription(state.values); 34 | return state.values.val; 35 | }; 36 | -------------------------------------------------------------------------------- /src/core/decoders/string.ts: -------------------------------------------------------------------------------- 1 | import { isValidDate } from "../../utils"; 2 | import { FieldDecoder, _FieldDecoderImpl } from "../types/field-decoder"; 3 | import { opaque } from "../types/type-mapper-util"; 4 | 5 | /** 6 | * Define field of type `string`. 7 | * Default initial value will be `""`. 8 | * Accepts strings, numbers, booleans and valid Date instances (which are serialized using toISOString method). 9 | * 10 | * @example 11 | * ``` 12 | * const Schema = new FormSchemaBuilder() 13 | * .fields({ 14 | * x: FormFields.string() // x: string 15 | * }) 16 | * .build() 17 | * ``` 18 | */ 19 | export const string = (): FieldDecoder => { 20 | const decoder: _FieldDecoderImpl = { 21 | fieldType: "string", 22 | 23 | init: () => "", 24 | 25 | decode: value => { 26 | switch (typeof value) { 27 | case "string": 28 | return { ok: true, value }; 29 | 30 | case "number": 31 | return Number.isFinite(value) 32 | ? { ok: true, value: value.toString() } 33 | : { ok: false }; 34 | 35 | case "boolean": 36 | return { ok: true, value: String(value) }; 37 | 38 | case "object": 39 | return isValidDate(value) 40 | ? { ok: true, value: value.toISOString() } 41 | : { ok: false }; 42 | 43 | default: 44 | return { ok: false }; 45 | } 46 | }, 47 | }; 48 | 49 | return opaque(decoder); 50 | }; 51 | -------------------------------------------------------------------------------- /src/core/helpers/resolve-touched.spec.ts: -------------------------------------------------------------------------------- 1 | import { resolveTouched } from "./resolve-touched"; 2 | 3 | describe("resolveTouched", () => { 4 | it("handles primitive fields", () => { 5 | expect(resolveTouched(true)).toBe(true); 6 | 7 | expect(resolveTouched(false)).toBe(false); 8 | }); 9 | 10 | it("handles array fields", () => { 11 | expect(resolveTouched([false, true, false])).toBe(true); 12 | 13 | expect(resolveTouched([false, false, false])).toBe(false); 14 | }); 15 | 16 | it("handles object fields", () => { 17 | expect( 18 | resolveTouched({ 19 | a: false, 20 | b: true, 21 | c: false, 22 | }) 23 | ).toBe(true); 24 | 25 | expect( 26 | resolveTouched({ 27 | a: false, 28 | b: false, 29 | c: false, 30 | }) 31 | ).toBe(false); 32 | }); 33 | 34 | it("handles nested object and array fields", () => { 35 | expect( 36 | resolveTouched({ 37 | bool: false, 38 | arr: [false, false, false], 39 | nested: { 40 | nestedArr: [ 41 | [false, false], 42 | [false, true], 43 | ], 44 | }, 45 | }) 46 | ).toBe(true); 47 | 48 | expect( 49 | resolveTouched({ 50 | bool: false, 51 | arr: [false, false, false], 52 | nested: { 53 | nestedArr: [ 54 | [false, false], 55 | [false, false], 56 | ], 57 | }, 58 | }) 59 | ).toBe(false); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /src/core/hooks/use-form-errors/use-form-errors.ts: -------------------------------------------------------------------------------- 1 | import { useSubscription } from "../../../utils/use-subscription"; 2 | import { useFormtsContext } from "../../context"; 3 | import { FieldError } from "../../types/field-error"; 4 | import { FormController } from "../../types/form-controller"; 5 | import { FormSchema } from "../../types/form-schema"; 6 | 7 | /** 8 | * Hook used to gain access to errors of all invalid form fields. 9 | * Causes the component to subscribe to changes of all field errors. 10 | * 11 | * @param Schema - created using `FormSchemaBuilder`, needed for type inference. 12 | * @param controller - obtained by using `useFormController` hook, used to connect to form state. 13 | * Injected automatically via React Context when used inside `FormProvider` component. 14 | * 15 | * @returns array of all field errors represented as `FieldError` objects. Each object contains field ID and it's error. 16 | * 17 | * @example 18 | * ```ts 19 | * const Schema = new FormSchemaBuilder()...; 20 | * 21 | * const MyForm: React.FC = () => { 22 | * const controller = useFormController({ Schema }) 23 | * const errors = useFormErrors(Schema, controller) 24 | * 25 | * ... 26 | * } 27 | * ``` 28 | */ 29 | export const useFormErrors = ( 30 | _Schema: FormSchema, 31 | controller?: FormController 32 | ): Array> => { 33 | const { atoms } = useFormtsContext(controller); 34 | useSubscription(atoms.formErrors); 35 | return atoms.formErrors.val; 36 | }; 37 | -------------------------------------------------------------------------------- /src/core/decoders/date.ts: -------------------------------------------------------------------------------- 1 | import { isValidDate } from "../../utils"; 2 | import { 3 | FieldDecoder, 4 | _FieldDecoderBaseImpl, 5 | _FieldDecoderImpl, 6 | } from "../types/field-decoder"; 7 | import { opaque } from "../types/type-mapper-util"; 8 | 9 | /** 10 | * Define field holding an instance of JS Date or `null` 11 | * Will check that value is a valid date at runtime. 12 | * Default initial value will be `null` 13 | * Accepts valid Date instances and strings or numbers that create valid Date when passed into Date constructor. 14 | * 15 | * @example 16 | * ``` 17 | * const Schema = new FormSchemaBuilder() 18 | * .fields({ 19 | * x: FormFields.date() // x: Date | null 20 | * }) 21 | * .build() 22 | * ``` 23 | */ 24 | export const date = (): FieldDecoder => { 25 | const decoder: _FieldDecoderBaseImpl = { 26 | fieldType: "date", 27 | 28 | init: () => null, 29 | 30 | decode: value => { 31 | switch (typeof value) { 32 | case "object": 33 | return value === null || isValidDate(value) 34 | ? { ok: true, value } 35 | : { ok: false }; 36 | 37 | case "string": 38 | case "number": { 39 | const valueAsDate = new Date(value); 40 | return isValidDate(valueAsDate) 41 | ? { ok: true, value: valueAsDate } 42 | : { ok: false }; 43 | } 44 | 45 | default: 46 | return { ok: false }; 47 | } 48 | }, 49 | }; 50 | 51 | return opaque(decoder as _FieldDecoderImpl); 52 | }; 53 | -------------------------------------------------------------------------------- /src/core/atoms/field-deps-atoms.ts: -------------------------------------------------------------------------------- 1 | import { Atom } from "../../utils/atoms"; 2 | import * as Helpers from "../helpers"; 3 | import { FieldDescriptor } from "../types/field-descriptor"; 4 | import { FormtsAtomState } from "../types/formts-state"; 5 | import { impl } from "../types/type-mapper-util"; 6 | 7 | type FieldPath = string; 8 | 9 | export type FieldDependenciesAtom = Atom.Readonly<{}>; 10 | 11 | export class FieldDependenciesAtomCache { 12 | private readonly cache: Partial< 13 | Record 14 | > = {}; 15 | 16 | constructor(private readonly formtsState: FormtsAtomState) {} 17 | 18 | /** 19 | * creates new FieldDependenciesAtom or returns existing instance 20 | */ 21 | get(field: FieldDescriptor): FieldDependenciesAtom { 22 | const key = impl(field).__path; 23 | if (this.cache[key] == null) { 24 | this.cache[key] = this.createDependenciesStateAtom(field); 25 | } 26 | 27 | return this.cache[key]!; 28 | } 29 | 30 | private createDependenciesStateAtom( 31 | field: FieldDescriptor 32 | ): FieldDependenciesAtom { 33 | return Atom.fuse( 34 | (_branchErrors, _branchValidating) => { 35 | return {}; 36 | }, 37 | Atom.fuse( 38 | x => Helpers.constructBranchErrorsString(x, field), 39 | this.formtsState.errors 40 | ), 41 | Atom.fuse( 42 | x => Helpers.constructBranchValidatingString(x, field), 43 | this.formtsState.validating 44 | ) 45 | ); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/core/decoders/number.ts: -------------------------------------------------------------------------------- 1 | import { isValidDate } from "../../utils"; 2 | import { FieldDecoder, _FieldDecoderImpl } from "../types/field-decoder"; 3 | import { opaque } from "../types/type-mapper-util"; 4 | 5 | /** 6 | * Define field of type `number | ""` (empty string is needed to represent state of empty number inputs) 7 | * Default initial value will be `""`. 8 | * Accepts finite numbers, strings representing finite numbers and valid Date instances 9 | * 10 | * @example 11 | * ``` 12 | * const Schema = new FormSchemaBuilder() 13 | * .fields({ 14 | * x: FormFields.number() // x: number | "" 15 | * }) 16 | * .build() 17 | * ``` 18 | */ 19 | export const number = (): FieldDecoder => { 20 | const decoder: _FieldDecoderImpl = { 21 | fieldType: "number", 22 | 23 | init: () => "", 24 | 25 | decode: value => { 26 | switch (typeof value) { 27 | case "number": 28 | return Number.isFinite(value) ? { ok: true, value } : { ok: false }; 29 | 30 | case "string": { 31 | if (value.trim() === "") { 32 | return { ok: true, value: "" }; 33 | } 34 | 35 | const numValue = Number(value); 36 | return Number.isNaN(numValue) 37 | ? { ok: false } 38 | : { ok: true, value: numValue }; 39 | } 40 | 41 | case "object": 42 | return isValidDate(value) 43 | ? { ok: true, value: value.valueOf() } 44 | : { ok: false }; 45 | 46 | default: 47 | return { ok: false }; 48 | } 49 | }, 50 | }; 51 | 52 | return opaque(decoder); 53 | }; 54 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // see https://www.typescriptlang.org/tsconfig to better understand tsconfigs 3 | "include": ["src", "types"], 4 | "compilerOptions": { 5 | "module": "esnext", 6 | "lib": ["dom", "esnext"], 7 | "target": "ES5", 8 | "importHelpers": true, 9 | // output .d.ts declaration files for consumers 10 | "declaration": true, 11 | // output .js.map sourcemap files for consumers 12 | "sourceMap": true, 13 | // match output dir to input dir. e.g. dist/index instead of dist/src/index 14 | "rootDir": "./src", 15 | // stricter type-checking for stronger correctness. Recommended by TS 16 | "strict": true, 17 | // linter checks for common issues 18 | "noImplicitReturns": true, 19 | "noFallthroughCasesInSwitch": true, 20 | // noUnused* overlap with @typescript-eslint/no-unused-vars, can disable if duplicative 21 | "noUnusedLocals": true, 22 | "noUnusedParameters": true, 23 | // use Node's module resolution algorithm, instead of the legacy TS one 24 | "moduleResolution": "node", 25 | // transpile JSX to React.createElement 26 | "jsx": "react", 27 | // interop between ESM and CJS modules. Recommended by TS 28 | "esModuleInterop": true, 29 | // significant perf increase by skipping checking .d.ts files, particularly those in node_modules. Recommended by TS 30 | "skipLibCheck": true, 31 | // error out if import and file system have a casing mismatch. Recommended by TS 32 | "forceConsistentCasingInFileNames": true, 33 | // `tsdx build` ignores this option, but it is commonly used when type-checking separately with `tsc` 34 | "noEmit": true 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/utils/utility-types.ts: -------------------------------------------------------------------------------- 1 | export class Nominal { 2 | // @ts-ignore 3 | private __tag!: Tag; 4 | 5 | // @ts-ignore 6 | private __p1!: P1; 7 | 8 | // @ts-ignore 9 | private __p2!: P2; 10 | 11 | // @ts-ignore 12 | private __p2!: P3; 13 | } 14 | 15 | export type UnionToIntersection = ( 16 | U extends any ? (k: U) => void : never 17 | ) extends (k: infer I) => void 18 | ? I 19 | : never; 20 | 21 | export type IsUnion = [T] extends [UnionToIntersection] ? false : true; 22 | export type IsStringUnion = [T] extends [string] ? IsUnion : false; 23 | 24 | export type Constructor = new (...args: any[]) => T; 25 | 26 | export type ArrayElement = Arr extends Array ? E : never; 27 | 28 | // prettier-ignore 29 | export type DeepPartial = T extends Function 30 | ? T 31 | : T extends Array 32 | ? Array> 33 | : T extends object 34 | ? { [P in keyof T]?: DeepPartial } 35 | : T | undefined; 36 | 37 | export type IdentityDict = IsUnion extends true 38 | ? { [K in T]: K } 39 | : never; 40 | 41 | export type Falsy = null | undefined | false; 42 | 43 | export const isFalsy = (x: unknown): x is Falsy => { 44 | return x === null || x === undefined || x === false; 45 | }; 46 | 47 | export type Primitive = string | number | boolean; 48 | 49 | export type WidenType = [T] extends [string] 50 | ? string 51 | : [T] extends [number] 52 | ? number 53 | : [T] extends [boolean] 54 | ? boolean 55 | : T; 56 | 57 | export type NoInfer = [A][A extends any ? 0 : never]; 58 | 59 | export type NonEmptyArray = [T, ...T[]]; 60 | -------------------------------------------------------------------------------- /src/core/types/formts-context.ts: -------------------------------------------------------------------------------- 1 | import { ChangeEvent } from "react"; 2 | 3 | import { Task } from "../../utils/task"; 4 | import type { FormAtoms } from "../atoms"; 5 | 6 | import { FieldDescriptor } from "./field-descriptor"; 7 | import { FieldError } from "./field-error"; 8 | import { ValidationResult, ValidationTrigger } from "./form-validator"; 9 | import { FormtsOptions } from "./formts-options"; 10 | import { FormtsAtomState, InitialValues } from "./formts-state"; 11 | 12 | export type InternalFormtsMethods = { 13 | validateField: ( 14 | field: FieldDescriptor, 15 | trigger?: ValidationTrigger 16 | ) => Task; 17 | validateForm: () => Task>; 18 | setFieldValue: (field: FieldDescriptor, value: T) => Task; 19 | setFieldValueFromEvent: ( 20 | field: FieldDescriptor, 21 | event: ChangeEvent 22 | ) => Task; 23 | setFieldTouched: ( 24 | field: FieldDescriptor, 25 | touched: boolean 26 | ) => Task; 27 | setFieldErrors: (...fields: ValidationResult) => Task; 28 | resetForm: (newInitialValues?: InitialValues) => Task; 29 | resetField: (field: FieldDescriptor) => Task; 30 | submitForm: ( 31 | onSuccess: (values: Values) => Task, 32 | onFailure: (errors: Array>) => Task 33 | ) => Task; 34 | }; 35 | 36 | // internal context consumed by hooks 37 | export type InternalFormtsContext = { 38 | options: FormtsOptions; 39 | state: FormtsAtomState; 40 | methods: InternalFormtsMethods; 41 | atoms: FormAtoms; 42 | }; 43 | -------------------------------------------------------------------------------- /src/core/helpers/make-validation-handlers.ts: -------------------------------------------------------------------------------- 1 | import type { Dispatch } from "react"; 2 | 3 | import { keys } from "../../utils"; 4 | import { FormtsAction } from "../types/formts-state"; 5 | 6 | type FieldPath = string; 7 | 8 | export const makeValidationHandlers = ( 9 | dispatch: Dispatch> 10 | ) => { 11 | const uuid = generateSimpleUuid(); 12 | let pendingValidationStartFields: Record = {}; 13 | 14 | return { 15 | /** enqueues dispatch of 'validatingStart' action */ 16 | onFieldValidationStart: (fieldPath: FieldPath) => { 17 | pendingValidationStartFields[fieldPath] = true; 18 | }, 19 | 20 | /** 21 | * if run before flush (sync validation scenario) - cancels out start action and no action is dispatched 22 | * if run after flush (async validation scenario) - dispatches 'validatingStop' action 23 | */ 24 | onFieldValidationEnd: (fieldPath: FieldPath) => { 25 | if (pendingValidationStartFields[fieldPath]) { 26 | delete pendingValidationStartFields[fieldPath]; 27 | } else { 28 | dispatch({ 29 | type: "validatingStop", 30 | payload: { path: fieldPath, uuid }, 31 | }); 32 | } 33 | }, 34 | 35 | /** 36 | * Runs pending start dispatch. 37 | * Should be called after invoking validation function, but before awaiting it's finish 38 | */ 39 | flushValidationHandlers: () => { 40 | keys(pendingValidationStartFields).forEach(path => { 41 | dispatch({ 42 | type: "validatingStart", 43 | payload: { path, uuid }, 44 | }); 45 | }); 46 | pendingValidationStartFields = {}; 47 | }, 48 | }; 49 | }; 50 | 51 | const generateSimpleUuid = () => 52 | `${new Date().valueOf().toString()}#${Math.floor(Math.random() * 1000)}`; 53 | -------------------------------------------------------------------------------- /src/core/decoders/choice.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FieldDecoder, 3 | _ChoiceFieldDecoderImpl, 4 | _FieldDecoderImpl, 5 | } from "../types/field-decoder"; 6 | import { opaque } from "../types/type-mapper-util"; 7 | 8 | /** 9 | * Define field of given string literal union type. 10 | * Default initial value will be first option received. 11 | * Accepts string and number values which are present on provided options list. 12 | * 13 | * **requires at least one option to be provided** 14 | * 15 | * @example 16 | * ``` 17 | * const Schema = new FormSchemaBuilder() 18 | * .fields({ 19 | * x: FormFields.choice("A", "B", "C") // x: "A" | "B" | "C" 20 | * }) 21 | * .build() 22 | * ``` 23 | */ 24 | export const choice = ( 25 | firstOption: Opts, 26 | ...otherOptions: Opts[] 27 | ): FieldDecoder => { 28 | const options = [firstOption, ...otherOptions]; 29 | 30 | const optionsDictionary = options.reduce>( 31 | (dict, opt) => { 32 | dict[opt] = opt; 33 | return dict; 34 | }, 35 | {} 36 | ); 37 | 38 | const decoder: _ChoiceFieldDecoderImpl = { 39 | options, 40 | 41 | fieldType: "choice", 42 | 43 | init: () => firstOption, 44 | 45 | decode: value => { 46 | switch (typeof value) { 47 | case "string": { 48 | const option = optionsDictionary[value]; 49 | return option != null ? { ok: true, value: option } : { ok: false }; 50 | } 51 | case "number": { 52 | if (Number.isFinite(value)) { 53 | const option = optionsDictionary[value.toString()]; 54 | return option != null ? { ok: true, value: option } : { ok: false }; 55 | } else { 56 | return { ok: false }; 57 | } 58 | } 59 | default: 60 | return { ok: false }; 61 | } 62 | }, 63 | }; 64 | 65 | return opaque(decoder as _FieldDecoderImpl); 66 | }; 67 | -------------------------------------------------------------------------------- /src/core/types/type-mapper-util.ts: -------------------------------------------------------------------------------- 1 | import { FieldDecoder, _FieldDecoderImpl } from "./field-decoder"; 2 | import { 3 | FieldDescriptor, 4 | ArrayFieldDescriptor, 5 | ObjectFieldDescriptor, 6 | _FieldDescriptorImpl, 7 | _NTHHandler, 8 | } from "./field-descriptor"; 9 | import { FieldTemplate, _FieldTemplateImpl } from "./field-template"; 10 | import { FormController, _FormControllerImpl } from "./form-controller"; 11 | import { FormValidator, _FormValidatorImpl } from "./form-validator"; 12 | 13 | /** 14 | * expose implementation details of opaque type. 15 | * for internal use only 16 | */ 17 | export const impl: GetImplFn = (it: any) => it; 18 | type GetImplFn = { 19 | ( 20 | it: ArrayFieldDescriptor 21 | ): _FieldDescriptorImpl; 22 | 23 | , Err>( 24 | it: ObjectFieldDescriptor 25 | ): _FieldDescriptorImpl; 26 | 27 | (it: FieldDecoder): _FieldDecoderImpl; 28 | 29 | (it: FormValidator): _FormValidatorImpl< 30 | V, 31 | Err 32 | >; 33 | 34 | (it: FieldDescriptor): _FieldDescriptorImpl; 35 | ( 36 | it: FieldTemplate | FieldDescriptor 37 | ): _FieldTemplateImpl; 38 | 39 | (it: FormController): _FormControllerImpl; 40 | 41 | (it: ArrayFieldDescriptor["nth"]): _NTHHandler< 42 | T 43 | >; 44 | }; 45 | 46 | /** 47 | * hide implementation details of impl type. 48 | * for internal use only 49 | */ 50 | export const opaque: GetOpaque = (it: any) => it; 51 | type GetOpaque = { 52 | (it: _FieldDescriptorImpl): FieldDescriptor; 53 | (it: _FieldDecoderImpl): FieldDecoder; 54 | (it: _FormControllerImpl): FormController; 55 | (it: _FormValidatorImpl): FormValidator< 56 | V, 57 | Err 58 | >; 59 | }; 60 | -------------------------------------------------------------------------------- /src/core/types/form-validator.ts: -------------------------------------------------------------------------------- 1 | import { Nominal } from "../../utils"; 2 | import { Task } from "../../utils/task"; 3 | 4 | import { FieldDescriptor } from "./field-descriptor"; 5 | 6 | export type Validator = 7 | | Validator.Sync 8 | | Validator.Async; 9 | 10 | export namespace Validator { 11 | /** 12 | * Function responsible for validating single field. 13 | * 14 | * @param value - value to be validated of type `T` 15 | * 16 | * @returns validation error of type `Err`, or `null` when field is valid 17 | */ 18 | export type Sync = (value: T) => Err | null; 19 | 20 | /** 21 | * Function responsible for validating single field asynchronously. 22 | * 23 | * @param value - value to be validated of type `T` 24 | * 25 | * @returns Promise of validation error of type `Err`, or `null` when field is valid 26 | */ 27 | export type Async = (value: T) => Promise; 28 | } 29 | 30 | export type ValidationTrigger = "change" | "blur" | "submit"; 31 | 32 | export type ValidationResult = Array<{ 33 | path: FieldPath; 34 | error: Err | null; 35 | }>; 36 | 37 | export type ValidateIn = { 38 | fields: Array>; 39 | trigger?: ValidationTrigger; 40 | getValue: GetValue; 41 | onFieldValidationStart?: (fieldPath: FieldPath) => void; 42 | onFieldValidationEnd?: (fieldPath: FieldPath) => void; 43 | }; 44 | 45 | export type GetValue = { 46 |

(field: FieldDescriptor): P; 47 |

(path: string): P; 48 | }; 49 | 50 | /** 51 | * Object created via `FormValidatorBuilder`, used by passing it to `useFormController` hook. 52 | */ 53 | export interface FormValidator 54 | extends Nominal<"FormValidator", Values, Err> {} 55 | 56 | // @ts-ignore 57 | export type _FormValidatorImpl = { 58 | validate: (input: ValidateIn) => Task, unknown>; 59 | }; 60 | 61 | export type FieldPath = string; 62 | -------------------------------------------------------------------------------- /src/core/types/field-decoder.ts: -------------------------------------------------------------------------------- 1 | import { ArrayElement, IsStringUnion, Nominal } from "../../utils"; 2 | 3 | // prettier-ignore 4 | export type _FieldDecoderImpl = [T] extends [Array] 5 | ? _ArrayFieldDecoderImpl 6 | : [T] extends [object] 7 | ? _ObjectFieldDecoderImpl 8 | : IsStringUnion extends true 9 | ? _ChoiceFieldDecoderImpl 10 | : _FieldDecoderBaseImpl 11 | 12 | export type _FieldDecoderBaseImpl = { 13 | fieldType: FieldType; 14 | init: () => T; 15 | decode: (value: unknown) => DecoderResult; 16 | }; 17 | 18 | export type _ArrayFieldDecoderImpl = _FieldDecoderBaseImpl & { 19 | inner: _FieldDecoderImpl>; 20 | }; 21 | 22 | export type _ObjectFieldDecoderImpl = _FieldDecoderBaseImpl & { 23 | inner: { [K in keyof T]: _FieldDecoderImpl }; 24 | }; 25 | 26 | export type _ChoiceFieldDecoderImpl = _FieldDecoderBaseImpl & { 27 | options: T[]; 28 | }; 29 | 30 | export const isChoiceDecoder = ( 31 | it: unknown 32 | ): it is _ChoiceFieldDecoderImpl => 33 | typeof (it as any).decode === "function" && 34 | (it as any).fieldType === "choice"; 35 | 36 | export const isObjectDecoder = ( 37 | it: unknown 38 | ): it is _ObjectFieldDecoderImpl => 39 | typeof (it as any).decode === "function" && 40 | (it as any).fieldType === "object"; 41 | 42 | export const isArrayDecoder = ( 43 | it: unknown 44 | ): it is _ArrayFieldDecoderImpl => 45 | typeof (it as any).decode === "function" && (it as any).fieldType === "array"; 46 | 47 | /** 48 | * Object containing run-time type information about a field. 49 | * Should be used together with `FormSchemaBuilder` class. 50 | */ 51 | export interface FieldDecoder extends Nominal<"FieldDecoder", T> {} 52 | 53 | export type FieldType = 54 | | "number" 55 | | "string" 56 | | "choice" 57 | | "bool" 58 | | "array" 59 | | "date" 60 | | "object"; 61 | 62 | export type DecoderResult = 63 | | { ok: true; value: T } 64 | | { ok: false; value?: undefined }; 65 | -------------------------------------------------------------------------------- /src/core/types/formts-state.ts: -------------------------------------------------------------------------------- 1 | import { Atom } from "../../utils/atoms"; 2 | 3 | import { FieldDescriptor } from "./field-descriptor"; 4 | 5 | // internal state & actions 6 | export type FormtsAction = 7 | | { type: "resetForm"; payload: { newInitialValues?: InitialValues } } 8 | | { type: "resetField"; payload: { field: FieldDescriptor } } 9 | | { 10 | type: "setTouched"; 11 | payload: { field: FieldDescriptor; touched: boolean }; 12 | } 13 | | { 14 | type: "setValue"; 15 | payload: { field: FieldDescriptor; value: any }; 16 | } 17 | | { type: "setErrors"; payload: Array<{ path: string; error: Err | null }> } 18 | | { type: "validatingStart"; payload: { path: string; uuid: string } } 19 | | { type: "validatingStop"; payload: { path: string; uuid: string } } 20 | | { type: "submitStart" } 21 | | { type: "submitSuccess" } 22 | | { type: "submitFailure" }; 23 | 24 | export type FormtsAtomState = { 25 | initialValues: Atom; 26 | values: Atom; 27 | touched: Atom>; 28 | errors: Atom>; 29 | validating: Atom; 30 | isSubmitting: Atom; 31 | successfulSubmitCount: Atom; 32 | failedSubmitCount: Atom; 33 | }; 34 | 35 | export type TouchedValues = [V] extends [Array] 36 | ? Array> 37 | : [V] extends [object] 38 | ? { [P in keyof V]: TouchedValues } 39 | : boolean; 40 | 41 | /** DeepPartial, except for objects inside arrays */ 42 | // prettier-ignore 43 | export type InitialValues = T extends Function 44 | ? T 45 | : T extends Array 46 | ? T 47 | : T extends object 48 | ? { [P in keyof T]?: InitialValues } 49 | : T | undefined; 50 | 51 | type FieldPath = string; 52 | type Uuid = string; 53 | 54 | export type FieldErrors = Record; 55 | 56 | export type FieldValidatingState = Record>; 57 | -------------------------------------------------------------------------------- /src/core/atoms/form-handle-atom.ts: -------------------------------------------------------------------------------- 1 | import { deepEqual, keys, values } from "../../utils"; 2 | import { Atom } from "../../utils/atoms"; 3 | import { resolveTouched } from "../helpers"; 4 | import { FormSchema } from "../types/form-schema"; 5 | import { FormtsAtomState } from "../types/formts-state"; 6 | import { impl } from "../types/type-mapper-util"; 7 | 8 | export type FormHandleAtom = Atom.Readonly<{ 9 | isTouched: boolean; 10 | isChanged: boolean; 11 | isValid: boolean; 12 | isValidating: boolean; 13 | isSubmitting: boolean; 14 | successfulSubmitCount: number; 15 | failedSubmitCount: number; 16 | }>; 17 | 18 | export const createFormHandleAtom = ( 19 | state: FormtsAtomState, 20 | Schema: FormSchema 21 | ): FormHandleAtom => 22 | Atom.fuse( 23 | ( 24 | isTouched, 25 | isChanged, 26 | isValid, 27 | isValidating, 28 | isSubmitting, 29 | successfulSubmitCount, 30 | failedSubmitCount 31 | ) => ({ 32 | isTouched, 33 | isChanged, 34 | isValid, 35 | isValidating, 36 | isSubmitting, 37 | successfulSubmitCount, 38 | failedSubmitCount, 39 | }), 40 | 41 | Atom.fuse( 42 | (sc, fc, touched) => sc > 0 || fc > 0 || resolveTouched(touched), 43 | state.successfulSubmitCount, 44 | state.failedSubmitCount, 45 | state.touched 46 | ), 47 | Atom.fuse( 48 | (...fieldsChanged) => fieldsChanged.some(Boolean), 49 | ...values(Schema).map(field => { 50 | const fieldLens = impl(field).__lens; 51 | return Atom.fuse( 52 | (initialValue, fieldValue) => !deepEqual(fieldValue, initialValue), 53 | Atom.entangle(state.initialValues, fieldLens), 54 | Atom.entangle(state.values, fieldLens) 55 | ); 56 | }) 57 | ), 58 | Atom.fuse(x => values(x).every(err => err == null), state.errors), 59 | Atom.fuse(x => keys(x).length > 0, state.validating), 60 | state.isSubmitting, 61 | state.successfulSubmitCount, 62 | state.failedSubmitCount 63 | ); 64 | -------------------------------------------------------------------------------- /src/core/atoms/field-state-atoms.ts: -------------------------------------------------------------------------------- 1 | import { deepEqual } from "../../utils"; 2 | import { Atom } from "../../utils/atoms"; 3 | import { FieldDescriptor } from "../types/field-descriptor"; 4 | import { FormtsAtomState, TouchedValues } from "../types/formts-state"; 5 | import { impl } from "../types/type-mapper-util"; 6 | 7 | type FieldPath = string; 8 | 9 | export type FieldStateAtom = Atom.Readonly<{ 10 | value: T; 11 | changed: boolean; 12 | touched: TouchedValues; 13 | formSubmitted: boolean; 14 | }>; 15 | 16 | export class FieldStateAtomCache { 17 | private readonly cache: Partial< 18 | Record> 19 | > = {}; 20 | 21 | constructor(private readonly formtsState: FormtsAtomState) {} 22 | 23 | /** 24 | * creates new FieldStateAtom or returns existing instance 25 | */ 26 | get(field: FieldDescriptor): FieldStateAtom { 27 | const key = impl(field).__path; 28 | if (this.cache[key] == null) { 29 | const atom = this.createFieldStateAtom(field); 30 | this.cache[key] = atom as FieldStateAtom; 31 | } 32 | 33 | return this.cache[key] as FieldStateAtom; 34 | } 35 | 36 | private createFieldStateAtom( 37 | field: FieldDescriptor 38 | ): FieldStateAtom { 39 | const lens = impl(field).__lens; 40 | const fieldValueAtom = Atom.entangle(this.formtsState.values, lens); 41 | 42 | return Atom.fuse( 43 | (value, changed, touched, formSubmitted) => ({ 44 | value, 45 | changed, 46 | touched: touched as any, 47 | formSubmitted, 48 | }), 49 | fieldValueAtom, 50 | Atom.fuse( 51 | (initialValue, value) => !deepEqual(value, initialValue), 52 | Atom.entangle(this.formtsState.initialValues, lens), 53 | fieldValueAtom 54 | ), 55 | Atom.entangle(this.formtsState.touched, lens), 56 | Atom.fuse( 57 | (sc, fc) => sc + fc > 0, 58 | this.formtsState.successfulSubmitCount, 59 | this.formtsState.failedSubmitCount 60 | ) 61 | ); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/core/types/form-handle.spec.ts: -------------------------------------------------------------------------------- 1 | import { GenericFieldDescriptor } from "./field-descriptor"; 2 | import { FormHandle } from "./form-handle"; 3 | 4 | describe("FormHandle type", () => { 5 | describe(".setFieldValue", () => { 6 | it("only allows passing values assignable to type imposed by given FieldDescriptor", () => { 7 | const handle: FormHandle<{}, "ERR"> = {} as any; 8 | const field: GenericFieldDescriptor<"A" | "B", "ERR"> = {} as any; 9 | 10 | () => { 11 | handle.setFieldValue(field, "A"); 12 | 13 | handle.setFieldValue(field, "B"); 14 | 15 | // @ts-expect-error 16 | handle.setFieldValue(field, "C"); 17 | }; 18 | }); 19 | 20 | it("only works for fields with matching Err type parameter", () => { 21 | const handle: FormHandle<{}, "ERR_1"> = {} as any; 22 | const field1: GenericFieldDescriptor = {} as any; 23 | const field2: GenericFieldDescriptor = {} as any; 24 | 25 | () => { 26 | handle.setFieldValue(field1, "A"); 27 | 28 | // @ts-expect-error 29 | handle.setFieldValue(field2, "A"); 30 | }; 31 | }); 32 | }); 33 | 34 | describe(".setFieldError", () => { 35 | it("only works for fields with matching Err type parameter", () => { 36 | const handle: FormHandle<{}, "ERR_1"> = {} as any; 37 | const field1: GenericFieldDescriptor = {} as any; 38 | const field2: GenericFieldDescriptor = {} as any; 39 | 40 | () => { 41 | handle.setFieldError(field1, "ERR_1"); 42 | 43 | // @ts-expect-error 44 | handle.setFieldError(field2, "ERR_1"); 45 | }; 46 | }); 47 | 48 | it("only works for errors matching Err type parameter", () => { 49 | const handle: FormHandle<{}, "ERR_1"> = {} as any; 50 | const field: GenericFieldDescriptor = {} as any; 51 | 52 | () => { 53 | handle.setFieldError(field, "ERR_1"); 54 | 55 | // @ts-expect-error 56 | handle.setFieldError(field, "ERR_2"); 57 | }; 58 | }); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /src/core/hooks/use-form-controller/use-form-controller.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useMemo } from "react"; 2 | 3 | import { FormAtoms } from "../../atoms"; 4 | import { 5 | FormController, 6 | _FormControllerImpl, 7 | } from "../../types/form-controller"; 8 | import { InternalFormtsContext } from "../../types/formts-context"; 9 | import { FormtsOptions } from "../../types/formts-options"; 10 | import { opaque } from "../../types/type-mapper-util"; 11 | 12 | import { createStateDispatch, getInitialState } from "./formts-dispatch"; 13 | import { createFormtsMethods } from "./formts-methods"; 14 | 15 | /** 16 | * Hook that manages form state - should be used in main form component. 17 | * Does not cause the component to subscribe to any form state changes. 18 | * 19 | * @param options `FormtsOptions` used to configure form. Requires Schema created using `FormSchemaBuilder`. 20 | * 21 | * @returns `FormController` object which connects other hooks to form state. 22 | * Can be passed directly to other hooks or via `FormProvider` component. 23 | * 24 | * @example 25 | * ```ts 26 | * const Schema = new FormSchemaBuilder()...; 27 | * 28 | * const MyForm: React.FC = () => { 29 | * const controller = useFormController({ Schema }) 30 | * const formHandle = useFormHandle(Schema, controller) 31 | * 32 | * ... 33 | * } 34 | * ``` 35 | */ 36 | export const useFormController = ( 37 | options: FormtsOptions 38 | ): FormController => { 39 | const state = useMemo(() => getInitialState(options), []); 40 | const dispatch = useCallback(createStateDispatch(state, options), [state]); 41 | const atoms = useMemo(() => new FormAtoms(state, options.Schema), [state]); 42 | const methods = useMemo( 43 | () => createFormtsMethods({ options, state, dispatch }), 44 | [state, dispatch, options.validator] 45 | ); 46 | 47 | const __ctx: InternalFormtsContext = useMemo( 48 | () => ({ 49 | options, 50 | state, 51 | methods, 52 | atoms, 53 | }), 54 | [state, methods, atoms] 55 | ); 56 | 57 | return useMemo(() => opaque({ __ctx }), [__ctx]); 58 | }; 59 | -------------------------------------------------------------------------------- /src/core/builders/validator/field-validator.ts: -------------------------------------------------------------------------------- 1 | import { Falsy, NoInfer } from "../../../utils"; 2 | import { 3 | createRegexForTemplate, 4 | pathIsTemplate, 5 | } from "../../types/field-template"; 6 | import { 7 | FieldPath, 8 | ValidationTrigger, 9 | Validator, 10 | } from "../../types/form-validator"; 11 | import { impl } from "../../types/type-mapper-util"; 12 | 13 | import { 14 | FieldDescTuple, 15 | ValidateConfig, 16 | ValidationFieldPointer, 17 | } from "./form-validator-builder"; 18 | 19 | export type FieldValidator = { 20 | id: string; 21 | path: FieldPath; 22 | triggers?: Array; 23 | validators: (...deps: [...Dependencies]) => Array>; 24 | dependencies?: readonly [...FieldDescTuple]; 25 | debounce?: number; 26 | regex?: RegExp; 27 | }; 28 | 29 | export type CreateFieldValidatorFn = { 30 | ( 31 | config: ValidateConfig 32 | ): FieldValidator; 33 | 34 | ( 35 | field: ValidationFieldPointer, 36 | ...rules: Array>> 37 | ): FieldValidator; 38 | }; 39 | 40 | export const createFieldValidator: CreateFieldValidatorFn = < 41 | T, 42 | Err, 43 | Deps extends any[] 44 | >( 45 | x: ValidateConfig | ValidationFieldPointer, 46 | ...rules: Array> 47 | ): FieldValidator => { 48 | const config: ValidateConfig = 49 | (x as any)["field"] != null 50 | ? { ...(x as ValidateConfig) } 51 | : { field: x as ValidationFieldPointer, rules: () => rules }; 52 | 53 | const path = impl(config.field).__path; 54 | const regex = pathIsTemplate(path) ? createRegexForTemplate(path) : undefined; 55 | 56 | return { 57 | id: getUuid(), 58 | path, 59 | regex, 60 | triggers: config.triggers, 61 | validators: config.rules, 62 | dependencies: config.dependencies, 63 | debounce: config.debounce, 64 | }; 65 | }; 66 | 67 | const getUuid = (() => { 68 | let index = 0; 69 | return () => (index++).toString(); 70 | })(); 71 | -------------------------------------------------------------------------------- /src/core/decoders/object.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FieldDecoder, 3 | _FieldDecoderImpl, 4 | _ObjectFieldDecoderImpl, 5 | } from "../types/field-decoder"; 6 | import { impl } from "../types/type-mapper-util"; 7 | 8 | // prettier-ignore 9 | type ObjectFieldDecoderWithGuards< 10 | O extends object 11 | > = O[keyof O] extends undefined 12 | ? void 13 | : FieldDecoder 14 | 15 | /** 16 | * Define nested object field with shape defined by `innerDecoders` param. 17 | * Accepts any objects containing all specified properties which are valid in respect to rules imposed by their respective decoders. 18 | * 19 | * **Does not accept empty objects and objects with reserved 'root' property** 20 | * 21 | * @example 22 | * ``` 23 | * const Schema = new FormSchemaBuilder() 24 | * .fields({ 25 | * x: FormFields.object({ 26 | * foo: FormFields.string(), 27 | * bar: FormFields.number() 28 | * }) // x: { foo: string; bar: number | "" } 29 | * }) 30 | * .build() 31 | * ``` 32 | */ 33 | export const object = ( 34 | innerDecoders: { 35 | [K in keyof O]: FieldDecoder; 36 | } 37 | ): ObjectFieldDecoderWithGuards => { 38 | const decoder: _ObjectFieldDecoderImpl = { 39 | fieldType: "object", 40 | 41 | inner: innerDecoders as any, // meh 42 | 43 | init: () => 44 | Object.keys(innerDecoders).reduce((initialValues, key) => { 45 | const prop = key as keyof O; 46 | initialValues[prop] = impl(innerDecoders[prop]).init(); 47 | return initialValues; 48 | }, {} as O), 49 | 50 | decode: value => { 51 | if (typeof value !== "object" || value == null) { 52 | return { ok: false }; 53 | } 54 | 55 | const decodedObject = {} as O; 56 | for (let key of Object.keys(innerDecoders)) { 57 | const result = impl(innerDecoders[key as keyof O]).decode( 58 | (value as O)[key as keyof O] 59 | ); 60 | if (!result.ok) { 61 | return { ok: false }; 62 | } else { 63 | decodedObject[key as keyof O] = result.value; 64 | } 65 | } 66 | 67 | return { ok: true, value: decodedObject }; 68 | }, 69 | }; 70 | 71 | return decoder as any; // meh 72 | }; 73 | -------------------------------------------------------------------------------- /src/core/helpers/make-validation-handlers.spec.ts: -------------------------------------------------------------------------------- 1 | import { Task } from "../../utils/task"; 2 | 3 | import { makeValidationHandlers } from "./make-validation-handlers"; 4 | 5 | const enqueueEffect = (effect: () => void) => { 6 | setTimeout(effect, 0); 7 | }; 8 | 9 | describe("makeValidationHandlers", () => { 10 | it("does not dispatch any actions for sync validation flow", async () => { 11 | const dispatch = jest.fn(); 12 | 13 | const { 14 | onFieldValidationStart, 15 | onFieldValidationEnd, 16 | flushValidationHandlers, 17 | } = makeValidationHandlers(dispatch); 18 | 19 | // onFieldValidationEnd is called before flushValidationHandlers 20 | const syncValidationFlow = Task.all( 21 | Task.from(() => { 22 | onFieldValidationStart("path"); 23 | }), 24 | Task.from(() => { 25 | onFieldValidationEnd("path"); 26 | }), 27 | Task.from(() => { 28 | flushValidationHandlers(); 29 | }) 30 | ); 31 | 32 | await syncValidationFlow.runPromise(); 33 | 34 | expect(dispatch).not.toHaveBeenCalled(); 35 | }); 36 | 37 | it("dispatches validating{Start|End} actions for async validation flow", async () => { 38 | const dispatch = jest.fn(); 39 | 40 | const { 41 | onFieldValidationStart, 42 | onFieldValidationEnd, 43 | flushValidationHandlers, 44 | } = makeValidationHandlers(dispatch); 45 | 46 | // onFieldValidationEnd is called after flushValidationHandlers 47 | const asyncValidationFlow = Task.all( 48 | Task.from(() => { 49 | onFieldValidationStart("path"); 50 | }), 51 | Task.make(({ resolve }) => { 52 | enqueueEffect(() => { 53 | onFieldValidationEnd("path"); 54 | resolve(); 55 | }); 56 | }), 57 | Task.from(() => { 58 | flushValidationHandlers(); 59 | }) 60 | ); 61 | 62 | await asyncValidationFlow.runPromise(); 63 | 64 | expect(dispatch).toHaveBeenCalledTimes(2); 65 | expect(dispatch).toHaveBeenCalledWith( 66 | expect.objectContaining({ type: "validatingStart" }) 67 | ); 68 | expect(dispatch).toHaveBeenCalledWith( 69 | expect.objectContaining({ type: "validatingStop" }) 70 | ); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /src/core/decoders/date.spec.ts: -------------------------------------------------------------------------------- 1 | import { impl } from "../types/type-mapper-util"; 2 | 3 | import { date } from "./date"; 4 | 5 | describe("date decoder", () => { 6 | it("should provide it's field type", () => { 7 | const decoder = impl(date()); 8 | 9 | expect(decoder.fieldType).toBe("date"); 10 | }); 11 | 12 | it("should provide initial field value", () => { 13 | const decoder = impl(date()); 14 | 15 | expect(decoder.init()).toBe(null); 16 | }); 17 | 18 | it("should decode null value", () => { 19 | const decoder = impl(date()); 20 | const value = null; 21 | 22 | expect(decoder.decode(value)).toEqual({ ok: true, value }); 23 | }); 24 | 25 | it("should decode valid date instance", () => { 26 | const decoder = impl(date()); 27 | const value = new Date(); 28 | 29 | expect(decoder.decode(value)).toEqual({ ok: true, value }); 30 | }); 31 | 32 | it("should NOT decode invalid date instance", () => { 33 | const decoder = impl(date()); 34 | const value = new Date("foobar"); 35 | 36 | expect(decoder.decode(value)).toEqual({ ok: false }); 37 | }); 38 | 39 | it("should decode date strings", () => { 40 | const decoder = impl(date()); 41 | 42 | [ 43 | new Date().toISOString(), 44 | new Date().toUTCString(), 45 | new Date().toString(), 46 | ].forEach(string => 47 | expect(decoder.decode(string)).toEqual({ 48 | ok: true, 49 | value: new Date(string), 50 | }) 51 | ); 52 | }); 53 | 54 | it("should NOT decode random strings", () => { 55 | const decoder = impl(date()); 56 | 57 | [ 58 | "", 59 | "foo", 60 | new Date().toISOString() + "foo", 61 | "12414252135", 62 | "May", 63 | ].forEach(string => expect(decoder.decode(string)).toEqual({ ok: false })); 64 | }); 65 | 66 | it("should decode numbers as timestamp values", () => { 67 | const decoder = impl(date()); 68 | 69 | [0, -10000000, new Date().valueOf()].forEach(number => 70 | expect(decoder.decode(number)).toEqual({ 71 | ok: true, 72 | value: new Date(number), 73 | }) 74 | ); 75 | }); 76 | 77 | it("should NOT decode undefined value", () => { 78 | const decoder = impl(date()); 79 | const value = undefined; 80 | 81 | expect(decoder.decode(value)).toEqual({ ok: false }); 82 | }); 83 | }); 84 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | defaults: &defaults 4 | working_directory: ~/repo 5 | docker: 6 | - image: cimg/node:18.16.1 7 | 8 | jobs: 9 | branch_check: 10 | <<: *defaults 11 | steps: 12 | - checkout 13 | 14 | - restore_cache: 15 | keys: 16 | - v2-dependencies-{{ checksum "package.json" }} 17 | 18 | - run: yarn install 19 | 20 | - run: 21 | name: Build 22 | command: yarn run build 23 | 24 | - run: 25 | name: Run tests 26 | command: yarn test:ci 27 | 28 | - save_cache: 29 | paths: 30 | - node_modules 31 | key: v2-dependencies-{{ checksum "package.json" }} 32 | 33 | - persist_to_workspace: 34 | root: ~/repo 35 | paths: . 36 | 37 | deploy_master: 38 | <<: *defaults 39 | steps: 40 | - attach_workspace: 41 | at: ~/repo 42 | 43 | - run: 44 | name: Avoid hosts unknown for github 45 | command: 46 | mkdir ~/.ssh/ && echo -e "Host github.com\n\tStrictHostKeyChecking 47 | no\n" > ~/.ssh/config 48 | 49 | - add_ssh_keys: 50 | fingerprints: 51 | - e1:a6:de:60:53:1f:e5:65:d5:3a:c0:ab:26:ac:0a:42 52 | 53 | - run: 54 | name: Set GH user name&email 55 | command: | 56 | git config user.name "CircleCI Release Job" 57 | git config user.email "circle@ci.com" 58 | 59 | - run: 60 | name: Updated documentation files 61 | command: | 62 | yarn generate-docs 63 | git add docs/ 64 | git commit -m "docs: update typedoc files [ci skip]" 65 | 66 | - run: 67 | name: Create release 68 | command: yarn run release 69 | 70 | - run: 71 | name: Push release to GH 72 | command: git push --follow-tags origin master 73 | 74 | - run: 75 | name: Authenticate with registry 76 | command: 77 | echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ~/repo/.npmrc 78 | 79 | - run: 80 | name: Publish package 81 | command: npm publish --access public 82 | 83 | workflows: 84 | version: 2 85 | branch: 86 | jobs: 87 | - branch_check 88 | - deploy_master: 89 | requires: 90 | - branch_check 91 | filters: 92 | branches: 93 | only: 94 | - master 95 | -------------------------------------------------------------------------------- /src/core/types/field-template.ts: -------------------------------------------------------------------------------- 1 | import { Nominal, range } from "../../utils"; 2 | 3 | import { _FieldDecoderImpl } from "./field-decoder"; 4 | 5 | //@ts-ignore 6 | export type _FieldTemplateImpl = { 7 | __path: string; 8 | }; 9 | 10 | /** 11 | * Pointer to a form field template. 12 | * Used to interact with Formts validation API via .every() method. 13 | */ 14 | // @ts-ignore 15 | export interface FieldTemplate 16 | extends Nominal<"FieldTemplate", Err> {} 17 | 18 | // prettier-ignore 19 | export type GenericFieldTemplate = 20 | [T] extends [Array] 21 | ? ArrayFieldTemplate 22 | : [T] extends [object] 23 | ? ObjectFieldTemplate 24 | : FieldTemplate; 25 | 26 | // prettier-ignore 27 | export type ArrayFieldTemplate, Err> = 28 | & FieldTemplate 29 | & { 30 | readonly nth: (index: number) => GenericFieldTemplate; 31 | readonly every: () => GenericFieldTemplate; 32 | }; 33 | 34 | // prettier-ignore 35 | export type ObjectFieldTemplate = 36 | & FieldTemplate 37 | & { readonly [K in keyof T]: GenericFieldTemplate }; 38 | 39 | export const pathIsTemplate = (x: string): boolean => x.includes("[*]"); 40 | 41 | const templateMark = "[*]"; 42 | 43 | export const generateFieldPathsFromTemplate = ( 44 | template: string, 45 | getValue: (path: string) => unknown 46 | ): string[] => { 47 | const templateIndex = template.indexOf(templateMark); 48 | if (templateIndex === -1) { 49 | return [template]; 50 | } else { 51 | const root = template.slice(0, templateIndex); 52 | const childPath = template.slice(templateIndex + templateMark.length); 53 | const value = getValue(root); 54 | 55 | if (!Array.isArray(value) || value.length <= 0) { 56 | return []; 57 | } else { 58 | return range(0, value.length - 1).flatMap(index => { 59 | const indexedTemplate = `${root}[${index}]${childPath}`; 60 | return generateFieldPathsFromTemplate(indexedTemplate, getValue); 61 | }); 62 | } 63 | } 64 | }; 65 | 66 | export const createRegexForTemplate = (template: string) => { 67 | const templateRegex = template 68 | .replace(new RegExp("\\.", "g"), "\\.") 69 | .replace(new RegExp("\\[", "g"), "\\[") 70 | .replace(new RegExp("\\]", "g"), "\\]") 71 | .replace(new RegExp("\\*", "g"), "(\\d+)"); 72 | 73 | return new RegExp(`\^${templateRegex}\$`); 74 | }; 75 | -------------------------------------------------------------------------------- /src/core/decoders/bool.spec.ts: -------------------------------------------------------------------------------- 1 | import { impl } from "../types/type-mapper-util"; 2 | 3 | import { bool } from "./bool"; 4 | 5 | describe("bool decoder", () => { 6 | it("should provide it's field type", () => { 7 | const decoder = impl(bool()); 8 | 9 | expect(decoder.fieldType).toBe("bool"); 10 | }); 11 | 12 | it("should provide initial field value", () => { 13 | const decoder = impl(bool()); 14 | 15 | expect(decoder.init()).toBe(false); 16 | }); 17 | 18 | it("should decode boolean values", () => { 19 | const decoder = impl(bool()); 20 | 21 | expect(decoder.decode(true)).toEqual({ ok: true, value: true }); 22 | expect(decoder.decode(false)).toEqual({ ok: true, value: false }); 23 | }); 24 | 25 | it("should decode matching string values", () => { 26 | const decoder = impl(bool()); 27 | 28 | expect(decoder.decode("true")).toEqual({ ok: true, value: true }); 29 | expect(decoder.decode("True")).toEqual({ ok: true, value: true }); 30 | expect(decoder.decode("TRUE")).toEqual({ ok: true, value: true }); 31 | expect(decoder.decode("true ")).toEqual({ ok: true, value: true }); 32 | 33 | expect(decoder.decode("false")).toEqual({ ok: true, value: false }); 34 | expect(decoder.decode("False")).toEqual({ ok: true, value: false }); 35 | expect(decoder.decode("FALSE")).toEqual({ ok: true, value: false }); 36 | expect(decoder.decode("false ")).toEqual({ ok: true, value: false }); 37 | 38 | expect(decoder.decode("")).toEqual({ ok: false }); 39 | expect(decoder.decode("1")).toEqual({ ok: false }); 40 | expect(decoder.decode("0")).toEqual({ ok: false }); 41 | 42 | expect(decoder.decode("truee")).toEqual({ ok: false }); 43 | expect(decoder.decode("yes")).toEqual({ ok: false }); 44 | expect(decoder.decode("no")).toEqual({ ok: false }); 45 | }); 46 | 47 | it("should NOT decode numbers", () => { 48 | const decoder = impl(bool()); 49 | 50 | [0, 666, NaN, +Infinity, -Infinity].forEach(value => 51 | expect(decoder.decode(value)).toEqual({ ok: false }) 52 | ); 53 | }); 54 | 55 | it("should NOT decode objects", () => { 56 | const decoder = impl(bool()); 57 | 58 | [{}, { foo: "bar" }, new Error("error"), []].forEach(value => 59 | expect(decoder.decode(value)).toEqual({ ok: false }) 60 | ); 61 | }); 62 | 63 | it("should NOT decode nullable values", () => { 64 | const decoder = impl(bool()); 65 | 66 | [null, undefined].forEach(value => 67 | expect(decoder.decode(value)).toEqual({ ok: false }) 68 | ); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /src/core/decoders/string.spec.ts: -------------------------------------------------------------------------------- 1 | import { impl } from "../types/type-mapper-util"; 2 | 3 | import { string } from "./string"; 4 | 5 | describe("string decoder", () => { 6 | it("should provide it's field type", () => { 7 | const decoder = impl(string()); 8 | 9 | expect(decoder.fieldType).toBe("string"); 10 | }); 11 | 12 | it("should provide initial field value", () => { 13 | const decoder = impl(string()); 14 | 15 | expect(decoder.init()).toBe(""); 16 | }); 17 | 18 | it("should decode string values", () => { 19 | const decoder = impl(string()); 20 | 21 | ["", " ", "foo", " BAR ", "🔥"].forEach(value => 22 | expect(decoder.decode(value)).toEqual({ ok: true, value }) 23 | ); 24 | }); 25 | 26 | it("should decode valid Date instance values", () => { 27 | const decoder = impl(string()); 28 | const date = new Date(); 29 | 30 | expect(decoder.decode(date)).toEqual({ 31 | ok: true, 32 | value: date.toISOString(), 33 | }); 34 | }); 35 | 36 | it("should NOT decode invalid Date instance values", () => { 37 | const decoder = impl(string()); 38 | const date = new Date("foobar"); 39 | 40 | expect(decoder.decode(date)).toEqual({ ok: false }); 41 | }); 42 | 43 | it("should decode finite number values", () => { 44 | const decoder = impl(string()); 45 | 46 | [-100, 0, 666.666, Number.MAX_SAFE_INTEGER, Number.EPSILON].forEach(value => 47 | expect(decoder.decode(value)).toEqual({ 48 | ok: true, 49 | value: value.toString(), 50 | }) 51 | ); 52 | }); 53 | 54 | it("should NOT decode infinite number values", () => { 55 | const decoder = impl(string()); 56 | 57 | [NaN, +Infinity, -Infinity].forEach(value => 58 | expect(decoder.decode(value)).toEqual({ ok: false }) 59 | ); 60 | }); 61 | 62 | it("should decode boolean values", () => { 63 | const decoder = impl(string()); 64 | 65 | expect(decoder.decode(true)).toEqual({ ok: true, value: "true" }); 66 | expect(decoder.decode(false)).toEqual({ ok: true, value: "false" }); 67 | }); 68 | 69 | it("should NOT decode objects", () => { 70 | const decoder = impl(string()); 71 | 72 | [{}, { foo: "bar" }, new Error("error"), []].forEach(value => 73 | expect(decoder.decode(value)).toEqual({ ok: false }) 74 | ); 75 | }); 76 | 77 | it("should NOT decode nullable values", () => { 78 | const decoder = impl(string()); 79 | 80 | [null, undefined].forEach(value => 81 | expect(decoder.decode(value)).toEqual({ ok: false }) 82 | ); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /src/core/decoders/choice.spec.ts: -------------------------------------------------------------------------------- 1 | import { impl } from "../types/type-mapper-util"; 2 | 3 | import { choice } from "./choice"; 4 | 5 | describe("number decoder", () => { 6 | it("should force the user to provide input options", () => { 7 | // @ts-expect-error 8 | const invalidDecoder = choice(); 9 | }); 10 | 11 | it("should provide it's field type", () => { 12 | const decoder = impl(choice("A", "B", "C")); 13 | 14 | expect(decoder.fieldType).toBe("choice"); 15 | }); 16 | 17 | it("should provide initial field value", () => { 18 | const decoder = impl(choice("A", "B", "C")); 19 | 20 | expect(decoder.init()).toBe("A"); 21 | }); 22 | 23 | it("should expose it's options", () => { 24 | const decoder = impl(choice("", "A", "B", "C")); 25 | 26 | expect(decoder.options).toEqual(["", "A", "B", "C"]); 27 | }); 28 | 29 | it("should decode whitelisted values", () => { 30 | const decoder = impl(choice("", "A", "B", "C")); 31 | 32 | ["", "A", "B", "C"].forEach(value => 33 | expect(decoder.decode(value)).toEqual({ ok: true, value }) 34 | ); 35 | }); 36 | 37 | it("should NOT decode other string values", () => { 38 | const decoder = impl(choice("A", "B", "C")); 39 | 40 | [ 41 | "a", 42 | "b", 43 | "c", 44 | "", 45 | " ", 46 | "A ", 47 | " A", 48 | " A ", 49 | "ABC", 50 | "foobar", 51 | ].forEach(value => expect(decoder.decode(value)).toEqual({ ok: false })); 52 | }); 53 | 54 | it("should decode matching number values", () => { 55 | const decoder = impl(choice("0", "1")); 56 | 57 | expect(decoder.decode(0)).toEqual({ ok: true, value: "0" }); 58 | expect(decoder.decode(1)).toEqual({ ok: true, value: "1" }); 59 | expect(decoder.decode(2)).toEqual({ ok: false }); 60 | expect(decoder.decode(NaN)).toEqual({ ok: false }); 61 | }); 62 | 63 | it("should NOT decode boolean values", () => { 64 | const decoder = impl(choice("true", "false", "foo")); 65 | 66 | [true, false].forEach(value => 67 | expect(decoder.decode(value)).toEqual({ ok: false }) 68 | ); 69 | }); 70 | 71 | it("should NOT decode objects", () => { 72 | const decoder = impl(choice("A", "B", "C")); 73 | 74 | [{}, { foo: "bar" }, new Error("error"), []].forEach(value => 75 | expect(decoder.decode(value)).toEqual({ ok: false }) 76 | ); 77 | }); 78 | 79 | it("should NOT decode nullable values", () => { 80 | const decoder = impl(choice("A", "B", "C")); 81 | 82 | [null, undefined].forEach(value => 83 | expect(decoder.decode(value)).toEqual({ ok: false }) 84 | ); 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.1", 3 | "name": "@virtuslab/formts", 4 | "description": "Type-safe, declarative and performant React form & validation library", 5 | "repository": "git@github.com:VirtusLab/formts.git", 6 | "author": "Mikołaj Klaman ", 7 | "license": "MIT", 8 | "private": false, 9 | "main": "__dist/index.js", 10 | "module": "__dist/esm/index.js", 11 | "typings": "__dist/index.d.ts", 12 | "side-effects": false, 13 | "files": [ 14 | "__dist", 15 | "__src", 16 | "validators" 17 | ], 18 | "keywords": [ 19 | "react", 20 | "typescript", 21 | "forms", 22 | "validation" 23 | ], 24 | "scripts": { 25 | "start": "tsdx watch", 26 | "typecheck": "tsc -p ./tsconfig.json", 27 | "build": "yarn typecheck && tsdx build && sh ./scripts/postbuild.sh", 28 | "generate-docs": "typedoc src/index.ts", 29 | "test": "tsdx test", 30 | "test:ci": "tsdx test --ci --runInBand", 31 | "lint": "eslint --fix ./src/**/*.ts && prettier --write ./src", 32 | "prepare": "yarn build", 33 | "prepack": "sh ./scripts/prepack.sh", 34 | "postpack": "sh ./scripts/postpack.sh", 35 | "release": "standard-version --releaseCommitMessageFormat \"chore(release): {{currentTag}} [ci skip]\"" 36 | }, 37 | "husky": { 38 | "hooks": { 39 | "pre-commit": "lint-staged", 40 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" 41 | } 42 | }, 43 | "lint-staged": { 44 | "*.{ts,tsx}": [ 45 | "jest --bail --findRelatedTests", 46 | "eslint --fix", 47 | "prettier --write" 48 | ], 49 | "*.{md,json}": [ 50 | "prettier --write" 51 | ] 52 | }, 53 | "devDependencies": { 54 | "@commitlint/cli": "^11.0.0", 55 | "@commitlint/config-conventional": "^11.0.0", 56 | "@testing-library/react-hooks": "^8.0.1", 57 | "@types/jest": "~29.5.2", 58 | "@types/react": "^16.9.50", 59 | "@typescript-eslint/eslint-plugin": "^5.61.0", 60 | "@typescript-eslint/parser": "^5.61.0", 61 | "babel-jest": "29.6.0", 62 | "conditional-type-checks": "^1.0.5", 63 | "eslint": "^7.9.0", 64 | "eslint-config-prettier": "^6.11.0", 65 | "eslint-plugin-import": "^2.22.0", 66 | "husky": "4.0.0", 67 | "jest": "~29.6.0", 68 | "lint-staged": ">=10", 69 | "prettier": "^2.1.2", 70 | "react": "^16.13.1", 71 | "react-test-renderer": "^16.13.1", 72 | "standard-version": "^9.0.0", 73 | "ts-jest": "~29.1.1", 74 | "tsdx": "^0.14.1", 75 | "tslib": "^2.6.0", 76 | "typedoc": "^0.24.8", 77 | "typescript": "5.1.6", 78 | "yarn": "^1.22.5" 79 | }, 80 | "peerDependencies": { 81 | "react": "^16.8.0" 82 | }, 83 | "resolutions": { 84 | "**/typescript": "^5.1.6", 85 | "**/@typescript-eslint/eslint-plugin": "^5.61.0", 86 | "**/@typescript-eslint/parser": "^5.61.0", 87 | "**/jest": "^29.6.0", 88 | "**/ts-jest": "^29.1.1" 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/core/helpers/field-matcher.ts: -------------------------------------------------------------------------------- 1 | import { FieldDescriptor } from "../types/field-descriptor"; 2 | import { 3 | createRegexForTemplate, 4 | FieldTemplate, 5 | pathIsTemplate, 6 | } from "../types/field-template"; 7 | import { impl } from "../types/type-mapper-util"; 8 | 9 | type FieldPath = string; 10 | type FieldId = string; 11 | 12 | type FieldLike = FieldDescriptor | FieldPath | FieldId; 13 | 14 | type AbstractFieldLike = FieldLike | FieldTemplate; 15 | 16 | /** 17 | * Utility class for comparing field IDs against other field Ids or FieldDescriptor objects 18 | */ 19 | export class FieldMatcher { 20 | private readonly fieldPath: string; 21 | 22 | /** 23 | * @param field field ID or FieldDescriptor 24 | */ 25 | constructor(field: FieldLike) { 26 | this.fieldPath = getPath(field); 27 | } 28 | 29 | /** 30 | * returns true when `otherField` points to this FieldDescriptor 31 | * 32 | * @param otherField field ID or `FieldDescriptor` or `FieldTemplate` (like FieldDescriptor.every()) 33 | */ 34 | matches(otherField: AbstractFieldLike): boolean { 35 | const otherPath = getPath(otherField); 36 | if (pathIsTemplate(otherPath)) { 37 | const otherPathRegex = createRegexForTemplate(otherPath); 38 | return this.fieldPath.match(otherPathRegex) != null; 39 | } else { 40 | return this.fieldPath === otherPath; 41 | } 42 | } 43 | 44 | /** 45 | * returns true when `otherField` points to parent FieldDescriptor 46 | * 47 | * @param otherField field ID or `FieldDescriptor` or `FieldTemplate` (like FieldDescriptor.every()) 48 | */ 49 | isChildOf(otherField: AbstractFieldLike): boolean { 50 | const otherPath = getPath(otherField); 51 | 52 | const commonPath = this.fieldPath.substring(0, otherPath.length); 53 | if (!new FieldMatcher(commonPath).matches(otherPath)) { 54 | return false; 55 | } 56 | 57 | switch (this.fieldPath.charAt(otherPath.length)) { 58 | case ".": // object child 59 | case "[": // array element child 60 | return true; 61 | 62 | default: 63 | return false; 64 | } 65 | } 66 | 67 | /** 68 | * returns true when `otherField` points to child FieldDescriptor 69 | * 70 | * @param otherField field ID or `FieldDescriptor` or `FieldTemplate` (like FieldDescriptor.every()) 71 | */ 72 | isParentOf(otherField: AbstractFieldLike): boolean { 73 | const otherPath = getPath(otherField); 74 | 75 | const commonPath = otherPath.substring(0, this.fieldPath.length); 76 | if (!this.matches(commonPath)) { 77 | return false; 78 | } 79 | 80 | switch (otherPath.charAt(this.fieldPath.length)) { 81 | case ".": // object parent 82 | case "[": // array parent 83 | return true; 84 | 85 | default: 86 | return false; 87 | } 88 | } 89 | } 90 | 91 | const getPath = (field: AbstractFieldLike) => 92 | typeof field === "string" ? field : impl(field).__path; 93 | -------------------------------------------------------------------------------- /src/core/decoders/number.spec.ts: -------------------------------------------------------------------------------- 1 | import { impl } from "../types/type-mapper-util"; 2 | 3 | import { number } from "./number"; 4 | 5 | describe("number decoder", () => { 6 | it("should provide it's field type", () => { 7 | const decoder = impl(number()); 8 | 9 | expect(decoder.fieldType).toBe("number"); 10 | }); 11 | 12 | it("should provide initial field value", () => { 13 | const decoder = impl(number()); 14 | 15 | expect(decoder.init()).toBe(""); 16 | }); 17 | 18 | it("should decode empty string value", () => { 19 | const decoder = impl(number()); 20 | 21 | ["", " ", " "].forEach(value => 22 | expect(decoder.decode(value)).toEqual({ ok: true, value: "" }) 23 | ); 24 | }); 25 | 26 | it("should decode finite number values", () => { 27 | const decoder = impl(number()); 28 | 29 | [-100, 0, 666.666, Number.MAX_SAFE_INTEGER, Number.EPSILON].forEach(value => 30 | expect(decoder.decode(value)).toEqual({ ok: true, value }) 31 | ); 32 | }); 33 | 34 | it("should NOT decode infinite number values", () => { 35 | const decoder = impl(number()); 36 | 37 | [NaN, +Infinity, -Infinity].forEach(value => 38 | expect(decoder.decode(value)).toEqual({ ok: false }) 39 | ); 40 | }); 41 | 42 | it("should decode string values parsable into numbers", () => { 43 | const decoder = impl(number()); 44 | 45 | ["0", "-42", "0.5", "10000", "10e-2"].forEach(value => 46 | expect(decoder.decode(value)).toEqual({ ok: true, value: Number(value) }) 47 | ); 48 | }); 49 | 50 | it("should NOT decode malformed string values", () => { 51 | const decoder = impl(number()); 52 | 53 | ["foo", "🔥", "0,5", "123foo", "10 000"].forEach(value => 54 | expect(decoder.decode(value)).toEqual({ ok: false }) 55 | ); 56 | }); 57 | 58 | it("should NOT decode boolean values", () => { 59 | const decoder = impl(number()); 60 | 61 | [true, false].forEach(value => 62 | expect(decoder.decode(value)).toEqual({ ok: false }) 63 | ); 64 | }); 65 | 66 | it("should NOT decode objects", () => { 67 | const decoder = impl(number()); 68 | 69 | [{}, { foo: "bar" }, new Error("error"), []].forEach(value => 70 | expect(decoder.decode(value)).toEqual({ ok: false }) 71 | ); 72 | }); 73 | 74 | it("should decode valid Date instances", () => { 75 | const decoder = impl(number()); 76 | const date = new Date(); 77 | 78 | expect(decoder.decode(date)).toEqual({ ok: true, value: date.valueOf() }); 79 | }); 80 | 81 | it("should NOT decode invalid Date instances", () => { 82 | const decoder = impl(number()); 83 | const date = new Date("foobar"); 84 | 85 | expect(decoder.decode(date)).toEqual({ ok: false }); 86 | }); 87 | 88 | it("should NOT decode nullable values", () => { 89 | const decoder = impl(number()); 90 | 91 | [null, undefined].forEach(value => 92 | expect(decoder.decode(value)).toEqual({ ok: false }) 93 | ); 94 | }); 95 | }); 96 | -------------------------------------------------------------------------------- /src/core/decoders/array.spec.ts: -------------------------------------------------------------------------------- 1 | import { impl } from "../types/type-mapper-util"; 2 | 3 | import { array } from "./array"; 4 | import { number } from "./number"; 5 | import { string } from "./string"; 6 | 7 | describe("array decoder", () => { 8 | it("should provide it's field type", () => { 9 | const decoder = impl(array(string())); 10 | 11 | expect(decoder.fieldType).toBe("array"); 12 | }); 13 | 14 | it("should provide initial field value", () => { 15 | const decoder = impl(array(string())); 16 | 17 | expect(decoder.init()).toEqual([]); 18 | }); 19 | 20 | it("should expose inner decoder", () => { 21 | const inner = number(); 22 | const decoder = impl(array(inner)); 23 | 24 | expect(decoder.inner).toBe(inner); 25 | }); 26 | 27 | describe("combined with string decoder", () => { 28 | it("should decode empty arrays", () => { 29 | const decoder = impl(array(string())); 30 | 31 | expect(decoder.decode([])).toEqual({ ok: true, value: [] }); 32 | }); 33 | 34 | it("should decode string array", () => { 35 | const decoder = impl(array(string())); 36 | const value = ["foo", "bar", "baz", ""]; 37 | 38 | expect(decoder.decode(value)).toEqual({ ok: true, value }); 39 | }); 40 | 41 | it("should NOT decode mixed array", () => { 42 | const decoder = impl(array(string())); 43 | const value = [null, "foo", 42, "", []]; 44 | 45 | expect(decoder.decode(value)).toEqual({ ok: false }); 46 | }); 47 | }); 48 | 49 | describe("combined with number decoder", () => { 50 | it("should decode empty arrays", () => { 51 | const decoder = impl(array(number())); 52 | 53 | expect(decoder.decode([])).toEqual({ ok: true, value: [] }); 54 | }); 55 | 56 | it("should decode number array", () => { 57 | const decoder = impl(array(number())); 58 | const value = [-1, 0, 10, 66.6]; 59 | 60 | expect(decoder.decode(value)).toEqual({ ok: true, value }); 61 | }); 62 | 63 | it("should NOT decode mixed array", () => { 64 | const decoder = impl(array(string())); 65 | const value = [null, "foo", 42, "", []]; 66 | 67 | expect(decoder.decode(value)).toEqual({ ok: false }); 68 | }); 69 | }); 70 | 71 | describe("combined with another array decoder", () => { 72 | it("should decode empty arrays", () => { 73 | const decoder = impl(array(array(string()))); 74 | 75 | expect(decoder.decode([])).toEqual({ ok: true, value: [] }); 76 | expect(decoder.decode([[]])).toEqual({ ok: true, value: [[]] }); 77 | }); 78 | 79 | it("should decode nested string array", () => { 80 | const decoder = impl(array(array(string()))); 81 | const value = [["", "foobar"], [], ["?"]]; 82 | 83 | expect(decoder.decode(value)).toEqual({ ok: true, value }); 84 | }); 85 | 86 | it("should NOT decode mixed array", () => { 87 | const decoder = impl(array(string())); 88 | const value = [null, "foo", 42, "", []]; 89 | 90 | expect(decoder.decode(value)).toEqual({ ok: false }); 91 | }); 92 | }); 93 | }); 94 | -------------------------------------------------------------------------------- /src/core/types/form-handle.ts: -------------------------------------------------------------------------------- 1 | import { NoInfer } from "../../utils"; 2 | 3 | import { GenericFieldDescriptor } from "./field-descriptor"; 4 | import { FieldError } from "./field-error"; 5 | import { InitialValues } from "./formts-state"; 6 | 7 | /** 8 | * Used to interact with the form as a whole 9 | */ 10 | export type FormHandle = { 11 | /** True if any form field is touched */ 12 | isTouched: boolean; 13 | 14 | /** True if any form field is changed */ 15 | isChanged: boolean; 16 | 17 | /** True if there are no validation errors */ 18 | isValid: boolean; 19 | 20 | /** True if validation process of any field is ongoing */ 21 | isValidating: boolean; 22 | 23 | /** True if form submission process is ongoing (due to validation on submit or unfulfilled Promise returned from submit handler) */ 24 | isSubmitting: boolean; 25 | 26 | submitCount: { 27 | /** will be incremented after every call of `submit` function, sum of `valid` and `invalid` counts. */ 28 | total: number; 29 | /** will be incremented after every call of `submit` function when validation passes */ 30 | valid: number; 31 | /** will be incremented after every failed call of `submit` function when validation fails */ 32 | invalid: number; 33 | }; 34 | 35 | /** 36 | * Resets the form cleaning all validation errors and touched flags. 37 | * Form values will be set to initial values. 38 | * 39 | * @param newInitialValues if provided will replace initialValues passed into `useFormController` hook. 40 | */ 41 | reset: (newInitialValues?: InitialValues) => void; 42 | 43 | /** 44 | * Runs validation of all fields. 45 | * Use this together with `React.useEffect` hook if you want to run form validation on init. 46 | */ 47 | validate: () => void; 48 | 49 | /** 50 | * Runs form validation with 'submit' trigger and invokes `onSuccess` or `onFailure` callback. 51 | * Sets `isSubmitting` flag to true when validation or `onSuccess` callback promise are running. 52 | * Freezes changes to form values during submission process. 53 | * Sets `isTouched` flag of all fields to true. 54 | * 55 | * @param onSuccess - callback invoked after successful submit validation. 56 | * Receives form values. Can return Promise which will affect `isSubmitting` flag. 57 | * 58 | * @param onFailure - callback invoked after failed submit validation. Receives all form errors. (optional) 59 | */ 60 | submit: ( 61 | onSuccess: (values: Values) => void | Promise, 62 | onFailure?: (errors: Array>) => void 63 | ) => void; 64 | 65 | /** 66 | * Sets value for given field. 67 | * Will cause field validation to run with the `change` trigger. 68 | * Will set `isTouched` flag for the field to `true`. 69 | */ 70 | setFieldValue: ( 71 | field: GenericFieldDescriptor, 72 | value: NoInfer 73 | ) => void; 74 | 75 | /** Sets error for given field, affecting it's `isValid` flag */ 76 | setFieldError: ( 77 | field: GenericFieldDescriptor, 78 | error: Err | null 79 | ) => void; 80 | }; 81 | -------------------------------------------------------------------------------- /src/utils/lenses.ts: -------------------------------------------------------------------------------- 1 | import { ArrayElement } from "./utility-types"; 2 | 3 | type Void = undefined | null; 4 | 5 | type KeyOf = O extends object 6 | ? O extends Array 7 | ? never 8 | : keyof O 9 | : never; 10 | 11 | type Prop> = O extends object ? O[K] : undefined; 12 | 13 | /** 14 | * Toy implementation of lenses, for something more advanced see https://github.com/gcanti/monocle-ts 15 | * Note: Partial lenses are somewhat supported but can produce partial results typed as non-partial :( 16 | */ 17 | export type Lens = { 18 | get: (state: S) => T; 19 | update: (state: S, setter: (current: T) => T) => S; 20 | }; 21 | 22 | export namespace Lens { 23 | /** identity lens, useful as starting point in compose function */ 24 | export const identity = (): Lens => ({ 25 | get: it => it, 26 | update: (it, setter) => setter(it), 27 | }); 28 | 29 | /** selects object property */ 30 | export const prop = >(prop: P): Lens> => { 31 | type Ret = Lens>; 32 | const get: Ret["get"] = state => state?.[prop] as Prop; 33 | const update: Ret["update"] = (state, setter) => ({ 34 | ...state, 35 | [prop]: setter(get(state)), 36 | }); 37 | 38 | return { get, update }; 39 | }; 40 | 41 | /** selects array item at specified index */ 42 | export const index = ( 43 | i: number 44 | ): Lens | undefined> => { 45 | type Ret = Lens | undefined>; 46 | const get: Ret["get"] = state => state?.[i]; 47 | const update: Ret["update"] = (state, setter) => 48 | Object.assign([], state, { [i]: setter(get(state)) }); 49 | 50 | return { get, update }; 51 | }; 52 | 53 | // prettier-ignore 54 | export type Compose = { 55 | (l1: Lens): Lens; 56 | (l1: Lens, l2: Lens): Lens; 57 | (l1: Lens, l2: Lens, l3: Lens): Lens; 58 | (l1: Lens, l2: Lens, l3: Lens, l4: Lens): Lens; 59 | (l1: Lens, l2: Lens, l3: Lens, l4: Lens, l5: Lens): Lens; 60 | (l1: Lens, l2: Lens, l3: Lens, l4: Lens, l5: Lens, l6: Lens): Lens; 61 | (l1: Lens, l2: Lens, l3: Lens, l4: Lens, l5: Lens, l6: Lens, l7: Lens): Lens; 62 | }; 63 | 64 | const compose2 = (l1: Lens, l2: Lens): Lens => { 65 | const get: Lens["get"] = state => l2.get(l1.get(state)); 66 | 67 | const update: Lens["update"] = (state, setter) => 68 | l1.update(state, b => l2.update(b, c => setter(c))); 69 | 70 | return { get, update }; 71 | }; 72 | 73 | /** combines multiple lenses into single lens operating on nested structure */ 74 | export const compose: Compose = (...lenses: Array>) => { 75 | const [first, ...rest] = lenses; 76 | return rest.reduce(compose2, first); 77 | }; 78 | } 79 | -------------------------------------------------------------------------------- /src/core/hooks/use-form-handle/use-form-handle.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | 3 | import { Task } from "../../../utils/task"; 4 | import { useSubscription } from "../../../utils/use-subscription"; 5 | import { useFormtsContext } from "../../context"; 6 | import { FormController } from "../../types/form-controller"; 7 | import { FormHandle } from "../../types/form-handle"; 8 | import { FormSchema } from "../../types/form-schema"; 9 | import { impl } from "../../types/type-mapper-util"; 10 | 11 | /** 12 | * Hook used to gain access to form-wide methods and properties computed from all fields. 13 | * Causes the component to subscribe to changes to form state that affect the computed properties. 14 | * 15 | * @param Schema - created using `FormSchemaBuilder`, needed for type inference. 16 | * @param controller - obtained by using `useFormController` hook, used to connect to form state. 17 | * Injected automatically via React Context when used inside `FormProvider` component. 18 | * 19 | * @returns `FormHandle` used to interact with form. 20 | * 21 | * @example 22 | * ```ts 23 | * const Schema = new FormSchemaBuilder()...; 24 | * 25 | * const MyForm: React.FC = () => { 26 | * const controller = useFormController({ Schema }) 27 | * const formHandle = useFormHandle(Schema, controller) 28 | * 29 | * ... 30 | * } 31 | * ``` 32 | */ 33 | export const useFormHandle = ( 34 | _Schema: FormSchema, 35 | controller?: FormController 36 | ): FormHandle => { 37 | const { atoms, methods } = useFormtsContext(controller); 38 | useSubscription(atoms.formHandle); 39 | 40 | return useMemo( 41 | () => ({ 42 | get isSubmitting() { 43 | return atoms.formHandle.val.isSubmitting; 44 | }, 45 | 46 | get isTouched() { 47 | return atoms.formHandle.val.isTouched; 48 | }, 49 | 50 | get isChanged() { 51 | return atoms.formHandle.val.isChanged; 52 | }, 53 | 54 | get isValid() { 55 | return atoms.formHandle.val.isValid; 56 | }, 57 | 58 | get isValidating() { 59 | return atoms.formHandle.val.isValidating; 60 | }, 61 | 62 | get submitCount() { 63 | const { 64 | successfulSubmitCount, 65 | failedSubmitCount, 66 | } = atoms.formHandle.val; 67 | 68 | return { 69 | valid: successfulSubmitCount, 70 | invalid: failedSubmitCount, 71 | total: successfulSubmitCount + failedSubmitCount, 72 | }; 73 | }, 74 | 75 | reset: newInitialValues => 76 | methods.resetForm(newInitialValues).runPromise(), 77 | 78 | validate: () => methods.validateForm().runPromise(), 79 | 80 | submit: (onSuccess, onFailure) => 81 | methods 82 | .submitForm( 83 | values => Task.from(() => onSuccess(values)).map(() => {}), 84 | errors => Task.from(() => onFailure?.(errors)) 85 | ) 86 | .runPromise(), 87 | 88 | setFieldValue: (field, value) => 89 | methods.setFieldValue(field, value).runPromise(), 90 | 91 | setFieldError: (field, error) => 92 | methods 93 | .setFieldErrors({ path: impl(field).__path, error }) 94 | .runPromise(), 95 | }), 96 | [atoms.formHandle.val] 97 | ); 98 | }; 99 | -------------------------------------------------------------------------------- /src/core/types/field-decoder.spec.ts: -------------------------------------------------------------------------------- 1 | import { assert, IsExact } from "conditional-type-checks"; 2 | 3 | import { DecoderResult, _FieldDecoderImpl, FieldType } from "./field-decoder"; 4 | 5 | describe("_FieldDecoderImpl type", () => { 6 | it("handles string fields", () => { 7 | type Actual = _FieldDecoderImpl; 8 | type Expected = { 9 | fieldType: FieldType; 10 | init: () => string; 11 | decode: (val: unknown) => DecoderResult; 12 | }; 13 | 14 | assert>(true); 15 | }); 16 | 17 | it("handles number fields", () => { 18 | type Actual = _FieldDecoderImpl; 19 | type Expected = { 20 | fieldType: FieldType; 21 | init: () => number | ""; 22 | decode: (val: unknown) => DecoderResult; 23 | }; 24 | 25 | assert>(true); 26 | }); 27 | 28 | it("handles boolean fields", () => { 29 | type Actual = _FieldDecoderImpl; 30 | type Expected = { 31 | fieldType: FieldType; 32 | init: () => boolean; 33 | decode: (val: unknown) => DecoderResult; 34 | }; 35 | 36 | assert>(true); 37 | }); 38 | 39 | it("handles choice fields", () => { 40 | type Actual = _FieldDecoderImpl<"A" | "B" | "C">; 41 | type Expected = { 42 | fieldType: FieldType; 43 | options: Array<"A" | "B" | "C">; 44 | init: () => "A" | "B" | "C"; 45 | decode: (val: unknown) => DecoderResult<"A" | "B" | "C">; 46 | }; 47 | 48 | assert>(true); 49 | }); 50 | 51 | it("handles date fields", () => { 52 | type Actual = _FieldDecoderImpl; 53 | type Expected = { 54 | fieldType: FieldType; 55 | init: () => Date | null; 56 | decode: (val: unknown) => DecoderResult; 57 | }; 58 | 59 | assert>(true); 60 | }); 61 | 62 | it("handles array fields", () => { 63 | type Actual = _FieldDecoderImpl; 64 | type Expected = { 65 | fieldType: FieldType; 66 | inner: _FieldDecoderImpl; 67 | init: () => string[]; 68 | decode: (val: unknown) => DecoderResult; 69 | }; 70 | 71 | assert>(true); 72 | }); 73 | 74 | it("handles object fields", () => { 75 | type Actual = _FieldDecoderImpl<{ 76 | str: string; 77 | num: number | ""; 78 | arr: boolean[]; 79 | nested: { str: string }; 80 | }>; 81 | 82 | type Expected = { 83 | fieldType: FieldType; 84 | 85 | inner: { 86 | str: _FieldDecoderImpl; 87 | num: _FieldDecoderImpl; 88 | arr: _FieldDecoderImpl; 89 | nested: _FieldDecoderImpl<{ str: string }>; 90 | }; 91 | 92 | init: () => { 93 | str: string; 94 | num: number | ""; 95 | arr: boolean[]; 96 | nested: { str: string }; 97 | }; 98 | 99 | decode: ( 100 | val: unknown 101 | ) => DecoderResult<{ 102 | str: string; 103 | num: number | ""; 104 | arr: boolean[]; 105 | nested: { str: string }; 106 | }>; 107 | }; 108 | 109 | assert>(true); 110 | }); 111 | }); 112 | -------------------------------------------------------------------------------- /src/core/types/formts-state.spec.ts: -------------------------------------------------------------------------------- 1 | import { IsExact, assert } from "conditional-type-checks"; 2 | 3 | import { TouchedValues } from "./formts-state"; 4 | 5 | type SomeValues = { 6 | string: string; 7 | choice: "A" | "B" | "C"; 8 | num: number | ""; 9 | bool: boolean; 10 | arrayString: string[]; 11 | arrayChoice: Array<"A" | "B" | "C">; 12 | arrayArrayString: string[][]; 13 | obj: { string: string }; 14 | objObj: { nested: { num: number | "" } }; 15 | objObjArray: { nested: { arrayString: string[] } }; 16 | arrayObj: Array<{ string: string }>; 17 | date: Date | null; 18 | }; 19 | 20 | describe("TouchedValues type", () => { 21 | type Touched = TouchedValues; 22 | 23 | it("is an object with a key for every input object key", () => { 24 | type Actual = keyof Touched; 25 | type Expected = 26 | | "string" 27 | | "choice" 28 | | "num" 29 | | "bool" 30 | | "arrayString" 31 | | "arrayChoice" 32 | | "arrayArrayString" 33 | | "obj" 34 | | "objObj" 35 | | "objObjArray" 36 | | "arrayObj" 37 | | "date"; 38 | 39 | assert>(true); 40 | }); 41 | 42 | it("handles string fields", () => { 43 | type Actual = Touched["string"]; 44 | type Expected = boolean; 45 | 46 | assert>(true); 47 | }); 48 | 49 | it("handles choice fields", () => { 50 | type Actual = Touched["choice"]; 51 | type Expected = boolean; 52 | 53 | assert>(true); 54 | }); 55 | 56 | it("handles number fields", () => { 57 | type Actual = Touched["num"]; 58 | type Expected = boolean; 59 | 60 | assert>(true); 61 | }); 62 | 63 | it("handles boolean fields", () => { 64 | type Actual = Touched["bool"]; 65 | type Expected = boolean; 66 | 67 | assert>(true); 68 | }); 69 | 70 | it("handles array fields", () => { 71 | type Actual = Touched["arrayString"]; 72 | type Expected = boolean[]; 73 | 74 | assert>(true); 75 | }); 76 | 77 | it("handles nested array fields", () => { 78 | type Actual = Touched["arrayArrayString"]; 79 | type Expected = boolean[][]; 80 | 81 | assert>(true); 82 | }); 83 | 84 | it("handles object fields", () => { 85 | type Actual = Touched["obj"]; 86 | type Expected = { string: boolean }; 87 | 88 | assert>(true); 89 | }); 90 | 91 | it("handles nested object fields", () => { 92 | type Actual = Touched["objObj"]; 93 | type Expected = { nested: { num: boolean } }; 94 | 95 | assert>(true); 96 | }); 97 | 98 | it("handles deeply nested array fields", () => { 99 | type Actual = Touched["objObjArray"]; 100 | type Expected = { nested: { arrayString: boolean[] } }; 101 | 102 | assert>(true); 103 | }); 104 | 105 | it("handles array of objects", () => { 106 | type Actual = Touched["arrayObj"]; 107 | type Expected = Array<{ string: boolean }>; 108 | 109 | assert>(true); 110 | }); 111 | 112 | it("handles date fields", () => { 113 | type Actual = Touched["date"]; 114 | type Expected = boolean; 115 | 116 | assert>(true); 117 | }); 118 | }); 119 | -------------------------------------------------------------------------------- /src/utils/atoms.ts: -------------------------------------------------------------------------------- 1 | import { Nominal } from "../utils"; 2 | import { Lens } from "../utils/lenses"; 3 | 4 | type SubscriberFunc = (val: T) => void; 5 | type SubscriptionKey = Nominal<"SubscriptionKey"> & string; 6 | 7 | /** 8 | * Composable and observable container for mutable state 9 | */ 10 | export type Atom = { 11 | val: T; 12 | set: (state: T) => void; 13 | subscribe: (fn: SubscriberFunc) => SubscriptionKey; 14 | unsubscribe: (key: SubscriptionKey) => void; 15 | }; 16 | 17 | export namespace Atom { 18 | /** 19 | * Create atom holding provided initial value 20 | */ 21 | export const of = (initial: T): Atom => { 22 | const subscribers: SubscriberFunc[] = []; 23 | let val = initial; 24 | 25 | return { 26 | get val(): T { 27 | return val; 28 | }, 29 | set(it) { 30 | if (it !== val) { 31 | val = it; 32 | subscribers.forEach(s => s(it)); 33 | } 34 | }, 35 | subscribe(fn) { 36 | const key = subscribers.length; 37 | subscribers[key] = fn; 38 | return key.toString() as SubscriptionKey; 39 | }, 40 | unsubscribe(key) { 41 | delete subscribers[+key]; 42 | }, 43 | }; 44 | }; 45 | 46 | /** 47 | * Create atom connected to provided source atom via a Lens. 48 | * Updates to either atom will be reflected in the other one. 49 | */ 50 | export const entangle = (atom: Atom

, lens: Lens): Atom => { 51 | const subscribers: SubscriberFunc[] = []; 52 | let val = lens.get(atom.val); 53 | 54 | atom.subscribe(it => { 55 | const newVal = lens.get(it); 56 | if (newVal !== val) { 57 | val = newVal; 58 | subscribers.forEach(s => s(newVal)); 59 | } 60 | }); 61 | 62 | return { 63 | get val(): Q { 64 | return val; 65 | }, 66 | set(it: Q) { 67 | if (it !== val) { 68 | val = it; 69 | atom.set(lens.update(atom.val, () => it)); 70 | subscribers.forEach(s => s(it)); 71 | } 72 | }, 73 | subscribe(fn) { 74 | const key = subscribers.length; 75 | subscribers[key] = fn; 76 | return key.toString() as SubscriptionKey; 77 | }, 78 | unsubscribe(key) { 79 | delete subscribers[+key]; 80 | }, 81 | }; 82 | }; 83 | 84 | export interface Readonly extends Omit, "set"> {} 85 | 86 | /** 87 | * Combine multiple atoms using `combinator` function into readonly atom. 88 | * This is similar to redux selector 89 | */ 90 | export const fuse = ( 91 | combinator: (...values: readonly [...T]) => Q, 92 | ...atoms: { [P in keyof T]: Atom | Atom.Readonly } 93 | ): Atom.Readonly => { 94 | const subscribers: SubscriberFunc[] = []; 95 | let val = combinator(...(atoms.map(a => a.val) as T)); 96 | 97 | atoms.forEach(a => 98 | a.subscribe(() => { 99 | const newVal = combinator(...(atoms.map(a => a.val) as T)); 100 | if (newVal !== val) { 101 | val = newVal; 102 | subscribers.forEach(s => s(newVal)); 103 | } 104 | }) 105 | ); 106 | 107 | return { 108 | get val(): Q { 109 | return val; 110 | }, 111 | subscribe(fn) { 112 | const key = subscribers.length; 113 | subscribers[key] = fn; 114 | return key.toString() as SubscriptionKey; 115 | }, 116 | unsubscribe(key) { 117 | delete subscribers[+key]; 118 | }, 119 | }; 120 | }; 121 | } 122 | -------------------------------------------------------------------------------- /src/core/builders/schema/form-schema-builder.ts: -------------------------------------------------------------------------------- 1 | import { FormSchema } from "../../types/form-schema"; 2 | 3 | import { createFormSchema, DecodersMap } from "./create-schema"; 4 | 5 | /** 6 | * Builds schema which defines shape of the form values and type of validation errors. 7 | * The schema is used not only for compile-time type-safety but also for runtime validation of form values. 8 | * The schema can be defined top-level, so that it can be exported to nested Form components for usage together with `useField` hook. 9 | * 10 | * @returns 11 | * FormSchema - used to interact with Formts API and point to specific form fields 12 | * 13 | * @example 14 | * ``` 15 | * import { FormSchemaBuilder, FormFields } from "@virtuslab/formts" 16 | * 17 | * const Schema = new FormSchemaBuilder() 18 | * .fields({ 19 | * name: FormFields.string(), 20 | * age: FormFields.number(), 21 | * }) 22 | * .errors() 23 | * .build() 24 | * ``` 25 | */ 26 | export class FormSchemaBuilder { 27 | private decoders: DecodersMap = {} as any; 28 | 29 | /** 30 | * Builds schema which defines shape of the form values and type of validation errors. 31 | * The schema is used not only for compile-time type-safety but also for runtime validation of form values. 32 | * The schema can be defined top-level, so that it can be exported to nested Form components for usage together with `useField` hook. 33 | * 34 | * @returns 35 | * FormSchema - used to interact with Formts API and point to specific form fields 36 | * 37 | * @example 38 | * ``` 39 | * import { FormSchemaBuilder, FormFields } from "@virtuslab/formts" 40 | * 41 | * const Schema = new FormSchemaBuilder() 42 | * .fields({ 43 | * name: FormFields.string(), 44 | * age: FormFields.number(), 45 | * }) 46 | * .errors() 47 | * .build() 48 | * ``` 49 | */ 50 | constructor() {} 51 | 52 | /** 53 | * Define form fields as dictionary of decoders. Use `FormFields` import. 54 | * 55 | * @example 56 | * ``` 57 | * new FormSchemaBuilder() 58 | * .fields({ 59 | * name: FormFields.string(), 60 | * age: FormFields.number(), 61 | * }) 62 | * ``` 63 | */ 64 | fields = (fields: DecodersMap) => { 65 | this.decoders = fields; 66 | 67 | return (this as any) as SchemaBuilder$Fields; 68 | }; 69 | 70 | /** 71 | * Define form errors to be used by `FormValidatorBuilder`. 72 | * 73 | * @example 74 | * ``` 75 | * new FormSchemaBuilder() 76 | * .errors() 77 | * ``` 78 | */ 79 | errors = () => { 80 | return (this as any) as SchemaBuilder$Errors; 81 | }; 82 | 83 | // @ts-ignore 84 | private build = () => createFormSchema(this.decoders); 85 | } 86 | 87 | interface SchemaBuilder$Errors { 88 | /** 89 | * Define form fields as dictionary of decoders. Use `FormFields` import. 90 | * 91 | * @example 92 | * ``` 93 | * new FormSchemaBuilder() 94 | * .fields({ 95 | * name: FormFields.string(), 96 | * age: FormFields.number(), 97 | * }) 98 | * ``` 99 | */ 100 | fields: ( 101 | fields: DecodersMap 102 | ) => SchemaBuilder$Complete; 103 | } 104 | 105 | interface SchemaBuilder$Fields { 106 | /** 107 | * Define form errors to be used by `FormValidatorBuilder`. 108 | * 109 | * @example 110 | * ``` 111 | * new FormSchemaBuilder() 112 | * .errors() 113 | * ``` 114 | */ 115 | errors: () => SchemaBuilder$Complete; 116 | 117 | /** finalize construction of `FormSchema` */ 118 | build: () => FormSchema; 119 | } 120 | 121 | interface SchemaBuilder$Complete { 122 | /** finalize construction of `FormSchema` */ 123 | build: () => FormSchema; 124 | } 125 | -------------------------------------------------------------------------------- /src/core/helpers/decode-change-event.ts: -------------------------------------------------------------------------------- 1 | import { ChangeEvent } from "react"; 2 | 3 | import { 4 | _FieldDecoderImpl, 5 | DecoderResult, 6 | _ArrayFieldDecoderImpl, 7 | isArrayDecoder, 8 | } from "../types/field-decoder"; 9 | 10 | enum HtmlInputTypes { 11 | Radio = "radio", 12 | Checkbox = "checkbox", 13 | 14 | SelectOne = "select-one", 15 | SelectMultiple = "select-multiple", 16 | 17 | Date = "date", 18 | DateTimeLocal = "datetime-local", 19 | DateTime = "datetime", 20 | Month = "month", 21 | Time = "time", 22 | } 23 | 24 | type SimplifiedEventTarget = { 25 | type: string; 26 | value: unknown; 27 | checked?: boolean; 28 | valueAsNumber?: number; 29 | options?: Array<{ selected: boolean; value: unknown }>; 30 | }; 31 | 32 | type Input = { 33 | fieldDecoder: _FieldDecoderImpl; 34 | getValue: () => T; 35 | event: ChangeEvent; 36 | }; 37 | 38 | export const decodeChangeEvent = ({ 39 | fieldDecoder, 40 | getValue, 41 | event, 42 | }: Input): DecoderResult => { 43 | const target = parseEventTarget(event); 44 | 45 | if (!target) { 46 | return { ok: false }; 47 | } 48 | 49 | switch (target.type) { 50 | case HtmlInputTypes.Radio: 51 | return target.checked ? fieldDecoder.decode(target.value) : { ok: false }; 52 | 53 | case HtmlInputTypes.Checkbox: 54 | switch (fieldDecoder.fieldType) { 55 | case "bool": 56 | return fieldDecoder.decode(target.checked); 57 | case "array": { 58 | return fieldDecoder.decode( 59 | resolveCheckboxArrayValue(target, fieldDecoder, getValue() as any) 60 | ); 61 | } 62 | 63 | default: 64 | return { ok: false }; 65 | } 66 | 67 | case HtmlInputTypes.SelectMultiple: { 68 | switch (fieldDecoder.fieldType) { 69 | case "array": { 70 | return fieldDecoder.decode( 71 | target.options?.filter(opt => opt.selected).map(opt => opt.value) 72 | ); 73 | } 74 | 75 | default: 76 | return { ok: false }; 77 | } 78 | } 79 | 80 | case HtmlInputTypes.Date: 81 | case HtmlInputTypes.DateTimeLocal: 82 | case HtmlInputTypes.DateTime: 83 | case HtmlInputTypes.Month: 84 | case HtmlInputTypes.Time: 85 | switch (fieldDecoder.fieldType) { 86 | case "date": 87 | case "number": 88 | return fieldDecoder.decode(target.valueAsNumber); 89 | default: 90 | return fieldDecoder.decode(target.value); 91 | } 92 | 93 | default: 94 | return fieldDecoder.decode(target.value); 95 | } 96 | }; 97 | 98 | const parseEventTarget = ( 99 | event?: ChangeEvent 100 | ): SimplifiedEventTarget | null => 101 | event?.target 102 | ? { 103 | type: event.target.type ?? "", 104 | value: event.target.value, 105 | checked: event.target.checked, 106 | valueAsNumber: event.target.valueAsNumber, 107 | options: event.target.options 108 | ? parseSelectOptions(event.target.options) 109 | : undefined, 110 | } 111 | : null; 112 | 113 | const parseSelectOptions = (options: HTMLOptionsCollection) => 114 | Array.from(options).map(it => ({ 115 | selected: it.selected, 116 | value: it.value, 117 | })); 118 | 119 | const resolveCheckboxArrayValue = ( 120 | target: SimplifiedEventTarget, 121 | decoder: _FieldDecoderImpl, 122 | currentValue: T[] 123 | ): T[] | null => { 124 | if (!isArrayDecoder(decoder)) { 125 | return null; 126 | } 127 | 128 | const elementResult = decoder.inner.decode(target.value); 129 | 130 | if (!elementResult.ok) { 131 | return null; 132 | } 133 | const element = elementResult.value as T; 134 | 135 | if (target.checked) { 136 | return [...currentValue, element]; 137 | } else { 138 | return currentValue.filter(it => it !== element); 139 | } 140 | }; 141 | -------------------------------------------------------------------------------- /docs/assets/highlight.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --light-hl-0: #795E26; 3 | --dark-hl-0: #DCDCAA; 4 | --light-hl-1: #000000; 5 | --dark-hl-1: #D4D4D4; 6 | --light-hl-2: #A31515; 7 | --dark-hl-2: #CE9178; 8 | --light-hl-3: #AF00DB; 9 | --dark-hl-3: #C586C0; 10 | --light-hl-4: #001080; 11 | --dark-hl-4: #9CDCFE; 12 | --light-hl-5: #0000FF; 13 | --dark-hl-5: #569CD6; 14 | --light-hl-6: #0070C1; 15 | --dark-hl-6: #4FC1FF; 16 | --light-hl-7: #267F99; 17 | --dark-hl-7: #4EC9B0; 18 | --light-hl-8: #800000; 19 | --dark-hl-8: #808080; 20 | --light-hl-9: #E50000; 21 | --dark-hl-9: #9CDCFE; 22 | --light-hl-10: #800000; 23 | --dark-hl-10: #569CD6; 24 | --light-hl-11: #000000FF; 25 | --dark-hl-11: #D4D4D4; 26 | --light-hl-12: #008000; 27 | --dark-hl-12: #6A9955; 28 | --light-hl-13: #098658; 29 | --dark-hl-13: #B5CEA8; 30 | --light-code-background: #FFFFFF; 31 | --dark-code-background: #1E1E1E; 32 | } 33 | 34 | @media (prefers-color-scheme: light) { :root { 35 | --hl-0: var(--light-hl-0); 36 | --hl-1: var(--light-hl-1); 37 | --hl-2: var(--light-hl-2); 38 | --hl-3: var(--light-hl-3); 39 | --hl-4: var(--light-hl-4); 40 | --hl-5: var(--light-hl-5); 41 | --hl-6: var(--light-hl-6); 42 | --hl-7: var(--light-hl-7); 43 | --hl-8: var(--light-hl-8); 44 | --hl-9: var(--light-hl-9); 45 | --hl-10: var(--light-hl-10); 46 | --hl-11: var(--light-hl-11); 47 | --hl-12: var(--light-hl-12); 48 | --hl-13: var(--light-hl-13); 49 | --code-background: var(--light-code-background); 50 | } } 51 | 52 | @media (prefers-color-scheme: dark) { :root { 53 | --hl-0: var(--dark-hl-0); 54 | --hl-1: var(--dark-hl-1); 55 | --hl-2: var(--dark-hl-2); 56 | --hl-3: var(--dark-hl-3); 57 | --hl-4: var(--dark-hl-4); 58 | --hl-5: var(--dark-hl-5); 59 | --hl-6: var(--dark-hl-6); 60 | --hl-7: var(--dark-hl-7); 61 | --hl-8: var(--dark-hl-8); 62 | --hl-9: var(--dark-hl-9); 63 | --hl-10: var(--dark-hl-10); 64 | --hl-11: var(--dark-hl-11); 65 | --hl-12: var(--dark-hl-12); 66 | --hl-13: var(--dark-hl-13); 67 | --code-background: var(--dark-code-background); 68 | } } 69 | 70 | :root[data-theme='light'] { 71 | --hl-0: var(--light-hl-0); 72 | --hl-1: var(--light-hl-1); 73 | --hl-2: var(--light-hl-2); 74 | --hl-3: var(--light-hl-3); 75 | --hl-4: var(--light-hl-4); 76 | --hl-5: var(--light-hl-5); 77 | --hl-6: var(--light-hl-6); 78 | --hl-7: var(--light-hl-7); 79 | --hl-8: var(--light-hl-8); 80 | --hl-9: var(--light-hl-9); 81 | --hl-10: var(--light-hl-10); 82 | --hl-11: var(--light-hl-11); 83 | --hl-12: var(--light-hl-12); 84 | --hl-13: var(--light-hl-13); 85 | --code-background: var(--light-code-background); 86 | } 87 | 88 | :root[data-theme='dark'] { 89 | --hl-0: var(--dark-hl-0); 90 | --hl-1: var(--dark-hl-1); 91 | --hl-2: var(--dark-hl-2); 92 | --hl-3: var(--dark-hl-3); 93 | --hl-4: var(--dark-hl-4); 94 | --hl-5: var(--dark-hl-5); 95 | --hl-6: var(--dark-hl-6); 96 | --hl-7: var(--dark-hl-7); 97 | --hl-8: var(--dark-hl-8); 98 | --hl-9: var(--dark-hl-9); 99 | --hl-10: var(--dark-hl-10); 100 | --hl-11: var(--dark-hl-11); 101 | --hl-12: var(--dark-hl-12); 102 | --hl-13: var(--dark-hl-13); 103 | --code-background: var(--dark-code-background); 104 | } 105 | 106 | .hl-0 { color: var(--hl-0); } 107 | .hl-1 { color: var(--hl-1); } 108 | .hl-2 { color: var(--hl-2); } 109 | .hl-3 { color: var(--hl-3); } 110 | .hl-4 { color: var(--hl-4); } 111 | .hl-5 { color: var(--hl-5); } 112 | .hl-6 { color: var(--hl-6); } 113 | .hl-7 { color: var(--hl-7); } 114 | .hl-8 { color: var(--hl-8); } 115 | .hl-9 { color: var(--hl-9); } 116 | .hl-10 { color: var(--hl-10); } 117 | .hl-11 { color: var(--hl-11); } 118 | .hl-12 { color: var(--hl-12); } 119 | .hl-13 { color: var(--hl-13); } 120 | pre, code { background: var(--code-background); } 121 | -------------------------------------------------------------------------------- /src/core/helpers/resolve-is-valid.spec.ts: -------------------------------------------------------------------------------- 1 | import { Lens } from "../../utils/lenses"; 2 | import { FieldDescriptor } from "../types/field-descriptor"; 3 | import { FieldErrors } from "../types/formts-state"; 4 | import { opaque } from "../types/type-mapper-util"; 5 | 6 | import { resolveIsValid } from "./resolve-is-valid"; 7 | 8 | const primitiveDescriptor = (path: string): FieldDescriptor => 9 | opaque({ 10 | __path: path, 11 | __decoder: { fieldType: "string" } as any, 12 | __lens: Lens.prop(path), // not used, 13 | }); 14 | 15 | const complexDescriptor = (path: string): FieldDescriptor => 16 | opaque({ 17 | __path: path, 18 | __decoder: { fieldType: "object" } as any, 19 | __lens: Lens.prop(path), // not used, 20 | }); 21 | 22 | describe("resolveIsValid", () => { 23 | it("handles primitive field errors", () => { 24 | const errors: FieldErrors = { 25 | foo: "error!", 26 | bar: undefined, 27 | }; 28 | 29 | expect(resolveIsValid(errors, primitiveDescriptor("foo"))).toBe(false); 30 | expect(resolveIsValid(errors, primitiveDescriptor("bar"))).toBe(true); 31 | expect(resolveIsValid(errors, primitiveDescriptor("baz"))).toBe(true); 32 | }); 33 | 34 | it("handles root array field errors", () => { 35 | const errors: FieldErrors = { 36 | array: "error!", 37 | }; 38 | 39 | expect(resolveIsValid(errors, complexDescriptor("array"))).toBe(false); 40 | expect(resolveIsValid(errors, primitiveDescriptor("array[42]"))).toBe(true); 41 | }); 42 | 43 | it("handles array item field errors", () => { 44 | const errors: FieldErrors = { 45 | "array[0]": "error!", 46 | }; 47 | 48 | expect(resolveIsValid(errors, primitiveDescriptor("array"))).toBe(true); 49 | expect(resolveIsValid(errors, complexDescriptor("array"))).toBe(false); 50 | expect(resolveIsValid(errors, primitiveDescriptor("array[0]"))).toBe(false); 51 | expect(resolveIsValid(errors, primitiveDescriptor("array[42]"))).toBe(true); 52 | }); 53 | 54 | it("handles root object field errors", () => { 55 | const errors: FieldErrors = { 56 | object: "error!", 57 | }; 58 | 59 | expect(resolveIsValid(errors, complexDescriptor("object"))).toBe(false); 60 | expect(resolveIsValid(errors, primitiveDescriptor("object.prop"))).toBe( 61 | true 62 | ); 63 | }); 64 | 65 | it("handles object property field errors", () => { 66 | const errors: FieldErrors = { 67 | "object.prop": "error!", 68 | }; 69 | 70 | expect(resolveIsValid(errors, primitiveDescriptor("object"))).toBe(true); 71 | expect(resolveIsValid(errors, complexDescriptor("object"))).toBe(false); 72 | expect(resolveIsValid(errors, primitiveDescriptor("object.prop"))).toBe( 73 | false 74 | ); 75 | expect( 76 | resolveIsValid(errors, primitiveDescriptor("object.otherProp")) 77 | ).toBe(true); 78 | }); 79 | 80 | it("handles nested object and array fields", () => { 81 | const errors: FieldErrors = { 82 | "nested.nestedArr[42].foo": "error!", 83 | }; 84 | 85 | expect(resolveIsValid(errors, complexDescriptor("nested"))).toBe(false); 86 | 87 | expect(resolveIsValid(errors, complexDescriptor("nested.nestedArr"))).toBe( 88 | false 89 | ); 90 | 91 | expect( 92 | resolveIsValid(errors, complexDescriptor("nested.nestedArr[42]")) 93 | ).toBe(false); 94 | 95 | expect( 96 | resolveIsValid(errors, primitiveDescriptor("nested.nestedArr[42].foo")) 97 | ).toBe(false); 98 | 99 | expect( 100 | resolveIsValid(errors, primitiveDescriptor("nested.otherProp")) 101 | ).toBe(true); 102 | 103 | expect( 104 | resolveIsValid(errors, complexDescriptor("nested.nestedArr[43]")) 105 | ).toBe(true); 106 | 107 | expect( 108 | resolveIsValid( 109 | errors, 110 | complexDescriptor("nested.nestedArr[42].otherProp") 111 | ) 112 | ).toBe(true); 113 | }); 114 | }); 115 | -------------------------------------------------------------------------------- /src/core/types/field-descriptor.ts: -------------------------------------------------------------------------------- 1 | import { Nominal, range, values } from "../../utils"; 2 | import { flatMap } from "../../utils/array"; 3 | import { Lens } from "../../utils/lenses"; 4 | 5 | import { _FieldDecoderImpl } from "./field-decoder"; 6 | import { GenericFieldTemplate } from "./field-template"; 7 | import { impl, opaque } from "./type-mapper-util"; 8 | 9 | // actual type, encapsulated away from public API 10 | export type _FieldDescriptorImpl = { 11 | __path: string; 12 | __decoder: _FieldDecoderImpl; 13 | __lens: Lens; // TODO maybe add root typing Lens 14 | __parent?: _FieldDescriptorImpl; 15 | }; 16 | 17 | export type _NTHHandler = { 18 | __rootPath: string; 19 | (n: number): _FieldDecoderImpl; 20 | }; 21 | 22 | /** 23 | * Pointer to a form field. 24 | * Used to interact with Formts API via `useField` hook. 25 | */ 26 | // @ts-ignore 27 | export interface FieldDescriptor 28 | extends Nominal<"FieldDescriptor", Err> {} 29 | 30 | // prettier-ignore 31 | export type GenericFieldDescriptor = 32 | [T] extends [Array] 33 | ? ArrayFieldDescriptor 34 | : [T] extends [object] 35 | ? ObjectFieldDescriptor 36 | : FieldDescriptor; 37 | 38 | // prettier-ignore 39 | export type ArrayFieldDescriptor, Err> = 40 | & FieldDescriptor 41 | & { 42 | readonly nth: (index: number) => GenericFieldDescriptor; 43 | readonly every: () => GenericFieldTemplate; 44 | }; 45 | 46 | // prettier-ignore 47 | export type ObjectFieldDescriptor = 48 | & FieldDescriptor 49 | & { readonly [K in keyof T]: GenericFieldDescriptor }; 50 | 51 | export const isArrayDescriptor = ( 52 | it: FieldDescriptor 53 | ): it is ArrayFieldDescriptor => 54 | impl(it).__decoder.fieldType === "array"; 55 | 56 | export const isObjectDescriptor = ( 57 | it: FieldDescriptor 58 | ): it is ObjectFieldDescriptor => 59 | impl(it).__decoder.fieldType === "object"; 60 | 61 | export const isPrimitiveDescriptor = ( 62 | field: FieldDescriptor 63 | ): boolean => { 64 | return !isArrayDescriptor(field) && !isObjectDescriptor(field); 65 | }; 66 | 67 | export const getArrayDescriptorChildren = , Err>( 68 | descriptor: ArrayFieldDescriptor, 69 | numberOfChildren: number 70 | ): Array> => { 71 | return range(0, numberOfChildren - 1).map(descriptor.nth); 72 | }; 73 | 74 | export const getObjectDescriptorChildren = ( 75 | descriptor: ObjectFieldDescriptor 76 | ): Array => { 77 | return values(descriptor); 78 | }; 79 | 80 | export const getChildrenDescriptors = ( 81 | descriptor: FieldDescriptor, 82 | getValue: (field: FieldDescriptor) => unknown 83 | ): Array> => { 84 | const root = [descriptor]; 85 | 86 | if (isObjectDescriptor(descriptor)) { 87 | const children = getObjectDescriptorChildren(descriptor); 88 | return root.concat( 89 | flatMap(children, x => getChildrenDescriptors(x, getValue)) 90 | ); 91 | } else if (isArrayDescriptor(descriptor)) { 92 | const numberOfChildren = (getValue(descriptor) as any[])?.length; 93 | if (numberOfChildren === 0) { 94 | return root; 95 | } 96 | const children = getArrayDescriptorChildren(descriptor, numberOfChildren); 97 | return root.concat( 98 | flatMap(children, x => 99 | getChildrenDescriptors(x as FieldDescriptor, getValue) 100 | ) 101 | ); 102 | } else { 103 | return root; 104 | } 105 | }; 106 | 107 | export const getParentsChain = ( 108 | descriptor: FieldDescriptor 109 | ): FieldDescriptor[] => { 110 | const parent = impl(descriptor).__parent; 111 | if (!parent) { 112 | return []; 113 | } else { 114 | const opaqueParent = opaque(parent) as FieldDescriptor; 115 | return [opaqueParent, ...getParentsChain(opaqueParent)]; 116 | } 117 | }; 118 | -------------------------------------------------------------------------------- /src/core/helpers/resolve-is-validating.spec.ts: -------------------------------------------------------------------------------- 1 | import { Lens } from "../../utils/lenses"; 2 | import { FieldDescriptor } from "../types/field-descriptor"; 3 | import { FieldValidatingState } from "../types/formts-state"; 4 | import { opaque } from "../types/type-mapper-util"; 5 | 6 | import { resolveIsValidating } from "./resolve-is-validating"; 7 | 8 | const primitiveDescriptor = (path: string): FieldDescriptor => 9 | opaque({ 10 | __path: path, 11 | __decoder: { fieldType: "string" } as any, 12 | __lens: Lens.prop(path), // not used, 13 | }); 14 | 15 | const complexDescriptor = (path: string): FieldDescriptor => 16 | opaque({ 17 | __path: path, 18 | __decoder: { fieldType: "object" } as any, 19 | __lens: Lens.prop(path), // not used, 20 | }); 21 | 22 | describe("resolveIsValidating", () => { 23 | it("handles primitive fields", () => { 24 | const state: FieldValidatingState = { 25 | foo: { aaa: true }, 26 | }; 27 | 28 | expect(resolveIsValidating(state, primitiveDescriptor("foo"))).toBe(true); 29 | expect(resolveIsValidating(state, primitiveDescriptor("bar"))).toBe(false); 30 | expect(resolveIsValidating(state, primitiveDescriptor("baz"))).toBe(false); 31 | }); 32 | 33 | it("handles root array fields", () => { 34 | const state: FieldValidatingState = { 35 | array: { aaa: true }, 36 | }; 37 | 38 | expect(resolveIsValidating(state, complexDescriptor("array"))).toBe(true); 39 | expect(resolveIsValidating(state, primitiveDescriptor("array[42]"))).toBe( 40 | false 41 | ); 42 | }); 43 | 44 | it("handles array item field", () => { 45 | const state: FieldValidatingState = { 46 | "array[0]": { aaa: true }, 47 | }; 48 | 49 | expect(resolveIsValidating(state, primitiveDescriptor("array"))).toBe( 50 | false 51 | ); 52 | expect(resolveIsValidating(state, complexDescriptor("array"))).toBe(true); 53 | expect(resolveIsValidating(state, primitiveDescriptor("array[0]"))).toBe( 54 | true 55 | ); 56 | expect(resolveIsValidating(state, primitiveDescriptor("array[42]"))).toBe( 57 | false 58 | ); 59 | }); 60 | 61 | it("handles root object field", () => { 62 | const state: FieldValidatingState = { 63 | object: { aaa: true }, 64 | }; 65 | 66 | expect(resolveIsValidating(state, complexDescriptor("object"))).toBe(true); 67 | expect(resolveIsValidating(state, primitiveDescriptor("object.prop"))).toBe( 68 | false 69 | ); 70 | }); 71 | 72 | it("handles object property field errors", () => { 73 | const state: FieldValidatingState = { 74 | "object.prop": { aaa: true }, 75 | }; 76 | 77 | expect(resolveIsValidating(state, primitiveDescriptor("object"))).toBe( 78 | false 79 | ); 80 | expect(resolveIsValidating(state, complexDescriptor("object"))).toBe(true); 81 | expect(resolveIsValidating(state, primitiveDescriptor("object.prop"))).toBe( 82 | true 83 | ); 84 | expect( 85 | resolveIsValidating(state, primitiveDescriptor("object.otherProp")) 86 | ).toBe(false); 87 | }); 88 | 89 | it("handles nested object and array fields", () => { 90 | const state: FieldValidatingState = { 91 | "nested.nestedArr[42].foo": { aaa: true }, 92 | }; 93 | 94 | expect(resolveIsValidating(state, complexDescriptor("nested"))).toBe(true); 95 | 96 | expect( 97 | resolveIsValidating(state, complexDescriptor("nested.nestedArr")) 98 | ).toBe(true); 99 | 100 | expect( 101 | resolveIsValidating(state, complexDescriptor("nested.nestedArr[42]")) 102 | ).toBe(true); 103 | 104 | expect( 105 | resolveIsValidating( 106 | state, 107 | primitiveDescriptor("nested.nestedArr[42].foo") 108 | ) 109 | ).toBe(true); 110 | 111 | expect( 112 | resolveIsValidating(state, primitiveDescriptor("nested.otherProp")) 113 | ).toBe(false); 114 | 115 | expect( 116 | resolveIsValidating(state, complexDescriptor("nested.nestedArr[43]")) 117 | ).toBe(false); 118 | 119 | expect( 120 | resolveIsValidating( 121 | state, 122 | complexDescriptor("nested.nestedArr[42].otherProp") 123 | ) 124 | ).toBe(false); 125 | }); 126 | }); 127 | -------------------------------------------------------------------------------- /src/utils/object.ts: -------------------------------------------------------------------------------- 1 | import { DeepPartial, IdentityDict } from "./utility-types"; 2 | 3 | export const entries = (o: T): [keyof T, T[keyof T]][] => 4 | Object.entries(o) as any; 5 | 6 | export const keys = (o: T): (keyof T)[] => 7 | Object.keys(o) as (keyof T)[]; 8 | 9 | export const values = (o: T): T[keyof T][] => 10 | Object.values(o) as T[keyof T][]; 11 | 12 | export const filter = ( 13 | obj: Record, 14 | predicate: (input: { key: string; value: T }) => boolean 15 | ): Record => 16 | entries(obj).reduce((acc, [key, value]) => { 17 | if (predicate({ key, value })) { 18 | acc[key] = value; 19 | } 20 | return acc; 21 | }, {} as Record); 22 | 23 | type JsPropertyDescriptor = { 24 | configurable?: boolean; 25 | enumerable?: boolean; 26 | value?: T; 27 | writable?: boolean; 28 | get?(): T; 29 | set?(v: T): void; 30 | }; 31 | export const defineProperties = ( 32 | obj: T, 33 | props: { 34 | [K in keyof P]: JsPropertyDescriptor; 35 | } 36 | ) => Object.defineProperties(obj, props) as T & P; 37 | 38 | export const toIdentityDict = ( 39 | values: T[] 40 | ): IdentityDict => 41 | values.reduce((dict, val) => { 42 | (dict as any)[val] = val; 43 | return dict; 44 | }, {} as IdentityDict); 45 | 46 | export const isPlainObject = (it: unknown): it is object => 47 | it != null && typeof it === "object" && (it as any).constructor === Object; 48 | 49 | // dummy impl 50 | export const get = (o: T, path: string): any => { 51 | const pathSegments = getPathSegments(path); 52 | let current = o; 53 | 54 | for (let depth = 0; depth < pathSegments.length; depth++) { 55 | const segment = pathSegments[depth]; 56 | 57 | if (segment in current) { 58 | current = (current as any)[segment]; 59 | } else { 60 | return undefined; 61 | } 62 | } 63 | 64 | return current; 65 | }; 66 | 67 | // dummy impl 68 | export const set = (o: T, path: string, value: any): T => { 69 | const pathSegments = getPathSegments(path); 70 | if (pathSegments.length === 0) { 71 | return o; 72 | } else { 73 | return setRecursive(o, value, pathSegments); 74 | } 75 | }; 76 | 77 | const setRecursive = (obj: T, value: any, pathSegments: string[]): T => { 78 | const path = pathSegments[0]; 79 | const copy = Array.isArray(obj) ? [...obj] : ({ ...obj } as any); 80 | if (pathSegments.length === 1) { 81 | copy[path] = value; 82 | } else { 83 | copy[path] = setRecursive(copy[path], value, pathSegments.slice(1)); 84 | } 85 | return copy; 86 | }; 87 | 88 | const getPathSegments = (path: string): string[] => { 89 | return path 90 | .replace(/(\[|\])/g, ".") 91 | .split(".") 92 | .filter((x: string) => x.length > 0); 93 | }; 94 | 95 | export const deepMerge = ( 96 | origin: T, 97 | x: DeepPartial 98 | ): T => { 99 | return keys(origin).reduce((acc, key) => { 100 | const value = origin[key]; 101 | const toMerge = (x as Partial)[key]; 102 | acc[key] = 103 | toMerge !== undefined 104 | ? Array.isArray(toMerge) || 105 | typeof toMerge !== "object" || 106 | toMerge === null || 107 | (toMerge as any) instanceof Date 108 | ? toMerge 109 | : deepMerge(value, toMerge as any) 110 | : value; 111 | return acc; 112 | }, {} as T); 113 | }; 114 | 115 | export const deepEqual = (left: unknown, right: unknown): boolean => { 116 | if (typeof left !== typeof right) { 117 | return false; 118 | } 119 | 120 | if ((!left && !!right) || (!!left && !right)) { 121 | return false; 122 | } 123 | 124 | if (Array.isArray(left) && Array.isArray(right)) { 125 | return left.length === right.length 126 | ? left.every((el, i) => deepEqual(el, right[i])) 127 | : false; 128 | } 129 | 130 | if (isPlainObject(left) && isPlainObject(right)) { 131 | return ( 132 | deepEqual(Object.keys(left), Object.keys(right)) && 133 | deepEqual(Object.values(left), Object.values(right)) 134 | ); 135 | } 136 | 137 | if (left instanceof Date && right instanceof Date) { 138 | return deepEqual(left.getTime(), right.getTime()); 139 | } 140 | 141 | if (Number.isNaN(left) && Number.isNaN(right)) { 142 | return true; 143 | } 144 | 145 | return left === right; 146 | }; 147 | -------------------------------------------------------------------------------- /src/core/helpers/create-initial-values.spec.ts: -------------------------------------------------------------------------------- 1 | import { FormFields, FormSchemaBuilder } from "../builders"; 2 | 3 | import { createInitialValues } from "./create-initial-values"; 4 | 5 | describe("createInitialValues", () => { 6 | it("should be empty for empty schema", () => { 7 | // @ts-ignore 8 | const Schema = new FormSchemaBuilder().fields({}).build(); 9 | 10 | expect(createInitialValues(Schema)).toEqual({}); 11 | }); 12 | 13 | it("for one-element schema", () => { 14 | const Schema = new FormSchemaBuilder() 15 | .fields({ 16 | stringField: FormFields.string(), 17 | }) 18 | .build(); 19 | 20 | expect(createInitialValues(Schema)).toEqual({ stringField: "" }); 21 | }); 22 | 23 | it("for multi-element schema", () => { 24 | const Schema = new FormSchemaBuilder() 25 | .fields({ 26 | stringField: FormFields.string(), 27 | boolField: FormFields.bool(), 28 | numberField: FormFields.number(), 29 | arrayField: FormFields.array(FormFields.number()), 30 | choiceField: FormFields.choice("Banana", "Avocado", "Cream"), 31 | }) 32 | .build(); 33 | 34 | expect(createInitialValues(Schema)).toEqual({ 35 | stringField: "", 36 | boolField: false, 37 | numberField: "", 38 | arrayField: [], 39 | choiceField: "Banana", 40 | }); 41 | }); 42 | 43 | it("for multi-element schema with single-element init", () => { 44 | const Schema = new FormSchemaBuilder() 45 | .fields({ 46 | stringField: FormFields.string(), 47 | boolField: FormFields.bool(), 48 | numberField: FormFields.number(), 49 | arrayField: FormFields.array(FormFields.number()), 50 | choiceField: FormFields.choice("Banana", "Avocado", "Cream"), 51 | }) 52 | .build(); 53 | 54 | expect(createInitialValues(Schema, { stringField: "dodo" })).toEqual({ 55 | stringField: "dodo", 56 | boolField: false, 57 | numberField: "", 58 | arrayField: [], 59 | choiceField: "Banana", 60 | }); 61 | }); 62 | 63 | it("for multi-element schema with multiple-element init", () => { 64 | const Schema = new FormSchemaBuilder() 65 | .fields({ 66 | stringField: FormFields.string(), 67 | boolField: FormFields.bool(), 68 | numberField: FormFields.number(), 69 | arrayField: FormFields.array(FormFields.number()), 70 | choiceField: FormFields.choice("Banana", "Avocado", "Cream"), 71 | }) 72 | .build(); 73 | 74 | expect( 75 | createInitialValues(Schema, { 76 | stringField: "dodo", 77 | boolField: true, 78 | numberField: 0, 79 | arrayField: [1, 2, 3], 80 | choiceField: "Cream", 81 | }) 82 | ).toEqual({ 83 | stringField: "dodo", 84 | boolField: true, 85 | numberField: 0, 86 | arrayField: [1, 2, 3], 87 | choiceField: "Cream", 88 | }); 89 | }); 90 | 91 | it("for nested-object", () => { 92 | const Schema = new FormSchemaBuilder() 93 | .fields({ 94 | parent: FormFields.object({ 95 | kain: FormFields.choice("Banana", "Spinach"), 96 | abel: FormFields.bool(), 97 | }), 98 | }) 99 | .build(); 100 | 101 | expect(createInitialValues(Schema, { parent: { abel: true } })).toEqual({ 102 | parent: { 103 | kain: "Banana", 104 | abel: true, 105 | }, 106 | }); 107 | }); 108 | 109 | it("for array of objects", () => { 110 | const Schema = new FormSchemaBuilder() 111 | .fields({ 112 | arr: FormFields.array( 113 | FormFields.object({ 114 | one: FormFields.string(), 115 | two: FormFields.array(FormFields.number()), 116 | }) 117 | ), 118 | }) 119 | .build(); 120 | 121 | // @ts-expect-error 122 | createInitialValues(Schema, { arr: [{ two: [10] }] }); 123 | 124 | expect( 125 | createInitialValues(Schema, { arr: [{ one: "foo", two: [10] }] }) 126 | ).toEqual({ 127 | arr: [{ one: "foo", two: [10] }], 128 | }); 129 | }); 130 | 131 | it("for date field", () => { 132 | const Schema = new FormSchemaBuilder() 133 | .fields({ 134 | dateField: FormFields.date(), 135 | }) 136 | .build(); 137 | 138 | expect( 139 | createInitialValues(Schema, { dateField: Date.UTC(2021, 1, 1) }) 140 | ).toEqual({ dateField: Date.UTC(2021, 1, 1) }); 141 | }); 142 | }); 143 | -------------------------------------------------------------------------------- /src/core/types/field-handle.ts: -------------------------------------------------------------------------------- 1 | import { ChangeEvent } from "react"; 2 | 3 | import { ArrayElement, IdentityDict, IsUnion } from "../../utils"; 4 | 5 | import { FieldDescriptor } from "./field-descriptor"; 6 | 7 | /** 8 | * Interface for specific form field. 9 | * Received using `useField` hook. 10 | */ 11 | // prettier-ignore 12 | export type FieldHandle = 13 | & BaseFieldHandle 14 | & ArrayFieldHandle 15 | & ObjectFieldHandle 16 | & ChoiceFieldHandle; 17 | 18 | export type _FieldHandleApprox = BaseFieldHandle & { 19 | children?: 20 | | Array<_FieldHandleApprox> 21 | | Record>; 22 | options?: Record; 23 | addItem?: (item: ArrayElement) => void; 24 | removeItem?: (i: number) => void; 25 | }; 26 | 27 | export const toApproxFieldHandle = (it: FieldHandle) => 28 | it as _FieldHandleApprox; 29 | export const toFieldHandle = (it: _FieldHandleApprox) => 30 | it as FieldHandle; 31 | 32 | type BaseFieldHandle = { 33 | /** Unique string generated for each field in the form based on field path */ 34 | id: string; 35 | 36 | /** Field value */ 37 | value: T; 38 | 39 | /** Field error */ 40 | error: null | Err; 41 | 42 | /** True if `setValue` `handleChange` or `handleBlur` were called for this field */ 43 | isTouched: boolean; 44 | 45 | /** True if the field value is different from its initial value */ 46 | isChanged: boolean; 47 | 48 | /** True if the field has no error and none of its children fields have errors */ 49 | isValid: boolean; 50 | 51 | /** True if validation process of the field is ongoing */ 52 | isValidating: boolean; 53 | 54 | /** FieldDescriptor corresponding to the field */ 55 | descriptor: FieldDescriptor; 56 | 57 | /** 58 | * Sets field value. 59 | * Will cause field validation to run with the `change` trigger. 60 | * Will set `isTouched` to `true`. 61 | * If value is not of the desired type there is no effect. 62 | */ 63 | setValue: (value: T) => void; 64 | 65 | /** 66 | * Attempts to extract value out of the event based on field type and `event.target`. 67 | * Will cause field validation to run with the `change` trigger. 68 | * Will set `isTouched` to `true`. 69 | * If value of the desired type can't be extracted there is no effect. 70 | */ 71 | handleChange: (event: ChangeEvent) => void; 72 | 73 | /** Sets field error, affecting `isValid` flag */ 74 | setError: (error: null | Err) => void; 75 | 76 | /** Directly sets `isTouched` flag */ 77 | setTouched: (touched: boolean) => void; 78 | 79 | /** runs all validation rules of the field, regardless of their validation triggers */ 80 | validate: () => void; 81 | 82 | /** resets value to initial value and clears related field state */ 83 | reset: () => void; 84 | 85 | /** 86 | * Will cause field validation to run with the `blur` trigger. 87 | * Will set `isTouched` to `true`. 88 | */ 89 | handleBlur: () => void; 90 | }; 91 | 92 | type ArrayFieldHandle = T extends Array 93 | ? { 94 | /** 95 | * Will set the field value its copy with the item added at the end. 96 | * Will run field validation with `change` trigger. 97 | * Will set `isTouched` to `true`. 98 | */ 99 | addItem: (item: E) => void; 100 | 101 | /** 102 | * Will set the field value its copy with the item at index `i` removed. 103 | * Will run field validation with `change` trigger. 104 | * Will set `isTouched` to `true`. 105 | */ 106 | removeItem: (i: number) => void; 107 | 108 | /** 109 | * Array of FieldHandles for each item stored in field value 110 | */ 111 | children: Array>; 112 | } 113 | : {}; 114 | 115 | type ObjectFieldHandle = T extends Array 116 | ? {} 117 | : T extends object 118 | ? { 119 | /** Object containing FieldHandles for each nested field */ 120 | children: { [K in keyof T]: FieldHandle }; 121 | } 122 | : {}; 123 | 124 | type ChoiceFieldHandle = [T] extends [string] 125 | ? IsUnion extends true 126 | ? { 127 | /** Dictionary containing options specified in Schema using `choice` function (excluding `""`) */ 128 | options: IdentityDict>; 129 | } 130 | : {} 131 | : {}; 132 | -------------------------------------------------------------------------------- /src/core/helpers/make-touched-values.spec.ts: -------------------------------------------------------------------------------- 1 | import { makeTouchedValues, makeUntouchedValues } from "./make-touched-values"; 2 | 3 | describe("makeTouchedValues", () => { 4 | it("should be empty for empty values", () => { 5 | const values = {}; 6 | 7 | const touched = makeTouchedValues(values); 8 | 9 | expect(touched).toEqual({}); 10 | }); 11 | 12 | it("should work for primitive values", () => { 13 | const values = { 14 | string: "", 15 | num: 42, 16 | choice: "A", 17 | bool: true, 18 | }; 19 | 20 | const touched = makeTouchedValues(values); 21 | 22 | expect(touched).toEqual({ 23 | string: true, 24 | num: true, 25 | choice: true, 26 | bool: true, 27 | }); 28 | }); 29 | 30 | it("should work for object values", () => { 31 | const values = { 32 | obj: { 33 | string: "", 34 | nestedObj: { 35 | num: 42, 36 | }, 37 | emptyObj: {}, 38 | }, 39 | }; 40 | 41 | const touched = makeTouchedValues(values); 42 | 43 | expect(touched).toEqual({ 44 | obj: { 45 | string: true, 46 | nestedObj: { 47 | num: true, 48 | }, 49 | emptyObj: {}, 50 | }, 51 | }); 52 | }); 53 | 54 | it("should work for date values", () => { 55 | const values = { 56 | empty: null as Date | null, 57 | instance: new Date() as Date | null, 58 | }; 59 | 60 | const touched = makeTouchedValues(values); 61 | 62 | expect(touched).toEqual({ 63 | empty: true, 64 | instance: true, 65 | }); 66 | }); 67 | 68 | it("should work for array values", () => { 69 | const values = { 70 | empty: [], 71 | filled: ["foo", "bar"], 72 | }; 73 | 74 | const touched = makeTouchedValues(values); 75 | 76 | expect(touched).toEqual({ 77 | empty: true, 78 | filled: [true, true], 79 | }); 80 | }); 81 | 82 | it("should work for nested object and array values values", () => { 83 | const values = { 84 | objArr: { arr: ["a", "b", "C"] }, 85 | arrObj: [{ foo: "bar" }], 86 | }; 87 | 88 | const touched = makeTouchedValues(values); 89 | 90 | expect(touched).toEqual({ 91 | objArr: { arr: [true, true, true] }, 92 | arrObj: [{ foo: true }], 93 | }); 94 | }); 95 | }); 96 | 97 | describe("makeUntouchedValues", () => { 98 | it("should be empty for empty values", () => { 99 | const values = {}; 100 | 101 | const touched = makeUntouchedValues(values); 102 | 103 | expect(touched).toEqual({}); 104 | }); 105 | 106 | it("should work for primitive values", () => { 107 | const values = { 108 | string: "", 109 | num: 42, 110 | choice: "A", 111 | bool: true, 112 | }; 113 | 114 | const touched = makeUntouchedValues(values); 115 | 116 | expect(touched).toEqual({ 117 | string: false, 118 | num: false, 119 | choice: false, 120 | bool: false, 121 | }); 122 | }); 123 | 124 | it("should work for object values", () => { 125 | const values = { 126 | obj: { 127 | string: "", 128 | nestedObj: { 129 | num: 42, 130 | }, 131 | emptyObj: {}, 132 | }, 133 | }; 134 | 135 | const touched = makeUntouchedValues(values); 136 | 137 | expect(touched).toEqual({ 138 | obj: { 139 | string: false, 140 | nestedObj: { 141 | num: false, 142 | }, 143 | emptyObj: {}, 144 | }, 145 | }); 146 | }); 147 | 148 | it("should work for date values", () => { 149 | const values = { 150 | empty: null as Date | null, 151 | instance: new Date() as Date | null, 152 | }; 153 | 154 | const touched = makeUntouchedValues(values); 155 | 156 | expect(touched).toEqual({ 157 | empty: false, 158 | instance: false, 159 | }); 160 | }); 161 | 162 | it("should work for array values", () => { 163 | const values = { 164 | empty: [], 165 | filled: ["foo", "bar"], 166 | }; 167 | 168 | const touched = makeUntouchedValues(values); 169 | 170 | expect(touched).toEqual({ 171 | empty: [], 172 | filled: [false, false], 173 | }); 174 | }); 175 | 176 | it("should work for nested object and array values values", () => { 177 | const values = { 178 | objArr: { arr: ["a", "b", "C"] }, 179 | arrObj: [{ foo: "bar" }], 180 | }; 181 | 182 | const touched = makeUntouchedValues(values); 183 | 184 | expect(touched).toEqual({ 185 | objArr: { arr: [false, false, false] }, 186 | arrObj: [{ foo: false }], 187 | }); 188 | }); 189 | }); 190 | -------------------------------------------------------------------------------- /src/core/helpers/branch-values.spec.ts: -------------------------------------------------------------------------------- 1 | import { FieldDescriptor } from "../types/field-descriptor"; 2 | 3 | import { 4 | constructBranchErrorsString, 5 | constructBranchValidatingString, 6 | } from "./branch-values"; 7 | 8 | const mockField = (path: string): FieldDescriptor => 9 | ({ __path: path } as any); 10 | 11 | describe("constructBranchErrorsString", () => { 12 | it("produces 2 different strings when field error changes", () => { 13 | const before = constructBranchErrorsString( 14 | { field: "ERR 1" }, 15 | mockField("field") 16 | ); 17 | 18 | const after = constructBranchErrorsString( 19 | { field: "ERR 2" }, 20 | mockField("field") 21 | ); 22 | 23 | expect(before).not.toEqual(after); 24 | }); 25 | 26 | it("produces 2 different strings when nested field error changes", () => { 27 | const before = constructBranchErrorsString( 28 | { "field.nested[0]": "ERR 1" }, 29 | mockField("field.nested[0]") 30 | ); 31 | 32 | const after = constructBranchErrorsString( 33 | { "field.nested[0]": "ERR 2" }, 34 | mockField("field.nested[0]") 35 | ); 36 | 37 | expect(before).not.toEqual(after); 38 | }); 39 | 40 | it("produces 2 different strings when object child field error changes", () => { 41 | const before = constructBranchErrorsString( 42 | { "field.child": "ERR 1" }, 43 | mockField("field") 44 | ); 45 | 46 | const after = constructBranchErrorsString( 47 | { "field.child": "ERR 2" }, 48 | mockField("field") 49 | ); 50 | 51 | expect(before).not.toEqual(after); 52 | }); 53 | 54 | it("produces 2 different strings when array child field error changes", () => { 55 | const before = constructBranchErrorsString( 56 | { "field[2]": "ERR 1" }, 57 | mockField("field") 58 | ); 59 | 60 | const after = constructBranchErrorsString( 61 | { "field[2]": "ERR 2" }, 62 | mockField("field") 63 | ); 64 | 65 | expect(before).not.toEqual(after); 66 | }); 67 | 68 | it("produces 2 same strings when unrelated field error changes", () => { 69 | const before = constructBranchErrorsString( 70 | { field: "ERR 1", field2: "ERR 1" }, 71 | mockField("field") 72 | ); 73 | 74 | const after = constructBranchErrorsString( 75 | { field: "ERR 1", field2: "ERR 2" }, 76 | mockField("field") 77 | ); 78 | 79 | expect(before).toEqual(after); 80 | }); 81 | }); 82 | 83 | describe("constructBranchValidatingString", () => { 84 | it("produces 2 different strings when field validating state changes", () => { 85 | const before = constructBranchValidatingString( 86 | { field: {} }, 87 | mockField("field") 88 | ); 89 | 90 | const after = constructBranchValidatingString( 91 | { field: { "123": true } }, 92 | mockField("field") 93 | ); 94 | 95 | expect(before).not.toEqual(after); 96 | }); 97 | 98 | it("produces 2 different strings when nested field validating state changes", () => { 99 | const before = constructBranchValidatingString( 100 | { "field.nested[0]": {} }, 101 | mockField("field.nested[0]") 102 | ); 103 | 104 | const after = constructBranchValidatingString( 105 | { "field.nested[0]": { "123": true } }, 106 | mockField("field.nested[0]") 107 | ); 108 | 109 | expect(before).not.toEqual(after); 110 | }); 111 | 112 | it("produces 2 different strings when object child field validating state changes", () => { 113 | const before = constructBranchValidatingString( 114 | { "field.child": {} }, 115 | mockField("field") 116 | ); 117 | 118 | const after = constructBranchValidatingString( 119 | { "field.child": { "123": true } }, 120 | mockField("field") 121 | ); 122 | 123 | expect(before).not.toEqual(after); 124 | }); 125 | 126 | it("produces 2 different strings when array child field validating state changes", () => { 127 | const before = constructBranchValidatingString( 128 | { "field[2]": {} }, 129 | mockField("field") 130 | ); 131 | 132 | const after = constructBranchValidatingString( 133 | { "field[2]": { "123": true } }, 134 | mockField("field") 135 | ); 136 | 137 | expect(before).not.toEqual(after); 138 | }); 139 | 140 | it("produces 2 same strings when unrelated field validating state changes", () => { 141 | const before = constructBranchValidatingString( 142 | { field: {}, field2: {} }, 143 | mockField("field") 144 | ); 145 | 146 | const after = constructBranchValidatingString( 147 | { field: {}, field2: { "123": true } }, 148 | mockField("field") 149 | ); 150 | 151 | expect(before).toEqual(after); 152 | }); 153 | }); 154 | -------------------------------------------------------------------------------- /src/core/decoders/object.spec.ts: -------------------------------------------------------------------------------- 1 | import { assert, IsExact } from "conditional-type-checks"; 2 | 3 | import { impl } from "../types/type-mapper-util"; 4 | 5 | import { array } from "./array"; 6 | import { bool } from "./bool"; 7 | import { choice } from "./choice"; 8 | import { date } from "./date"; 9 | import { number } from "./number"; 10 | import { object } from "./object"; 11 | import { string } from "./string"; 12 | 13 | describe("object decoder", () => { 14 | it("should force the user to provide object properties", () => { 15 | const invalidDecoder = object({}); 16 | assert>(true); 17 | }); 18 | 19 | it("should provide it's field type", () => { 20 | const decoder = impl(object({ str: string() })); 21 | 22 | expect(decoder.fieldType).toBe("object"); 23 | }); 24 | 25 | it("should provide initial value composed from inner decoders", () => { 26 | const decoder = impl( 27 | object({ 28 | bool: bool(), 29 | str: string(), 30 | num: number(), 31 | arr: array(string()), 32 | choice: choice("A", "B"), 33 | }) 34 | ); 35 | 36 | expect(decoder.init()).toEqual({ 37 | bool: false, 38 | str: "", 39 | num: "", 40 | arr: [], 41 | choice: "A", 42 | }); 43 | }); 44 | 45 | it("should expose inner decoders", () => { 46 | const innerDecoders = { 47 | bool: bool(), 48 | str: string(), 49 | num: number(), 50 | arr: array(string()), 51 | choice: choice("A", "B"), 52 | }; 53 | const decoder = impl(object(innerDecoders)); 54 | 55 | expect(decoder.inner).toEqual(innerDecoders); 56 | }); 57 | 58 | describe("combined with primitive decoders", () => { 59 | it("should decode valid object", () => { 60 | const decoder = impl( 61 | object({ 62 | bool: bool(), 63 | str: string(), 64 | num: number(), 65 | choice: choice("A", "B"), 66 | date: date(), 67 | }) 68 | ); 69 | 70 | const value = { 71 | bool: false, 72 | str: "foo", 73 | num: 42, 74 | choice: "B", 75 | date: new Date(), 76 | }; 77 | 78 | expect(decoder.decode(value)).toEqual({ ok: true, value }); 79 | }); 80 | 81 | it("should NOT decode invalid object", () => { 82 | const decoder = impl( 83 | object({ 84 | bool: bool(), 85 | str: string(), 86 | num: number(), 87 | choice: choice("A", "B"), 88 | date: date(), 89 | }) 90 | ); 91 | 92 | const value = { 93 | bool: false, 94 | str: "foo", 95 | num: 42, 96 | choice: "invalid choice", 97 | date: new Date(), 98 | }; 99 | 100 | expect(decoder.decode(value)).toEqual({ ok: false }); 101 | }); 102 | 103 | it("should NOT decode empty object", () => { 104 | const decoder = impl( 105 | object({ 106 | bool: bool(), 107 | str: string(), 108 | num: number(), 109 | choice: choice("A", "B"), 110 | date: date(), 111 | }) 112 | ); 113 | 114 | const value = {}; 115 | 116 | expect(decoder.decode(value)).toEqual({ ok: false }); 117 | }); 118 | 119 | it("should NOT decode nulls", () => { 120 | const decoder = impl( 121 | object({ 122 | bool: bool(), 123 | str: string(), 124 | num: number(), 125 | choice: choice("A", "B"), 126 | date: date(), 127 | }) 128 | ); 129 | 130 | const value = null; 131 | 132 | expect(decoder.decode(value)).toEqual({ ok: false }); 133 | }); 134 | }); 135 | 136 | describe("combined with nested arrays and objects", () => { 137 | it("should decode valid object", () => { 138 | const decoder = impl( 139 | object({ 140 | arr: array(object({ bool: bool() })), 141 | obj: object({ arr: array(bool()) }), 142 | }) 143 | ); 144 | 145 | const value = { 146 | arr: [{ bool: true }, { bool: false }], 147 | obj: { arr: [false, true] }, 148 | }; 149 | 150 | expect(decoder.decode(value)).toEqual({ ok: true, value }); 151 | }); 152 | 153 | it("should NOT decode invalid object", () => { 154 | const decoder = impl( 155 | object({ 156 | arr: array(object({ bool: bool() })), 157 | obj: object({ arr: array(bool()) }), 158 | }) 159 | ); 160 | 161 | const value = { 162 | arr: [{ bool: "huh?" }, { bool: false }], 163 | obj: { arr: [false, true] }, 164 | }; 165 | 166 | expect(decoder.decode(value)).toEqual({ ok: false }); 167 | }); 168 | }); 169 | }); 170 | -------------------------------------------------------------------------------- /src/core/types/field-template.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | generateFieldPathsFromTemplate, 3 | createRegexForTemplate, 4 | } from "./field-template"; 5 | 6 | describe("generateFieldPathsFromTemplate", () => { 7 | it("array[*]", () => { 8 | const template = "array[*]"; 9 | const getValue = (path: string): any => { 10 | switch (path) { 11 | case "array": 12 | return [1, 2, 3]; 13 | } 14 | }; 15 | 16 | const paths = generateFieldPathsFromTemplate(template, getValue); 17 | 18 | expect(paths).toEqual(["array[0]", "array[1]", "array[2]"]); 19 | }); 20 | 21 | it("array[*].a.b.c", () => { 22 | const template = "array[*].a.b.c"; 23 | const getValue = (path: string): any => { 24 | switch (path) { 25 | case "array": 26 | return [1, 2, 3]; 27 | } 28 | }; 29 | 30 | const paths = generateFieldPathsFromTemplate(template, getValue); 31 | 32 | expect(paths).toEqual([ 33 | "array[0].a.b.c", 34 | "array[1].a.b.c", 35 | "array[2].a.b.c", 36 | ]); 37 | }); 38 | 39 | it("array[*][*]", () => { 40 | const template = "array[*][*]"; 41 | const getValue = (path: string): any => { 42 | switch (path) { 43 | case "array": 44 | return [1, 2, 3]; 45 | case "array[0]": 46 | return [1]; 47 | case "array[1]": 48 | return [1, 2]; 49 | case "array[2]": 50 | return [1, 2, 3]; 51 | } 52 | }; 53 | 54 | const paths = generateFieldPathsFromTemplate(template, getValue); 55 | 56 | expect(paths).toEqual([ 57 | "array[0][0]", 58 | "array[1][0]", 59 | "array[1][1]", 60 | "array[2][0]", 61 | "array[2][1]", 62 | "array[2][2]", 63 | ]); 64 | }); 65 | 66 | it("array[*][*] with array=[]", () => { 67 | const template = "array[*][*]"; 68 | const getValue = (path: string): any => { 69 | switch (path) { 70 | case "array": 71 | return []; 72 | case "array[0]": 73 | return [1]; 74 | case "array[1]": 75 | return [1, 2]; 76 | case "array[2]": 77 | return [1, 2, 3]; 78 | } 79 | }; 80 | 81 | const paths = generateFieldPathsFromTemplate(template, getValue); 82 | 83 | expect(paths).toEqual([]); 84 | }); 85 | 86 | it("array[*][*] with empty nested arrays", () => { 87 | const template = "array[*][*]"; 88 | const getValue = (path: string): any => { 89 | switch (path) { 90 | case "array": 91 | return [1, 2, 3]; 92 | case "array[0]": 93 | return []; 94 | case "array[1]": 95 | return [1, 2]; 96 | case "array[2]": 97 | return []; 98 | } 99 | }; 100 | 101 | const paths = generateFieldPathsFromTemplate(template, getValue); 102 | 103 | expect(paths).toEqual(["array[1][0]", "array[1][1]"]); 104 | }); 105 | 106 | it("array[*].list[*]", () => { 107 | const template = "array[*].list[*]"; 108 | const getValue = (path: string): any => { 109 | switch (path) { 110 | case "array": 111 | return [1, 2, 3]; 112 | case "array[0].list": 113 | return [1]; 114 | case "array[1].list": 115 | return [1]; 116 | case "array[2].list": 117 | return [1]; 118 | } 119 | }; 120 | 121 | const paths = generateFieldPathsFromTemplate(template, getValue); 122 | 123 | expect(paths).toEqual([ 124 | "array[0].list[0]", 125 | "array[1].list[0]", 126 | "array[2].list[0]", 127 | ]); 128 | }); 129 | }); 130 | 131 | describe("pathMatchesTemplatePath", () => { 132 | it("array[0] match template array[*]", () => { 133 | const template = "array[*]"; 134 | const path = "array[0]"; 135 | 136 | const regex = createRegexForTemplate(template); 137 | 138 | expect(!!path.match(regex)).toBeTruthy(); 139 | }); 140 | 141 | it("array[1].obj.str match template array[*].obj.str", () => { 142 | const template = "array[*].obj.str"; 143 | const path = "array[1].obj.str"; 144 | 145 | const regex = createRegexForTemplate(template); 146 | 147 | expect(!!path.match(regex)).toBeTruthy(); 148 | }); 149 | 150 | it("array[1][2][3] match template array[*][*][*]", () => { 151 | const template = "array[*][*][*]"; 152 | const path = "array[1][2][3]"; 153 | 154 | const regex = createRegexForTemplate(template); 155 | 156 | expect(!!path.match(regex)).toBeTruthy(); 157 | }); 158 | 159 | it("array[1][2][3] match template array[*][2][*]", () => { 160 | const template = "array[*][2][*]"; 161 | const path = "array[1][2][3]"; 162 | 163 | const regex = createRegexForTemplate(template); 164 | 165 | expect(!!path.match(regex)).toBeTruthy(); 166 | }); 167 | 168 | it("array[1][2][2] not match template array[*][2][3]", () => { 169 | const template = "array[*][2][3]"; 170 | const path = "array[1][2][2]"; 171 | 172 | const regex = createRegexForTemplate(template); 173 | 174 | expect(!!path.match(regex)).toBeFalsy(); 175 | }); 176 | }); 177 | -------------------------------------------------------------------------------- /src/core/builders/schema/create-schema.ts: -------------------------------------------------------------------------------- 1 | import { assertNever, defineProperties, keys } from "../../../utils"; 2 | import { Lens } from "../../../utils/lenses"; 3 | import { FieldDecoder, _FieldDecoderImpl } from "../../types/field-decoder"; 4 | import { _FieldDescriptorImpl } from "../../types/field-descriptor"; 5 | import { _FieldTemplateImpl } from "../../types/field-template"; 6 | import { FormSchema } from "../../types/form-schema"; 7 | 8 | export type DecodersMap = keyof O extends never 9 | ? never 10 | : { [K in keyof O]: FieldDecoder }; 11 | 12 | export const createFormSchema = ( 13 | decoders: DecodersMap 14 | ): FormSchema => createObjectSchema(decoders, Lens.identity()); 15 | 16 | const createObjectSchema = ( 17 | decodersMap: DecodersMap, 18 | lens: Lens, 19 | path?: string, 20 | parent?: _FieldDescriptorImpl 21 | ) => { 22 | return keys(decodersMap).reduce((schema, key) => { 23 | const decoder = decodersMap[key]; 24 | const keyStr = String(key); 25 | 26 | (schema as any)[key] = createFieldDescriptor( 27 | decoder as any, 28 | Lens.compose(lens, Lens.prop(key as any)), 29 | path ? `${path}.${keyStr}` : `${keyStr}`, 30 | parent 31 | ); 32 | 33 | return schema; 34 | }, {} as FormSchema); 35 | }; 36 | 37 | const createFieldDescriptor = ( 38 | decoder: _FieldDecoderImpl, 39 | lens: Lens, 40 | path: string, 41 | parent?: _FieldDescriptorImpl 42 | ): _FieldDescriptorImpl => { 43 | // these properties are hidden implementation details and thus should not be enumerable 44 | const rootDescriptor = defineProperties( 45 | {}, 46 | { 47 | __decoder: hiddenJsProperty(decoder), 48 | __path: hiddenJsProperty(path), 49 | __lens: hiddenJsProperty(lens), 50 | __parent: hiddenJsProperty(parent), 51 | } 52 | ); 53 | 54 | switch (decoder.fieldType) { 55 | case "bool": 56 | case "number": 57 | case "string": 58 | case "date": 59 | case "choice": 60 | return rootDescriptor; 61 | 62 | case "array": { 63 | const nthHandler = (i: number) => 64 | createFieldDescriptor( 65 | decoder.inner as _FieldDecoderImpl, 66 | Lens.compose(lens, Lens.index(i)), 67 | `${path}[${i}]`, 68 | rootDescriptor 69 | ); 70 | 71 | const nth = defineProperties(nthHandler, { 72 | __rootPath: hiddenJsProperty(path), 73 | }); 74 | 75 | const every = () => 76 | createFieldTemplate( 77 | decoder.inner as _FieldDecoderImpl, 78 | `${path}[*]` 79 | ); 80 | 81 | return Object.assign(rootDescriptor, { nth, every }); 82 | } 83 | 84 | case "object": { 85 | const props = createObjectSchema( 86 | decoder.inner as DecodersMap, 87 | lens, 88 | path, 89 | rootDescriptor 90 | ); 91 | return Object.assign(rootDescriptor, props); 92 | } 93 | 94 | default: 95 | return assertNever(decoder.fieldType); 96 | } 97 | }; 98 | 99 | const createFieldTemplate = ( 100 | decoder: _FieldDecoderImpl, 101 | path: string 102 | ): _FieldTemplateImpl => { 103 | // these properties are hidden implementation details and thus should not be enumerable 104 | const rootDescriptor = defineProperties( 105 | {}, 106 | { __path: hiddenJsProperty(path) } 107 | ); 108 | 109 | switch (decoder.fieldType) { 110 | case "bool": 111 | case "number": 112 | case "string": 113 | case "date": 114 | case "choice": 115 | return rootDescriptor; 116 | 117 | case "array": { 118 | const nthHandler = (i: number) => 119 | createFieldTemplate( 120 | decoder.inner as _FieldDecoderImpl, 121 | `${path}[${i}]` 122 | ); 123 | 124 | const nth = defineProperties(nthHandler, { 125 | __rootPath: hiddenJsProperty(path), 126 | }); 127 | 128 | const every = () => 129 | createFieldTemplate( 130 | decoder.inner as _FieldDecoderImpl, 131 | `${path}[*]` 132 | ); 133 | 134 | return Object.assign(rootDescriptor, { nth, every }); 135 | } 136 | 137 | case "object": { 138 | const props = createObjectTemplateSchema( 139 | decoder.inner as DecodersMap, 140 | path 141 | ); 142 | return Object.assign(rootDescriptor, props); 143 | } 144 | 145 | default: 146 | return assertNever(decoder.fieldType); 147 | } 148 | }; 149 | 150 | const createObjectTemplateSchema = ( 151 | decodersMap: DecodersMap, 152 | path: string 153 | ) => { 154 | return keys(decodersMap).reduce((schema, key) => { 155 | const decoder = decodersMap[key]; 156 | const keyStr = String(key); 157 | 158 | (schema as any)[key] = createFieldTemplate( 159 | decoder as any, 160 | `${path}.${keyStr}` 161 | ); 162 | 163 | return schema; 164 | }, {} as { [x in keyof O]: _FieldTemplateImpl }); 165 | }; 166 | 167 | const hiddenJsProperty = (value: T) => 168 | ({ 169 | value, 170 | enumerable: false, 171 | writable: false, 172 | configurable: false, 173 | } as const); 174 | -------------------------------------------------------------------------------- /src/core/types/form-schema.spec.ts: -------------------------------------------------------------------------------- 1 | import { IsExact, assert } from "conditional-type-checks"; 2 | 3 | import { FieldDescriptor } from "./field-descriptor"; 4 | import { ArrayFieldTemplate, FieldTemplate } from "./field-template"; 5 | import { FormSchema, ExtractFormValues } from "./form-schema"; 6 | 7 | type SomeValues = { 8 | string: string; 9 | choice: "A" | "B" | "C"; 10 | num: number | ""; 11 | bool: boolean; 12 | arrayString: string[]; 13 | arrayChoice: Array<"A" | "B" | "C">; 14 | arrayArrayString: string[][]; 15 | obj: { string: string }; 16 | objObj: { nested: { num: number | "" } }; 17 | objObjArray: { nested: { arrayString: string[] } }; 18 | arrayObj: Array<{ string: string }>; 19 | date: Date | null; 20 | }; 21 | 22 | type SomeErr = "err1" | "err2"; 23 | 24 | describe("FormSchema type", () => { 25 | type Schema = FormSchema; 26 | 27 | it("is an object with a key for every input object key", () => { 28 | type Actual = keyof Schema; 29 | type Expected = 30 | | "string" 31 | | "choice" 32 | | "num" 33 | | "bool" 34 | | "arrayString" 35 | | "arrayChoice" 36 | | "arrayArrayString" 37 | | "obj" 38 | | "objObj" 39 | | "objObjArray" 40 | | "arrayObj" 41 | | "date"; 42 | 43 | assert>(true); 44 | }); 45 | 46 | it("handles string fields", () => { 47 | type Actual = Schema["string"]; 48 | type Expected = FieldDescriptor; 49 | 50 | assert>(true); 51 | }); 52 | 53 | it("handles choice fields", () => { 54 | type Actual = Schema["choice"]; 55 | type Expected = FieldDescriptor<"A" | "B" | "C", SomeErr>; 56 | 57 | assert>(true); 58 | }); 59 | 60 | it("handles number fields", () => { 61 | type Actual = Schema["num"]; 62 | type Expected = FieldDescriptor; 63 | 64 | assert>(true); 65 | }); 66 | 67 | it("handles boolean fields", () => { 68 | type Actual = Schema["bool"]; 69 | type Expected = FieldDescriptor; 70 | 71 | assert>(true); 72 | }); 73 | 74 | it("handles array fields", () => { 75 | type Actual = Schema["arrayString"]; 76 | 77 | type Expected = FieldDescriptor & { 78 | nth: (index: number) => FieldDescriptor; 79 | every: () => FieldTemplate; 80 | }; 81 | 82 | assert>(true); 83 | }); 84 | 85 | it("handles nested array fields", () => { 86 | type Actual = Schema["arrayArrayString"]; 87 | 88 | type Expected = FieldDescriptor & { 89 | nth: ( 90 | index: number 91 | ) => FieldDescriptor & { 92 | nth: (index: number) => FieldDescriptor; 93 | every: () => FieldTemplate; 94 | }; 95 | every: () => ArrayFieldTemplate; 96 | }; 97 | 98 | assert>(true); 99 | }); 100 | 101 | it("handles object fields", () => { 102 | type Actual = Schema["obj"]; 103 | 104 | type Expected = FieldDescriptor<{ string: string }, SomeErr> & { 105 | string: FieldDescriptor; 106 | }; 107 | 108 | assert>(true); 109 | }); 110 | 111 | it("handles nested object fields", () => { 112 | type Actual = Schema["objObj"]; 113 | 114 | type Expected = FieldDescriptor< 115 | { nested: { num: number | "" } }, 116 | SomeErr 117 | > & { 118 | nested: FieldDescriptor<{ num: number | "" }, SomeErr> & { 119 | num: FieldDescriptor; 120 | }; 121 | }; 122 | 123 | assert>(true); 124 | }); 125 | 126 | it("handles deeply nested array fields", () => { 127 | type Actual = Schema["objObjArray"]; 128 | 129 | type Expected = FieldDescriptor< 130 | { nested: { arrayString: string[] } }, 131 | SomeErr 132 | > & { 133 | nested: FieldDescriptor<{ arrayString: string[] }, SomeErr> & { 134 | arrayString: FieldDescriptor & { 135 | nth: (index: number) => FieldDescriptor; 136 | every: () => FieldTemplate; 137 | }; 138 | }; 139 | }; 140 | 141 | assert>(true); 142 | }); 143 | 144 | it("handles array of objects", () => { 145 | type Actual = Schema["arrayObj"]; 146 | 147 | type Expected = FieldDescriptor, SomeErr> & { 148 | nth: ( 149 | index: number 150 | ) => FieldDescriptor<{ string: string }, SomeErr> & { 151 | string: FieldDescriptor; 152 | }; 153 | every: () => FieldTemplate<{ string: string }, SomeErr> & { 154 | string: FieldTemplate; 155 | }; 156 | }; 157 | 158 | assert>(true); 159 | }); 160 | 161 | it("handles date fields", () => { 162 | type Actual = Schema["date"]; 163 | 164 | type Expected = FieldDescriptor; 165 | 166 | assert>(true); 167 | }); 168 | }); 169 | 170 | describe("ExtractFormValues type", () => { 171 | it("extracts type of form values out of FormSchema type", () => { 172 | type Schema = FormSchema; 173 | 174 | type Actual = ExtractFormValues; 175 | type Expected = SomeValues; 176 | 177 | assert>(true); 178 | }); 179 | 180 | it("resolves to never for invalid input", () => { 181 | type Actual = ExtractFormValues<{}>; 182 | type Expected = never; 183 | 184 | assert>(true); 185 | }); 186 | }); 187 | -------------------------------------------------------------------------------- /src/core/types/field-descriptor.spec.ts: -------------------------------------------------------------------------------- 1 | import { FormFields, FormSchemaBuilder } from "../builders"; 2 | 3 | import { 4 | FieldDescriptor, 5 | getChildrenDescriptors, 6 | getParentsChain, 7 | } from "./field-descriptor"; 8 | import { impl } from "./type-mapper-util"; 9 | 10 | const Schema = new FormSchemaBuilder() 11 | .fields({ 12 | theString: FormFields.string(), 13 | theNumber: FormFields.number(), 14 | theArray: FormFields.array(FormFields.string()), 15 | theObject: FormFields.object({ 16 | foo: FormFields.string(), 17 | }), 18 | theObjectArray: FormFields.object({ 19 | arr: FormFields.array(FormFields.string()), 20 | }), 21 | }) 22 | .errors() 23 | .build(); 24 | 25 | describe("getChildrenDescriptors", () => { 26 | it("should return no children for primitive types", () => { 27 | const getValue = () => ""; 28 | 29 | { 30 | const children = getChildrenDescriptors(Schema.theString, getValue); 31 | expect(children.length).toEqual(1); 32 | expect(impl(children[0]).__path).toEqual("theString"); 33 | } 34 | 35 | { 36 | const children = getChildrenDescriptors(Schema.theNumber, getValue); 37 | expect(children.length).toEqual(1); 38 | expect(impl(children[0]).__path).toEqual("theNumber"); 39 | } 40 | }); 41 | 42 | it("should return no children for empty array", () => { 43 | const getValue = (field: FieldDescriptor) => { 44 | const path = impl(field).__path; 45 | switch (path) { 46 | case "theArray": 47 | return []; 48 | default: 49 | return null; 50 | } 51 | }; 52 | 53 | const children = getChildrenDescriptors(Schema.theArray, getValue); 54 | 55 | expect(children.length).toEqual(1); 56 | expect(impl(children[0]).__path).toEqual("theArray"); 57 | }); 58 | 59 | it("should return n children for n-element array", () => { 60 | const getValue = (field: FieldDescriptor) => { 61 | const path = impl(field).__path; 62 | switch (path) { 63 | case "theArray": 64 | return ["one", "two", "three"]; 65 | case "theArray[0]": 66 | return "one"; 67 | case "theArray[1]": 68 | return "two"; 69 | case "theArray[2]": 70 | return "three"; 71 | default: 72 | return null; 73 | } 74 | }; 75 | 76 | const children = getChildrenDescriptors(Schema.theArray, getValue); 77 | 78 | expect(children.length).toEqual(4); 79 | expect(impl(children[0]).__path).toEqual("theArray"); 80 | expect(impl(children[1]).__path).toEqual("theArray[0]"); 81 | expect(impl(children[2]).__path).toEqual("theArray[1]"); 82 | expect(impl(children[3]).__path).toEqual("theArray[2]"); 83 | }); 84 | 85 | it("should return proper children for object", () => { 86 | const getValue = (field: FieldDescriptor) => { 87 | const path = impl(field).__path; 88 | switch (path) { 89 | case "theObject": 90 | return { foo: "foo" }; 91 | case "theObject.foo": 92 | return "foo"; 93 | default: 94 | return null; 95 | } 96 | }; 97 | 98 | const children = getChildrenDescriptors(Schema.theObject, getValue); 99 | 100 | expect(children.length).toEqual(2); 101 | expect(impl(children[0]).__path).toEqual("theObject"); 102 | expect(impl(children[1]).__path).toEqual("theObject.foo"); 103 | }); 104 | 105 | it("should return proper children for object of arrays", () => { 106 | const getValue = (field: FieldDescriptor) => { 107 | const path = impl(field).__path; 108 | switch (path) { 109 | case "theObjectArray": 110 | return { arr: [""] }; 111 | case "theObjectArray.arr": 112 | return [""]; 113 | case "theObjectArray.arr[0]": 114 | return ""; 115 | default: 116 | return null; 117 | } 118 | }; 119 | 120 | const children = getChildrenDescriptors(Schema.theObjectArray, getValue); 121 | 122 | expect(children.length).toEqual(3); 123 | expect(impl(children[0]).__path).toEqual("theObjectArray"); 124 | expect(impl(children[1]).__path).toEqual("theObjectArray.arr"); 125 | expect(impl(children[2]).__path).toEqual("theObjectArray.arr[0]"); 126 | }); 127 | 128 | it("should return proper children for lower level", () => { 129 | const getValue = (field: FieldDescriptor) => { 130 | const path = impl(field).__path; 131 | switch (path) { 132 | case "theObjectArray": 133 | return { arr: [""] }; 134 | case "theObjectArray.arr": 135 | return [""]; 136 | case "theObjectArray.arr[0]": 137 | return ""; 138 | default: 139 | return null; 140 | } 141 | }; 142 | 143 | const children = getChildrenDescriptors( 144 | Schema.theObjectArray.arr, 145 | getValue 146 | ); 147 | 148 | expect(children.length).toEqual(2); 149 | expect(impl(children[0]).__path).toEqual("theObjectArray.arr"); 150 | expect(impl(children[1]).__path).toEqual("theObjectArray.arr[0]"); 151 | }); 152 | }); 153 | 154 | describe("getParentsChain", () => { 155 | it("should return empty chain for root fields", () => { 156 | expect(getParentsChain(Schema.theString)).toEqual([]); 157 | }); 158 | 159 | it("should return [theObject] for theObject.foo", () => { 160 | const parents = getParentsChain(Schema.theObject.foo); 161 | 162 | expect(parents.length).toEqual(1); 163 | expect(impl(parents[0]).__path).toEqual("theObject"); 164 | }); 165 | 166 | it("should return [theObjectArray.arr, theObjectArray] for theObjectArray.arr[2]", () => { 167 | const parents = getParentsChain(Schema.theObjectArray.arr.nth(2)); 168 | 169 | expect(parents.length).toEqual(2); 170 | expect(impl(parents[0]).__path).toEqual("theObjectArray.arr"); 171 | expect(impl(parents[1]).__path).toEqual("theObjectArray"); 172 | }); 173 | }); 174 | -------------------------------------------------------------------------------- /src/utils/task.ts: -------------------------------------------------------------------------------- 1 | import { Atom } from "./atoms"; 2 | 3 | /** 4 | * Alternative Promise implementation: 5 | * - allows for keeping code synchronous until async is actually needed 6 | * - delayed execution 7 | * - monadic API 8 | * - typed error 9 | */ 10 | export class Task { 11 | // @ts-ignore 12 | static success(): Task; 13 | static success(val: T): Task; 14 | static success(val: T) { 15 | return Task.make(({ resolve }) => { 16 | resolve(val); 17 | }); 18 | } 19 | 20 | static failure(err: Err) { 21 | return Task.make(({ reject }) => { 22 | reject(err); 23 | }); 24 | } 25 | 26 | static make(exec: Executor): Task { 27 | return new Task(exec); 28 | } 29 | 30 | static from(provider: () => Task | Promise | T) { 31 | return Task.make(({ resolve, reject }) => { 32 | try { 33 | const it = provider(); 34 | if (isPromise(it)) { 35 | it.then(resolve, reject); 36 | } else if (it instanceof Task) { 37 | it.run({ onSuccess: resolve, onFailure: reject }); 38 | } else { 39 | resolve(it); 40 | } 41 | } catch (err) { 42 | reject(err as Err); 43 | } 44 | }); 45 | } 46 | 47 | /** 48 | * Combine several tasks into one 49 | * which runs all provided tasks in parallel. 50 | * It will fail if any of the tasks fail. 51 | */ 52 | static all[]>( 53 | ...tasks: TasksArr 54 | ): Task, TaskErrorUnion> { 55 | return Task.make(({ resolve, reject }) => { 56 | const results: TaskValuesTuple = [] as any; 57 | const completed: true[] = []; 58 | 59 | if (tasks.length === 0) { 60 | resolve(results); 61 | } 62 | 63 | const resolveIfReady = () => { 64 | if (tasks.every((_, i) => completed[i])) { 65 | resolve(results); 66 | } 67 | }; 68 | 69 | tasks.forEach((task, i) => { 70 | task.run({ 71 | onSuccess: val => { 72 | results[i] = val; 73 | completed[i] = true; 74 | resolveIfReady(); 75 | }, 76 | onFailure: reject, 77 | }); 78 | }); 79 | }); 80 | } 81 | 82 | private constructor(private readonly exec: Executor) {} 83 | 84 | run(handlers: { 85 | onSuccess: (val: T) => void; 86 | onFailure: (err: Err) => void; 87 | }): void { 88 | let isComplete = false; 89 | 90 | this.exec({ 91 | resolve: val => { 92 | if (!isComplete) { 93 | handlers.onSuccess(val); 94 | isComplete = true; 95 | } 96 | }, 97 | reject: err => { 98 | if (!isComplete) { 99 | handlers.onFailure(err); 100 | isComplete = true; 101 | } 102 | }, 103 | }); 104 | } 105 | 106 | runPromise(): Promise { 107 | return new Promise((onSuccess, onFailure) => { 108 | this.run({ onSuccess, onFailure }); 109 | }); 110 | } 111 | 112 | runPromiseOrGet(): T | Promise { 113 | const valueA = Atom.of>({ is: "none" }); 114 | const errorA = Atom.of>({ is: "none" }); 115 | 116 | this.run({ 117 | onSuccess: val => valueA.set({ is: "some", val }), 118 | onFailure: err => errorA.set({ is: "some", val: err }), 119 | }); 120 | 121 | if (valueA.val.is == "some") { 122 | return valueA.val.val; 123 | } 124 | if (errorA.val.is == "some") { 125 | throw errorA.val.val; 126 | } 127 | 128 | return new Promise((resolve, reject) => { 129 | valueA.subscribe(maybeVal => { 130 | maybeVal.is == "some" && resolve(maybeVal.val); 131 | }); 132 | errorA.subscribe(maybeErr => { 133 | maybeErr.is == "some" && reject(maybeErr.val); 134 | }); 135 | }); 136 | } 137 | 138 | map(fn: (val: T) => Q): Task { 139 | return Task.make(({ resolve, reject }) => { 140 | this.run({ 141 | onSuccess: val => { 142 | resolve(fn(val)); 143 | }, 144 | onFailure: reject, 145 | }); 146 | }); 147 | } 148 | 149 | flatMap(fn: (val: T) => Task): Task { 150 | return Task.make(({ resolve, reject }) => { 151 | this.run({ 152 | onSuccess: val => { 153 | fn(val).run({ 154 | onSuccess: resolve, 155 | onFailure: reject, 156 | }); 157 | }, 158 | onFailure: reject, 159 | }); 160 | }); 161 | } 162 | 163 | mapErr(fn: (err: Err) => Err2): Task { 164 | return Task.make(({ resolve, reject }) => { 165 | this.run({ 166 | onSuccess: resolve, 167 | onFailure: err => { 168 | reject(fn(err)); 169 | }, 170 | }); 171 | }); 172 | } 173 | 174 | flatMapErr(fn: (err: Err) => Task): Task { 175 | return Task.make(({ resolve, reject }) => { 176 | this.run({ 177 | onSuccess: resolve, 178 | onFailure: err => { 179 | fn(err).run({ 180 | onSuccess: resolve, 181 | onFailure: reject, 182 | }); 183 | }, 184 | }); 185 | }); 186 | } 187 | } 188 | 189 | type Executor = (handlers: { 190 | resolve: (val: T) => void; 191 | reject: (err: Err) => void; 192 | }) => void; 193 | 194 | type TaskTuple = { 195 | [Index in keyof Values]: Task; 196 | }; 197 | 198 | type TaskValuesTuple[]> = TasksArr extends [ 199 | ...TaskTuple 200 | ] 201 | ? Values 202 | : never; 203 | 204 | type TaskErrorUnion[]> = TTasks extends Task< 205 | any, 206 | infer Err 207 | >[] 208 | ? Err 209 | : never; 210 | 211 | const isPromise = (val: unknown): val is Promise => 212 | val != null && typeof (val as any).then === "function"; 213 | 214 | type Maybe = { is: "some"; val: T } | { is: "none" }; 215 | -------------------------------------------------------------------------------- /src/core/hooks/use-form-controller/formts-dispatch.ts: -------------------------------------------------------------------------------- 1 | import { filter, range } from "../../../utils"; 2 | import { Atom } from "../../../utils/atoms"; 3 | import { 4 | createInitialValues, 5 | isExactOrChildPath, 6 | makeTouchedValues, 7 | makeUntouchedValues, 8 | resolveTouched, 9 | } from "../../helpers"; 10 | import { FormtsOptions } from "../../types/formts-options"; 11 | import { FormtsAction, FormtsAtomState } from "../../types/formts-state"; 12 | import { impl } from "../../types/type-mapper-util"; 13 | 14 | export const getInitialState = ({ 15 | Schema, 16 | initialValues, 17 | }: FormtsOptions): FormtsAtomState => { 18 | const values = createInitialValues(Schema, initialValues); 19 | const touched = makeUntouchedValues(values); 20 | 21 | return { 22 | initialValues: Atom.of(values), 23 | values: Atom.of(values), 24 | touched: Atom.of(touched), 25 | errors: Atom.of({}), 26 | validating: Atom.of({}), 27 | isSubmitting: Atom.of(false), 28 | successfulSubmitCount: Atom.of(0), 29 | failedSubmitCount: Atom.of(0), 30 | }; 31 | }; 32 | 33 | export const createStateDispatch = ( 34 | state: FormtsAtomState, 35 | { Schema }: FormtsOptions 36 | ) => (action: FormtsAction) => { 37 | switch (action.type) { 38 | case "resetForm": { 39 | if (action.payload.newInitialValues != null) { 40 | state.initialValues.set( 41 | createInitialValues(Schema, action.payload.newInitialValues) 42 | ); 43 | } 44 | 45 | const touched = makeUntouchedValues(state.initialValues.val); 46 | 47 | state.values.set(state.initialValues.val); 48 | state.touched.set(touched); 49 | state.errors.set({}); 50 | state.validating.set({}); 51 | state.isSubmitting.set(false); 52 | state.successfulSubmitCount.set(0); 53 | state.failedSubmitCount.set(0); 54 | break; 55 | } 56 | 57 | case "resetField": { 58 | const { field } = action.payload; 59 | const lens = impl(field).__lens; 60 | 61 | const initialValue = lens.get(state.initialValues.val); 62 | state.values.set(lens.update(state.values.val, () => initialValue)); 63 | 64 | state.touched.set( 65 | lens.update(state.touched.val, () => makeUntouchedValues(initialValue)) 66 | ); 67 | 68 | state.errors.set( 69 | filter(state.errors.val, ({ key }) => !isExactOrChildPath(field)(key)) 70 | ); 71 | 72 | break; 73 | } 74 | 75 | case "setTouched": { 76 | const { touched } = action.payload; 77 | const lens = impl(action.payload.field).__lens; 78 | 79 | if (resolveTouched(lens.get(state.touched.val)) !== touched) { 80 | const value = lens.get(state.values.val); 81 | const newTouchedState = lens.update(state.touched.val, () => 82 | touched ? makeTouchedValues(value) : makeUntouchedValues(value) 83 | ); 84 | 85 | state.touched.set(newTouchedState); 86 | } 87 | 88 | break; 89 | } 90 | 91 | case "setValue": { 92 | const { field, value } = action.payload; 93 | const lens = impl(field).__lens; 94 | const path = impl(field).__path; 95 | 96 | const resolveErrors = () => { 97 | if (!Array.isArray(value)) { 98 | return state.errors.val; 99 | } 100 | 101 | const currentValue = lens.get(state.values.val) as unknown[]; 102 | 103 | if (currentValue.length <= value.length) { 104 | return state.errors.val; 105 | } 106 | 107 | const hangingIndexes = range(value.length, currentValue.length - 1); 108 | const errors = filter( 109 | state.errors.val, 110 | ({ key }) => 111 | !hangingIndexes.some(i => key.startsWith(`${path}[${i}]`)) 112 | ); 113 | 114 | return errors; 115 | }; 116 | state.errors.set(resolveErrors()); 117 | 118 | const values = lens.update(state.values.val, () => value); 119 | state.values.set(values); 120 | 121 | if (resolveTouched(lens.get(state.touched.val)) === false) { 122 | const touched = lens.update(state.touched.val, () => 123 | makeTouchedValues(value) 124 | ); 125 | state.touched.set(touched); 126 | } 127 | 128 | break; 129 | } 130 | 131 | case "setErrors": { 132 | if (action.payload.length > 0) { 133 | const errors = action.payload.reduce( 134 | (dict, { path, error }) => { 135 | if (error != null) { 136 | dict[path] = error; 137 | } else { 138 | delete dict[path]; 139 | } 140 | return dict; 141 | }, 142 | { ...state.errors.val } 143 | ); 144 | 145 | state.errors.set(errors); 146 | } 147 | break; 148 | } 149 | 150 | case "validatingStart": { 151 | const { path, uuid } = action.payload; 152 | 153 | const validating = { 154 | ...state.validating.val, 155 | [path]: { ...state.validating.val[path], [uuid]: true as const }, 156 | }; 157 | 158 | state.validating.set(validating); 159 | break; 160 | } 161 | 162 | case "validatingStop": { 163 | const { path, uuid } = action.payload; 164 | 165 | const validating = (() => { 166 | if (state.validating.val[path] == null) { 167 | return state.validating.val; 168 | } 169 | 170 | const validating = { ...state.validating.val }; 171 | const uuids = { ...validating[path] }; 172 | validating[path] = uuids; 173 | 174 | delete uuids[uuid]; 175 | 176 | if (Object.keys(uuids).length === 0) { 177 | delete validating[path]; 178 | } 179 | 180 | return validating; 181 | })(); 182 | 183 | state.validating.set(validating); 184 | break; 185 | } 186 | 187 | case "submitStart": { 188 | state.isSubmitting.set(true); 189 | break; 190 | } 191 | case "submitSuccess": { 192 | state.isSubmitting.set(false); 193 | state.successfulSubmitCount.set(state.successfulSubmitCount.val + 1); 194 | break; 195 | } 196 | case "submitFailure": { 197 | state.isSubmitting.set(false); 198 | state.failedSubmitCount.set(state.failedSubmitCount.val + 1); 199 | break; 200 | } 201 | 202 | default: 203 | exhaustivityCheck(action); 204 | } 205 | }; 206 | 207 | const exhaustivityCheck = (_action: never) => {}; 208 | -------------------------------------------------------------------------------- /src/core/hooks/use-form-controller/formts-methods.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { get, logger, values } from "../../../utils"; 4 | import { Task } from "../../../utils/task"; 5 | import * as Helpers from "../../helpers"; 6 | import { FieldDescriptor } from "../../types/field-descriptor"; 7 | import { FieldError } from "../../types/field-error"; 8 | import { 9 | ValidationResult, 10 | ValidationTrigger, 11 | } from "../../types/form-validator"; 12 | import { InternalFormtsMethods } from "../../types/formts-context"; 13 | import { FormtsOptions } from "../../types/formts-options"; 14 | import { 15 | FormtsAction, 16 | FormtsAtomState, 17 | InitialValues, 18 | } from "../../types/formts-state"; 19 | import { impl } from "../../types/type-mapper-util"; 20 | 21 | type Input = { 22 | options: FormtsOptions; 23 | state: FormtsAtomState; 24 | dispatch: React.Dispatch>; 25 | }; 26 | 27 | export const createFormtsMethods = ({ 28 | options, 29 | state, 30 | dispatch, 31 | }: Input): InternalFormtsMethods => { 32 | const getField = (field: FieldDescriptor | string): T => { 33 | return typeof field === "string" 34 | ? get(state.values.val, field) 35 | : impl(field).__lens.get(state.values.val); 36 | }; 37 | 38 | const validateField = ( 39 | field: FieldDescriptor, 40 | trigger?: ValidationTrigger 41 | ): Task => { 42 | return _runValidation([field], trigger).map(it => void it); 43 | }; 44 | 45 | const validateForm = ( 46 | trigger?: ValidationTrigger 47 | ): Task> => { 48 | const topLevelDescriptors = values(options.Schema); 49 | return _runValidation(topLevelDescriptors, trigger); 50 | }; 51 | 52 | const _runValidation = ( 53 | fields: Array>, 54 | trigger?: ValidationTrigger 55 | ): Task, unknown> => { 56 | if (options.validator == null) { 57 | return Task.success([]); 58 | } 59 | const { 60 | onFieldValidationStart, 61 | onFieldValidationEnd, 62 | flushValidationHandlers, 63 | } = Helpers.makeValidationHandlers(dispatch); 64 | 65 | const validationTask = impl(options.validator) 66 | .validate({ 67 | fields, 68 | trigger, 69 | getValue: getField, 70 | onFieldValidationStart, 71 | onFieldValidationEnd, 72 | }) 73 | .flatMap(errors => setFieldErrors(...errors).map(() => errors)); 74 | 75 | return Task.all(validationTask, Task.from(flushValidationHandlers)).map( 76 | ([validationResult]) => validationResult 77 | ); 78 | }; 79 | 80 | const setFieldValue = ( 81 | field: FieldDescriptor, 82 | value: T 83 | ): Task => { 84 | if (state.isSubmitting.val) { 85 | return Task.success(); 86 | } 87 | 88 | const { __decoder, __path } = impl(field); 89 | const decodeResult = __decoder.decode(value); 90 | 91 | if (!decodeResult.ok) { 92 | logger.warn( 93 | `Can not set field value for: '${__path}' [${__decoder.fieldType}] - illegal value type.`, 94 | value 95 | ); 96 | return Task.success(); 97 | } 98 | 99 | return _setDecodedFieldValue(field, decodeResult.value); 100 | }; 101 | 102 | const setFieldValueFromEvent = ( 103 | field: FieldDescriptor, 104 | event: React.ChangeEvent 105 | ): Task => { 106 | if (state.isSubmitting.val) { 107 | return Task.success(); 108 | } 109 | 110 | const { __decoder, __path } = impl(field); 111 | const decodeResult = Helpers.decodeChangeEvent({ 112 | event, 113 | fieldDecoder: __decoder, 114 | getValue: () => getField(field), 115 | }); 116 | 117 | if (!decodeResult.ok) { 118 | logger.warn( 119 | `Can not set field value for: '${__path}' [${__decoder.fieldType}] - failed to extract valid value from event.target.`, 120 | event?.target 121 | ); 122 | return Task.success(undefined); 123 | } 124 | 125 | return _setDecodedFieldValue(field, decodeResult.value); 126 | }; 127 | 128 | const _setDecodedFieldValue = ( 129 | field: FieldDescriptor, 130 | value: T 131 | ): Task => { 132 | dispatch({ type: "setValue", payload: { field, value } }); 133 | 134 | return validateField(field, "change"); 135 | }; 136 | 137 | const setFieldTouched = ( 138 | field: FieldDescriptor, 139 | touched: boolean 140 | ): Task => 141 | Task.from(() => 142 | dispatch({ type: "setTouched", payload: { field, touched } }) 143 | ); 144 | 145 | const setFieldErrors = ( 146 | ...fields: Array<{ 147 | path: string; 148 | error: Err | null; 149 | }> 150 | ): Task => 151 | Task.from(() => 152 | dispatch({ 153 | type: "setErrors", 154 | payload: fields, 155 | }) 156 | ); 157 | 158 | const resetForm = (newInitialValues?: InitialValues): Task => 159 | Task.from(() => 160 | dispatch({ type: "resetForm", payload: { newInitialValues } }) 161 | ); 162 | 163 | const resetField = (field: FieldDescriptor): Task => 164 | Task.from(() => dispatch({ type: "resetField", payload: { field } })); 165 | 166 | const submitForm = ( 167 | onSuccess: (values: Values) => Task, 168 | onFailure: (errors: Array>) => Task 169 | ): Task => { 170 | dispatch({ type: "submitStart" }); 171 | 172 | return validateForm("submit") 173 | .map(errors => 174 | errors 175 | .filter(({ error }) => error != null) 176 | .map(it => ({ error: it.error!, fieldId: it.path })) 177 | ) 178 | .flatMap(errors => { 179 | if (errors.length > 0) { 180 | dispatch({ type: "submitFailure" }); 181 | return onFailure(errors); 182 | } else { 183 | return onSuccess(state.values.val).map(() => { 184 | dispatch({ type: "submitSuccess" }); 185 | }); 186 | } 187 | }) 188 | .mapErr(err => { 189 | dispatch({ type: "submitFailure" }); 190 | return err; 191 | }); 192 | }; 193 | 194 | return { 195 | validateField, 196 | validateForm, 197 | setFieldValue, 198 | setFieldValueFromEvent, 199 | setFieldTouched, 200 | setFieldErrors, 201 | resetForm, 202 | resetField, 203 | submitForm, 204 | }; 205 | }; 206 | -------------------------------------------------------------------------------- /src/core/hooks/use-field/use-field.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | 3 | import { keys, toIdentityDict } from "../../../utils"; 4 | import { Task } from "../../../utils/task"; 5 | import { useSubscription } from "../../../utils/use-subscription"; 6 | import { FieldStateAtom, FormAtoms } from "../../atoms"; 7 | import { useFormtsContext } from "../../context"; 8 | import * as Helpers from "../../helpers"; 9 | import { isChoiceDecoder } from "../../types/field-decoder"; 10 | import { 11 | FieldDescriptor, 12 | GenericFieldDescriptor, 13 | isArrayDescriptor, 14 | isObjectDescriptor, 15 | } from "../../types/field-descriptor"; 16 | import { FieldHandle, toFieldHandle } from "../../types/field-handle"; 17 | import { FormController } from "../../types/form-controller"; 18 | import { InternalFormtsMethods } from "../../types/formts-context"; 19 | import { FormtsAtomState } from "../../types/formts-state"; 20 | import { impl } from "../../types/type-mapper-util"; 21 | 22 | /** 23 | * Hook used to gain access to field-specific state and methods 24 | * Causes the component to subscribe to all changes of the field's state. 25 | * 26 | * @param fieldDescriptor - pointer to a field containing type information and more. Obtained from FormSchema. 27 | * @param controller - obtained by using `useFormController` hook, used to connect to form state. 28 | * Injected automatically via React Context when used inside `FormProvider` component. 29 | * 30 | * @returns `FormHandle` object used to interact with the field 31 | * 32 | * @example 33 | * ```ts 34 | * const Schema = new FormSchemaBuilder()...; 35 | * 36 | * const MyForm: React.FC = () => { 37 | * const controller = useFormController({ Schema }) 38 | * const username = useField(Schema.username, controller) 39 | * 40 | * ... 41 | * } 42 | * ``` 43 | */ 44 | export const useField = ( 45 | fieldDescriptor: GenericFieldDescriptor, 46 | controller?: FormController 47 | ): FieldHandle => { 48 | const { methods, state, atoms } = useFormtsContext(controller); 49 | 50 | const fieldState = atoms.fieldStates.get(fieldDescriptor); 51 | const dependencies = atoms.fieldDependencies.get(fieldDescriptor); 52 | 53 | useSubscription(fieldState); 54 | useSubscription(dependencies); 55 | 56 | return useMemo( 57 | () => createFieldHandle(fieldDescriptor, methods, fieldState, state, atoms), 58 | [impl(fieldDescriptor).__path, fieldState.val, dependencies.val] 59 | ); 60 | }; 61 | 62 | const createFieldHandle = ( 63 | descriptor: FieldDescriptor, 64 | methods: InternalFormtsMethods, 65 | fieldState: FieldStateAtom, 66 | formState: FormtsAtomState, 67 | atoms: FormAtoms 68 | ): FieldHandle => 69 | toFieldHandle({ 70 | descriptor, 71 | 72 | id: impl(descriptor).__path, 73 | 74 | get value() { 75 | return fieldState.val.value; 76 | }, 77 | 78 | get isTouched() { 79 | return ( 80 | fieldState.val.formSubmitted || 81 | Helpers.resolveTouched(fieldState.val.touched) 82 | ); 83 | }, 84 | 85 | get isChanged() { 86 | return fieldState.val.changed; 87 | }, 88 | 89 | get error() { 90 | return formState.errors.val[impl(descriptor).__path] ?? null; 91 | }, 92 | 93 | get isValid() { 94 | return Helpers.resolveIsValid(formState.errors.val, descriptor); 95 | }, 96 | 97 | get isValidating() { 98 | return Helpers.resolveIsValidating(formState.validating.val, descriptor); 99 | }, 100 | 101 | get children() { 102 | if (isArrayDescriptor(descriptor)) { 103 | const value = (fieldState.val.value as unknown) as unknown[]; 104 | return value.map((_, i) => { 105 | const childDescriptor = descriptor.nth(i); 106 | const childState = atoms.fieldStates.get(childDescriptor); 107 | return createFieldHandle( 108 | descriptor.nth(i), 109 | methods, 110 | childState, 111 | formState, 112 | atoms 113 | ); 114 | }); 115 | } 116 | 117 | if (isObjectDescriptor(descriptor)) { 118 | return keys(descriptor).reduce( 119 | (acc, key) => 120 | Object.defineProperty(acc, key, { 121 | enumerable: true, 122 | get: function () { 123 | const nestedDescriptor = descriptor[key]; 124 | const childState = atoms.fieldStates.get(nestedDescriptor); 125 | return createFieldHandle( 126 | nestedDescriptor, 127 | methods, 128 | childState, 129 | formState, 130 | atoms 131 | ); 132 | }, 133 | }), 134 | {} 135 | ); 136 | } 137 | 138 | return undefined; 139 | }, 140 | 141 | get options() { 142 | const decoder = impl(descriptor).__decoder; 143 | return isChoiceDecoder(decoder) 144 | ? toIdentityDict((decoder.options as string[]).filter(opt => opt != "")) 145 | : undefined; 146 | }, 147 | 148 | handleBlur: () => 149 | Task.all( 150 | methods.setFieldTouched(descriptor, true), 151 | methods.validateField(descriptor, "blur") 152 | ).runPromise(), 153 | 154 | setValue: val => methods.setFieldValue(descriptor, val).runPromise(), 155 | 156 | handleChange: event => 157 | methods.setFieldValueFromEvent(descriptor, event).runPromise(), 158 | 159 | setError: error => 160 | methods 161 | .setFieldErrors({ path: impl(descriptor).__path, error }) 162 | .runPromise(), 163 | 164 | setTouched: touched => 165 | methods.setFieldTouched(descriptor, touched).runPromise(), 166 | 167 | addItem: item => { 168 | if (isArrayDescriptor(descriptor)) { 169 | const array = (fieldState.val.value as unknown) as unknown[]; 170 | const updatedArray = [...array, item]; 171 | return methods.setFieldValue(descriptor, updatedArray).runPromise(); 172 | } 173 | 174 | return Promise.resolve(); 175 | }, 176 | 177 | removeItem: index => { 178 | if (isArrayDescriptor(descriptor)) { 179 | const array = (fieldState.val.value as unknown) as unknown[]; 180 | const updatedArray = array.filter((_, i) => i !== index); 181 | return methods.setFieldValue(descriptor, updatedArray).runPromise(); 182 | } 183 | 184 | return Promise.resolve(); 185 | }, 186 | 187 | validate: () => methods.validateField(descriptor).runPromise(), 188 | 189 | reset: () => methods.resetField(descriptor).runPromise(), 190 | }); 191 | --------------------------------------------------------------------------------