diff --git a/demo-app/app/routes/index.tsx b/demo-app/app/routes/index.tsx index 47303b8..48a7e13 100644 --- a/demo-app/app/routes/index.tsx +++ b/demo-app/app/routes/index.tsx @@ -3,9 +3,9 @@ import type { ActionFunction } from "@remix-run/server-runtime"; import { json, redirect } from "@remix-run/server-runtime"; import * as React from "react"; import type { - ErrorMessages, + ErrorMessage, + InputDefinition, ServerFormInfo, - Validations, } from "remix-validity-state"; import { Field, @@ -14,51 +14,65 @@ import { validateServerFormData, } from "remix-validity-state"; -type MyFormValidations = { - firstName: Validations; - middleInitial: Validations; - lastName: Validations; - emailAddress: Validations; -}; - -type ActionData = { - serverFormInfo: ServerFormInfo; -}; +interface FormSchema { + inputs: { + firstName: InputDefinition; + middleInitial: InputDefinition; + lastName: InputDefinition; + emailAddress: InputDefinition; + }; + errorMessages: { + tooShort: ErrorMessage; + }; +} -// Validations for our entire form, composed of raw HTML validation attributes -// to be spread directly onto , as well as custo validations that will -// run both client and server side. -// Specified in an object here so they can be leveraged for server-side validation -const formValidations: MyFormValidations = { - firstName: { - // Standard HTML validations have primitives as their value - required: true, - minLength: 5, - pattern: "^[a-zA-Z]+$", - }, - middleInitial: { - pattern: "^[a-zA-Z]{1}$", - }, - lastName: { - required: true, - minLength: 5, - pattern: "^[a-zA-Z]+$", - }, - emailAddress: { - type: "email", - required: true, - async uniqueEmail(value) { - await new Promise((r) => setTimeout(r, 1000)); - return value !== "john@doe.com" && value !== "jane@doe.com"; +let formDefinition: FormSchema = { + inputs: { + firstName: { + validationAttrs: { + required: true, + minLength: 5, + pattern: "^[a-zA-Z]+$", + }, + }, + middleInitial: { + validationAttrs: { + pattern: "^[a-zA-Z]{1}$", + }, + }, + lastName: { + validationAttrs: { + required: true, + minLength: 5, + pattern: "^[a-zA-Z]+$", + }, + }, + emailAddress: { + validationAttrs: { + type: "email", + required: true, + }, + customValidations: { + async uniqueEmail(value) { + await new Promise((r) => setTimeout(r, 1000)); + return value !== "john@doe.com" && value !== "jane@doe.com"; + }, + }, + errorMessages: { + uniqueEmail(attrValue, name, value) { + return `The email address "${value}" is already in use!`; + }, + }, }, }, + errorMessages: { + tooShort: (attrValue, name, value) => + `The ${name} field must be at least ${attrValue} characters long, but you have only entered ${value.length} characters`, + }, }; -const customErrorMessages: ErrorMessages = { - tooShort: (attrValue, name, value) => - `The ${name} field must be at least ${attrValue} characters long, but you have only entered ${value.length} characters`, - uniqueEmail: (attrValue, name, value) => - `The email address "${value}" is already in use!`, +type ActionData = { + serverFormInfo: ServerFormInfo; }; export const action: ActionFunction = async ({ request }) => { @@ -69,10 +83,8 @@ export const action: ActionFunction = async ({ request }) => { // We currently only get it back from the server on serverFormInfo.valid. At // the moment, client side the
doesn't really know about any of it's // descendant inputs. Maybe we can do a pub/sub through context? - const serverFormInfo = await validateServerFormData( - formData, - formValidations - ); + const serverFormInfo = await validateServerFormData(formData, formDefinition); + if (!serverFormInfo.valid) { return json({ serverFormInfo }); } @@ -83,12 +95,12 @@ export const action: ActionFunction = async ({ request }) => { // DOM construction function EmailAddress() { let { info, getInputAttrs, getLabelAttrs, getErrorsAttrs } = - useValidatedInput({ name: "emailAddress" }); + useValidatedInput({ name: "emailAddress" }); return (

- + {info.touched && info.errorMessages ? (
    {Object.entries(info.errorMessages).map(([validation, msg]) => ( @@ -101,7 +113,8 @@ function EmailAddress() { } export default function Index() { - let actionData = useActionData(); + let actionData = useActionData() as ActionData; + let formRef = React.useRef(null); // Use built-in browser validation prior to JS loading, then switch @@ -200,12 +213,12 @@ export default function Index() {
    , + serverFormInfo: actionData?.serverFormInfo as ServerFormInfo< + typeof formDefinition + >, }} > @@ -221,7 +234,10 @@ export default function Index() {

    - This middle initial input has pattern="^[a-zA-Z]{1}$" + This middle initial input has{" "} + + pattern="^[a-zA-Z]{"{"}1{"}"}$" +

    @@ -230,7 +246,7 @@ export default function Index() {

    - This first name input has{" "} + This last name input has{" "} required="true" minLength="5" pattern="^[a-zA-Z]+$"

    diff --git a/demo-app/package-lock.json b/demo-app/package-lock.json index 3110524..3f9dfdc 100644 --- a/demo-app/package-lock.json +++ b/demo-app/package-lock.json @@ -11,7 +11,7 @@ "@remix-run/serve": "^1.7.0", "react": "^18.2.0", "react-dom": "^18.2.0", - "remix-validity-state": "^0.5.0" + "remix-validity-state": "^0.6.0" }, "devDependencies": { "@remix-run/dev": "^1.7.0", @@ -10491,9 +10491,9 @@ } }, "node_modules/remix-validity-state": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/remix-validity-state/-/remix-validity-state-0.5.0.tgz", - "integrity": "sha512-j6HiwgTsuO+bsuXmxVBPkTTVUkodBiWgGztuufYT8NK6X1YkD/MLgzngPl9CstMn+fuHl1oc0cm0nx2AIh4RnQ==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/remix-validity-state/-/remix-validity-state-0.6.0.tgz", + "integrity": "sha512-DD8X76zgpdMG66oU2miodJO0LsNmpULKyU+ljcoYCoHDSzBr13tHEMPk91+RqT0THKXT+l9JOdt6mmJlBr0CKg==", "dependencies": { "@babel/runtime": "7.17.8" }, @@ -19498,9 +19498,9 @@ } }, "remix-validity-state": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/remix-validity-state/-/remix-validity-state-0.5.0.tgz", - "integrity": "sha512-j6HiwgTsuO+bsuXmxVBPkTTVUkodBiWgGztuufYT8NK6X1YkD/MLgzngPl9CstMn+fuHl1oc0cm0nx2AIh4RnQ==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/remix-validity-state/-/remix-validity-state-0.6.0.tgz", + "integrity": "sha512-DD8X76zgpdMG66oU2miodJO0LsNmpULKyU+ljcoYCoHDSzBr13tHEMPk91+RqT0THKXT+l9JOdt6mmJlBr0CKg==", "requires": { "@babel/runtime": "7.17.8" } diff --git a/demo-app/package.json b/demo-app/package.json index 6d066e1..461d660 100644 --- a/demo-app/package.json +++ b/demo-app/package.json @@ -12,7 +12,7 @@ "@remix-run/serve": "^1.7.0", "react": "^18.2.0", "react-dom": "^18.2.0", - "remix-validity-state": "^0.5.0" + "remix-validity-state": "^0.6.0" }, "devDependencies": { "@remix-run/dev": "^1.7.0", diff --git a/package-lock.json b/package-lock.json index 2f38841..24a00f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@babel/preset-typescript": "7.16.7", "@remix-run/eslint-config": "1.3.4", "@rollup/plugin-babel": "5.3.1", + "@rollup/plugin-typescript": "^11.0.0", "@types/react": "^18.0.19", "eslint": "8.12.0", "eslint-config-prettier": "8.5.0", @@ -1969,6 +1970,60 @@ } } }, + "node_modules/@rollup/plugin-typescript": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-typescript/-/plugin-typescript-11.0.0.tgz", + "integrity": "sha512-goPyCWBiimk1iJgSTgsehFD5OOFHiAknrRJjqFCudcW8JtWiBlK284Xnn4flqMqg6YAjVG/EE+3aVzrL5qNSzQ==", + "dev": true, + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.14.0||^3.0.0", + "tslib": "*", + "typescript": ">=3.7.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + }, + "tslib": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-typescript/node_modules/@rollup/pluginutils": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.0.2.tgz", + "integrity": "sha512-pTd9rIsP92h+B6wWwFbW8RkZv4hiR/xKsqre4SIuAOaOEQRxi0lqLke9k2/7WegC85GgUs9pjmOjCUi3In4vwA==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-typescript/node_modules/@types/estree": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.0.tgz", + "integrity": "sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ==", + "dev": true + }, "node_modules/@rollup/pluginutils": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz", @@ -3559,6 +3614,12 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -3972,9 +4033,9 @@ } }, "node_modules/is-core-module": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.8.1.tgz", - "integrity": "sha512-SdNCUs284hr40hFTFP6l0IfZ/RSrMXF3qgoRHd3/79unUTvrFO/JoXwkGm+5J/Oe3E/b5GsnG330uUNgRpu1PA==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.11.0.tgz", + "integrity": "sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==", "dev": true, "dependencies": { "has": "^1.0.3" @@ -4818,12 +4879,12 @@ } }, "node_modules/resolve": { - "version": "1.22.0", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.0.tgz", - "integrity": "sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw==", + "version": "1.22.1", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz", + "integrity": "sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==", "dev": true, "dependencies": { - "is-core-module": "^2.8.1", + "is-core-module": "^2.9.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, @@ -6664,6 +6725,35 @@ "@rollup/pluginutils": "^3.1.0" } }, + "@rollup/plugin-typescript": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-typescript/-/plugin-typescript-11.0.0.tgz", + "integrity": "sha512-goPyCWBiimk1iJgSTgsehFD5OOFHiAknrRJjqFCudcW8JtWiBlK284Xnn4flqMqg6YAjVG/EE+3aVzrL5qNSzQ==", + "dev": true, + "requires": { + "@rollup/pluginutils": "^5.0.1", + "resolve": "^1.22.1" + }, + "dependencies": { + "@rollup/pluginutils": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.0.2.tgz", + "integrity": "sha512-pTd9rIsP92h+B6wWwFbW8RkZv4hiR/xKsqre4SIuAOaOEQRxi0lqLke9k2/7WegC85GgUs9pjmOjCUi3In4vwA==", + "dev": true, + "requires": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^2.3.1" + } + }, + "@types/estree": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.0.tgz", + "integrity": "sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ==", + "dev": true + } + } + }, "@rollup/pluginutils": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz", @@ -7816,6 +7906,12 @@ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true }, + "estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true + }, "esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -8122,9 +8218,9 @@ "dev": true }, "is-core-module": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.8.1.tgz", - "integrity": "sha512-SdNCUs284hr40hFTFP6l0IfZ/RSrMXF3qgoRHd3/79unUTvrFO/JoXwkGm+5J/Oe3E/b5GsnG330uUNgRpu1PA==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.11.0.tgz", + "integrity": "sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==", "dev": true, "requires": { "has": "^1.0.3" @@ -8741,12 +8837,12 @@ "dev": true }, "resolve": { - "version": "1.22.0", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.0.tgz", - "integrity": "sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw==", + "version": "1.22.1", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz", + "integrity": "sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==", "dev": true, "requires": { - "is-core-module": "^2.8.1", + "is-core-module": "^2.9.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" } diff --git a/package.json b/package.json index 4980287..3a5513c 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "src/", "dist/" ], - "sideEffects": "false", + "sideEffects": false, "repository": { "type": "git", "url": "git+https://github.com/brophdawg11/remix-validity-state.git" @@ -29,7 +29,7 @@ }, "homepage": "https://github.com/brophdawg11/remix-validity-state#readme", "scripts": { - "build": "tsc -b && rollup -c ./rollup.config.js", + "build": "rollup -c ./rollup.config.js", "clean": "rm -rf dist/", "dev": "OUTPUT_DIR=demo-app/node_modules/remix-validity-state npm run build -- --watch", "lint": "eslint --ext .js,.ts,.tsx .", @@ -47,6 +47,7 @@ "@babel/preset-typescript": "7.16.7", "@remix-run/eslint-config": "1.3.4", "@rollup/plugin-babel": "5.3.1", + "@rollup/plugin-typescript": "^11.0.0", "@types/react": "^18.0.19", "eslint": "8.12.0", "eslint-config-prettier": "8.5.0", diff --git a/rollup.config.js b/rollup.config.js index 5fe2a31..fc2cfb4 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -1,4 +1,5 @@ import babel from "@rollup/plugin-babel"; +import typescript from "@rollup/plugin-typescript"; import packageJson from "./package.json"; @@ -41,6 +42,7 @@ export default function rollup() { ], external: ["react", "@babel/runtime/helpers/extends"], plugins: [ + typescript(), babel({ exclude: /node_modules/, babelHelpers: "runtime", diff --git a/src/index.tsx b/src/index.tsx index d1348a6..a7c1594 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -11,7 +11,7 @@ type Mutable = { }; // Restrict object keys to strings, and don't permit number/Symbol -type KeyOfString = Extract; +type KeyOf = Extract; // Extract the value type for an object type ValueOf = T[keyof T]; @@ -19,7 +19,7 @@ type ValueOf = T[keyof T]; /** * Validation attributes built-in to the browser */ -interface InputValidations { +interface BuiltInValidationAttrs { type?: string; required?: boolean; minLength?: number; @@ -29,66 +29,73 @@ interface InputValidations { pattern?: string; } -/** - * An HTML validation attribute that can be placed on an input - */ -export type ValidationAttribute = - | "type" - | "required" - | "minLength" - | "maxLength" - | "min" - | "max" - | "pattern"; +type ValidityStateKey = KeyOf< + Pick< + ValidityState, + | "typeMismatch" + | "valueMissing" + | "tooShort" + | "tooLong" + | "rangeUnderflow" + | "rangeOverflow" + | "patternMismatch" + > +>; /** * Custom validation function */ -export interface CustomValidation { - (val: string, formData?: FormData): boolean | Promise; +export interface CustomValidations { + [key: string]: ( + val: string, + formData?: FormData + ) => boolean | Promise; } /** - * Union type for both built-in and custom validations + * Error message - static string or () => string */ -export type Validations = - | { - [thing in ValidationAttribute]?: InputValidations[thing]; - } - | Record; +export type ErrorMessage = + | string + | ((attrValue: string | undefined, name: string, value: string) => string); /** - * Mutable version of ValidityState that we can write to + * Definition for a single input in a form (validations + error messages) */ -type MutableValidityState = Mutable; +export interface InputDefinition { + validationAttrs?: BuiltInValidationAttrs; + customValidations?: CustomValidations; + errorMessages?: { + [key: string]: ErrorMessage; + }; +} /** - * Extended ValidityState which weill also contain our custom validations + * Form information (inputs, validations, error messages) */ -export type ExtendedValidityState = MutableValidityState & - Record; +export interface FormDefinition { + inputs: { + [key: string]: InputDefinition; + }; + errorMessages: { + [key: string]: ErrorMessage; + }; +} /** - * The DOM ValidityState key representing a validation error + * Mutable version of ValidityState that we can write to */ -export type ValidityStateKey = keyof ValidityState; +type MutableValidityState = Mutable; /** - * Map of inputName -> HTML validations for the input + * Extended ValidityState which weill also contain our custom validations */ -export type FormValidations = Record; +export type ExtendedValidityState = MutableValidityState & + Record; /** - * Form level InputInfo + * Client-side state of the input */ -export type FormInfo = Record; - -// validation key -> UI message to display -export type ErrorMessage = - | string - | ((attrValue: string | undefined, name: string, value: string) => string); -export type ErrorMessages = Record; - export interface InputInfo { touched: boolean; dirty: boolean; @@ -98,9 +105,9 @@ export interface InputInfo { } // Server-side only (currently) - validate all specified inputs in the formData -export type ServerFormInfo = { +export type ServerFormInfo = { submittedFormData: Record; - inputs: Record, InputInfo>; + inputs: Record, InputInfo>; valid: boolean; }; @@ -108,15 +115,14 @@ export type ServerFormInfo = { * Validator to link HTML attribute to ValidityState key as well as provide an * implementation for server side validation */ -interface Validator { +interface BuiltInValidator { domKey: ValidityStateKey; validate(value: string, attrValue: string): boolean; errorMessage: ErrorMessage; } -interface FormContextObject { - formValidations: T; - errorMessages?: ErrorMessages; +interface FormContextObject { + formDefinition: T; serverFormInfo?: ServerFormInfo; } @@ -131,8 +137,24 @@ type AssignableRef = //////////////////////////////////////////////////////////////////////////////// //#region Constants + Utils -// Browser built-in validations -const builtInValidations: Record = { +// Map of ValidityState key -> HTML attribute (i.e., valueMissing -> required) +const builtInValidityToAttrMapping: Record< + ValidityStateKey, + KeyOf +> = { + typeMismatch: "type", + valueMissing: "required", + tooShort: "minLength", + tooLong: "maxLength", + rangeUnderflow: "min", + rangeOverflow: "max", + patternMismatch: "pattern", +}; + +const builtInValidations: Record< + KeyOf, + BuiltInValidator +> = { type: { domKey: "typeMismatch", validate: (value: string, attrValue: string): boolean => { @@ -157,54 +179,45 @@ const builtInValidations: Record = { }, required: { domKey: "valueMissing", - validate: (value: string, attrValue: string): boolean => value.length > 0, + validate: (value) => value.length > 0, errorMessage: () => `Field is required`, }, minLength: { domKey: "tooShort", - validate: (value: string, attrValue: string): boolean => + validate: (value, attrValue) => value.length === 0 || value.length >= Number(attrValue), errorMessage: (attrValue) => `Value must be at least ${attrValue} characters`, }, maxLength: { domKey: "tooLong", - validate: (value: string, attrValue: string): boolean => + validate: (value, attrValue) => value.length === 0 || value.length <= Number(attrValue), errorMessage: (attrValue) => `Value must be at most ${attrValue} characters`, }, min: { domKey: "rangeUnderflow", - validate: (value: string, attrValue: string): boolean => + validate: (value, attrValue) => value.length === 0 || Number(value) < Number(attrValue), errorMessage: (attrValue) => `Value must be greater than or equal to ${attrValue}`, }, max: { domKey: "rangeOverflow", - validate: (value: string, attrValue: string): boolean => + validate: (value, attrValue) => value.length === 0 || Number(value) > Number(attrValue), errorMessage: (attrValue) => `Value must be less than or equal to ${attrValue}`, }, pattern: { domKey: "patternMismatch", - validate: (value: string, attrValue: string): boolean => + validate: (value, attrValue) => value.length === 0 || new RegExp(attrValue).test(value), errorMessage: () => `Value does not match the expected pattern`, }, }; -const builtInValidityToAttrMapping: Record = - Object.entries(builtInValidations).reduce( - (acc, e) => - Object.assign(acc, { - [e[1].domKey]: e[0], - }), - {} - ); - function invariant(value: boolean, message?: string): asserts value; function invariant( value: T | null | undefined, @@ -281,38 +294,43 @@ function getBaseValidityState(): ExtendedValidityState { async function validateInput( inputEl: HTMLInputElement | null, value: string, - inputValidations: Validations, + inputDef: InputDefinition, formData?: FormData ): Promise { let validity = getBaseValidityState(); - await Promise.all( - Object.entries(inputValidations || {}).map(async ([attr, attrValue]) => { - // FIXME: - //@ts-ignore - const builtInValidation: Validator = builtInValidations[attr]; - let isInvalid = false; - if (builtInValidation) { - isInvalid = inputEl?.validity - ? inputEl?.validity[builtInValidation.domKey] - : !builtInValidation.validate(value, String(attrValue)); - } else { - // During SSR we get this passed in, client-side we can lazily generate - // only for custom validations - let currentFormData = - formData || (inputEl?.form ? new FormData(inputEl.form) : undefined); - isInvalid = !(await attrValue(value, currentFormData)); - } + + if (inputDef.validationAttrs) { + for (let _attr of Object.keys(inputDef.validationAttrs)) { + let attr = _attr as KeyOf; + let attrValue = inputDef.validationAttrs[attr]; + let builtInValidation = builtInValidations[attr]; + let isInvalid = inputEl?.validity + ? inputEl?.validity[builtInValidation.domKey] + : !builtInValidation.validate(value, String(attrValue)); validity[builtInValidation?.domKey || attr] = isInvalid; validity.valid = validity.valid && !isInvalid; - }) - ); + } + } + + // TODO: Should we skip running these if we already know it's invalid? + if (inputDef.customValidations) { + let currentFormData = + formData || (inputEl?.form ? new FormData(inputEl.form) : undefined); + for (let name of Object.keys(inputDef.customValidations)) { + let validate = inputDef.customValidations[name]; + let isInvalid = !(await validate(value, currentFormData)); + validity[name] = isInvalid; + validity.valid = validity.valid && !isInvalid; + } + } + return validity; } // Perform all validations for a submitted form on the server -export async function validateServerFormData( +export async function validateServerFormData( formData: FormData, - formValidations: T + formDefinition: T ): Promise> { // Echo back submitted form data for input pre-population const submittedFormData = Array.from(formData.entries()).reduce( @@ -320,25 +338,22 @@ export async function validateServerFormData( {} ); - const inputs = {} as Record, InputInfo>; + // Unsure if there's a better way to do this - but this complains since we + // haven't filled in the keys yet + // @ts-expect-error + const inputs: Record, InputInfo> = {}; + let valid = true; - let entries = Object.entries(formValidations) as Array< - [KeyOfString, ValueOf] + let entries = Object.entries(formDefinition.inputs) as Array< + [KeyOf, InputDefinition] >; await Promise.all( - entries.map(async (e) => { - let name = e[0]; - let inputValidations = e[1]; - const value = formData.get(name); + entries.map(async ([inputName, inputDef]) => { + const value = formData.get(inputName); if (typeof value === "string") { - let validity = await validateInput( - null, - value, - inputValidations, - formData - ); + let validity = await validateInput(null, value, inputDef, formData); // Always assume inputs have been modified during SSR validation - inputs[name] = { + inputs[inputName] = { touched: true, dirty: true, state: "done", @@ -356,11 +371,11 @@ export async function validateServerFormData( //#region Contexts + Components + Hooks export const FormContext = - React.createContext | null>(null); + React.createContext | null>(null); // Shout out for this nifty little approach! // https://www.hipsterbrown.com/musings/musing/react-context-with-generics/ -export function FormContextProvider({ +export function FormContextProvider({ children, value, }: React.PropsWithChildren<{ value: FormContextObject }>) { @@ -368,7 +383,7 @@ export function FormContextProvider({ } export function useOptionalFormContext< - T extends FormValidations + T extends FormDefinition >(): FormContextObject | null { const context = React.useContext>( FormContext as unknown as React.Context> @@ -379,33 +394,9 @@ export function useOptionalFormContext< return null; } -// Listen/Unlisten for the given event and call at most one time -function useOneTimeListener( - ref: React.RefObject, - event: string, - cb: () => void -) { - let unlisten: (() => void) | null = null; - - let onEvent = React.useCallback(() => { - cb(); - unlisten?.(); - }, [cb, unlisten]); - - unlisten = React.useCallback<() => void>(() => { - ref.current?.removeEventListener(event, onEvent); - }, [event, onEvent, ref]); - - React.useEffect(() => { - ref.current?.addEventListener(event, onEvent, { once: true }); - return unlisten || (() => {}); - }, [event, onEvent, ref, unlisten]); -} - -interface UseValidatedInputOpts { - name: KeyOfString; - formValidations?: T; - errorMessages?: ErrorMessages; +interface UseValidatedInputOpts { + name: KeyOf; + formDefinition?: T; serverFormInfo?: ServerFormInfo; ref?: | React.ForwardedRef @@ -413,32 +404,30 @@ interface UseValidatedInputOpts { } // Handle validations for a single input -export function useValidatedInput( +export function useValidatedInput( opts: UseValidatedInputOpts ) { let ctx = useOptionalFormContext(); let id = React.useId(); let name = opts.name; - let formValidations = opts.formValidations || ctx?.formValidations; - let errorMessages = - opts.errorMessages || ctx?.errorMessages - ? { - ...ctx?.errorMessages, - ...opts.errorMessages, - } - : undefined; - // TODO: Can this cast from context be avoided? - let serverFormInfo = - opts.serverFormInfo || (ctx?.serverFormInfo as ServerFormInfo); + let formDefinition = opts.formDefinition || ctx?.formDefinition; invariant( - formValidations !== undefined, + formDefinition, "useValidatedInput() must either be used inside a " + - "or be passed a formValidations prop" + "or be passed a `formDefinition` object" ); + let errorMessages = (key: ValidityStateKey, inputName: KeyOf) => + formDefinition?.inputs?.[inputName]?.errorMessages?.[key] || + formDefinition?.errorMessages?.[key]; + + // TODO: Can this cast from context be avoided? + let serverFormInfo = + opts.serverFormInfo || (ctx?.serverFormInfo as ServerFormInfo); + let wasSubmitted = serverFormInfo != null; - let prevServerFormInfo = React.useRef( + let prevServerFormInfo = React.useRef | undefined>( serverFormInfo ); let inputRef = React.useRef(null); @@ -459,11 +448,15 @@ export function useValidatedInput( currentErrorMessages = Object.entries(validity) .filter((e) => e[0] !== "valid" && e[1]) .reduce((acc, [validation, valid]) => { - let attr = builtInValidityToAttrMapping[validation]; + let attr = builtInValidityToAttrMapping[ + validation as ValidityStateKey + ] as KeyOf; let message = - errorMessages?.[validation] || builtInValidations[attr]?.errorMessage; + errorMessages(validation as ValidityStateKey, name) || + builtInValidations[attr]?.errorMessage; if (typeof message === "function") { - let attrValue = formValidations?.[name]?.[attr]; + let attrValue = + formDefinition?.inputs?.[name]?.validationAttrs?.[attr]; message = message( attrValue ? String(attrValue) : undefined, name, @@ -479,7 +472,28 @@ export function useValidatedInput( let showErrors = validity?.valid === false && validationState === "done" && touched; - useOneTimeListener(inputRef, "blur", () => setTouched(true)); + React.useEffect(() => { + let inputEl = inputRef.current; + if (!inputEl) { + return; + } + let handler = () => setTouched(true); + inputEl.addEventListener("blur", handler); + return () => inputEl?.removeEventListener("blur", handler); + }, [inputRef]); + + React.useEffect(() => { + let inputEl = inputRef.current; + if (!inputEl) { + return; + } + let handler = function (this: HTMLInputElement) { + setDirty(true); + setValue(this.value); + }; + inputEl.addEventListener("input", handler); + return () => inputEl?.removeEventListener("input", handler); + }, [inputRef]); React.useEffect(() => { async function go() { @@ -507,7 +521,7 @@ export function useValidatedInput( } // Validate the input - let inputValidations = formValidations?.[name]; + let inputValidations = formDefinition?.inputs[name]; if (!inputValidations) { console.warn(`No validations found for the "${name}" input`); setValidationState("done"); @@ -529,7 +543,9 @@ export function useValidatedInput( } go().catch((e) => console.error("Error in validateInput useEffect", e)); - }, [dirty, touched, value, formValidations, name, serverFormInfo]); + + return () => controller.current?.abort(); + }, [dirty, touched, value, formDefinition, name, serverFormInfo]); function getClasses(type: "label" | "input", className?: string) { return composeClassNames([ @@ -544,26 +560,17 @@ export function useValidatedInput( // Provide the caller a prop getter to be spread onto the function getInputAttrs({ - onChange, ...attrs }: React.ComponentPropsWithoutRef<"input"> = {}): React.ComponentPropsWithoutRef<"input"> { - let validationAttrs = Object.entries(formValidations?.[name] || {}).reduce( - (acc, [attr, value]) => - attr in builtInValidations - ? Object.assign(acc, { [attr]: value }) - : acc, - {} - ); + let validationAttrs = Object.entries( + formDefinition?.inputs[name]?.validationAttrs || {} + ).reduce((acc, [attr, value]) => Object.assign(acc, { [attr]: value }), {}); let inputAttrs = { ref: composedRef, name, id: getInputId(name, id), className: getClasses("input", attrs.className), defaultValue: serverFormInfo?.submittedFormData?.lastName, - onChange: callAll(onChange, (e: React.ChangeEvent) => { - setDirty(true); - setValue(e.target.value); - }), ...(showErrors ? { "aria-invalid": true, @@ -617,33 +624,31 @@ export function useValidatedInput( }; } -export interface FieldProps +export interface FieldProps extends UseValidatedInputOpts, Omit, "name"> { label: string; } // Syntactic sugar component to handle