├── .github ├── ISSUE_TEMPLATE │ └── issue-template.md └── workflows │ ├── npm-publish.yml │ └── test.yml ├── .gitignore ├── .pnpmrc ├── .prettierrc ├── .vscode └── settings.json ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── package.json ├── packages ├── codesandboxes │ ├── .eslintrc.js │ ├── .prettierrc │ ├── README.md │ ├── boxes │ │ ├── 3rdparty │ │ │ ├── index.html │ │ │ ├── package.json │ │ │ ├── src │ │ │ │ ├── App.tsx │ │ │ │ ├── data.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── styles.css │ │ │ └── tsconfig.json │ │ ├── async-validation │ │ │ ├── index.html │ │ │ ├── package.json │ │ │ ├── src │ │ │ │ ├── App.tsx │ │ │ │ ├── colors.ts │ │ │ │ ├── index.tsx │ │ │ │ └── styles.css │ │ │ └── tsconfig.json │ │ ├── checkboxes │ │ │ ├── index.html │ │ │ ├── package.json │ │ │ ├── src │ │ │ │ ├── App.tsx │ │ │ │ ├── colors.ts │ │ │ │ ├── index.tsx │ │ │ │ └── styles.css │ │ │ └── tsconfig.json │ │ ├── file │ │ │ ├── index.html │ │ │ ├── package.json │ │ │ ├── src │ │ │ │ ├── App.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── styles.css │ │ │ └── tsconfig.json │ │ ├── formdata-event │ │ │ ├── index.html │ │ │ ├── package.json │ │ │ ├── src │ │ │ │ ├── App.tsx │ │ │ │ ├── data.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── styles.css │ │ │ └── tsconfig.json │ │ ├── index.html │ │ ├── input-props │ │ │ ├── index.html │ │ │ ├── package.json │ │ │ ├── src │ │ │ │ ├── App.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── input-props.ts │ │ │ │ └── styles.css │ │ │ └── tsconfig.json │ │ ├── internalization │ │ │ ├── index.html │ │ │ ├── package.json │ │ │ ├── src │ │ │ │ ├── App.tsx │ │ │ │ ├── i18n.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── styles.css │ │ │ └── tsconfig.json │ │ ├── render-function │ │ │ ├── index.html │ │ │ ├── package.json │ │ │ ├── src │ │ │ │ ├── App.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── styles.css │ │ │ └── tsconfig.json │ │ ├── signup │ │ │ ├── index.html │ │ │ ├── package.json │ │ │ ├── src │ │ │ │ ├── App.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── styles.css │ │ │ └── tsconfig.json │ │ ├── todos │ │ │ ├── index.html │ │ │ ├── package.json │ │ │ ├── src │ │ │ │ ├── App.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── styles.css │ │ │ └── tsconfig.json │ │ └── use-value │ │ │ ├── index.html │ │ │ ├── package.json │ │ │ ├── src │ │ │ ├── App.tsx │ │ │ ├── helpers.tsx │ │ │ ├── index.tsx │ │ │ └── styles.css │ │ │ └── tsconfig.json │ ├── merge-deps.mjs │ ├── package.json │ ├── shared │ │ ├── index.html │ │ └── index.tsx │ ├── tsconfig.json │ ├── upgrade-deps.sh │ └── vite.config.ts ├── react-zorm │ ├── .eslintrc.js │ ├── .gitignore │ ├── .np-config.json │ ├── .prettierrc │ ├── CHANGELOG.md │ ├── __tests__ │ │ ├── create-form.test.tsx │ │ ├── create-issues-chain.test.tsx │ │ ├── error-chain.test.tsx │ │ ├── field-chain.test.tsx │ │ ├── field-type-inspection.test.ts │ │ ├── input-props.test.tsx │ │ ├── parse-form.test.tsx │ │ ├── setup-tests.ts │ │ ├── test-helpers.tsx │ │ ├── use-value.test.tsx │ │ └── use-zorm.test.tsx │ ├── e2e │ │ ├── formdata-event.tsx │ │ ├── index.html │ │ ├── index.tsx │ │ ├── invalid-event.tsx │ │ ├── register.tsx │ │ ├── the-tests.spec.ts │ │ └── validate-on-blur.tsx │ ├── jest.config.js │ ├── package.json │ ├── playwright.config.ts │ ├── src │ │ ├── chains.tsx │ │ ├── index.tsx │ │ ├── input-props.ts │ │ ├── parse-form.ts │ │ ├── set-in.ts │ │ ├── types.tsx │ │ ├── use-value.ts │ │ ├── use-zorm.tsx │ │ └── utils.ts │ ├── tsconfig.build.json │ ├── tsconfig.json │ └── vite.config.ts └── remix-example │ ├── .eslintrc │ ├── .gitignore │ ├── .prettierrc │ ├── README.md │ ├── app │ ├── entry.client.tsx │ ├── entry.server.tsx │ ├── root.tsx │ ├── routes │ │ ├── index.tsx │ │ └── server-side-validation.tsx │ └── styles.css │ ├── package.json │ ├── public │ └── favicon.ico │ ├── remix.config.js │ ├── remix.env.d.ts │ ├── server.js │ ├── tsconfig.json │ └── vercel.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml └── react-zorm.svg /.github/ISSUE_TEMPLATE/issue-template.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Open an issue 3 | about: 'If you just have a question use the Discussions or ping @esamatti on twitter instead' 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 20 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish npm package 2 | 3 | on: 4 | push: 5 | branches: 6 | - release/*/* 7 | jobs: 8 | publish: 9 | runs-on: ubuntu-latest 10 | timeout-minutes: 10 11 | steps: 12 | - uses: actions/checkout@v3 13 | - name: Publish a npm package 14 | uses: valu-digital/npm-packages/.github/release-action@master 15 | with: 16 | npm_token: "${{ secrets.NPM_TOKEN }}" 17 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | concurrency: 4 | group: "test-${{ github.ref }}" 5 | cancel-in-progress: true 6 | 7 | on: 8 | push: 9 | branches: [master] 10 | paths: 11 | - "packages/react-zorm/**" 12 | - ".github/workflows/test.yml" 13 | - "pnpm-lock.yaml" 14 | pull_request: 15 | paths: 16 | - "packages/react-zorm/**" 17 | - ".github/workflows/test.yml" 18 | - "pnpm-lock.yaml" 19 | jobs: 20 | test: 21 | timeout-minutes: 20 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@v3 25 | 26 | - uses: actions/setup-node@v3 27 | with: 28 | node-version: "16.x" 29 | 30 | - uses: pnpm/action-setup@v2.2.4 31 | with: 32 | version: "7.x" 33 | 34 | - name: Set pnpm store path 35 | run: echo "PNPM_STORE_PATH=$(pnpm store path)" >> $GITHUB_ENV 36 | 37 | - name: Cache pnpm modules 38 | uses: actions/cache@v3 39 | with: 40 | path: ${{ env.PNPM_STORE_PATH }} 41 | key: pnpm-test-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} 42 | restore-keys: | 43 | pnpm-test-${{ runner.os }}- 44 | 45 | - name: Cache playwright browsers 46 | uses: actions/cache@v2 47 | with: 48 | path: ~/.cache/ms-playwright 49 | key: playwright-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} 50 | restore-keys: | 51 | playwright-${{ runner.os }}- 52 | 53 | - name: Install dependencies 54 | run: pnpm install --frozen-lockfile --filter react-zorm 55 | 56 | - name: Build 57 | run: | 58 | set -eu 59 | cd packages/react-zorm 60 | pnpm run build 61 | 62 | - name: Check types 63 | run: | 64 | set -eu 65 | cd packages/react-zorm 66 | pnpm run tsc 67 | 68 | - name: Check run tests 69 | run: | 70 | set -eu 71 | cd packages/react-zorm 72 | pnpm run jest 73 | 74 | - name: Check lints 75 | run: | 76 | set -eu 77 | cd packages/react-zorm 78 | pnpm run eslint 79 | 80 | - name: Install Playwright Browsers 81 | run: cd packages/react-zorm && ./node_modules/.bin/playwright install --with-deps 82 | 83 | - name: Run Playwright tests 84 | run: | 85 | set -eu 86 | cd packages/react-zorm 87 | pnpm run playwright-test 88 | 89 | - name: Run size-limit 90 | run: | 91 | set -eu 92 | cd packages/react-zorm 93 | pnpm run size-limit 94 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .parcel-cache 3 | dist 4 | out -------------------------------------------------------------------------------- /.pnpmrc: -------------------------------------------------------------------------------- 1 | strict-peer-dependencies=false -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "tabWidth": 4 4 | } 5 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | "**/dist": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | Here 2 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | This is [pnpm][pnpm] monorepo. Maybe a bit overkill for such a small lib but eh, pnpm is 4 | quite smooth with these. 5 | 6 | So install pnpm and clone the repository 7 | 8 | ``` 9 | git clone https://github.com/esamattis/react-zorm.git 10 | ``` 11 | 12 | Install deps with pnpm 13 | 14 | ``` 15 | pnpm install --frozen-lockfile 16 | ``` 17 | 18 | ## Tests 19 | 20 | Run tests 21 | 22 | ``` 23 | cd packages/react-zorm 24 | pnpm test 25 | ``` 26 | 27 | Playwright tests 28 | 29 | ``` 30 | cd packages/react-zorm 31 | pnpm run playwright-test 32 | ``` 33 | 34 | To manually debug playwright tests start the script script 35 | 36 | ``` 37 | pnpm run dev 38 | ``` 39 | 40 | And run playwright as headed: 41 | 42 | ``` 43 | pnpm run playwright-test --headed 44 | ``` 45 | 46 | ## Packaging 47 | 48 | If you need to run fork of Zorm in your project you can build and package it 49 | with: 50 | 51 | ``` 52 | cd packages/react-zorm 53 | pnpm build 54 | pnpm pack 55 | ``` 56 | 57 | This will generate a file like `react-zorm-0.6.0.tgz`. Add it to your project 58 | git and install it: 59 | 60 | ``` 61 | npm install ./react-zorm-0.6.0.tgz 62 | ``` 63 | 64 | And it will be refenced by the `file:` protocol in your package.json. 65 | 66 | [pnpm]: https://pnpm.io/ 67 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Esa-Matti Suuronen 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 | 23 | ==== 24 | 25 | setIn implemenation from Final Form 26 | 27 | MIT License 28 | 29 | Copyright (c) 2017 Erik Rasmussen 30 | 31 | Permission is hereby granted, free of charge, to any person obtaining a copy 32 | of this software and associated documentation files (the "Software"), to deal 33 | in the Software without restriction, including without limitation the rights 34 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 35 | copies of the Software, and to permit persons to whom the Software is 36 | furnished to do so, subject to the following conditions: 37 | 38 | The above copyright notice and this permission notice shall be included in all 39 | copies or substantial portions of the Software. 40 | 41 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 42 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 43 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 44 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 45 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 46 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 47 | SOFTWARE. 48 | 49 | ==== 50 | 51 | toPath implementation adapted from lodash/.internal/stringToPath. Lodash license: 52 | 53 | The MIT License 54 | 55 | Copyright JS Foundation and other contributors 56 | 57 | Based on Underscore.js, copyright Jeremy Ashkenas, 58 | DocumentCloud and Investigative Reporters & Editors 59 | 60 | This software consists of voluntary contributions made by many 61 | individuals. For exact contribution history, see the revision history 62 | available at https://github.com/lodash/lodash 63 | 64 | The following license applies to all parts of this software except as 65 | documented below: 66 | 67 | ==== 68 | 69 | Permission is hereby granted, free of charge, to any person obtaining 70 | a copy of this software and associated documentation files (the 71 | "Software"), to deal in the Software without restriction, including 72 | without limitation the rights to use, copy, modify, merge, publish, 73 | distribute, sublicense, and/or sell copies of the Software, and to 74 | permit persons to whom the Software is furnished to do so, subject to 75 | the following conditions: 76 | 77 | The above copyright notice and this permission notice shall be 78 | included in all copies or substantial portions of the Software. 79 | 80 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 81 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 82 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 83 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 84 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 85 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 86 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 87 | 88 | ==== 89 | 90 | Copyright and related rights for sample code are waived via CC0. Sample 91 | code is defined as all source code displayed within the prose of the 92 | documentation. 93 | 94 | CC0: http://creativecommons.org/publicdomain/zero/1.0/ 95 | 96 | ==== 97 | 98 | Files located in the node_modules and vendor directories are externally 99 | maintained libraries used by this software which have their own 100 | licenses; we recommend you read them, as their terms may differ from the 101 | terms above. 102 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "prettier": "2.5.1" 4 | }, 5 | "pnpm": { 6 | "overrides": { 7 | "prettier": "2.5.1", 8 | "zod": "3.21.4", 9 | "typescript": "4.5.5", 10 | "react": "18.2.0", 11 | "react-dom": "18.2.0", 12 | "@types/react": "18.0.21", 13 | "@types/react-dom": "18.0.6" 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/codesandboxes/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: ["react-hooks"], 3 | parser: "@typescript-eslint/parser", 4 | rules: { 5 | "react-hooks/rules-of-hooks": "error", 6 | "react-hooks/exhaustive-deps": "warn", 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /packages/codesandboxes/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "tabWidth": 4 4 | } 5 | -------------------------------------------------------------------------------- /packages/codesandboxes/README.md: -------------------------------------------------------------------------------- 1 | # Codesandbox demos 2 | 3 | ## Run locally 4 | 5 | ``` 6 | pnpm i 7 | pnpm run dev 8 | ``` 9 | 10 | And open the logged url 11 | 12 | ## Updating box deps 13 | 14 | See and update `merge-deps.mjs` and run 15 | 16 | ``` 17 | pnpm run upgrade-box-deps 18 | ``` 19 | -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/3rdparty/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | Local Vite Example 10 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/3rdparty/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-zorm-3rdparty", 3 | "version": "1.0.0", 4 | "description": "", 5 | "keywords": [], 6 | "main": "src/index.tsx", 7 | "dependencies": { 8 | "react-select": "5.2.2", 9 | "react": "18.2.0", 10 | "react-dom": "18.2.0", 11 | "react-scripts": "4.0.3", 12 | "react-zorm": "0.9.0", 13 | "zod": "3.21.4", 14 | "@types/react": "18.0.21", 15 | "@types/react-dom": "18.0.6" 16 | }, 17 | "devDependencies": { 18 | "typescript": "4.8.4" 19 | }, 20 | "scripts": { 21 | "start": "react-scripts start", 22 | "build": "react-scripts build", 23 | "test": "react-scripts test --env=jsdom", 24 | "eject": "react-scripts eject" 25 | }, 26 | "browserslist": [ 27 | ">0.2%", 28 | "not dead", 29 | "not ie <= 11", 30 | "not op_mini all" 31 | ] 32 | } -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/3rdparty/src/App.tsx: -------------------------------------------------------------------------------- 1 | import "./styles.css"; 2 | import React, { useState } from "react"; 3 | import { z } from "zod"; 4 | import { useZorm } from "react-zorm"; 5 | import Select from "react-select"; 6 | 7 | import { stateOptions } from "./data"; 8 | 9 | function SelectState(props: { 10 | defaultStates: string[]; 11 | getName: (index: number) => string; 12 | onBlur?: () => any; 13 | }) { 14 | const [values, setValues] = useState(null); 15 | 16 | const defaulValues = props.defaultStates.map((value) => { 17 | return { 18 | label: stateOptions.find((o) => o.value === value)?.label, 19 | value, 20 | }; 21 | }); 22 | 23 | const syncValues: string[] = values ?? props.defaultStates; 24 | 25 | return ( 26 | <> 27 | 44 | ); 45 | })} 46 | 47 | ); 48 | } 49 | 50 | const FormSchema = z.object({ 51 | states: z.array(z.string()).min(3), 52 | }); 53 | 54 | export default function ReactSelectExample() { 55 | const zo = useZorm("3rdparty", FormSchema, { 56 | onValidSubmit(e) { 57 | e.preventDefault(); 58 | alert(JSON.stringify(e.data, null, 2)); 59 | }, 60 | }); 61 | const disabled = zo.validation?.success === false; 62 | 63 | return ( 64 |
65 | Select at least 3 states: 66 | { 68 | zo.validate(); 69 | }} 70 | defaultStates={[stateOptions[30].value]} 71 | getName={(index) => zo.fields.states(index)("name")} 72 | /> 73 |
{zo.errors.states()?.message}
74 | 77 |
78 |                 Validation status: {JSON.stringify(zo.validation, null, 2)}
79 |             
80 | 81 | ); 82 | } 83 | -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/3rdparty/src/data.tsx: -------------------------------------------------------------------------------- 1 | export interface StateOption { 2 | readonly value: string; 3 | readonly label: string; 4 | } 5 | 6 | export const stateOptions: readonly StateOption[] = [ 7 | { value: "AL", label: "Alabama" }, 8 | { value: "AK", label: "Alaska" }, 9 | { value: "AS", label: "American Samoa" }, 10 | { value: "AZ", label: "Arizona" }, 11 | { value: "AR", label: "Arkansas" }, 12 | { value: "CA", label: "California" }, 13 | { value: "CO", label: "Colorado" }, 14 | { value: "CT", label: "Connecticut" }, 15 | { value: "DE", label: "Delaware" }, 16 | { value: "DC", label: "District Of Columbia" }, 17 | { value: "FM", label: "Federated States Of Micronesia" }, 18 | { value: "FL", label: "Florida" }, 19 | { value: "GA", label: "Georgia" }, 20 | { value: "GU", label: "Guam" }, 21 | { value: "HI", label: "Hawaii" }, 22 | { value: "ID", label: "Idaho" }, 23 | { value: "IL", label: "Illinois" }, 24 | { value: "IN", label: "Indiana" }, 25 | { value: "IA", label: "Iowa" }, 26 | { value: "KS", label: "Kansas" }, 27 | { value: "KY", label: "Kentucky" }, 28 | { value: "LA", label: "Louisiana" }, 29 | { value: "ME", label: "Maine" }, 30 | { value: "MH", label: "Marshall Islands" }, 31 | { value: "MD", label: "Maryland" }, 32 | { value: "MA", label: "Massachusetts" }, 33 | { value: "MI", label: "Michigan" }, 34 | { value: "MN", label: "Minnesota" }, 35 | { value: "MS", label: "Mississippi" }, 36 | { value: "MO", label: "Missouri" }, 37 | { value: "MT", label: "Montana" }, 38 | { value: "NE", label: "Nebraska" }, 39 | { value: "NV", label: "Nevada" }, 40 | { value: "NH", label: "New Hampshire" }, 41 | { value: "NJ", label: "New Jersey" }, 42 | { value: "NM", label: "New Mexico" }, 43 | { value: "NY", label: "New York" }, 44 | { value: "NC", label: "North Carolina" }, 45 | { value: "ND", label: "North Dakota" }, 46 | { value: "MP", label: "Northern Mariana Islands" }, 47 | { value: "OH", label: "Ohio" }, 48 | { value: "OK", label: "Oklahoma" }, 49 | { value: "OR", label: "Oregon" }, 50 | { value: "PW", label: "Palau" }, 51 | { value: "PA", label: "Pennsylvania" }, 52 | { value: "PR", label: "Puerto Rico" }, 53 | { value: "RI", label: "Rhode Island" }, 54 | { value: "SC", label: "South Carolina" }, 55 | { value: "SD", label: "South Dakota" }, 56 | { value: "TN", label: "Tennessee" }, 57 | { value: "TX", label: "Texas" }, 58 | { value: "UT", label: "Utah" }, 59 | { value: "VT", label: "Vermont" }, 60 | { value: "VI", label: "Virgin Islands" }, 61 | { value: "VA", label: "Virginia" }, 62 | { value: "WA", label: "Washington" }, 63 | { value: "WV", label: "West Virginia" }, 64 | { value: "WI", label: "Wisconsin" }, 65 | { value: "WY", label: "Wyoming" }, 66 | ]; 67 | -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/3rdparty/src/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from "react-dom/client"; 2 | import React from "react"; 3 | 4 | import App from "./App"; 5 | 6 | const rootElement = document.getElementById("root"); 7 | if (!rootElement) { 8 | throw new Error("No root element found"); 9 | } 10 | const root = createRoot(rootElement); 11 | root.render(); 12 | -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/3rdparty/src/styles.css: -------------------------------------------------------------------------------- 1 | button[type="submit"] { 2 | margin-top: 2rem; 3 | padding: 1rem; 4 | } 5 | fieldset { 6 | margin: 1rem; 7 | } 8 | 9 | .errored { 10 | border: 4px solid red; 11 | } 12 | 13 | .error-message { 14 | font-size: small; 15 | color: red; 16 | } 17 | 18 | input, 19 | button { 20 | display: block; 21 | } 22 | -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/3rdparty/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "./src/**/*" 4 | ], 5 | "compilerOptions": { 6 | "strict": true, 7 | "esModuleInterop": true, 8 | "lib": [ 9 | "dom", 10 | "es2015" 11 | ], 12 | "jsx": "react-jsx" 13 | } 14 | } -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/async-validation/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | Local Vite Example 10 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/async-validation/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-zorm-signup-form-example", 3 | "version": "1.0.0", 4 | "description": "", 5 | "keywords": [], 6 | "main": "src/index.tsx", 7 | "dependencies": { 8 | "react": "18.2.0", 9 | "react-query": "^3.34.19", 10 | "react-dom": "18.2.0", 11 | "react-scripts": "4.0.3", 12 | "react-zorm": "0.9.0", 13 | "zod": "3.21.4", 14 | "@types/react": "18.0.21", 15 | "@types/react-dom": "18.0.6" 16 | }, 17 | "devDependencies": { 18 | "typescript": "4.8.4" 19 | }, 20 | "scripts": { 21 | "start": "react-scripts start", 22 | "build": "react-scripts build", 23 | "test": "react-scripts test --env=jsdom", 24 | "eject": "react-scripts eject" 25 | }, 26 | "browserslist": [ 27 | ">0.2%", 28 | "not dead", 29 | "not ie <= 11", 30 | "not op_mini all" 31 | ] 32 | } -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/async-validation/src/App.tsx: -------------------------------------------------------------------------------- 1 | import "./styles.css"; 2 | import React from "react"; 3 | import { z } from "zod"; 4 | import { createCustomIssues, useZorm } from "react-zorm"; 5 | import { QueryClient, QueryClientProvider, useMutation } from "react-query"; 6 | 7 | const booleanCheckbox = () => 8 | z 9 | .string() 10 | // Unchecked checkbox is just missing so it must be optional 11 | .optional() 12 | // Transform the value to boolean 13 | .transform(Boolean); 14 | 15 | const SignupSchema = z.object({ 16 | username: z.string().min(3), 17 | password: z.string().min(5), 18 | terms: booleanCheckbox().refine((value) => value === true, { 19 | message: "You must agree!", 20 | }), 21 | }); 22 | 23 | async function validateUsername(username: string) { 24 | // In real life this would make a POST call to a server where this code 25 | // would run. But for this demo we run it inline 26 | await new Promise((r) => setTimeout(r, 1000)); 27 | const issues = createCustomIssues(SignupSchema); 28 | 29 | if (username === "bob") { 30 | issues.username(`Username ${username} is already in use`); 31 | } 32 | 33 | return { 34 | issues: issues.toArray(), 35 | }; 36 | } 37 | 38 | function Err(props: { children: string }) { 39 | return
{props.children}
; 40 | } 41 | 42 | function ZormFormExample() { 43 | const usernameValidation = useMutation(validateUsername); 44 | 45 | const zo = useZorm("signup", SignupSchema, { 46 | customIssues: usernameValidation.data?.issues, 47 | }); 48 | 49 | return ( 50 |
51 |
52 |
53 | Signup 54 |
55 | 62 | Try username "bob" to demonstrate async validation 63 | error 64 | 65 | Username: 66 | { 71 | usernameValidation.mutate(e.target.value); 72 | }} 73 | /> 74 | {usernameValidation.isLoading ? ( 75 |
76 | Checking username... 77 |
78 | ) : null} 79 | {zo.errors.username((err) => ( 80 | {err.message} 81 | ))} 82 |
83 | 84 |
85 | Password: 86 | 91 | {zo.errors.password((err) => ( 92 | {err.message} 93 | ))} 94 |
95 | 96 |
97 | 103 | 106 | {zo.errors.terms((err) => ( 107 | {err.message} 108 | ))} 109 |
110 | 111 | 112 |
113 |
114 |
115 | ); 116 | } 117 | 118 | const queryClient = new QueryClient(); 119 | 120 | export default function App() { 121 | return ( 122 | 123 | 124 | 125 | ); 126 | } 127 | -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/async-validation/src/colors.ts: -------------------------------------------------------------------------------- 1 | interface Color { 2 | name: string; 3 | code: string; 4 | } 5 | 6 | export const COLORS: Color[] = [ 7 | { 8 | name: "Red", 9 | code: "red", 10 | }, 11 | { 12 | name: "Green", 13 | code: "green", 14 | }, 15 | { 16 | name: "Blue", 17 | code: "blue", 18 | }, 19 | { 20 | name: "Cyan", 21 | code: "Cyan", 22 | }, 23 | { 24 | name: "Magenta", 25 | code: "magenta", 26 | }, 27 | { 28 | name: "Yellow", 29 | code: "yellow", 30 | }, 31 | ]; 32 | -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/async-validation/src/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from "react-dom/client"; 2 | import React from "react"; 3 | 4 | import App from "./App"; 5 | 6 | const rootElement = document.getElementById("root"); 7 | if (!rootElement) { 8 | throw new Error("No root element found"); 9 | } 10 | const root = createRoot(rootElement); 11 | root.render(); 12 | -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/async-validation/src/styles.css: -------------------------------------------------------------------------------- 1 | input[type="text"], 2 | input[type="email"], 3 | input[type="password"], 4 | button { 5 | display: block; 6 | } 7 | 8 | input[type="checkbox"] { 9 | margin-top: 1rem; 10 | } 11 | 12 | button { 13 | margin-top: 1rem; 14 | } 15 | 16 | .error { 17 | color: red; 18 | } 19 | 20 | .ok { 21 | color: rgb(153, 255, 0); 22 | margin-top: 2rem; 23 | font-size: 20pt; 24 | } 25 | -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/async-validation/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["./src/**/*"], 3 | "compilerOptions": { 4 | "strict": true, 5 | "esModuleInterop": true, 6 | "lib": ["dom", "es2020"], 7 | "jsx": "react-jsx" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/checkboxes/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | Local Vite Example 10 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/checkboxes/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-zorm-signup-form-example", 3 | "version": "1.0.0", 4 | "description": "", 5 | "keywords": [], 6 | "main": "src/index.tsx", 7 | "dependencies": { 8 | "react": "18.2.0", 9 | "react-dom": "18.2.0", 10 | "react-scripts": "4.0.3", 11 | "react-zorm": "0.9.0", 12 | "zod": "3.21.4", 13 | "@types/react": "18.0.21", 14 | "@types/react-dom": "18.0.6" 15 | }, 16 | "devDependencies": { 17 | "typescript": "4.8.4" 18 | }, 19 | "scripts": { 20 | "start": "react-scripts start", 21 | "build": "react-scripts build", 22 | "test": "react-scripts test --env=jsdom", 23 | "eject": "react-scripts eject" 24 | }, 25 | "browserslist": [ 26 | ">0.2%", 27 | "not dead", 28 | "not ie <= 11", 29 | "not op_mini all" 30 | ] 31 | } -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/checkboxes/src/App.tsx: -------------------------------------------------------------------------------- 1 | import "./styles.css"; 2 | import React from "react"; 3 | import { z } from "zod"; 4 | import { useZorm } from "react-zorm"; 5 | import { COLORS } from "./colors"; 6 | 7 | /** 8 | * Helper Zod type to convert checkbox values to booleans 9 | */ 10 | const booleanCheckbox = () => 11 | z 12 | .string() 13 | // Unchecked checkbox is just missing so it must be optional 14 | .optional() 15 | // Transform the value to boolean 16 | .transform(Boolean); 17 | 18 | const arrayCheckbox = () => 19 | z 20 | .array(z.string().nullish()) 21 | .nullish() 22 | // Remove all nulls to ensure string[] 23 | .transform((a) => (a ?? []).flatMap((item) => (item ? item : []))); 24 | // Why .flatMap() and not .filter(): 25 | // https://twitter.com/esamatti/status/1485718780508618758 26 | 27 | const FormSchema = z.object({ 28 | colors: arrayCheckbox().refine( 29 | (colors) => { 30 | return colors.length > 2; 31 | }, 32 | { message: "Select at least 3 colors" }, 33 | ), 34 | 35 | acceptTerms: booleanCheckbox().refine( 36 | (val) => { 37 | return val === true; 38 | }, 39 | { message: "You must accept the terms" }, 40 | ), 41 | allowSpam: booleanCheckbox(), 42 | }); 43 | 44 | function ErrorMessage(props: { message: string }) { 45 | return
{props.message}
; 46 | } 47 | 48 | export default function SelectColors() { 49 | const zo = useZorm("signup", FormSchema, { 50 | onValidSubmit(e) { 51 | e.preventDefault(); 52 | alert("Form ok!\n" + JSON.stringify(e.data, null, 2)); 53 | }, 54 | }); 55 | const disabled = zo.validation?.success === false; 56 | 57 | return ( 58 |
59 |

Checkboxes!

60 |
61 | Select at least 3 colors 62 | 63 | {COLORS.map((color, index) => { 64 | return ( 65 |
66 | 72 | 75 |
76 | ); 77 | })} 78 | 79 | {zo.errors.colors((e) => ( 80 | 81 | ))} 82 |
83 | 84 |
85 | Misc 86 | 87 | 92 | 95 | {zo.errors.acceptTerms((e) => ( 96 | 97 | ))} 98 | 99 |
100 | 105 | 108 |
109 | 110 | 113 |
114 |                 Validation status: {JSON.stringify(zo.validation, null, 2)}
115 |             
116 |
117 | ); 118 | } 119 | -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/checkboxes/src/colors.ts: -------------------------------------------------------------------------------- 1 | interface Color { 2 | name: string; 3 | code: string; 4 | } 5 | 6 | export const COLORS: Color[] = [ 7 | { 8 | name: "Red", 9 | code: "red", 10 | }, 11 | { 12 | name: "Green", 13 | code: "green", 14 | }, 15 | { 16 | name: "Blue", 17 | code: "blue", 18 | }, 19 | { 20 | name: "Cyan", 21 | code: "Cyan", 22 | }, 23 | { 24 | name: "Magenta", 25 | code: "magenta", 26 | }, 27 | { 28 | name: "Yellow", 29 | code: "yellow", 30 | }, 31 | ]; 32 | -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/checkboxes/src/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from "react-dom/client"; 2 | import React from "react"; 3 | 4 | import App from "./App"; 5 | 6 | const rootElement = document.getElementById("root"); 7 | if (!rootElement) { 8 | throw new Error("No root element found"); 9 | } 10 | const root = createRoot(rootElement); 11 | root.render(); 12 | -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/checkboxes/src/styles.css: -------------------------------------------------------------------------------- 1 | button[type="submit"] { 2 | margin-top: 2rem; 3 | padding: 1rem; 4 | } 5 | fieldset { 6 | margin: 1rem; 7 | } 8 | 9 | .errored { 10 | border: 4px solid red; 11 | } 12 | 13 | .error-message { 14 | font-size: small; 15 | color: red; 16 | } 17 | -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/checkboxes/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["./src/**/*"], 3 | "compilerOptions": { 4 | "strict": true, 5 | "esModuleInterop": true, 6 | "lib": ["dom", "es2020"], 7 | "jsx": "react-jsx" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/file/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | Local Vite Example 10 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/file/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-zorm-signup-form-example", 3 | "version": "1.0.0", 4 | "description": "", 5 | "keywords": [], 6 | "main": "src/index.tsx", 7 | "dependencies": { 8 | "react": "18.2.0", 9 | "react-dom": "18.2.0", 10 | "react-scripts": "4.0.3", 11 | "react-zorm": "0.9.0", 12 | "zod": "3.21.4", 13 | "@types/react": "18.0.21", 14 | "@types/react-dom": "18.0.6" 15 | }, 16 | "devDependencies": { 17 | "typescript": "4.8.4" 18 | }, 19 | "scripts": { 20 | "start": "react-scripts start", 21 | "build": "react-scripts build", 22 | "test": "react-scripts test --env=jsdom", 23 | "eject": "react-scripts eject" 24 | }, 25 | "browserslist": [ 26 | ">0.2%", 27 | "not dead", 28 | "not ie <= 11", 29 | "not op_mini all" 30 | ] 31 | } -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/file/src/App.tsx: -------------------------------------------------------------------------------- 1 | import "./styles.css"; 2 | import React from "react"; 3 | import { z } from "zod"; 4 | import { useZorm } from "react-zorm"; 5 | 6 | const MAX_FILE_SIZE = 500000; 7 | const ACCEPTED_IMAGE_TYPES = [ 8 | "image/jpeg", 9 | "image/jpg", 10 | "image/png", 11 | "image/webp", 12 | ]; 13 | 14 | const FormSchema = z.object({ 15 | image: z 16 | .instanceof(File) 17 | .refine((file) => file.name !== undefined, "Please upload an image.") 18 | .refine((file) => file.size <= MAX_FILE_SIZE, `Max image size is 5MB.`) 19 | .refine( 20 | (file) => ACCEPTED_IMAGE_TYPES.includes(file.type), 21 | "Only .jpg, .jpeg, .png and .webp formats are supported.", 22 | ), 23 | }); 24 | 25 | function ErrorMessage(props: { message: string }) { 26 | return
{props.message}
; 27 | } 28 | 29 | export default function Signup() { 30 | const zo = useZorm("signup", FormSchema, { 31 | onValidSubmit(e) { 32 | e.preventDefault(); 33 | alert(` 34 | File: ${e.data.image.name} 35 | Size: ${e.data.image.size} 36 | `); 37 | }, 38 | }); 39 | const disabled = zo.validation?.success === false; 40 | 41 | return ( 42 |
43 | Select an image: 44 | 49 | {zo.errors.image((e) => ( 50 | 51 | ))} 52 | 55 | {zo.validation?.success ? ( 56 | <> 57 |

File: {zo.validation.data.image.name}

58 |

Size: {zo.validation.data.image.size}

59 | 60 | ) : null} 61 | 62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/file/src/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from "react-dom/client"; 2 | import React from "react"; 3 | 4 | import App from "./App"; 5 | 6 | const rootElement = document.getElementById("root"); 7 | if (!rootElement) { 8 | throw new Error("No root element found"); 9 | } 10 | const root = createRoot(rootElement); 11 | root.render(); 12 | -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/file/src/styles.css: -------------------------------------------------------------------------------- 1 | button[type="submit"] { 2 | margin-top: 2rem; 3 | padding: 1rem; 4 | } 5 | fieldset { 6 | margin: 1rem; 7 | } 8 | 9 | .errored { 10 | border: 4px solid red; 11 | } 12 | 13 | .error-message { 14 | font-size: small; 15 | color: red; 16 | } 17 | 18 | input, 19 | button { 20 | display: block; 21 | } 22 | -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/file/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["./src/**/*"], 3 | "compilerOptions": { 4 | "strict": true, 5 | "esModuleInterop": true, 6 | "lib": ["dom", "es2021"], 7 | "jsx": "react-jsx" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/formdata-event/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | Local Vite Example 10 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/formdata-event/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-zorm-3rdparty", 3 | "version": "1.0.0", 4 | "description": "", 5 | "keywords": [], 6 | "main": "src/index.tsx", 7 | "dependencies": { 8 | "react-select": "5.2.2", 9 | "react": "18.2.0", 10 | "react-dom": "18.2.0", 11 | "react-scripts": "4.0.3", 12 | "react-zorm": "0.9.0", 13 | "zod": "3.21.4", 14 | "@types/react": "18.0.21", 15 | "@types/react-dom": "18.0.6" 16 | }, 17 | "devDependencies": { 18 | "typescript": "4.8.4" 19 | }, 20 | "scripts": { 21 | "start": "react-scripts start", 22 | "build": "react-scripts build", 23 | "test": "react-scripts test --env=jsdom", 24 | "eject": "react-scripts eject" 25 | }, 26 | "browserslist": [ 27 | ">0.2%", 28 | "not dead", 29 | "not ie <= 11", 30 | "not op_mini all" 31 | ] 32 | } -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/formdata-event/src/App.tsx: -------------------------------------------------------------------------------- 1 | import "./styles.css"; 2 | import React, { useEffect, useState } from "react"; 3 | import { z } from "zod"; 4 | import { useZorm } from "react-zorm"; 5 | import Select from "react-select"; 6 | 7 | import { stateOptions } from "./data"; 8 | 9 | const FormSchema = z.object({ 10 | states: z 11 | .array( 12 | z.object({ 13 | code: z.string(), 14 | }), 15 | ) 16 | .min(3), 17 | }); 18 | 19 | export default function ReactSelectExample() { 20 | const zo = useZorm("3rdparty", FormSchema, { 21 | onValidSubmit(e) { 22 | e.preventDefault(); 23 | alert(JSON.stringify(e.data, null, 2)); 24 | }, 25 | onFormData(e) { 26 | // React Select does not render actual elements so we use 27 | // the formdata event to add component state to the form. 28 | // 29 | // [ 30 | // {code: ...}, 31 | // {code: ...}, 32 | // ] 33 | // 34 | values.forEach((value, index) => { 35 | // The same as hidden input: 36 | // 37 | e.formData.set(zo.fields.states(index).code(), value); 38 | }); 39 | }, 40 | }); 41 | 42 | // Helper state for react-select 43 | const [values, setValues] = useState(["CT"]); 44 | 45 | const disabled = zo.validation?.success === false; 46 | 47 | return ( 48 |
49 | Select at least 3 states: 50 | 42 | {zo.errors.name((e) => ( 43 | 44 | ))} 45 |
Props {pretty(zo.fields.name(inputProps))}
46 | 47 | 48 | <> 49 | email: 50 | 54 | {zo.errors.email((e) => ( 55 | 56 | ))} 57 |
Props {pretty(zo.fields.email(inputProps))}
58 | 59 | 60 | <> 61 | date: 62 | 66 | {zo.errors.date((e) => ( 67 | 68 | ))} 69 |
Props {pretty(zo.fields.date(inputProps))}
70 | 71 | 72 | <> 73 | Integer: 74 | 78 | {zo.errors.integer((e) => ( 79 | 80 | ))} 81 |
Props {pretty(zo.fields.integer(inputProps))}
82 | 83 | 84 | <> 85 | Float: 86 | 91 | {zo.errors.float((e) => ( 92 | 93 | ))} 94 |
Props {pretty(zo.fields.float(inputProps))}
95 | 96 | 97 | <> 98 | Password: 99 | 104 | {zo.errors.password((e) => ( 105 | 106 | ))} 107 |
Props {pretty(zo.fields.password(inputProps))}
108 | 109 | 110 | 113 |
Validation status: {pretty(zo.validation)}
114 | 115 | ); 116 | } 117 | 118 | function pretty(value: any) { 119 | return JSON.stringify(value, null, 2); 120 | } 121 | -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/input-props/src/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from "react-dom/client"; 2 | import React from "react"; 3 | 4 | import App from "./App"; 5 | 6 | const rootElement = document.getElementById("root"); 7 | if (!rootElement) { 8 | throw new Error("No root element found"); 9 | } 10 | const root = createRoot(rootElement); 11 | root.render(); 12 | -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/input-props/src/input-props.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ZodType, 3 | ZodEffects, 4 | ZodString, 5 | ZodNumber, 6 | ZodDate, 7 | ZodDefault, 8 | ZodOptional, 9 | ZodNullable, 10 | } from "zod"; 11 | import { RenderProps } from "react-zorm"; 12 | 13 | export interface InputProps { 14 | type: string; 15 | name: string; 16 | required?: boolean; 17 | min?: number; 18 | max?: number; 19 | minLength?: number; 20 | maxLength?: number; 21 | pattern?: string; 22 | step?: string | number; 23 | defaultValue?: string | number; 24 | ["aria-invalid"]?: boolean; 25 | ["aria-errormessage"]?: string; 26 | } 27 | 28 | function removeZodEffects(type: ZodType): ZodType { 29 | // remove .refine() etc. 30 | if (type instanceof ZodEffects) { 31 | return removeZodEffects(type.innerType()); 32 | } 33 | 34 | return type; 35 | } 36 | 37 | function stringCheckProps(type: ZodString) { 38 | const checks = type._def.checks; 39 | 40 | const props: Partial = { 41 | type: "text", 42 | }; 43 | 44 | for (const check of checks) { 45 | if (check.kind === "min") { 46 | props.minLength = check.value; 47 | } 48 | 49 | if (check.kind === "max") { 50 | props.maxLength = check.value; 51 | } 52 | 53 | if (check.kind === "regex") { 54 | props.pattern = check.regex.toString().slice(1, -1); 55 | } 56 | 57 | if (check.kind === "email") { 58 | props.type = "email"; 59 | } 60 | 61 | // TODO the rest... 62 | } 63 | 64 | return props; 65 | } 66 | 67 | function numberCheckProps(type: ZodNumber) { 68 | const checks = type._def.checks; 69 | 70 | const props: Partial = { 71 | type: "number", 72 | step: "any", 73 | }; 74 | 75 | for (const check of checks) { 76 | if (check.kind === "min") { 77 | props.min = check.value; 78 | } 79 | 80 | if (check.kind === "max") { 81 | props.max = check.value; 82 | } 83 | 84 | if (check.kind === "int" && props.step === "any") { 85 | // defaults to 1 so we can remove it if limited to ints 86 | delete props.step; 87 | } 88 | 89 | if (check.kind === "multipleOf") { 90 | props.step = check.value; 91 | } 92 | } 93 | 94 | return props; 95 | } 96 | 97 | function dateCheckProps(type: ZodDate) { 98 | const checks = type._def.checks; 99 | 100 | const props: Partial = { 101 | type: "date", 102 | }; 103 | 104 | for (const check of checks) { 105 | if (check.kind === "min") { 106 | props.min = check.value; 107 | } 108 | 109 | if (check.kind === "max") { 110 | props.max = check.value; 111 | } 112 | } 113 | 114 | return props; 115 | } 116 | 117 | function collectProps( 118 | type: ZodType, 119 | _props: Partial = {}, 120 | ): Partial { 121 | const props = _props ?? {}; 122 | 123 | type = removeZodEffects(type); 124 | 125 | if (type instanceof ZodDefault) { 126 | props.defaultValue = type._def.defaultValue(); 127 | } else if (type instanceof ZodOptional || type instanceof ZodNullable) { 128 | props.required = false; 129 | } else if (type instanceof ZodString) { 130 | Object.assign(props, stringCheckProps(type)); 131 | } else if (type instanceof ZodNumber) { 132 | Object.assign(props, numberCheckProps(type)); 133 | } else if (type instanceof ZodDate) { 134 | Object.assign(props, dateCheckProps(type)); 135 | } 136 | 137 | // Remove optional/nullable wrapping etc. There's probably a better way to do this. 138 | const anyType = type as any; 139 | if (anyType._def?.innerType) { 140 | return collectProps(anyType._def.innerType, props); 141 | } 142 | 143 | return props; 144 | } 145 | 146 | export function inputProps(field: RenderProps): InputProps { 147 | const props: InputProps = { 148 | type: "text", 149 | required: true, 150 | ...collectProps(field.type), 151 | name: field.name, 152 | }; 153 | 154 | if (props.required === false) { 155 | delete props.required; 156 | } 157 | 158 | if (field.issues.length > 0) { 159 | props["aria-invalid"] = true; 160 | props["aria-errormessage"] = field.errorId; 161 | } 162 | 163 | return props; 164 | } 165 | -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/input-props/src/styles.css: -------------------------------------------------------------------------------- 1 | button[type="submit"] { 2 | margin-top: 2rem; 3 | padding: 1rem; 4 | } 5 | fieldset { 6 | margin: 1rem; 7 | } 8 | 9 | .errored { 10 | border: 4px solid red; 11 | } 12 | 13 | .error-message { 14 | font-size: small; 15 | color: red; 16 | } 17 | 18 | input, 19 | button { 20 | display: block; 21 | } 22 | -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/input-props/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "./src/**/*" 4 | ], 5 | "compilerOptions": { 6 | "strict": true, 7 | "esModuleInterop": true, 8 | "lib": [ 9 | "dom", 10 | "es2015" 11 | ], 12 | "jsx": "react-jsx" 13 | } 14 | } -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/internalization/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | Local Vite Example 10 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/internalization/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-zorm-signup-form-example", 3 | "version": "1.0.0", 4 | "description": "", 5 | "keywords": [], 6 | "main": "src/index.tsx", 7 | "dependencies": { 8 | "react": "18.2.0", 9 | "react-dom": "18.2.0", 10 | "react-scripts": "4.0.3", 11 | "react-zorm": "0.9.0", 12 | "zod": "3.21.4", 13 | "@types/react": "18.0.21", 14 | "@types/react-dom": "18.0.6" 15 | }, 16 | "devDependencies": { 17 | "typescript": "4.8.4" 18 | }, 19 | "scripts": { 20 | "start": "react-scripts start", 21 | "build": "react-scripts build", 22 | "test": "react-scripts test --env=jsdom", 23 | "eject": "react-scripts eject" 24 | }, 25 | "browserslist": [ 26 | ">0.2%", 27 | "not dead", 28 | "not ie <= 11", 29 | "not op_mini all" 30 | ] 31 | } -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/internalization/src/App.tsx: -------------------------------------------------------------------------------- 1 | import "./styles.css"; 2 | import React from "react"; 3 | import { z } from "zod"; 4 | import { useZorm } from "react-zorm"; 5 | import { useTranslations } from "./i18n"; 6 | 7 | const FormSchema = z.object({ 8 | language: z.union([z.literal("en"), z.literal("fi")]), 9 | name: z.string().min(1), 10 | password: z 11 | .string() 12 | .min(8) 13 | .refine( 14 | (s) => { 15 | return /[0-9]/.test(s); 16 | }, 17 | { 18 | message: "Password must at least contain one number", 19 | params: { 20 | code: "number_missing", 21 | }, 22 | }, 23 | ), 24 | }); 25 | 26 | function ErrorMessage(props: { message: string }) { 27 | return
{props.message}
; 28 | } 29 | 30 | export default function Signup() { 31 | const [lang, setLang] = React.useState<"en" | "fi">("en"); 32 | const t = useTranslations(lang); 33 | 34 | const zo = useZorm("signup", FormSchema, { 35 | onValidSubmit(e) { 36 | e.preventDefault(); 37 | alert("Form ok!\n" + JSON.stringify(e.data, null, 2)); 38 | }, 39 | }); 40 | const disabled = zo.validation?.success === false; 41 | 42 | return ( 43 |
44 | {t.language()} 45 | 55 | 56 | 65 | 66 |
67 | 68 | {t.name()} 69 | 74 | {zo.errors.name((e) => { 75 | return ; 76 | })} 77 | {t.password()} 78 | 83 | {zo.errors.password((e) => { 84 | return ; 85 | })} 86 | 89 |
90 |                 {t.validationStatus()} {JSON.stringify(zo.validation, null, 2)}
91 |             
92 | 93 | ); 94 | } 95 | -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/internalization/src/i18n.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Very simple translations system that uses functions to get the translation 3 | */ 4 | 5 | import { ZodIssue } from "zod"; 6 | 7 | const ENGLISH_TRANSLATIONS = { 8 | /** 9 | * In english translations we can just use the default error messages from Zod 10 | */ 11 | badPassword(issue: ZodIssue) { 12 | return issue.message; 13 | }, 14 | 15 | badUsername(issue: ZodIssue) { 16 | return issue.message; 17 | }, 18 | 19 | validationStatus() { 20 | return "Validation status:"; 21 | }, 22 | 23 | signup() { 24 | return "Signup!"; 25 | }, 26 | 27 | language() { 28 | return "Language"; 29 | }, 30 | 31 | name() { 32 | return "Name"; 33 | }, 34 | 35 | password() { 36 | return "Password"; 37 | }, 38 | }; 39 | 40 | /** 41 | * Match the Finnish translations to the English ones using the typeof 42 | * ENGLISH_TRANSLATIONS 43 | */ 44 | const FINNISH_TRANSLATIONS: typeof ENGLISH_TRANSLATIONS = { 45 | badPassword(issue: ZodIssue) { 46 | // Use custom translations for the issues used in this field 47 | if (issue.code === "too_small") { 48 | return `Salasanan täytyy olla vähintään ${issue.minimum} merkkiä pitkä`; 49 | } 50 | 51 | // We can target the Zod custom refinements too using .params option 52 | if ( 53 | issue.code === "custom" && 54 | issue.params?.code === "number_missing" 55 | ) { 56 | return "Salasanassa täytyy olle vähintään yksi numero"; 57 | } 58 | 59 | // Fallback to to the Zod default error message if forget to translate 60 | return issue.message; 61 | }, 62 | 63 | badUsername(issue: ZodIssue) { 64 | if (issue.code === "too_small") { 65 | return `Käyttäjätunnuksen täytyy olla vähintään ${issue.minimum} merkkiä pitkä`; 66 | } 67 | 68 | return issue.message; 69 | }, 70 | 71 | validationStatus() { 72 | return "Lomakkeen tila: "; 73 | }, 74 | 75 | signup() { 76 | return "Luo tunnus!"; 77 | }, 78 | 79 | name() { 80 | return "Nimi"; 81 | }, 82 | 83 | language() { 84 | return "Kieli"; 85 | }, 86 | 87 | password() { 88 | return "Salasana"; 89 | }, 90 | }; 91 | 92 | export function useTranslations(lang: "en" | "fi") { 93 | if (lang === "en") { 94 | return ENGLISH_TRANSLATIONS; 95 | } else { 96 | return FINNISH_TRANSLATIONS; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/internalization/src/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from "react-dom/client"; 2 | import React from "react"; 3 | 4 | import App from "./App"; 5 | 6 | const rootElement = document.getElementById("root"); 7 | if (!rootElement) { 8 | throw new Error("No root element found"); 9 | } 10 | const root = createRoot(rootElement); 11 | root.render(); 12 | -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/internalization/src/styles.css: -------------------------------------------------------------------------------- 1 | button[type="submit"] { 2 | margin-top: 2rem; 3 | padding: 1rem; 4 | } 5 | 6 | fieldset { 7 | margin: 1rem; 8 | } 9 | 10 | .errored { 11 | border: 4px solid red; 12 | } 13 | 14 | .error-message { 15 | font-size: small; 16 | color: red; 17 | } 18 | 19 | label, 20 | input:not([type="radio"]), 21 | button { 22 | display: block; 23 | } 24 | -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/internalization/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "./src/**/*" 4 | ], 5 | "compilerOptions": { 6 | "strict": true, 7 | "esModuleInterop": true, 8 | "lib": [ 9 | "dom", 10 | "es2015" 11 | ], 12 | "jsx": "react-jsx" 13 | } 14 | } -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/render-function/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | Local Vite Example 10 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/render-function/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-zorm-render-function", 3 | "version": "1.0.0", 4 | "description": "", 5 | "keywords": [], 6 | "main": "src/index.tsx", 7 | "dependencies": { 8 | "react": "18.2.0", 9 | "react-dom": "18.2.0", 10 | "react-scripts": "4.0.3", 11 | "react-zorm": "0.9.0", 12 | "zod": "3.21.4", 13 | "@types/react": "18.0.21", 14 | "@types/react-dom": "18.0.6" 15 | }, 16 | "devDependencies": { 17 | "typescript": "4.8.4" 18 | }, 19 | "scripts": { 20 | "start": "react-scripts start", 21 | "build": "react-scripts build", 22 | "test": "react-scripts test --env=jsdom", 23 | "eject": "react-scripts eject" 24 | }, 25 | "browserslist": [ 26 | ">0.2%", 27 | "not dead", 28 | "not ie <= 11", 29 | "not op_mini all" 30 | ] 31 | } -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/render-function/src/App.tsx: -------------------------------------------------------------------------------- 1 | import "./styles.css"; 2 | import { z } from "zod"; 3 | import { RenderProps, useZorm } from "react-zorm"; 4 | 5 | const nameString = () => 6 | z 7 | .string() 8 | .min(1) 9 | .max(10) 10 | .refine((s) => !s || s[0] === s[0].toUpperCase(), { 11 | message: "First letter must start with a capital letter", 12 | }) 13 | .refine((s) => !s.includes(" "), { 14 | message: "Name must not contain spaces", 15 | }); 16 | 17 | const FormSchema = z.object({ 18 | firstName: nameString(), 19 | lastName: nameString(), 20 | }); 21 | 22 | function textField(props: RenderProps) { 23 | const hasError = props.issues.length > 0; 24 | return ( 25 |
26 | 33 | {props.issues.map((issue, i) => ( 34 |
35 | {issue.message} 36 |
37 | ))} 38 |
39 | ); 40 | } 41 | 42 | export default function Form() { 43 | const zo = useZorm("render-function", FormSchema, { 44 | onValidSubmit(e) { 45 | e.preventDefault(); 46 | }, 47 | }); 48 | 49 | return ( 50 |
{ 54 | e.preventDefault(); 55 | }} 56 | > 57 |

Resuable Render Functions

58 | 59 |
60 |

First Name

61 | {zo.fields.firstName(textField)} 62 |
63 | 64 |
65 |

Last Name

66 | {zo.fields.lastName(textField)} 67 |
68 | 69 | 70 |
Form result: {JSON.stringify(zo.validation, null, 2)}
71 |
72 | ); 73 | } 74 | -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/render-function/src/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from "react-dom/client"; 2 | import React from "react"; 3 | 4 | import App from "./App"; 5 | 6 | const rootElement = document.getElementById("root"); 7 | if (!rootElement) { 8 | throw new Error("No root element found"); 9 | } 10 | const root = createRoot(rootElement); 11 | root.render(); 12 | -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/render-function/src/styles.css: -------------------------------------------------------------------------------- 1 | button[type="submit"] { 2 | margin-top: 2rem; 3 | padding: 1rem; 4 | } 5 | 6 | .hoverable { 7 | padding: 1rem; 8 | margin: 2px; 9 | display: inline; 10 | border: 1px solid gray; 11 | } 12 | 13 | .hoverable:hover { 14 | background-color: aliceblue; 15 | } 16 | 17 | .errored { 18 | border: 4px solid red; 19 | } 20 | 21 | .error-message { 22 | font-size: small; 23 | color: red; 24 | } 25 | -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/render-function/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "./src/**/*" 4 | ], 5 | "compilerOptions": { 6 | "strict": true, 7 | "esModuleInterop": true, 8 | "lib": [ 9 | "dom", 10 | "es2015" 11 | ], 12 | "jsx": "react-jsx" 13 | } 14 | } -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/signup/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | Local Vite Example 10 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/signup/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-zorm-signup-form-example", 3 | "version": "1.0.0", 4 | "description": "", 5 | "keywords": [], 6 | "main": "src/index.tsx", 7 | "dependencies": { 8 | "react": "18.2.0", 9 | "react-dom": "18.2.0", 10 | "react-scripts": "4.0.3", 11 | "react-zorm": "0.9.0", 12 | "zod": "3.21.4", 13 | "@types/react": "18.0.21", 14 | "@types/react-dom": "18.0.6" 15 | }, 16 | "devDependencies": { 17 | "typescript": "4.8.4" 18 | }, 19 | "scripts": { 20 | "start": "react-scripts start", 21 | "build": "react-scripts build", 22 | "test": "react-scripts test --env=jsdom", 23 | "eject": "react-scripts eject" 24 | }, 25 | "browserslist": [ 26 | ">0.2%", 27 | "not dead", 28 | "not ie <= 11", 29 | "not op_mini all" 30 | ] 31 | } -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/signup/src/App.tsx: -------------------------------------------------------------------------------- 1 | import "./styles.css"; 2 | import React from "react"; 3 | import { z } from "zod"; 4 | import { useZorm } from "react-zorm"; 5 | 6 | const FormSchema = z.object({ 7 | name: z.string().min(1), 8 | password: z 9 | .string() 10 | .min(10) 11 | .refine((pw) => /[0-9]/.test(pw), "Password must contain a number"), 12 | }); 13 | 14 | function ErrorMessage(props: { message: string }) { 15 | return
{props.message}
; 16 | } 17 | 18 | export default function Signup() { 19 | const zo = useZorm("signup", FormSchema, { 20 | onValidSubmit(e) { 21 | e.preventDefault(); 22 | alert("Form ok!\n" + JSON.stringify(e.data, null, 2)); 23 | }, 24 | }); 25 | const disabled = zo.validation?.success === false; 26 | 27 | return ( 28 |
29 | Name: 30 | 35 | {zo.errors.name((e) => ( 36 | 37 | ))} 38 | Password: 39 | 44 | {zo.errors.password((e) => ( 45 | 46 | ))} 47 | 50 |
51 |                 Validation status: {JSON.stringify(zo.validation, null, 2)}
52 |             
53 | 54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/signup/src/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from "react-dom/client"; 2 | import React from "react"; 3 | 4 | import App from "./App"; 5 | 6 | const rootElement = document.getElementById("root"); 7 | if (!rootElement) { 8 | throw new Error("No root element found"); 9 | } 10 | const root = createRoot(rootElement); 11 | root.render(); 12 | -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/signup/src/styles.css: -------------------------------------------------------------------------------- 1 | button[type="submit"] { 2 | margin-top: 2rem; 3 | padding: 1rem; 4 | } 5 | fieldset { 6 | margin: 1rem; 7 | } 8 | 9 | .errored { 10 | border: 4px solid red; 11 | } 12 | 13 | .error-message { 14 | font-size: small; 15 | color: red; 16 | } 17 | 18 | input, 19 | button { 20 | display: block; 21 | } 22 | -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/signup/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "./src/**/*" 4 | ], 5 | "compilerOptions": { 6 | "strict": true, 7 | "esModuleInterop": true, 8 | "lib": [ 9 | "dom", 10 | "es2015" 11 | ], 12 | "jsx": "react-jsx" 13 | } 14 | } -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/todos/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | Local Vite Example 10 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/todos/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-zorm-signup-form-example", 3 | "version": "1.0.0", 4 | "description": "", 5 | "keywords": [], 6 | "main": "src/index.tsx", 7 | "dependencies": { 8 | "react": "18.2.0", 9 | "react-dom": "18.2.0", 10 | "react-scripts": "4.0.3", 11 | "react-zorm": "0.9.0", 12 | "zod": "3.21.4", 13 | "@types/react": "18.0.21", 14 | "@types/react-dom": "18.0.6" 15 | }, 16 | "devDependencies": { 17 | "typescript": "4.8.4" 18 | }, 19 | "scripts": { 20 | "start": "react-scripts start", 21 | "build": "react-scripts build", 22 | "test": "react-scripts test --env=jsdom", 23 | "eject": "react-scripts eject" 24 | }, 25 | "browserslist": [ 26 | ">0.2%", 27 | "not dead", 28 | "not ie <= 11", 29 | "not op_mini all" 30 | ] 31 | } -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/todos/src/App.tsx: -------------------------------------------------------------------------------- 1 | import "./styles.css"; 2 | import React, { useState } from "react"; 3 | 4 | import { z } from "zod"; 5 | import { useZorm, Zorm } from "react-zorm"; 6 | 7 | const FormSchema = z.object({ 8 | meta: z.object({ 9 | listName: z.string().min(1), 10 | }), 11 | todos: z.array( 12 | z.object({ 13 | task: z.string().min(1), 14 | priority: z 15 | .string() 16 | .refine( 17 | (val) => { 18 | return /^[0-9]+$/.test(val.trim()); 19 | }, 20 | { message: "must use positive numbers" }, 21 | ) 22 | .transform((s) => { 23 | return Number(s); 24 | }), 25 | }), 26 | ), 27 | }); 28 | 29 | function renderError(props: { message: string }) { 30 | return
{props.message}
; 31 | } 32 | 33 | function TodoItem(props: { zorm: Zorm; index: number }) { 34 | const todoError = props.zorm.errors.todos(props.index); 35 | const todoField = props.zorm.fields.todos(props.index); 36 | 37 | return ( 38 |
39 | Task 40 | 45 | {todoError.task(renderError)} 46 | Priority 47 | 52 | {todoError.priority(renderError)} 53 |
54 | ); 55 | } 56 | 57 | export default function TodoList() { 58 | const zo = useZorm("todos", FormSchema, { 59 | onValidSubmit(event) { 60 | event.preventDefault(); 61 | alert(JSON.stringify(event.data, null, 2)); 62 | }, 63 | }); 64 | 65 | const canSubmit = zo.validation?.success !== false; 66 | const [todos, setTodos] = useState(1); 67 | const addTodo = () => setTodos((n) => n + 1); 68 | 69 | const range = Array(todos) 70 | .fill(0) 71 | .map((_, i) => i); 72 | 73 | return ( 74 |
75 |

Todo List

76 | List name 77 | 82 | {zo.errors.meta.listName(renderError)} 83 |

Todos

84 | {range.map((index) => ( 85 | 86 | ))} 87 | 90 | 93 |
94 |                 Validation status: {JSON.stringify(zo.validation, null, 2)}
95 |             
96 | 97 | ); 98 | } 99 | -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/todos/src/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from "react-dom/client"; 2 | import React from "react"; 3 | 4 | import App from "./App"; 5 | 6 | const rootElement = document.getElementById("root"); 7 | if (!rootElement) { 8 | throw new Error("No root element found"); 9 | } 10 | const root = createRoot(rootElement); 11 | root.render(); 12 | -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/todos/src/styles.css: -------------------------------------------------------------------------------- 1 | button[type="submit"] { 2 | margin-top: 2rem; 3 | padding: 1rem; 4 | } 5 | fieldset { 6 | margin: 1rem; 7 | } 8 | 9 | .errored { 10 | border: 4px solid red; 11 | } 12 | 13 | .error-message { 14 | font-size: small; 15 | color: red; 16 | } 17 | 18 | input, 19 | button { 20 | display: block; 21 | } 22 | -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/todos/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "./src/**/*" 4 | ], 5 | "compilerOptions": { 6 | "strict": true, 7 | "esModuleInterop": true, 8 | "lib": [ 9 | "dom", 10 | "es2015" 11 | ], 12 | "jsx": "react-jsx" 13 | } 14 | } -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/use-value/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | Local Vite Example 10 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/use-value/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-zorm-signup-form-example", 3 | "version": "1.0.0", 4 | "description": "", 5 | "keywords": [], 6 | "main": "src/index.tsx", 7 | "dependencies": { 8 | "react": "18.2.0", 9 | "react-dom": "18.2.0", 10 | "react-scripts": "4.0.3", 11 | "react-zorm": "0.9.0", 12 | "zod": "3.21.4", 13 | "@types/react": "18.0.21", 14 | "@types/react-dom": "18.0.6" 15 | }, 16 | "devDependencies": { 17 | "typescript": "4.8.4" 18 | }, 19 | "scripts": { 20 | "start": "react-scripts start", 21 | "build": "react-scripts build", 22 | "test": "react-scripts test --env=jsdom", 23 | "eject": "react-scripts eject" 24 | }, 25 | "browserslist": [ 26 | ">0.2%", 27 | "not dead", 28 | "not ie <= 11", 29 | "not op_mini all" 30 | ] 31 | } -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/use-value/src/App.tsx: -------------------------------------------------------------------------------- 1 | import "./styles.css"; 2 | import React from "react"; 3 | import { z } from "zod"; 4 | import { VisualizeRenders } from "./helpers"; 5 | import { useZorm, useValue, Value, Zorm } from "react-zorm"; 6 | 7 | const FormSchema = z.object({ 8 | input1: z.string().min(1), 9 | input2: z.string().min(1), 10 | input3: z.string().min(1), 11 | input4: z.string().min(1), 12 | }); 13 | 14 | function Subcomponent(props: { zo: Zorm }) { 15 | const value = useValue({ 16 | zorm: props.zo, 17 | name: props.zo.fields.input4(), 18 | }); 19 | return ( 20 | 21 | Input4 {""}: {value} 22 | 23 | ); 24 | } 25 | 26 | export default function Form() { 27 | const zo = useZorm("signup", FormSchema, { 28 | onValidSubmit(e) { 29 | e.preventDefault(); 30 | alert("Form ok!\n" + JSON.stringify(e.data, null, 2)); 31 | }, 32 | }); 33 | const disabled = zo.validation?.success === false; 34 | const input2Value = useValue({ zorm: zo, name: zo.fields.input2() }); 35 | 36 | return ( 37 | 38 |
39 | Input1 (not read) 40 | 45 | Input2 useValue() 46 | 51 | Input3 {""} 52 | 57 | Input4 {""} 58 | 63 | 64 | Input2 useValue(): {input2Value} 65 | 66 | 67 | {(value) => ( 68 | 69 | Input3 {""}: {value} 70 | 71 | )} 72 | 73 | 74 | 77 |
78 |                     Validation status: {JSON.stringify(zo.validation, null, 2)}
79 |                 
80 | 81 |
82 | ); 83 | } 84 | -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/use-value/src/helpers.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef } from "react"; 2 | 3 | export function VisualizeRenders(props: { children: React.ReactNode }) { 4 | const renderRef = useRef(0); 5 | return ( 6 |
7 | {renderRef.current++} renders 8 | {props.children} 9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/use-value/src/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from "react-dom/client"; 2 | import React from "react"; 3 | 4 | import App from "./App"; 5 | 6 | const rootElement = document.getElementById("root"); 7 | if (!rootElement) { 8 | throw new Error("No root element found"); 9 | } 10 | const root = createRoot(rootElement); 11 | root.render(); 12 | -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/use-value/src/styles.css: -------------------------------------------------------------------------------- 1 | button[type="submit"] { 2 | margin-top: 2rem; 3 | padding: 1rem; 4 | } 5 | fieldset { 6 | margin: 1rem; 7 | } 8 | 9 | .errored { 10 | border: 4px solid red; 11 | } 12 | 13 | .error-message { 14 | font-size: small; 15 | color: red; 16 | } 17 | 18 | input, 19 | button { 20 | display: block; 21 | } 22 | 23 | legend { 24 | font-size: 16pt; 25 | font-weight: bold; 26 | } 27 | 28 | fieldset { 29 | border: 4px solid rgb(112, 112, 112); 30 | } 31 | 32 | .odd { 33 | background-color: rgb(208, 255, 204); 34 | } 35 | 36 | .even { 37 | background-color: rgb(219, 248, 255); 38 | } 39 | -------------------------------------------------------------------------------- /packages/codesandboxes/boxes/use-value/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "./src/**/*" 4 | ], 5 | "compilerOptions": { 6 | "strict": true, 7 | "esModuleInterop": true, 8 | "lib": [ 9 | "dom", 10 | "es2015" 11 | ], 12 | "jsx": "react-jsx" 13 | } 14 | } -------------------------------------------------------------------------------- /packages/codesandboxes/merge-deps.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "fs"; 2 | 3 | const rootPkg = JSON.parse(readFileSync("package.json"), "utf8"); 4 | const pkg = JSON.parse(readFileSync(process.argv[2], "utf8")); 5 | 6 | Object.assign(pkg.dependencies, { 7 | "@types/react": "18.0.21", 8 | "@types/react-dom": "18.0.6", 9 | react: "18.2.0", 10 | "react-dom": "18.2.0", 11 | "react-zorm": "0.9.0", 12 | zod: "3.21.4", 13 | }); 14 | 15 | pkg.devDependencies = { 16 | typescript: "4.8.4", 17 | }; 18 | 19 | writeFileSync(process.argv[2], JSON.stringify(pkg, null, 2)); 20 | -------------------------------------------------------------------------------- /packages/codesandboxes/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@zorm/codesandboxes", 3 | "private": true, 4 | "scripts": { 5 | "dev": "vite", 6 | "eslint": "eslint --max-warnings 0 \"boxes/**/*.tsx\"", 7 | "upgrade-box-deps": "sh upgrade-deps.sh" 8 | }, 9 | "devDependencies": { 10 | "@types/react": "18.0.21", 11 | "@types/react-dom": "18.0.6", 12 | "@typescript-eslint/parser": "5.40.0", 13 | "@vitejs/plugin-react": "^2.1.0", 14 | "eslint": "^8.25.0", 15 | "eslint-plugin-react-hooks": "4.6.0", 16 | "prettier": "^2.5.1", 17 | "react": "18.2.0", 18 | "react-dom": "18.2.0", 19 | "react-query": "^3.34.19", 20 | "react-select": "^5.2.2", 21 | "react-zorm": "workspace:*", 22 | "typescript": "^4.5.5", 23 | "vite": "^3.1.6", 24 | "vite-plugin-list-directory-contents": "^1.1.1", 25 | "zod": "^3.11.6" 26 | }, 27 | "volta": { 28 | "node": "16.15.1" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/codesandboxes/shared/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | Local Vite Example 10 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /packages/codesandboxes/shared/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from "react-dom/client"; 2 | import React from "react"; 3 | 4 | import App from "./App"; 5 | 6 | const rootElement = document.getElementById("root"); 7 | if (!rootElement) { 8 | throw new Error("No root element found"); 9 | } 10 | const root = createRoot(rootElement); 11 | root.render(); 12 | -------------------------------------------------------------------------------- /packages/codesandboxes/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "lib": ["es2018", "dom", "esnext", "DOM.Iterable"], 5 | "moduleResolution": "node", 6 | "jsx": "react", 7 | "forceConsistentCasingInFileNames": true, 8 | "strict": true, 9 | "noImplicitOverride": true, 10 | "noUncheckedIndexedAccess": true, 11 | "useUnknownInCatchVariables": false, 12 | "noEmit": true, 13 | "skipLibCheck": true, 14 | "esModuleInterop": true, 15 | "declaration": true 16 | }, 17 | "include": ["boxes"] 18 | } 19 | -------------------------------------------------------------------------------- /packages/codesandboxes/upgrade-deps.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -eu 4 | 5 | for package in boxes/*/package.json; do 6 | node merge-deps.mjs "$package" 7 | cp shared/index.html "$(dirname "$package")/index.html" 8 | cp shared/index.tsx "$(dirname "$package")/src/index.tsx" 9 | done 10 | -------------------------------------------------------------------------------- /packages/codesandboxes/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | import { directoryPlugin } from "vite-plugin-list-directory-contents"; 4 | 5 | const ROOT = __dirname + "/boxes"; 6 | 7 | export default defineConfig({ 8 | root: ROOT, 9 | plugins: [react(), directoryPlugin({ baseDir: ROOT })], 10 | }); 11 | -------------------------------------------------------------------------------- /packages/react-zorm/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: "@typescript-eslint/parser", 3 | plugins: ["@typescript-eslint", "react-hooks"], 4 | parserOptions: { 5 | project: "./tsconfig.json", 6 | }, 7 | rules: { 8 | "react-hooks/rules-of-hooks": "error", 9 | "react-hooks/exhaustive-deps": "warn", 10 | "@typescript-eslint/no-floating-promises": "error", 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /packages/react-zorm/.gitignore: -------------------------------------------------------------------------------- 1 | playwright-report/ 2 | esm -------------------------------------------------------------------------------- /packages/react-zorm/.np-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "cleanup": false 3 | } 4 | -------------------------------------------------------------------------------- /packages/react-zorm/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "tabWidth": 4 4 | } 5 | -------------------------------------------------------------------------------- /packages/react-zorm/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## v0.9.0 2 | 3 | 2023-04-03 4 | 5 | - Please give Feedback for the Automatic progressive HTML Attributes here: https://github.com/esamattis/react-zorm/discussions/48 6 | - Add errorId [2b5e52d](https://github.com/esamattis/react-zorm/commit/2b5e52d) - Esa-Matti Suuronen 7 | - Add aria attributes for invalid inputs [6a6ca8a](https://github.com/esamattis/react-zorm/commit/6a6ca8a) - Esa-Matti Suuronen 8 | 9 | All changes https://github.com/esamattis/react-zorm/compare/react-zorm/v0.8.0...react-zorm/v0.9.0 10 | 11 | ## v0.8.0 12 | 13 | 2023-03-28 14 | 15 | - Support multiple issues on a single field [#41](https://github.com/esamattis/react-zorm/pull/41) 16 | - Example 17 | - Add onFormData() helper [#44](https://github.com/esamattis/react-zorm/pull/44) 18 | - Example 19 | - Expose field type to the render function on the fields chain [#39](https://github.com/esamattis/react-zorm/pull/39) 20 | - See 21 | - Upgrade Remix example [#40](https://github.com/esamattis/react-zorm/pull/40) 22 | - See 23 | 24 | All changes https://github.com/esamattis/react-zorm/compare/react-zorm/v0.7.0...react-zorm/v0.8.0 25 | 26 | ## v0.7.0 27 | 28 | 2023-03-25 29 | 30 | - Add file input support [0504863](https://github.com/esamattis/react-zorm/commit/0504863) - Esa-Matti Suuronen 31 | - Eg. you can now use `z.instanceof(File)` as a form field type 32 | - See https://codesandbox.io/s/github/esamattis/react-zorm/tree/master/packages/codesandboxes/boxes/file?file=/src/App.tsx 33 | - Upgrade majors [426aff8](https://github.com/esamattis/react-zorm/commit/426aff8) - Esa-Matti Suuronen 34 | - Upgrade deps [bc1ad8c](https://github.com/esamattis/react-zorm/commit/bc1ad8c) - Esa-Matti Suuronen 35 | 36 | All changes https://github.com/esamattis/react-zorm/compare/react-zorm/v0.6.2...react-zorm/v0.7.0 37 | 38 | ## v0.6.2 39 | 40 | 2023-03-17 41 | 42 | - Fix validation narrowing #33 [9b9e116](https://github.com/esamattis/react-zorm/commit/9b9e116) - Esa-Matti Suuronen 43 | 44 | All changes https://github.com/esamattis/react-zorm/compare/react-zorm/v0.6.1...react-zorm/v0.6.2 45 | 46 | ## v0.6.1 47 | 48 | 2022-10-17 49 | 50 | - Add zo.form property and deprecate refObject [9f51496](https://github.com/esamattis/react-zorm/commit/9f51496) - Esa-Matti Suuronen 51 | - The refObject was never documented to be a public 52 | 53 | All changes https://github.com/esamattis/react-zorm/compare/react-zorm/v0.6.0...react-zorm/v0.6.1 54 | 55 | ## v0.6.0 56 | 57 | 2022-10-16 58 | 59 | - Publish as ESM to npm with the `"exports"` field 60 | - Dual packaging. CommonJS code is also published as previously. 61 | - `zod` and `react` are now correctly peer deps 62 | - There should no behavioral changes. Only the packaging is improved to enable 63 | tree shaking etc. 64 | - Test against React.js 18 in strict mode 65 | - Add Playwright test 66 | - Run tests in Github actions 67 | - Upgrade build and test deps 68 | - Test with latest Zod 69 | 70 | All changes https://github.com/esamattis/react-zorm/compare/react-zorm/v0.5.1...react-zorm/v0.6.0 71 | 72 | ## v0.5.1 73 | 74 | 2022-07-05 75 | 76 | - Add useValue() test for ` 17 | , 18 | ); 19 | 20 | const values = parseForm(Schema, form); 21 | 22 | expect(values).toEqual({ 23 | ding: "dong", 24 | }); 25 | 26 | assertNotAny(values.ding); 27 | }); 28 | 29 | test("object", () => { 30 | const Schema = z.object({ 31 | ob: z.object({ 32 | ding: z.string(), 33 | dong: z.string(), 34 | }), 35 | }); 36 | 37 | const fields = fieldChain("test", Schema, []); 38 | 39 | const form = makeForm( 40 |
41 | 42 | 43 |
, 44 | ); 45 | 46 | const values = parseForm(Schema, form); 47 | 48 | expect(values).toEqual({ 49 | ob: { 50 | ding: "value1", 51 | dong: "value2", 52 | }, 53 | }); 54 | }); 55 | 56 | test("array of objects", () => { 57 | const Schema = z.object({ 58 | things: z.array( 59 | z.object({ 60 | ding: z.string(), 61 | }), 62 | ), 63 | }); 64 | 65 | const fields = fieldChain("test", Schema, []); 66 | 67 | const form = makeForm( 68 |
69 | 70 | 71 |
, 72 | ); 73 | 74 | const values = parseForm(Schema, form); 75 | 76 | expect(values).toEqual({ 77 | things: [ 78 | // 79 | { ding: "value1" }, 80 | { ding: "value2" }, 81 | ], 82 | }); 83 | }); 84 | 85 | test("array of strings", () => { 86 | const Schema = z.object({ 87 | ob: z.object({ 88 | strings: z.array(z.string()), 89 | }), 90 | }); 91 | 92 | const fields = fieldChain("test", Schema, []); 93 | const form = makeForm( 94 |
95 | 96 | 97 |
, 98 | ); 99 | 100 | const values = parseForm(Schema, form); 101 | 102 | expect(values).toEqual({ 103 | ob: { 104 | strings: ["value1", "value2"], 105 | }, 106 | }); 107 | }); 108 | 109 | test.skip("types", () => { 110 | const FormValues = z.object({ 111 | value: z.string(), 112 | ob: z.object({ 113 | strings: z.array(z.string()), 114 | }), 115 | }); 116 | 117 | const fields = fieldChain("test", FormValues, []); 118 | 119 | assertNotAny(fields.ob); 120 | assertNotAny(fields.value()); 121 | assertNotAny(fields.value("name")); 122 | assertNotAny(fields.value("id")); 123 | assertNotAny(fields.ob.strings(0)); 124 | 125 | // @ts-expect-error 126 | fields.ob(); 127 | 128 | // @ts-expect-error 129 | fields.value("bad"); 130 | 131 | // @ts-expect-error 132 | fields.ob.strings("bad"); 133 | 134 | // @ts-expect-error 135 | fields.ob.strings("id"); 136 | 137 | // @ts-expect-error 138 | fields.ob.strings(); 139 | 140 | // @ts-expect-error 141 | fields.ob.strings(1).nope; 142 | 143 | // @ts-expect-error 144 | fields.ob.nope; 145 | 146 | // @ts-expect-error 147 | fields.nope(); 148 | 149 |
150 | 154 | 155 | 159 | 160 | 164 | 165 | 169 | 170 | 174 |
; 175 | }); 176 | -------------------------------------------------------------------------------- /packages/react-zorm/__tests__/create-issues-chain.test.tsx: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { assertIs, assertNotNil } from "@valu/assert"; 3 | import { createCustomIssues } from "../src/chains"; 4 | import { assertNotAny } from "./test-helpers"; 5 | 6 | test("single field", () => { 7 | const Schema = z.object({ 8 | field: z.string(), 9 | }); 10 | 11 | const chain = createCustomIssues(Schema); 12 | 13 | expect(chain.field("custom server error")).toEqual({ 14 | code: "custom", 15 | message: "custom server error", 16 | params: {}, 17 | path: ["field"], 18 | }); 19 | }); 20 | 21 | test("mutates inner state", () => { 22 | const Schema = z.object({ 23 | field: z.string(), 24 | }); 25 | 26 | const chain = createCustomIssues(Schema); 27 | 28 | expect(chain.toArray()).toEqual([]); 29 | expect(chain.hasIssues()).toBe(false); 30 | 31 | chain.field("custom server error"); 32 | 33 | expect(chain.hasIssues()).toBe(true); 34 | expect(chain.toArray()).toEqual([ 35 | { 36 | code: "custom", 37 | message: "custom server error", 38 | params: {}, 39 | path: ["field"], 40 | }, 41 | ]); 42 | }); 43 | 44 | test("nested field", () => { 45 | const Schema = z.object({ 46 | field: z.object({ 47 | nested: z.string(), 48 | }), 49 | }); 50 | 51 | const chain = createCustomIssues(Schema); 52 | 53 | expect(chain.field.nested("custom server error")).toEqual({ 54 | code: "custom", 55 | message: "custom server error", 56 | params: {}, 57 | path: ["field", "nested"], 58 | }); 59 | }); 60 | 61 | test("error on object", () => { 62 | const Schema = z.object({ 63 | field: z.object({ 64 | nested: z.string(), 65 | }), 66 | }); 67 | 68 | const chain = createCustomIssues(Schema); 69 | 70 | expect(chain.field("custom server error on object")).toEqual({ 71 | code: "custom", 72 | message: "custom server error on object", 73 | params: {}, 74 | path: ["field"], 75 | }); 76 | }); 77 | 78 | test("error on nested object in array", () => { 79 | const Schema = z.object({ 80 | field: z.object({ 81 | nestedArray: z.array( 82 | z.object({ 83 | deep: z.string(), 84 | }), 85 | ), 86 | }), 87 | }); 88 | 89 | const chain = createCustomIssues(Schema); 90 | 91 | expect(chain.field.nestedArray("custom server error on array")).toEqual({ 92 | code: "custom", 93 | message: "custom server error on array", 94 | params: {}, 95 | path: ["field", "nestedArray"], 96 | }); 97 | }); 98 | 99 | test("error on array item", () => { 100 | const Schema = z.object({ 101 | field: z.object({ 102 | array: z.array(z.string()), 103 | }), 104 | }); 105 | 106 | const chain = createCustomIssues(Schema); 107 | 108 | expect(chain.field.array(3)("error")).toEqual({ 109 | code: "custom", 110 | message: "error", 111 | params: {}, 112 | path: ["field", "array", 3], 113 | }); 114 | }); 115 | 116 | test("nested array fields", () => { 117 | const Schema = z.object({ 118 | field: z.object({ 119 | nestedArray: z.array( 120 | z.object({ 121 | deep: z.string(), 122 | }), 123 | ), 124 | }), 125 | }); 126 | 127 | const chain = createCustomIssues(Schema); 128 | 129 | expect(chain.field.nestedArray(3).deep("custom server error")).toEqual({ 130 | code: "custom", 131 | message: "custom server error", 132 | params: {}, 133 | path: ["field", "nestedArray", 3, "deep"], 134 | }); 135 | }); 136 | 137 | test("can convert to json", () => { 138 | const Schema = z.object({ 139 | field: z.string(), 140 | }); 141 | 142 | const chain = createCustomIssues(Schema); 143 | 144 | chain.field("error"); 145 | 146 | expect(chain.toJSON()).toEqual([ 147 | { 148 | code: "custom", 149 | message: "error", 150 | params: {}, 151 | path: ["field"], 152 | }, 153 | ]); 154 | 155 | expect(chain.toArray()).toEqual([ 156 | { 157 | code: "custom", 158 | message: "error", 159 | params: {}, 160 | path: ["field"], 161 | }, 162 | ]); 163 | 164 | expect(JSON.stringify(chain)).toEqual( 165 | '[{"code":"custom","path":["field"],"message":"error","params":{}}]', 166 | ); 167 | }); 168 | 169 | test("can toJSON() multiple issues", () => { 170 | const Schema = z.object({ 171 | field: z.object({ 172 | nested: z.string(), 173 | }), 174 | }); 175 | 176 | const chain = createCustomIssues(Schema); 177 | 178 | chain.field("error1"); 179 | chain.field.nested("error2"); 180 | 181 | expect(chain.toJSON()).toEqual([ 182 | { 183 | code: "custom", 184 | message: "error1", 185 | params: {}, 186 | path: ["field"], 187 | }, 188 | { 189 | code: "custom", 190 | message: "error2", 191 | params: {}, 192 | path: ["field", "nested"], 193 | }, 194 | ]); 195 | }); 196 | 197 | /** 198 | * Type tests 199 | */ 200 | (function () { 201 | const Schema = z.object({ 202 | field: z.object({ 203 | nestedArray: z.array( 204 | z.object({ 205 | deep: z.string(), 206 | }), 207 | ), 208 | }), 209 | }); 210 | 211 | const chain = createCustomIssues(Schema); 212 | 213 | // .toJSON() is only at the top level 214 | chain.toJSON(); 215 | // @ts-expect-error 216 | chain.field.toJSON(); 217 | 218 | // @ts-expect-error 219 | chain.bad; 220 | 221 | assertNotAny(chain); 222 | 223 | assertNotAny(chain.field.nestedArray(3).deep("custom server error")); 224 | 225 | // @ts-expect-error 226 | chain.field(/bad/); 227 | 228 | // returns the issue and not the chain 229 | // @ts-expect-error 230 | chain.field.nestedArray("array error").deep("should not work"); 231 | 232 | { 233 | const issue = chain.field.nestedArray("array error"); 234 | assertNotAny(issue); 235 | issue.message; 236 | } 237 | 238 | { 239 | const issue = chain.field.nestedArray(3)("array error"); 240 | assertNotAny(issue); 241 | issue.message; 242 | } 243 | }); 244 | -------------------------------------------------------------------------------- /packages/react-zorm/__tests__/error-chain.test.tsx: -------------------------------------------------------------------------------- 1 | import { z, ZodIssue } from "zod"; 2 | import { assertIs } from "@valu/assert"; 3 | import { errorChain } from "../src/chains"; 4 | import { assertNotAny } from "./test-helpers"; 5 | import { ErrorChain, ErrorGetter } from "../src/types"; 6 | 7 | test("can get error", () => { 8 | const Schema = z.object({ 9 | field: z.string(), 10 | }); 11 | 12 | const res = Schema.safeParse({}); 13 | assertIs(res.success, false as const); 14 | 15 | const chain = errorChain(Schema, res.error.issues); 16 | 17 | expect(chain.field()).toEqual({ 18 | code: "invalid_type", 19 | expected: "string", 20 | received: "undefined", 21 | path: ["field"], 22 | message: "Required", 23 | }); 24 | }); 25 | 26 | test("can get boolean true on error", () => { 27 | const Schema = z.object({ 28 | field: z.string(), 29 | }); 30 | 31 | const res = Schema.safeParse({}); 32 | assertIs(res.success, false as const); 33 | 34 | const chain = errorChain(Schema, res.error.issues); 35 | 36 | expect(chain.field(Boolean)).toBe(true); 37 | }); 38 | 39 | test("can get boolean false on success", () => { 40 | const Schema = z.object({ 41 | field: z.string(), 42 | }); 43 | 44 | const chain = errorChain(Schema, []); 45 | 46 | expect(chain.field(Boolean)).toBe(false); 47 | }); 48 | 49 | test("can use custom value", () => { 50 | const Schema = z.object({ 51 | field: z.string(), 52 | }); 53 | 54 | const res = Schema.safeParse({}); 55 | assertIs(res.success, false as const); 56 | 57 | const chain = errorChain(Schema, res.error.issues); 58 | 59 | expect(chain.field({ my: "thing" })).toEqual({ 60 | my: "thing", 61 | }); 62 | }); 63 | 64 | test("can use custom value in fn", () => { 65 | const Schema = z.object({ 66 | field: z.string(), 67 | }); 68 | 69 | const res = Schema.safeParse({}); 70 | assertIs(res.success, false as const); 71 | 72 | const chain = errorChain(Schema, res.error.issues); 73 | 74 | expect(chain.field(() => ({ my: "thing" }))).toEqual({ 75 | my: "thing", 76 | }); 77 | }); 78 | 79 | test("can get refined object error", () => { 80 | const Schema = z.object({ 81 | pw: z 82 | .object({ 83 | password: z.string(), 84 | password2: z.string(), 85 | }) 86 | .refine( 87 | (val) => { 88 | return val.password === val.password2; 89 | }, 90 | { message: "Passwords do not match" }, 91 | ), 92 | }); 93 | 94 | const res = Schema.safeParse({ 95 | pw: { 96 | password: "foo", 97 | password2: "bar", 98 | }, 99 | }); 100 | assertIs(res.success, false as const); 101 | 102 | const chain = errorChain(Schema, res.error.issues); 103 | 104 | expect(chain.pw()).toEqual({ 105 | code: "custom", 106 | message: "Passwords do not match", 107 | path: ["pw"], 108 | }); 109 | }); 110 | 111 | export function typeChecks() { 112 | { 113 | const Schema = z.object({ 114 | field: z.string(), 115 | list: z.array(z.string()), 116 | objectList: z.array( 117 | z.object({ 118 | nested: z.string(), 119 | }), 120 | ), 121 | }); 122 | 123 | const chain = errorChain(Schema, []); 124 | 125 | const arrayIssue: ZodIssue | undefined = chain.list(); 126 | assertNotAny(chain.list()); 127 | chain.list()?.message; 128 | 129 | const itemIssue: ZodIssue | undefined = chain.list(0)(); 130 | assertNotAny(chain.list(0)()); 131 | 132 | const hmm: ErrorGetter = chain.list(0); 133 | assertNotAny(chain.list(0)); 134 | 135 | { 136 | // Returns the number on normal field 137 | // @ts-expect-error 138 | const _: ErrorChain = chain.field(3); 139 | } 140 | 141 | { 142 | // array index set returns the chain again 143 | const _: ErrorChain = chain.objectList(3); 144 | assertNotAny(chain.objectList(3)); 145 | } 146 | 147 | { 148 | const _: string | undefined = chain.field(""); 149 | assertNotAny(chain.field("")); 150 | } 151 | 152 | { 153 | // has undefined 154 | // @ts-expect-error 155 | const _: string = chain.field(""); 156 | } 157 | 158 | { 159 | const _: number | undefined = chain.field(3); 160 | assertNotAny(chain.field(3)); 161 | } 162 | 163 | { 164 | // has undefined 165 | // @ts-expect-error 166 | const _: string = chain.field(""); 167 | } 168 | 169 | { 170 | const _: number | undefined = chain.field(() => 3); 171 | assertNotAny(chain.field(() => 3)); 172 | } 173 | 174 | { 175 | // has null 176 | // @ts-expect-error 177 | const _: number = chain.field(() => 3); 178 | } 179 | 180 | { 181 | const _: boolean = chain.field(Boolean); 182 | assertNotAny(chain.field(Boolean)); 183 | } 184 | } 185 | } 186 | 187 | test("can handle optional fields", () => { 188 | const Schema = z.object({ 189 | field: z.string().optional(), 190 | }); 191 | 192 | const chain = errorChain(Schema, []); 193 | 194 | expect(chain.field()).toBeUndefined(); 195 | }); 196 | 197 | test("can handle nullish fields", () => { 198 | const Schema = z.object({ 199 | field: z.string().nullish(), 200 | }); 201 | 202 | const chain = errorChain(Schema, []); 203 | 204 | expect(chain.field()).toBeUndefined(); 205 | }); 206 | 207 | test("can handle optional arrrays", () => { 208 | const Schema = z.object({ 209 | things: z 210 | .array( 211 | z.object({ 212 | field: z.string(), 213 | }), 214 | ) 215 | .optional(), 216 | }); 217 | 218 | const chain = errorChain(Schema, []); 219 | 220 | expect(chain.things(0).field()).toBeUndefined(); 221 | }); 222 | 223 | test("date field errors", () => { 224 | const Schema = z.object({ 225 | ding: z.string(), 226 | date: z.date(), 227 | }); 228 | 229 | const chain = errorChain(Schema, []); 230 | 231 | // @ts-expect-error 232 | const _notAny: string = chain.date(); 233 | 234 | expect(chain.date()).toBeUndefined(); 235 | }); 236 | -------------------------------------------------------------------------------- /packages/react-zorm/__tests__/field-chain.test.tsx: -------------------------------------------------------------------------------- 1 | import { fieldChain } from "../src/chains"; 2 | import { z } from "zod"; 3 | import { assertNotAny } from "./test-helpers"; 4 | import { FieldChain, FieldGetter } from "../src/types"; 5 | 6 | test("basic", () => { 7 | const Schema = z.object({ 8 | field: z.string(), 9 | }); 10 | 11 | const chain = fieldChain("form", Schema, []); 12 | 13 | expect(chain.field()).toEqual("field"); 14 | expect(chain.field("name")).toEqual("field"); 15 | expect(chain.field("id")).toEqual("form:field"); 16 | 17 | { 18 | const res: string = chain.field(); 19 | assertNotAny(chain.field()); 20 | } 21 | { 22 | const res: string = chain.field("id"); 23 | assertNotAny(chain.field("id")); 24 | } 25 | { 26 | const res: string = chain.field("name"); 27 | assertNotAny(chain.field("name")); 28 | } 29 | 30 | () => { 31 | // @ts-expect-error 32 | chain.bad(); 33 | 34 | // @ts-expect-error 35 | chain.field("crap"); 36 | }; 37 | }); 38 | 39 | test("nested object", () => { 40 | const Schema = z.object({ 41 | ob: z.object({ 42 | field: z.string(), 43 | }), 44 | }); 45 | 46 | const chain = fieldChain("form", Schema, []); 47 | 48 | expect(chain.ob.field()).toEqual("ob.field"); 49 | expect(chain.ob.field("name")).toEqual("ob.field"); 50 | expect(chain.ob.field("id")).toEqual("form:ob.field"); 51 | 52 | { 53 | const res: string = chain.ob.field(); 54 | assertNotAny(chain.ob.field()); 55 | } 56 | { 57 | const res: string = chain.ob.field("id"); 58 | assertNotAny(chain.ob.field("id")); 59 | } 60 | { 61 | const res: string = chain.ob.field("name"); 62 | assertNotAny(chain.ob.field("name")); 63 | } 64 | 65 | () => { 66 | // @ts-expect-error 67 | chain.ob.bad(); 68 | 69 | // @ts-expect-error 70 | chain.ob.field("crap"); 71 | }; 72 | }); 73 | 74 | test("array of strings", () => { 75 | const Schema = z.object({ 76 | things: z.array(z.string()), 77 | }); 78 | 79 | const chain = fieldChain("form", Schema, []); 80 | 81 | expect(chain.things(0)()).toEqual("things[0]"); 82 | expect(chain.things(0)("name")).toEqual("things[0]"); 83 | expect(chain.things(0)("id")).toEqual("form:things[0]"); 84 | 85 | { 86 | const res: string = chain.things(0)(); 87 | assertNotAny(chain.things(0)()); 88 | } 89 | { 90 | const res: string = chain.things(0)("id"); 91 | assertNotAny(chain.things(0)("id")); 92 | } 93 | { 94 | const res: string = chain.things(0)("name"); 95 | assertNotAny(chain.things(0)("name")); 96 | } 97 | 98 | () => { 99 | // @ts-expect-error 100 | chain.things(); 101 | // @ts-expect-error 102 | chain.things("id"); 103 | // @ts-expect-error 104 | chain.things("name"); 105 | 106 | { 107 | const res: FieldGetter = chain.things(0); 108 | assertNotAny(chain.things(0)); 109 | } 110 | }; 111 | }); 112 | 113 | test("array of objects", () => { 114 | const Schema = z.object({ 115 | things: z.array( 116 | z.object({ 117 | ding: z.string(), 118 | }), 119 | ), 120 | }); 121 | 122 | const chain = fieldChain("form", Schema, []); 123 | 124 | expect(chain.things(0).ding()).toEqual("things[0].ding"); 125 | expect(chain.things(0).ding("name")).toEqual("things[0].ding"); 126 | expect(chain.things(0).ding("id")).toEqual("form:things[0].ding"); 127 | 128 | { 129 | const res: string = chain.things(0).ding(); 130 | assertNotAny(chain.things(0).ding()); 131 | } 132 | { 133 | const res: string = chain.things(0).ding("id"); 134 | assertNotAny(chain.things(0).ding("id")); 135 | } 136 | { 137 | const res: string = chain.things(0).ding("name"); 138 | assertNotAny(chain.things(0).ding("name")); 139 | } 140 | 141 | () => { 142 | // @ts-expect-error 143 | chain.things(); 144 | // @ts-expect-error 145 | chain.things("id"); 146 | // @ts-expect-error 147 | chain.things("name"); 148 | 149 | { 150 | const res: FieldChain = chain.things(0); 151 | assertNotAny(chain.things(0)); 152 | } 153 | }; 154 | }); 155 | 156 | test("optional fields", () => { 157 | const Schema = z.object({ 158 | field: z.string().optional(), 159 | }); 160 | 161 | const chain = fieldChain("form", Schema, []); 162 | 163 | expect(chain.field()).toEqual("field"); 164 | expect(chain.field("name")).toEqual("field"); 165 | expect(chain.field("id")).toEqual("form:field"); 166 | 167 | { 168 | const res: string = chain.field(); 169 | assertNotAny(chain.field()); 170 | } 171 | { 172 | const res: string = chain.field("id"); 173 | assertNotAny(chain.field("id")); 174 | } 175 | { 176 | const res: string = chain.field("name"); 177 | assertNotAny(chain.field("name")); 178 | } 179 | 180 | () => { 181 | // @ts-expect-error 182 | chain.bad(); 183 | 184 | // @ts-expect-error 185 | chain.field("crap"); 186 | }; 187 | }); 188 | 189 | test("nullable fields", () => { 190 | const Schema = z.object({ 191 | field: z.string().nullable(), 192 | }); 193 | 194 | const chain = fieldChain("form", Schema, []); 195 | 196 | expect(chain.field()).toEqual("field"); 197 | expect(chain.field("name")).toEqual("field"); 198 | expect(chain.field("id")).toEqual("form:field"); 199 | 200 | { 201 | const res: string = chain.field(); 202 | assertNotAny(chain.field()); 203 | } 204 | { 205 | const res: string = chain.field("id"); 206 | assertNotAny(chain.field("id")); 207 | } 208 | { 209 | const res: string = chain.field("name"); 210 | assertNotAny(chain.field("name")); 211 | } 212 | 213 | () => { 214 | // @ts-expect-error 215 | chain.bad(); 216 | 217 | // @ts-expect-error 218 | chain.field("crap"); 219 | }; 220 | }); 221 | 222 | test("nullish fields", () => { 223 | const Schema = z.object({ 224 | field: z.string().nullish(), 225 | }); 226 | 227 | const chain = fieldChain("form", Schema, []); 228 | 229 | expect(chain.field()).toEqual("field"); 230 | expect(chain.field("name")).toEqual("field"); 231 | expect(chain.field("id")).toEqual("form:field"); 232 | 233 | { 234 | const res: string = chain.field(); 235 | assertNotAny(chain.field()); 236 | } 237 | { 238 | const res: string = chain.field("id"); 239 | assertNotAny(chain.field("id")); 240 | } 241 | { 242 | const res: string = chain.field("name"); 243 | assertNotAny(chain.field("name")); 244 | } 245 | 246 | () => { 247 | // @ts-expect-error 248 | chain.bad(); 249 | 250 | // @ts-expect-error 251 | chain.field("crap"); 252 | }; 253 | }); 254 | 255 | test("optional arrays", () => { 256 | const Schema = z.object({ 257 | things: z.array(z.object({ ding: z.string() })).optional(), 258 | }); 259 | 260 | const chain = fieldChain("form", Schema, []); 261 | 262 | expect(chain.things(0).ding("name")).toEqual("things[0].ding"); 263 | }); 264 | 265 | test("nullish arrays", () => { 266 | const Schema = z.object({ 267 | things: z.array(z.object({ ding: z.string() })).nullish(), 268 | }); 269 | 270 | const chain = fieldChain("form", Schema, []); 271 | 272 | expect(chain.things(0).ding("name")).toEqual("things[0].ding"); 273 | }); 274 | 275 | test("nullable arrays", () => { 276 | const Schema = z.object({ 277 | things: z.array(z.object({ ding: z.string() })).nullable(), 278 | }); 279 | 280 | const chain = fieldChain("form", Schema, []); 281 | 282 | expect(chain.things(0).ding("name")).toEqual("things[0].ding"); 283 | }); 284 | 285 | test("nullable array items", () => { 286 | const Schema = z.object({ 287 | things: z.array(z.object({ ding: z.string() }).nullable()), 288 | }); 289 | 290 | const chain = fieldChain("form", Schema, []); 291 | 292 | expect(chain.things(0).ding("name")).toEqual("things[0].ding"); 293 | }); 294 | 295 | test("nullish array items", () => { 296 | const Schema = z.object({ 297 | things: z.array(z.object({ ding: z.string() }).nullish()), 298 | }); 299 | 300 | const chain = fieldChain("form", Schema, []); 301 | 302 | expect(chain.things(0).ding("name")).toEqual("things[0].ding"); 303 | }); 304 | 305 | test("date field", () => { 306 | const Schema = z.object({ 307 | ding: z.string(), 308 | date: z.date(), 309 | }); 310 | 311 | const chain = fieldChain("form", Schema, []); 312 | 313 | expect(chain.date()).toEqual("date"); 314 | 315 | // @ts-expect-error 316 | const _notAny: number = chain.date(); 317 | }); 318 | -------------------------------------------------------------------------------- /packages/react-zorm/__tests__/field-type-inspection.test.ts: -------------------------------------------------------------------------------- 1 | import { fieldChain } from "../src/chains"; 2 | import { z } from "zod"; 3 | import { assertNotAny } from "./test-helpers"; 4 | import { FieldChain, FieldGetter } from "../src/types"; 5 | 6 | test("can access the zod type", () => { 7 | const Schema = z.object({ 8 | field: z.string(), 9 | }); 10 | 11 | const chain = fieldChain("form", Schema, []); 12 | 13 | expect(chain.field((field) => field.type)).toBeInstanceOf(z.ZodString); 14 | }); 15 | 16 | test("can access the zod type in nested object", () => { 17 | const Schema = z.object({ 18 | nest: z.object({ 19 | field: z.string(), 20 | }), 21 | }); 22 | 23 | const chain = fieldChain("form", Schema, []); 24 | 25 | expect(chain.nest.field((field) => field.type)).toBeInstanceOf(z.ZodString); 26 | }); 27 | 28 | test("can access the zod type in array", () => { 29 | const Schema = z.object({ 30 | arr: z.array(z.string()), 31 | }); 32 | 33 | const chain = fieldChain("form", Schema, []); 34 | 35 | expect(chain.arr(0)((field) => field.type)).toBeInstanceOf(z.ZodString); 36 | }); 37 | 38 | test("can access the zod type in complex type", () => { 39 | const Schema = z.object({ 40 | arr: z.array( 41 | z.object({ 42 | deep: z.string(), 43 | }), 44 | ), 45 | }); 46 | 47 | const chain = fieldChain("form", Schema, []); 48 | 49 | expect(chain.arr(0).deep((field) => field.type)).toBeInstanceOf( 50 | z.ZodString, 51 | ); 52 | }); 53 | 54 | test("can access wrapped types", () => { 55 | const Schema = z.object({ 56 | field: z.number().nullish(), 57 | }); 58 | 59 | const chain = fieldChain("form", Schema, []); 60 | 61 | // must return the wrapped type so users can detect nullish etc. 62 | expect(chain.field((field) => field.type)).toBeInstanceOf(z.ZodOptional); 63 | }); 64 | 65 | test("can access access through optional objects", () => { 66 | const Schema = z.object({ 67 | nest: z 68 | .object({ 69 | field: z.string(), 70 | }) 71 | .optional(), 72 | }); 73 | 74 | const chain = fieldChain("form", Schema, []); 75 | 76 | expect(chain.nest.field((field) => field.type)).toBeInstanceOf(z.ZodString); 77 | }); 78 | 79 | test("ZodEffects", () => { 80 | const Schema = z.object({ 81 | field: z.number().refine((n) => n % 2 === 0, { 82 | message: "Must be even", 83 | }), 84 | }); 85 | 86 | const chain = fieldChain("form", Schema, []); 87 | 88 | expect(chain.field((field) => field.type)).toBeInstanceOf(z.ZodEffects); 89 | }); 90 | 91 | test("can read min-max", () => { 92 | const Schema = z.object({ 93 | field: z.number().min(2).max(10), 94 | }); 95 | 96 | const chain = fieldChain("form", Schema, []); 97 | 98 | const field = chain.field((field) => field.type); 99 | 100 | if (!(field instanceof z.ZodNumber)) { 101 | throw new Error("Expected ZodNumber"); 102 | } 103 | 104 | expect(field._def.checks[0]).toMatchObject({ 105 | kind: "min", 106 | value: 2, 107 | }); 108 | 109 | expect(field._def.checks[1]).toMatchObject({ 110 | kind: "max", 111 | value: 10, 112 | }); 113 | }); 114 | 115 | test("can read regex", () => { 116 | const Schema = z.object({ 117 | field: z.string().regex(/^[a-z]+$/), 118 | }); 119 | 120 | const chain = fieldChain("form", Schema, []); 121 | 122 | const field = chain.field((field) => field.type); 123 | 124 | if (!(field instanceof z.ZodString)) { 125 | throw new Error("Expected ZodString"); 126 | } 127 | 128 | expect(field._def.checks[0]).toMatchObject({ 129 | kind: "regex", 130 | regex: /^[a-z]+$/, 131 | }); 132 | }); 133 | 134 | test("can access the input name", () => { 135 | const Schema = z.object({ 136 | arr: z.array( 137 | z.object({ 138 | deep: z.string(), 139 | }), 140 | ), 141 | }); 142 | 143 | const chain = fieldChain("form", Schema, []); 144 | 145 | expect(chain.arr(0).deep((field) => field.name)).toEqual("arr[0].deep"); 146 | }); 147 | 148 | test.skip("[type only] can return the from the chain", () => { 149 | const Schema = z.object({ 150 | field: z.string(), 151 | }); 152 | 153 | const chain = fieldChain("form", Schema, []); 154 | 155 | const _: z.ZodType = chain.field((field) => field.type); 156 | 157 | { 158 | // not any 159 | // @ts-expect-error 160 | const _: string = chain.field((field) => field.type); 161 | } 162 | }); 163 | -------------------------------------------------------------------------------- /packages/react-zorm/__tests__/input-props.test.tsx: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { unstable_inputProps as inputProps } from "../src"; 3 | 4 | test("sets type=text by default", () => { 5 | const props = inputProps({ 6 | id: "form:test", 7 | errorId: "error:form:test", 8 | name: "test", 9 | type: z.string(), 10 | issues: [], 11 | }); 12 | 13 | expect(props).toEqual({ 14 | name: "test", 15 | type: "text", 16 | required: true, 17 | }); 18 | }); 19 | 20 | test("sets aria-invalid when there is issues", () => { 21 | const props = inputProps({ 22 | name: "test", 23 | errorId: "error:form:test", 24 | type: z.string(), 25 | id: "form:test", 26 | issues: [ 27 | { 28 | code: "custom", 29 | message: "custom error", 30 | path: ["test"], 31 | }, 32 | ], 33 | }); 34 | expect(props).toEqual({ 35 | name: "test", 36 | type: "text", 37 | required: true, 38 | "aria-invalid": true, 39 | "aria-errormessage": "error:form:test", 40 | }); 41 | }); 42 | 43 | // TODO more tests 44 | -------------------------------------------------------------------------------- /packages/react-zorm/__tests__/parse-form.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { parseForm, parseFormAny } from "../src/parse-form"; 3 | import { assertNotAny, makeForm } from "./test-helpers"; 4 | import { z } from "zod"; 5 | import { fieldChain } from "../src/chains"; 6 | 7 | describe("parse with schema", () => { 8 | test("basic", () => { 9 | const Schema = z.object({ 10 | ding: z.string(), 11 | }); 12 | 13 | const form = makeForm( 14 |
15 | 16 |
, 17 | ); 18 | 19 | const res = parseForm(Schema, form); 20 | 21 | assertNotAny(res); 22 | 23 | // @ts-expect-error 24 | res.bad; 25 | 26 | expect(res).toEqual({ 27 | ding: "dong", 28 | }); 29 | }); 30 | 31 | test("handles sparse arrays", () => { 32 | const Schema = z.object({ 33 | things: z.array( 34 | z 35 | .object({ 36 | ding: z.string(), 37 | }) 38 | .nullish(), 39 | ), 40 | }); 41 | 42 | const form = makeForm( 43 |
44 | 45 |
, 46 | ); 47 | 48 | const res = parseForm(Schema, form); 49 | 50 | assertNotAny(res); 51 | 52 | expect(res).toEqual({ 53 | things: [undefined, { ding: "dong" }], 54 | }); 55 | }); 56 | }); 57 | 58 | describe("with any", () => { 59 | test("single field", () => { 60 | const form = makeForm( 61 |
62 | 63 |
, 64 | ); 65 | 66 | expect(parseFormAny(form)).toEqual({ 67 | ding: "dong", 68 | }); 69 | }); 70 | 71 | test("object", () => { 72 | const form = makeForm( 73 |
74 | 75 | 76 |
, 77 | ); 78 | 79 | expect(parseFormAny(form)).toEqual({ 80 | ding: { dong: "value" }, 81 | }); 82 | }); 83 | 84 | test("array", () => { 85 | const form = makeForm( 86 |
87 | 88 | 89 |
, 90 | ); 91 | 92 | expect(parseFormAny(form)).toEqual({ 93 | ding: ["value1", "value2"], 94 | }); 95 | }); 96 | 97 | test("array with objects", () => { 98 | const form = makeForm( 99 |
100 | 101 | 102 | 103 | 104 | 105 |
, 106 | ); 107 | 108 | expect(parseFormAny(form)).toEqual({ 109 | nest: [ 110 | // 111 | { ding: "value1", dong: "value2" }, 112 | { ding: "value3", dong: "value4" }, 113 | ], 114 | }); 115 | }); 116 | 117 | test("field with dot", () => { 118 | const form = makeForm( 119 |
120 | 121 |
, 122 | ); 123 | 124 | expect(parseFormAny(form)).toEqual({ 125 | "ding.dong": "value", 126 | }); 127 | }); 128 | 129 | test("field with space", () => { 130 | const form = makeForm( 131 |
132 | 133 |
, 134 | ); 135 | 136 | expect(parseFormAny(form)).toEqual({ 137 | "ding dong": "value", 138 | }); 139 | }); 140 | 141 | test("handles sparse arrays", () => { 142 | // Form with zero field 143 | const form = makeForm( 144 |
145 | 146 |
, 147 | ); 148 | 149 | const res = parseFormAny(form); 150 | 151 | // Assert there no hole the array 152 | expect("0" in res.things).toBe(true); 153 | 154 | expect(res).toEqual({ 155 | things: [undefined, { ding: "dong" }], 156 | }); 157 | }); 158 | 159 | test("can handle files ", () => { 160 | const form = new FormData(); 161 | const file = new File(["(⌐□_□)"], "chucknorris.txt", { 162 | type: "text/plain", 163 | }); 164 | form.append("myFile", file); 165 | 166 | const res = parseFormAny(form); 167 | 168 | expect(res).toEqual({ 169 | myFile: file, 170 | }); 171 | 172 | expect(res.myFile).toBe(file); 173 | }); 174 | }); 175 | 176 | describe("combine chains with parsing", () => { 177 | test("nested", () => { 178 | const Schema = z.object({ 179 | ding: z.object({ 180 | dong: z.string(), 181 | }), 182 | }); 183 | 184 | const chain = fieldChain("form", Schema, []); 185 | 186 | const form = makeForm( 187 |
188 | 189 |
, 190 | ); 191 | 192 | const res = parseForm(Schema, form); 193 | 194 | expect(res).toEqual({ 195 | ding: { 196 | dong: "value", 197 | }, 198 | }); 199 | }); 200 | }); 201 | -------------------------------------------------------------------------------- /packages/react-zorm/__tests__/setup-tests.ts: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom"; 2 | -------------------------------------------------------------------------------- /packages/react-zorm/__tests__/test-helpers.tsx: -------------------------------------------------------------------------------- 1 | import { assertNotNil } from "@valu/assert"; 2 | import { render } from "@testing-library/react"; 3 | 4 | export function makeForm(jsx: any) { 5 | render(jsx); 6 | const form = document.querySelector("form"); 7 | assertNotNil(form); 8 | return form; 9 | } 10 | 11 | type IsAny = unknown extends T ? (T extends {} ? T : never) : never; 12 | 13 | type NotAny = T extends IsAny ? never : T; 14 | 15 | export function assertNotAny(x: NotAny) {} 16 | -------------------------------------------------------------------------------- /packages/react-zorm/e2e/formdata-event.tsx: -------------------------------------------------------------------------------- 1 | import { useZorm } from "../src/index"; 2 | import { z } from "zod"; 3 | import React, { useEffect, useState } from "react"; 4 | import { registerTest } from "./register"; 5 | 6 | const Schema = z.object({ 7 | input: z.string().min(1), 8 | extra: z.string().min(1), 9 | }); 10 | 11 | function Test() { 12 | const [validFormData, setValidFormData] = 13 | useState>(); 14 | 15 | // use state to detect stale closure in onFormData 16 | const [extra, setExtra] = useState(""); 17 | 18 | const zo = useZorm("form", Schema, { 19 | onValidSubmit(e) { 20 | e.preventDefault(); 21 | setValidFormData(e.data); 22 | }, 23 | onFormData(e) { 24 | e.formData.set(zo.fields.extra(), extra); 25 | }, 26 | }); 27 | 28 | return ( 29 |
30 | 31 | 32 | 40 | 41 | 42 | 43 | {zo.errors.input((e) => ( 44 |
input: {e.code}
45 | ))} 46 | 47 | {zo.errors.extra((e) => ( 48 |
extra: {e.code}
49 | ))} 50 | 51 | {validFormData && ( 52 |
53 | formdata: {validFormData.extra} 54 |
55 | )} 56 | 57 |
{JSON.stringify(validFormData, null, 2)}
58 |
59 | ); 60 | } 61 | 62 | registerTest("formdata-event", Test); 63 | -------------------------------------------------------------------------------- /packages/react-zorm/e2e/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Document 7 | 12 | 13 | 14 |
15 | 16 | 17 |
18 | 19 | 20 | -------------------------------------------------------------------------------- /packages/react-zorm/e2e/index.tsx: -------------------------------------------------------------------------------- 1 | import "./validate-on-blur"; 2 | import "./formdata-event"; 3 | import "./invalid-event"; 4 | -------------------------------------------------------------------------------- /packages/react-zorm/e2e/invalid-event.tsx: -------------------------------------------------------------------------------- 1 | import { useZorm } from "../src/index"; 2 | import { z } from "zod"; 3 | import React, { useEffect, useState } from "react"; 4 | import { registerTest } from "./register"; 5 | 6 | const Schema = z.object({ 7 | input: z.string().min(1), 8 | }); 9 | 10 | function Test() { 11 | const zo = useZorm("form", Schema, { 12 | onValidSubmit(e) { 13 | e.preventDefault(); 14 | }, 15 | }); 16 | 17 | return ( 18 |
19 | 20 | 21 | 22 | 23 | {zo.errors.input((e) => ( 24 |
input: {e.code}
25 | ))} 26 |
27 | ); 28 | } 29 | 30 | registerTest("invalid-event", Test); 31 | -------------------------------------------------------------------------------- /packages/react-zorm/e2e/register.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { createRoot } from "react-dom/client"; 3 | import { StrictMode } from "react"; 4 | 5 | export function registerTest(testName: string, App: any) { 6 | document.addEventListener("DOMContentLoaded", () => { 7 | const a = document.createElement("a"); 8 | a.href = `?test=${testName}`; 9 | a.innerText = testName; 10 | document.querySelector(".tests")?.appendChild(a); 11 | 12 | const currentTest = new URLSearchParams(window.location.search).get( 13 | "test", 14 | ); 15 | if (currentTest !== testName) { 16 | return; 17 | } 18 | 19 | const rootElement = document.getElementById("root"); 20 | if (!rootElement) { 21 | throw new Error("No root element"); 22 | } 23 | 24 | const root = createRoot(rootElement); 25 | 26 | root.render( 27 | 28 | 29 | , 30 | ); 31 | }); 32 | } 33 | -------------------------------------------------------------------------------- /packages/react-zorm/e2e/the-tests.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | 3 | test("validates on blur after first submit", async ({ page }) => { 4 | await page.goto("/?test=validate-on-blur"); 5 | const error = page.locator(".error"); 6 | const input = page.locator("input"); 7 | 8 | await expect(error).not.toBeVisible(); 9 | await input.fill("a"); 10 | await page.keyboard.press("Enter"); 11 | await expect(error).toBeVisible(); 12 | 13 | await input.fill("long enough"); 14 | 15 | // Remove focus from input and it sould validate 16 | await page.locator("body").click(); 17 | await expect(error).not.toBeVisible(); 18 | 19 | // Should valiate on blur again 20 | await input.fill("s"); 21 | await page.locator("body").click(); 22 | await expect(error).toBeVisible(); 23 | }); 24 | 25 | test("can use the formdata inject data", async ({ page }) => { 26 | await page.goto("/?test=formdata-event"); 27 | const error = page.locator(".error"); 28 | const input = page.locator("input"); 29 | const validData = page.locator(".valid-data"); 30 | const setExtraButton = page.locator("button", { hasText: "set extra" }); 31 | 32 | await setExtraButton.click(); 33 | await input.fill("some text"); 34 | await page.keyboard.press("Enter"); 35 | 36 | await expect(validData).toHaveText("formdata: extra data"); 37 | await expect(error).not.toBeVisible(); 38 | }); 39 | 40 | test("validates on html errors", async ({ page }) => { 41 | await page.goto("/?test=invalid-event"); 42 | const error = page.locator(".error"); 43 | const input = page.locator("input"); 44 | 45 | await input.focus(); 46 | await page.keyboard.press("Enter"); 47 | 48 | // Constraint Validation API works 49 | // https://developer.mozilla.org/en-US/docs/Learn/Forms/Form_validation#the_constraint_validation_api 50 | const valueMissing = await page.evaluate(() => { 51 | const input = document.querySelector("form input"); 52 | if (!(input instanceof HTMLInputElement)) { 53 | throw new Error("input not found"); 54 | } 55 | 56 | return input.validity.valueMissing; 57 | }); 58 | 59 | expect(valueMissing).toBe(true); 60 | 61 | // Zorm errors are rendered 62 | await expect(error).toHaveText("input: too_small"); 63 | }); 64 | -------------------------------------------------------------------------------- /packages/react-zorm/e2e/validate-on-blur.tsx: -------------------------------------------------------------------------------- 1 | import { useZorm } from "../src/index"; 2 | import { z } from "zod"; 3 | import React from "react"; 4 | import { registerTest } from "./register"; 5 | 6 | const Schema = z.object({ 7 | thing: z.string().min(5), 8 | }); 9 | 10 | function Test() { 11 | const zo = useZorm("form", Schema); 12 | 13 | return ( 14 |
15 | 16 | 17 | {zo.errors.thing(() => ( 18 |
error
19 | ))} 20 |
21 | ); 22 | } 23 | 24 | registerTest("validate-on-blur", Test); 25 | -------------------------------------------------------------------------------- /packages/react-zorm/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: "jsdom", 3 | testPathIgnorePatterns: ["/node_modules", "dist", ".build"], 4 | testRegex: "(/__tests__/.+\\.(test|spec))\\.[jt]sx?$", 5 | setupFilesAfterEnv: ["/__tests__/setup-tests.ts"], 6 | transform: { 7 | "^.+\\.tsx?$": [ 8 | "babel-jest", 9 | { 10 | presets: [ 11 | "@babel/preset-typescript", 12 | "@babel/preset-react", 13 | [ 14 | "@babel/preset-env", 15 | { 16 | targets: { 17 | node: "current", 18 | }, 19 | }, 20 | ], 21 | ], 22 | }, 23 | ], 24 | }, 25 | moduleFileExtensions: ["ts", "tsx", "js"], 26 | maxWorkers: process.platform === "darwin" ? "50%" : "100%", 27 | // Automatically clear mock calls, instances and results before every test 28 | clearMocks: true, 29 | }; 30 | -------------------------------------------------------------------------------- /packages/react-zorm/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-zorm", 3 | "version": "0.9.0", 4 | "description": "", 5 | "author": "Esa-Matti Suuronen", 6 | "license": "ISC", 7 | "exports": { 8 | ".": { 9 | "types": "./dist/index.d.ts", 10 | "import": "./esm/react-zorm.mjs", 11 | "require": "./dist/index.js" 12 | }, 13 | "./package.json": "./package.json" 14 | }, 15 | "sideEffects": false, 16 | "main": "dist/index.js", 17 | "typings": "dist/index.d.ts", 18 | "repository": { 19 | "url": "https://github.com/esamattis/react-zorm/" 20 | }, 21 | "scripts": { 22 | "build": "run-p 'build:*'", 23 | "build:tsc": "tsc -p tsconfig.build.json", 24 | "build:vite": "vite build", 25 | "publish-build": "cp ../../README.md . && pnpm run build", 26 | "watch": "tsc -w -p tsconfig.build.json", 27 | "eslint": "eslint --max-warnings 0 \"src/**/*.ts\" \"src/**/*.tsx\" \"e2e/**/*.tsx\" \"e2e/**/*.ts\" \"__tests__/**/*.tsx\" \"__tests__/**/*.ts\"", 28 | "dev": "run-p 'dev:*'", 29 | "dev:vite": "vite", 30 | "playwright-test": "playwright test", 31 | "jest": "jest", 32 | "tsc": "tsc", 33 | "test": "tsc && jest && pnpm run eslint", 34 | "size-limit": "size-limit" 35 | }, 36 | "peerDependencies": { 37 | "react": ">=17.0.0", 38 | "react-dom": ">=17.0.0", 39 | "zod": ">=3.0.0" 40 | }, 41 | "files": [ 42 | "src", 43 | "esm", 44 | "dist" 45 | ], 46 | "devDependencies": { 47 | "@babel/core": "^7.21.3", 48 | "@babel/preset-env": "^7.20.2", 49 | "@babel/preset-react": "^7.18.6", 50 | "@babel/preset-typescript": "^7.21.0", 51 | "@playwright/test": "^1.32.1", 52 | "@size-limit/preset-small-lib": "^8.2.4", 53 | "@testing-library/dom": "^9.2.0", 54 | "@testing-library/jest-dom": "^5.16.5", 55 | "@testing-library/react": "^14.0.0", 56 | "@testing-library/user-event": "^14.4.3", 57 | "@types/jest": "^29.5.0", 58 | "@types/node": "^18.15.9", 59 | "@types/react": "18.0.29", 60 | "@types/react-dom": "18.0.11", 61 | "@types/testing-library__jest-dom": "^5.14.5", 62 | "@typescript-eslint/eslint-plugin": "5.56.0", 63 | "@typescript-eslint/parser": "5.56.0", 64 | "@valu/assert": "^1.3.3", 65 | "babel-jest": "^29.5.0", 66 | "esbuild": "^0.17.13", 67 | "eslint": "8.36.0", 68 | "eslint-plugin-react-hooks": "4.6.0", 69 | "jest": "^29.5.0", 70 | "jest-environment-jsdom": "^29.5.0", 71 | "msw": "^1.2.1", 72 | "npm-run-all": "^4.1.5", 73 | "prettier": "2.8.7", 74 | "react": "18.2.0", 75 | "react-dom": "18.2.0", 76 | "size-limit": "^8.2.4", 77 | "typescript": "5.0.2", 78 | "vite": "^4.2.1", 79 | "zod": "3.21.4" 80 | }, 81 | "size-limit": [ 82 | { 83 | "path": "esm/react-zorm.mjs", 84 | "limit": "3 KB" 85 | } 86 | ] 87 | } 88 | -------------------------------------------------------------------------------- /packages/react-zorm/playwright.config.ts: -------------------------------------------------------------------------------- 1 | import type { PlaywrightTestConfig } from "@playwright/test"; 2 | import { devices } from "@playwright/test"; 3 | 4 | const projects: PlaywrightTestConfig["projects"] = [ 5 | { 6 | name: "chromium", 7 | use: { 8 | ...devices["Desktop Chrome"], 9 | }, 10 | }, 11 | ]; 12 | 13 | if (process.env.CI || process.env.ALL_PLAYWRIGHT_BROWSERS) { 14 | projects.push({ 15 | name: "firefox", 16 | use: { 17 | ...devices["Desktop Firefox"], 18 | }, 19 | }); 20 | } 21 | 22 | /** 23 | * See https://playwright.dev/docs/test-configuration. 24 | */ 25 | const config: PlaywrightTestConfig = { 26 | testDir: __dirname + "/e2e", 27 | /* Maximum time one test can run for. */ 28 | timeout: 30 * 1000, 29 | expect: { 30 | /** 31 | * Maximum time expect() should wait for the condition to be met. 32 | * For example in `await expect(locator).toHaveText();` 33 | */ 34 | timeout: 5000, 35 | }, 36 | /* Run tests in files in parallel */ 37 | fullyParallel: false, 38 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 39 | forbidOnly: !!process.env.CI, 40 | /* Retry on CI only */ 41 | retries: process.env.CI ? 2 : 0, 42 | /* Opt out of parallel tests on CI. */ 43 | workers: 1, 44 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 45 | reporter: "html", 46 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 47 | use: { 48 | /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ 49 | actionTimeout: 30000, 50 | /* Base URL to use in actions like `await page.goto('/')`. */ 51 | baseURL: "http://localhost:1934/", 52 | 53 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 54 | trace: "on-first-retry", 55 | }, 56 | 57 | /* Configure projects for major browsers */ 58 | projects, 59 | 60 | /* Folder for test artifacts such as screenshots, videos, traces, etc. */ 61 | // outputDir: 'test-results/', 62 | 63 | /* Run your local dev server before starting the tests */ 64 | webServer: { 65 | command: "pnpm run dev:vite", 66 | url: "http://localhost:1934/", 67 | reuseExistingServer: !process.env.CI, 68 | }, 69 | }; 70 | 71 | export default config; 72 | -------------------------------------------------------------------------------- /packages/react-zorm/src/chains.tsx: -------------------------------------------------------------------------------- 1 | import { ZodArray, ZodCustomIssue, ZodIssue, ZodObject, ZodType } from "zod"; 2 | import { z } from "zod"; 3 | import { 4 | ErrorChainFromSchema, 5 | ErrorGetter, 6 | FieldChainFromSchema, 7 | IssueCreatorFromSchema, 8 | IssueCreatorMethods, 9 | RenderProps, 10 | ZodCustomIssueWithMessage, 11 | } from "./types"; 12 | import { arrayEquals } from "./utils"; 13 | 14 | function addArrayIndex(path: readonly string[], index: number) { 15 | const last = path[path.length - 1]; 16 | return [...path.slice(0, -1), `${last}[${index}]`]; 17 | } 18 | 19 | function unwrapZodType(type: ZodType): ZodType { 20 | if (type instanceof z.ZodObject || type instanceof z.ZodArray) { 21 | return type; 22 | } 23 | 24 | if (type instanceof z.ZodEffects) { 25 | return unwrapZodType(type.innerType()); 26 | } 27 | 28 | const anyType = type as any; 29 | if (anyType._def?.innerType) { 30 | return unwrapZodType(anyType._def.innerType); 31 | } 32 | 33 | return type; 34 | } 35 | 36 | export function fieldChain( 37 | ns: string, 38 | schema: Schema, 39 | issues: ZodIssue[], 40 | ): FieldChainFromSchema { 41 | return new Proxy( 42 | {}, 43 | { 44 | get(_target, prop) { 45 | return _fieldChain(ns, schema, issues, [])[prop]; 46 | }, 47 | }, 48 | ) as any; 49 | } 50 | 51 | function _fieldChain( 52 | ns: string, 53 | schema: ZodType, 54 | issues: ZodIssue[], 55 | path: readonly string[], 56 | ) { 57 | const proxy: any = new Proxy(() => {}, { 58 | apply(_target, _thisArg, args) { 59 | if (typeof args[0] === "number") { 60 | const unwrapped = unwrapZodType(schema); 61 | if (!(unwrapped instanceof ZodArray)) { 62 | throw new Error( 63 | `Expected ZodArray at "${path.join(".")}" got ${ 64 | schema.constructor.name 65 | }`, 66 | ); 67 | } 68 | 69 | return _fieldChain( 70 | ns, 71 | unwrapped.element, 72 | issues, 73 | addArrayIndex(path, args[0]), 74 | ); 75 | } 76 | 77 | const name = path.join("."); 78 | const id = ns + ":" + path.join("."); 79 | 80 | if (args[0] === "id") { 81 | return id; 82 | } 83 | 84 | const errorId = "error:" + id; 85 | 86 | if (args[0] === "errorid") { 87 | return errorId; 88 | } 89 | 90 | if (typeof args[0] === "function") { 91 | const matching = issues.filter((issue) => { 92 | return arrayEquals(issue.path, path); 93 | }); 94 | 95 | const props: RenderProps = { 96 | id, 97 | name, 98 | type: schema, 99 | issues: matching, 100 | errorId, 101 | }; 102 | 103 | return args[0](props); 104 | } 105 | 106 | return name; 107 | }, 108 | 109 | get(_target, prop) { 110 | if (typeof prop !== "string") { 111 | throw new Error("Unexpected string property: " + String(prop)); 112 | } 113 | 114 | const unwrapped = unwrapZodType(schema); 115 | if (!(unwrapped instanceof ZodObject)) { 116 | throw new Error( 117 | `Expected ZodObject at "${path.join(".")}" got ${ 118 | schema.constructor.name 119 | }`, 120 | ); 121 | } 122 | 123 | return _fieldChain(ns, unwrapped.shape[prop], issues, [ 124 | ...path, 125 | prop, 126 | ]); 127 | }, 128 | }); 129 | 130 | return proxy; 131 | } 132 | 133 | export function errorChain( 134 | schema: Schema, 135 | issues: ZodIssue[], 136 | _path?: readonly (string | number)[], 137 | ): ErrorChainFromSchema & ErrorGetter { 138 | let path = _path || []; 139 | const proxy: any = new Proxy(() => {}, { 140 | apply(_target, _thisArg, args) { 141 | if (typeof args[0] === "number") { 142 | return errorChain(schema, issues, [...path, args[0]]); 143 | } 144 | 145 | const matching = issues.filter((issue) => { 146 | return arrayEquals(issue.path, path); 147 | }); 148 | const hasError = matching.length > 0; 149 | 150 | // Ex. zo.error.field(Boolean) 151 | if (args[0] === Boolean) { 152 | return Boolean(hasError); 153 | } 154 | 155 | // Ex. zo.error.field(error => error.message) 156 | if (typeof args[0] === "function") { 157 | if (hasError) { 158 | return args[0](...matching); 159 | } 160 | 161 | return undefined; 162 | } 163 | 164 | // Return itself when there is an error 165 | // Ex. className={zo.error.field("errored")} 166 | if (args[0]) { 167 | if (hasError) { 168 | return args[0]; 169 | } else { 170 | return undefined; 171 | } 172 | } 173 | 174 | // without args return the first error if any 175 | return matching[0]; 176 | }, 177 | 178 | get(_target, prop) { 179 | if (typeof prop === "string") { 180 | return errorChain(schema, issues, [...path, prop]); 181 | } 182 | 183 | return errorChain(schema, issues, path); 184 | }, 185 | }); 186 | 187 | return proxy; 188 | } 189 | 190 | export function createCustomIssues( 191 | schema: Schema, 192 | _state?: { 193 | path: (string | number)[]; 194 | issues: ZodCustomIssueWithMessage[]; 195 | }, 196 | ): IssueCreatorFromSchema { 197 | const state = _state 198 | ? _state 199 | : { 200 | path: [], 201 | issues: [], 202 | }; 203 | 204 | /** 205 | * Methods that are available at the chain root 206 | */ 207 | const methods: IssueCreatorMethods = { 208 | toJSON: () => state.issues.slice(0), 209 | toArray: () => state.issues.slice(0), 210 | hasIssues: () => state.issues.length > 0, 211 | }; 212 | 213 | const proxy: any = new Proxy(() => {}, { 214 | apply(_target, _thisArg, args) { 215 | if (typeof args[0] === "number") { 216 | return createCustomIssues(schema, { 217 | ...state, 218 | path: [...state.path, args[0]], 219 | }); 220 | } 221 | 222 | const issue: ZodCustomIssueWithMessage = { 223 | code: "custom", 224 | path: state.path, 225 | message: args[0], 226 | params: args[1] ?? {}, 227 | }; 228 | 229 | state.issues.push(issue); 230 | 231 | return issue; 232 | }, 233 | 234 | get(_target, prop) { 235 | if (state.path.length === 0 && prop in methods) { 236 | return (methods as any)[prop]; 237 | } 238 | 239 | if (typeof prop === "string") { 240 | return createCustomIssues(schema, { 241 | ...state, 242 | path: [...state.path, prop], 243 | }); 244 | } 245 | 246 | return createCustomIssues(schema, state); 247 | }, 248 | }); 249 | 250 | return proxy; 251 | } 252 | -------------------------------------------------------------------------------- /packages/react-zorm/src/index.tsx: -------------------------------------------------------------------------------- 1 | export { parseForm, safeParseForm, parseFormAny } from "./parse-form"; 2 | export { errorChain, fieldChain, createCustomIssues } from "./chains"; 3 | export { useZorm } from "./use-zorm"; 4 | export type { Zorm, ZodCustomIssueWithMessage, RenderProps } from "./types"; 5 | export type { ValueSubscription } from "./use-value"; 6 | export { useValue, Value } from "./use-value"; 7 | export { 8 | inputProps as unstable_inputProps, 9 | type InputProps, 10 | } from "./input-props"; 11 | -------------------------------------------------------------------------------- /packages/react-zorm/src/input-props.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ZodType, 3 | ZodEffects, 4 | ZodString, 5 | ZodNumber, 6 | ZodDate, 7 | ZodDefault, 8 | ZodOptional, 9 | ZodNullable, 10 | } from "zod"; 11 | import { RenderProps } from "."; 12 | 13 | export interface InputProps { 14 | type: string; 15 | name: string; 16 | required?: boolean; 17 | min?: number; 18 | max?: number; 19 | minLength?: number; 20 | maxLength?: number; 21 | pattern?: string; 22 | step?: string | number; 23 | defaultValue?: string | number; 24 | ["aria-invalid"]?: boolean; 25 | ["aria-errormessage"]?: string; 26 | } 27 | 28 | function removeZodEffects(type: ZodType): ZodType { 29 | // remove .refine() etc. 30 | if (type instanceof ZodEffects) { 31 | return removeZodEffects(type.innerType()); 32 | } 33 | 34 | return type; 35 | } 36 | 37 | function stringCheckProps(type: ZodString) { 38 | const checks = type._def.checks; 39 | 40 | const props: Partial = { 41 | type: "text", 42 | }; 43 | 44 | for (const check of checks) { 45 | if (check.kind === "min") { 46 | props.minLength = check.value; 47 | } 48 | 49 | if (check.kind === "max") { 50 | props.maxLength = check.value; 51 | } 52 | 53 | if (check.kind === "regex") { 54 | props.pattern = check.regex.toString().slice(1, -1); 55 | } 56 | 57 | if (check.kind === "email") { 58 | props.type = "email"; 59 | } 60 | 61 | // TODO the rest... 62 | } 63 | 64 | return props; 65 | } 66 | 67 | function numberCheckProps(type: ZodNumber) { 68 | const checks = type._def.checks; 69 | 70 | const props: Partial = { 71 | type: "number", 72 | step: "any", 73 | }; 74 | 75 | for (const check of checks) { 76 | if (check.kind === "min") { 77 | props.min = check.value; 78 | } 79 | 80 | if (check.kind === "max") { 81 | props.max = check.value; 82 | } 83 | 84 | if (check.kind === "int" && props.step === "any") { 85 | // defaults to 1 so we can remove it if limited to ints 86 | delete props.step; 87 | } 88 | 89 | if (check.kind === "multipleOf") { 90 | props.step = check.value; 91 | } 92 | } 93 | 94 | return props; 95 | } 96 | 97 | function dateCheckProps(type: ZodDate) { 98 | const checks = type._def.checks; 99 | 100 | const props: Partial = { 101 | type: "date", 102 | }; 103 | 104 | for (const check of checks) { 105 | if (check.kind === "min") { 106 | props.min = check.value; 107 | } 108 | 109 | if (check.kind === "max") { 110 | props.max = check.value; 111 | } 112 | } 113 | 114 | return props; 115 | } 116 | 117 | function collectProps( 118 | type: ZodType, 119 | _props: Partial = {}, 120 | ): Partial { 121 | const props = _props ?? {}; 122 | 123 | type = removeZodEffects(type); 124 | 125 | if (type instanceof ZodDefault) { 126 | props.defaultValue = type._def.defaultValue(); 127 | } else if (type instanceof ZodOptional || type instanceof ZodNullable) { 128 | props.required = false; 129 | } else if (type instanceof ZodString) { 130 | Object.assign(props, stringCheckProps(type)); 131 | } else if (type instanceof ZodNumber) { 132 | Object.assign(props, numberCheckProps(type)); 133 | } else if (type instanceof ZodDate) { 134 | Object.assign(props, dateCheckProps(type)); 135 | } 136 | 137 | // Remove optional/nullable wrapping etc. There's probably a better way to do this. 138 | const anyType = type as any; 139 | if (anyType._def?.innerType) { 140 | return collectProps(anyType._def.innerType, props); 141 | } 142 | 143 | return props; 144 | } 145 | 146 | export function inputProps(field: RenderProps): InputProps { 147 | const props: InputProps = { 148 | type: "text", 149 | required: true, 150 | ...collectProps(field.type), 151 | name: field.name, 152 | }; 153 | 154 | if (props.required === false) { 155 | delete props.required; 156 | } 157 | 158 | if (field.issues.length > 0) { 159 | props["aria-invalid"] = true; 160 | props["aria-errormessage"] = field.errorId; 161 | } 162 | 163 | return props; 164 | } 165 | -------------------------------------------------------------------------------- /packages/react-zorm/src/parse-form.ts: -------------------------------------------------------------------------------- 1 | import { SafeParseReturnType } from "zod"; 2 | import { setIn } from "./set-in"; 3 | import { GenericSchema } from "./types"; 4 | 5 | /** 6 | * Fix sparse array from nested objects. Puts undefineds to array holes. 7 | */ 8 | function fixHoles(ob: object | any[]) { 9 | if (Array.isArray(ob)) { 10 | const array = ob; 11 | for (let index = 0, length = array.length; index < length; index++) { 12 | if (!(index in array)) { 13 | array[index] = undefined; 14 | } else { 15 | fixHoles(array[index]); 16 | } 17 | } 18 | } 19 | 20 | if (ob === null) { 21 | return; 22 | } 23 | 24 | if (typeof ob === "object") { 25 | for (const value of Object.values(ob)) { 26 | fixHoles(value); 27 | } 28 | } 29 | } 30 | 31 | /** 32 | * Parse nested data from a form element or a FormData object. 33 | * 34 | * Ex. 35 | * 36 | * => { ding: [ {dong: "value"} ] } 37 | * 38 | * Inspired by Final Form. See https://8ypq7n41z0.codesandbox.io/ 39 | */ 40 | export function parseFormAny(form: HTMLFormElement | FormData) { 41 | let data: FormData; 42 | if ("onsubmit" in form) { 43 | data = new FormData(form); 44 | } else { 45 | data = form; 46 | } 47 | 48 | let ret: any = {}; 49 | 50 | for (const [key, value] of data.entries()) { 51 | ret = setIn(ret, key, value); 52 | } 53 | 54 | // Remove sparse arrays as Zod does not like them. 55 | // XXX Should probably just fix setIn() to avoid sparse arrays. 56 | fixHoles(ret); 57 | 58 | return ret; 59 | } 60 | 61 | export function parseForm

( 62 | schema: P, 63 | form: HTMLFormElement | FormData, 64 | ): ReturnType { 65 | return schema.parse(parseFormAny(form)); 66 | } 67 | 68 | export function safeParseForm

( 69 | schema: P, 70 | form: HTMLFormElement | FormData, 71 | ): SafeParseReturnType> { 72 | return schema.safeParse(parseFormAny(form)); 73 | } 74 | -------------------------------------------------------------------------------- /packages/react-zorm/src/set-in.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Ported to TypeScript from https://github.com/final-form/final-form/blob/ad1997b70de21df336331da466523534b5bdb63c/src/structure/toPath.js 3 | * https://github.com/final-form/final-form/blob/ad1997b70de21df336331da466523534b5bdb63c/LICENSE 4 | * Copyright (c) 2017 Erik Rasmussen 5 | * Copyright JS Foundation and other contributors 6 | * Based on Underscore.js, copyright Jeremy Ashkenas, 7 | * 8 | * Structure Tester 9 | * https://8ypq7n41z0.codesandbox.io/ 10 | */ 11 | 12 | type State = any; 13 | 14 | const charCodeOfDot = ".".charCodeAt(0); 15 | const reEscapeChar = /\\(\\)?/g; 16 | const rePropName = RegExp( 17 | // Match anything that isn't a dot or bracket. 18 | "[^.[\\]]+" + 19 | "|" + 20 | // Or match property names within brackets. 21 | "\\[(?:" + 22 | // Match a non-string expression. 23 | "([^\"'][^[]*)" + 24 | "|" + 25 | // Or match strings (supports escaping characters). 26 | "([\"'])((?:(?!\\2)[^\\\\]|\\\\.)*?)\\2" + 27 | ")\\]" + 28 | "|" + 29 | // Or match "" as the space between consecutive dots or empty brackets. 30 | "(?=(?:\\.|\\[\\])(?:\\.|\\[\\]|$))", 31 | "g", 32 | ); 33 | 34 | /** 35 | * Converts `string` to a property path array. 36 | * 37 | * @private 38 | * @param {string} string The string to convert. 39 | * @returns {Array} Returns the property path array. 40 | */ 41 | const stringToPath = (string: string) => { 42 | const result = []; 43 | if (string.charCodeAt(0) === charCodeOfDot) { 44 | result.push(""); 45 | } 46 | string.replace(rePropName, (match, expression, quote, subString) => { 47 | let key = match; 48 | if (quote) { 49 | key = subString.replace(reEscapeChar, "$1"); 50 | } else if (expression) { 51 | key = expression.trim(); 52 | } 53 | result.push(key); 54 | 55 | return ""; 56 | }); 57 | return result; 58 | }; 59 | 60 | const keysCache: { [key: string]: string[] } = {}; 61 | 62 | const toPath = (key: string): string[] => { 63 | if (key === null || key === undefined || !key.length) { 64 | return []; 65 | } 66 | if (typeof key !== "string") { 67 | throw new Error("toPath() expects a string"); 68 | } 69 | 70 | if (keysCache[key] == null) { 71 | keysCache[key] = stringToPath(key); 72 | } 73 | return keysCache[key]!; 74 | }; 75 | 76 | const setInRecursor = ( 77 | current: State, 78 | index: number, 79 | path: string[], 80 | value: any, 81 | destroyArrays: boolean, 82 | ): State => { 83 | if (index >= path.length) { 84 | // end of recursion 85 | return value; 86 | } 87 | const key = path[index]!; 88 | 89 | // determine type of key 90 | if (isNaN(key as any)) { 91 | // object set 92 | if (current === undefined || current === null) { 93 | // recurse 94 | const result = setInRecursor( 95 | undefined, 96 | index + 1, 97 | path, 98 | value, 99 | destroyArrays, 100 | ); 101 | 102 | // delete or create an object 103 | return result === undefined ? undefined : { [key]: result }; 104 | } 105 | if (Array.isArray(current)) { 106 | throw new Error("Cannot set a non-numeric property on an array"); 107 | } 108 | // current exists, so make a copy of all its values, and add/update the new one 109 | const result = setInRecursor( 110 | current[key], 111 | index + 1, 112 | path, 113 | value, 114 | destroyArrays, 115 | ); 116 | if (result === undefined) { 117 | const numKeys = Object.keys(current).length; 118 | if (current[key] === undefined && numKeys === 0) { 119 | // object was already empty 120 | return undefined; 121 | } 122 | if (current[key] !== undefined && numKeys <= 1) { 123 | // only key we had was the one we are deleting 124 | if (!isNaN(path[index - 1] as any) && !destroyArrays) { 125 | // we are in an array, so return an empty object 126 | return {}; 127 | } else { 128 | return undefined; 129 | } 130 | } 131 | const { [key]: _removed, ...final } = current; 132 | return final; 133 | } 134 | // set result in key 135 | return { 136 | ...current, 137 | [key]: result, 138 | }; 139 | } 140 | // array set 141 | const numericKey = Number(key); 142 | if (current === undefined || current === null) { 143 | // recurse 144 | const result = setInRecursor( 145 | undefined, 146 | index + 1, 147 | path, 148 | value, 149 | destroyArrays, 150 | ); 151 | 152 | // if nothing returned, delete it 153 | if (result === undefined) { 154 | return undefined; 155 | } 156 | 157 | // create an array 158 | const array = []; 159 | array[numericKey] = result; 160 | return array as any[]; 161 | } 162 | if (!Array.isArray(current)) { 163 | throw new Error("Cannot set a numeric property on an object"); 164 | } 165 | // recurse 166 | const existingValue = current[numericKey]; 167 | const result = setInRecursor( 168 | existingValue, 169 | index + 1, 170 | path, 171 | value, 172 | destroyArrays, 173 | ); 174 | 175 | // current exists, so make a copy of all its values, and add/update the new one 176 | const array = [...current]; 177 | if (destroyArrays && result === undefined) { 178 | array.splice(numericKey, 1); 179 | if (array.length === 0) { 180 | return undefined; 181 | } 182 | } else { 183 | array[numericKey] = result; 184 | } 185 | return array; 186 | }; 187 | 188 | export const setIn = (state: {}, key: string, value: any): any => { 189 | if (state === undefined || state === null) { 190 | throw new Error(`Cannot call setIn() with ${String(state)} state`); 191 | } 192 | if (key === undefined || key === null) { 193 | throw new Error(`Cannot call setIn() with ${String(key)} key`); 194 | } 195 | // Recursive function needs to accept and return State, but public API should 196 | // only deal with Objects 197 | return setInRecursor(state, 0, toPath(key), value, false) as any; 198 | }; 199 | -------------------------------------------------------------------------------- /packages/react-zorm/src/types.tsx: -------------------------------------------------------------------------------- 1 | import { SafeParseReturnType, ZodCustomIssue, ZodIssue, ZodType } from "zod"; 2 | 3 | type Primitive = string | number | boolean | bigint | symbol | undefined | null; 4 | 5 | export type DeepNonNullable = T extends Primitive | Date | File 6 | ? NonNullable 7 | : T extends {} 8 | ? { [K in keyof T]-?: DeepNonNullable } 9 | : Required; 10 | 11 | export interface ZormError { 12 | issues: ZodIssue[]; 13 | } 14 | 15 | export type GenericSchema = ZodType; 16 | 17 | export interface RenderProps { 18 | name: string; 19 | id: string; 20 | errorId: string; 21 | type: ZodType; 22 | issues: ZodIssue[]; 23 | } 24 | 25 | export type FieldGetter = < 26 | Arg extends 27 | | undefined 28 | | "name" 29 | | "id" 30 | | "errorid" 31 | | ((props: RenderProps) => any), 32 | >( 33 | arg?: Arg, 34 | ) => undefined extends Arg 35 | ? string 36 | : Arg extends (props: RenderProps) => any 37 | ? ReturnType 38 | : string; 39 | 40 | export type FieldChain = { 41 | [P in keyof T]: T[P] extends Array 42 | ? ( 43 | index: number, 44 | ) => FieldChain extends string 45 | ? FieldGetter 46 | : FieldChain 47 | : T[P] extends Date 48 | ? FieldGetter 49 | : T[P] extends File 50 | ? FieldGetter 51 | : T[P] extends object 52 | ? FieldChain 53 | : FieldGetter; 54 | }; 55 | 56 | export type FieldChainFromSchema = FieldChain< 57 | DeepNonNullable> 58 | >; 59 | 60 | export interface ErrorGetter { 61 | /** 62 | * Get the Zod Issue 63 | */ 64 | (): ZodIssue | undefined; 65 | 66 | /** 67 | * Return true when there is an error 68 | */ 69 | (bool: typeof Boolean): boolean; 70 | 71 | /** 72 | * Call the function on error and return its value 73 | */ 74 | any>(render: Fn): 75 | | ReturnType 76 | | undefined; 77 | 78 | /** 79 | * Return the given value on error 80 | */ 81 | (value: T): T | undefined; 82 | } 83 | 84 | export interface ArrayErrorGetter extends ErrorGetter { 85 | (index: number): T; 86 | } 87 | 88 | export type ErrorChain = { 89 | [P in keyof T]: T[P] extends Array 90 | ? ArrayErrorGetter< 91 | ErrorChain extends string 92 | ? ErrorGetter 93 | : ErrorChain & ErrorGetter 94 | > 95 | : T[P] extends object 96 | ? ErrorChain & ErrorGetter 97 | : ErrorGetter; 98 | }; 99 | 100 | export type ErrorChainFromSchema = ErrorChain< 101 | DeepNonNullable> 102 | >; 103 | 104 | export type SafeParseResult = SafeParseReturnType< 105 | any, 106 | ReturnType 107 | >; 108 | 109 | export interface Zorm { 110 | /** 111 | * @deprecated use .form instead 112 | */ 113 | refObject: React.MutableRefObject; 114 | form: HTMLFormElement | undefined; 115 | ref: (form: HTMLFormElement | null) => void; 116 | fields: FieldChainFromSchema; 117 | errors: ErrorChainFromSchema & ErrorGetter; 118 | validate(): SafeParseResult; 119 | validation: SafeParseResult | null; 120 | customIssues: ZodIssue[]; 121 | } 122 | 123 | /** 124 | * Create ZodCustomIssue for the field in the chain path 125 | */ 126 | export interface IssueCreator { 127 | ( 128 | message: string, 129 | params?: { 130 | [key: string]: any; 131 | }, 132 | ): ZodCustomIssue; 133 | } 134 | 135 | export interface ArrayIssueCreator extends IssueCreator { 136 | (index: number): T; 137 | } 138 | 139 | export type IssueCreatorChain = { 140 | [P in keyof T]: T[P] extends Array 141 | ? ArrayIssueCreator< 142 | IssueCreatorChain extends string 143 | ? IssueCreator 144 | : IssueCreatorChain & IssueCreator 145 | > 146 | : T[P] extends object 147 | ? IssueCreatorChain & IssueCreator 148 | : IssueCreator; 149 | }; 150 | 151 | export type ZodCustomIssueWithMessage = ZodCustomIssue & { message: string }; 152 | 153 | export interface IssueCreatorMethods { 154 | hasIssues(): boolean; 155 | toArray(): ZodCustomIssueWithMessage[]; 156 | // For direct JSON.stringify(chain) support 157 | toJSON(): ZodCustomIssueWithMessage[]; 158 | } 159 | 160 | export type IssueCreatorFromSchema = IssueCreatorChain< 161 | DeepNonNullable> 162 | > & 163 | IssueCreatorMethods; 164 | -------------------------------------------------------------------------------- /packages/react-zorm/src/use-value.ts: -------------------------------------------------------------------------------- 1 | import { RefObject, useEffect, useRef, useState } from "react"; 2 | import { isValuedElement } from "./utils"; 3 | 4 | export interface ValueSubscription { 5 | name: string; 6 | zorm: { 7 | refObject: React.MutableRefObject; 8 | }; 9 | initialValue?: T; 10 | event?: string; 11 | transform?: (value: string) => T; 12 | } 13 | 14 | export function useValue( 15 | opts: ValueSubscription, 16 | ): undefined extends T ? string : T { 17 | const [value, setValue] = useState(opts.initialValue ?? ""); 18 | const mapRef = useRef<((value: string) => T) | undefined>(opts.transform); 19 | 20 | useEffect(() => { 21 | const form = opts.zorm.refObject.current; 22 | if (!form) { 23 | return; 24 | } 25 | 26 | const listener = (e: { target: {} | null }) => { 27 | const input = e.target; 28 | 29 | if (!isValuedElement(input)) { 30 | return; 31 | } 32 | 33 | if (opts.name !== input.name) { 34 | return; 35 | } 36 | 37 | if (mapRef.current) { 38 | setValue(mapRef.current(input.value)); 39 | } else { 40 | setValue(input.value ?? ""); 41 | } 42 | }; 43 | 44 | const initialInput = form.querySelector(`[name="${opts.name}"]`); 45 | 46 | if (initialInput) { 47 | listener({ target: initialInput }); 48 | } 49 | 50 | const event = opts.event ?? "input"; 51 | 52 | form.addEventListener(event, listener); 53 | return () => { 54 | form.removeEventListener(event, listener); 55 | }; 56 | }, [opts.name, opts.zorm.refObject, opts.event]); 57 | 58 | return value; 59 | } 60 | 61 | export function Value( 62 | props: ValueSubscription & { 63 | children: (value: undefined extends T ? string : T) => any; 64 | }, 65 | ) { 66 | const value = useValue(props); 67 | return props.children(value); 68 | } 69 | -------------------------------------------------------------------------------- /packages/react-zorm/src/use-zorm.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; 2 | import { 3 | ZodType, 4 | ZodError, 5 | ZodCustomIssue, 6 | ZodIssue, 7 | SafeParseReturnType, 8 | } from "zod"; 9 | import { errorChain, fieldChain } from "./chains"; 10 | import { safeParseForm } from "./parse-form"; 11 | import type { Zorm } from "./types"; 12 | 13 | export interface ValidSubmitEvent { 14 | /** 15 | * Prevent the default form submission 16 | */ 17 | preventDefault(): void; 18 | 19 | /** 20 | * The form HTML Element 21 | */ 22 | target: HTMLFormElement; 23 | 24 | /** 25 | * Zod validated and parsed data 26 | */ 27 | data: Data; 28 | } 29 | 30 | export interface UseZormOptions> { 31 | /** 32 | * Called when the form is submitted with valid data 33 | */ 34 | onValidSubmit?: (event: ValidSubmitEvent) => any; 35 | 36 | setupListeners?: boolean; 37 | 38 | customIssues?: ZodIssue[]; 39 | 40 | onFormData?: (event: FormDataEvent) => any; 41 | } 42 | 43 | export function useZorm>( 44 | formName: string, 45 | schema: Schema, 46 | options?: UseZormOptions>, 47 | ): Zorm { 48 | type ValidationResult = SafeParseReturnType< 49 | any, 50 | ReturnType 51 | >; 52 | 53 | const formRef = useRef(); 54 | const submittedOnceRef = useRef(false); 55 | const submitRef = useRef< 56 | UseZormOptions>["onValidSubmit"] | undefined 57 | >(options?.onValidSubmit); 58 | submitRef.current = options?.onValidSubmit; 59 | 60 | const formDataRef = useRef< 61 | UseZormOptions>["onFormData"] | undefined 62 | >(options?.onFormData); 63 | formDataRef.current = options?.onFormData; 64 | 65 | const [validation, setValidation] = useState(null); 66 | 67 | const getForm = useCallback(() => { 68 | if (!formRef.current) { 69 | throw new Error("[react-zorm]: Form ref not passed"); 70 | } 71 | return formRef.current; 72 | }, []); 73 | 74 | const validate = useCallback(() => { 75 | const res = safeParseForm(schema, getForm()); 76 | setValidation(res); 77 | return res; 78 | }, [getForm, schema]); 79 | 80 | const changeHandler = useCallback(() => { 81 | if (!submittedOnceRef.current) { 82 | return; 83 | } 84 | 85 | validate(); 86 | }, [validate]); 87 | 88 | const formdataHandler = useCallback((event: FormDataEvent) => { 89 | formDataRef.current?.(event); 90 | }, []); 91 | 92 | const submitHandler = useCallback( 93 | (e: { preventDefault(): any }) => { 94 | submittedOnceRef.current = true; 95 | const validation = validate(); 96 | 97 | if (!validation.success) { 98 | e.preventDefault(); 99 | } else { 100 | submitRef.current?.({ 101 | data: validation.data, 102 | target: getForm(), 103 | preventDefault: () => { 104 | e.preventDefault(); 105 | }, 106 | }); 107 | } 108 | }, 109 | [getForm, validate], 110 | ); 111 | 112 | const invalidHandler = useCallback(() => { 113 | submittedOnceRef.current = true; 114 | validate(); 115 | }, [validate]); 116 | 117 | const callbackRef = useCallback( 118 | (form: HTMLFormElement | null) => { 119 | if (form !== formRef.current) { 120 | if (formRef.current) { 121 | const off = formRef.current.removeEventListener.bind( 122 | formRef.current, 123 | ); 124 | 125 | off("change", changeHandler); 126 | off("submit", submitHandler); 127 | off("invalid", invalidHandler, false); 128 | off("formdata", formdataHandler); 129 | } 130 | 131 | if (form && options?.setupListeners !== false) { 132 | form.addEventListener("change", changeHandler); 133 | form.addEventListener("submit", submitHandler); 134 | form.addEventListener("formdata", formdataHandler); 135 | 136 | // The form does not submit when it is invalid due to html5 137 | // attributes (ex. required, min, max, etc.). So detect 138 | // invalid form state with the "invalid" event and run our 139 | // own validation on it too. 140 | form.addEventListener( 141 | "invalid", 142 | invalidHandler, 143 | // "invalid" event does not bubble so listen on capture 144 | // phase by setting capture to true 145 | true, 146 | ); 147 | } 148 | formRef.current = form ?? undefined; 149 | } 150 | }, 151 | [ 152 | options?.setupListeners, 153 | changeHandler, 154 | submitHandler, 155 | invalidHandler, 156 | formdataHandler, 157 | ], 158 | ); 159 | 160 | return useMemo(() => { 161 | let customIssues = options?.customIssues ?? []; 162 | let error = !validation?.success ? validation?.error : undefined; 163 | 164 | const allIssues = [...(error?.issues ?? []), ...customIssues]; 165 | 166 | const errors = errorChain(schema, allIssues); 167 | const fields = fieldChain(formName, schema, allIssues); 168 | 169 | return { 170 | ref: callbackRef, 171 | refObject: formRef, 172 | validate, 173 | get form() { 174 | return formRef.current; 175 | }, 176 | validation, 177 | fields, 178 | errors, 179 | customIssues: customIssues, 180 | }; 181 | }, [ 182 | callbackRef, 183 | formName, 184 | options?.customIssues, 185 | schema, 186 | validate, 187 | validation, 188 | ]); 189 | } 190 | -------------------------------------------------------------------------------- /packages/react-zorm/src/utils.ts: -------------------------------------------------------------------------------- 1 | export function isValuedElement( 2 | input: any, 3 | ): input is HTMLInputElement | HTMLTextAreaElement { 4 | return ( 5 | input instanceof HTMLInputElement || 6 | input instanceof HTMLTextAreaElement || 7 | input instanceof HTMLSelectElement 8 | ); 9 | } 10 | 11 | export function arrayEquals(a: readonly any[], b: readonly any[]) { 12 | return ( 13 | a.length === b.length && 14 | a.every((item, index) => { 15 | return item === b[index]; 16 | }) 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /packages/react-zorm/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "sourceMap": true, 5 | "declarationMap": true, 6 | "noEmit": false, 7 | "outDir": "dist" 8 | }, 9 | "include": ["src"] 10 | } 11 | -------------------------------------------------------------------------------- /packages/react-zorm/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "esnext", 5 | "lib": ["es2018", "dom", "esnext", "DOM.Iterable"], 6 | "types": ["jest", "@testing-library/jest-dom"], 7 | "moduleResolution": "node", 8 | "jsx": "react", 9 | "forceConsistentCasingInFileNames": true, 10 | "strict": true, 11 | "noImplicitOverride": true, 12 | "noUncheckedIndexedAccess": true, 13 | "useUnknownInCatchVariables": false, 14 | "noEmit": true, 15 | "skipLibCheck": true, 16 | "esModuleInterop": true, 17 | "declaration": true 18 | }, 19 | "include": ["__tests__", "src", "example", "e2e"] 20 | } 21 | -------------------------------------------------------------------------------- /packages/react-zorm/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { resolve, join } from "path"; 2 | import { defineConfig } from "vite"; 3 | import { readdirSync } from "fs"; 4 | 5 | // const entries = readdirSync(join(__dirname, "bundled")); 6 | 7 | // const input = Object.fromEntries( 8 | // entries.flatMap((dir) => { 9 | // const entry = [dir, resolve(__dirname, "bundled", dir, "index.html")]; 10 | 11 | // // Wrap to extra [] to avoid flattening 12 | // return [entry]; 13 | // }), 14 | // ); 15 | 16 | export default defineConfig({ 17 | root: __dirname + "/e2e", 18 | server: { 19 | port: 1934, 20 | strictPort: true, 21 | }, 22 | build: { 23 | outDir: __dirname + "/esm", 24 | emptyOutDir: true, 25 | minify: false, 26 | target: "es2020", 27 | sourcemap: true, 28 | rollupOptions: { 29 | external: ["react", "react-dom", "zod"], 30 | }, 31 | lib: { 32 | entry: __dirname + "/src/index.tsx", 33 | formats: ["es"], 34 | }, 35 | }, 36 | }); 37 | -------------------------------------------------------------------------------- /packages/remix-example/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@remix-run/eslint-config", "@remix-run/eslint-config/node"] 3 | } 4 | -------------------------------------------------------------------------------- /packages/remix-example/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | .cache 4 | .env 5 | .vercel 6 | .output 7 | 8 | /build/ 9 | /public/build 10 | /api/index.js 11 | /api/index.js.map 12 | -------------------------------------------------------------------------------- /packages/remix-example/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "tabWidth": 4 4 | } 5 | -------------------------------------------------------------------------------- /packages/remix-example/README.md: -------------------------------------------------------------------------------- 1 | # React Zorm demo with Remix 2 | 3 | Running at 4 | 5 | Hacking 6 | 7 | ``` 8 | git clone https://github.com/esamattis/react-zorm.git 9 | cd packages/remix-example 10 | pnpm install 11 | pnpm run dev 12 | ``` 13 | 14 | npm should work too if don't have [pnpm](https://pnpm.io/) installed. 15 | -------------------------------------------------------------------------------- /packages/remix-example/app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | import { RemixBrowser } from "@remix-run/react"; 2 | import { hydrate } from "react-dom"; 3 | 4 | hydrate(, document); 5 | -------------------------------------------------------------------------------- /packages/remix-example/app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | import type { EntryContext } from "@remix-run/node"; 2 | import { RemixServer } from "@remix-run/react"; 3 | import { renderToString } from "react-dom/server"; 4 | 5 | export default function handleRequest( 6 | request: Request, 7 | responseStatusCode: number, 8 | responseHeaders: Headers, 9 | remixContext: EntryContext 10 | ) { 11 | let markup = renderToString( 12 | 13 | ); 14 | 15 | responseHeaders.set("Content-Type", "text/html"); 16 | 17 | return new Response("" + markup, { 18 | status: responseStatusCode, 19 | headers: responseHeaders, 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /packages/remix-example/app/root.tsx: -------------------------------------------------------------------------------- 1 | import type { MetaFunction } from "@remix-run/node"; 2 | import { 3 | Links, 4 | LiveReload, 5 | Meta, 6 | Outlet, 7 | Scripts, 8 | ScrollRestoration, 9 | } from "@remix-run/react"; 10 | 11 | import styles from "./styles.css"; 12 | 13 | export const meta: MetaFunction = () => ({ 14 | charset: "utf-8", 15 | title: "React Zorm Examples in Remix", 16 | viewport: "width=device-width,initial-scale=1", 17 | }); 18 | 19 | export function links() { 20 | return [{ rel: "stylesheet", href: styles }]; 21 | } 22 | 23 | export default function App() { 24 | return ( 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /packages/remix-example/app/routes/index.tsx: -------------------------------------------------------------------------------- 1 | export default function Index() { 2 | return ( 3 |

6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /packages/remix-example/app/routes/server-side-validation.tsx: -------------------------------------------------------------------------------- 1 | // This code is live at https://react-zorm.vercel.app/server-side-validation 2 | import { Form, useActionData, useNavigation } from "@remix-run/react"; 3 | import { z } from "zod"; 4 | import { useZorm, parseForm, createCustomIssues } from "react-zorm"; 5 | import type { ActionArgs } from "@remix-run/node"; 6 | import { json } from "@remix-run/node"; 7 | 8 | /** 9 | * Handle checkbox as boolean 10 | */ 11 | const booleanCheckbox = () => 12 | z 13 | .string() 14 | // Unchecked checkbox is just missing so it must be optional 15 | .optional() 16 | // Transform the value to boolean 17 | .transform(Boolean); 18 | 19 | /** 20 | * The form schema 21 | */ 22 | const SignupSchema = z.object({ 23 | email: z.string().email(), 24 | password: z.string().min(5), 25 | terms: booleanCheckbox().refine((value) => value === true, { 26 | message: "You must agree!", 27 | }), 28 | }); 29 | 30 | /** 31 | * The form route 32 | */ 33 | export default function ZormFormExample() { 34 | /** 35 | * The form response or undefined when the form is not submitted yet 36 | */ 37 | const formResponse = useActionData(); 38 | 39 | const zo = useZorm("signup", SignupSchema, { 40 | // Pass server issues to Zorm as custom issues. Zorm will handle them 41 | // like any other Zod issues 42 | customIssues: formResponse?.serverIssues, 43 | }); 44 | 45 | const submitting = useNavigation().state === "submitting"; 46 | 47 | return ( 48 |
49 |
50 |
51 | Signup 52 |
53 | Email: 54 | 59 | {zo.errors.email((err) => ( 60 | // This will render client-side errors as well as 61 | // the server-side issues that where assigned to the 62 | // "email" field 63 | {err.message} 64 | ))} 65 |
66 | 67 |
68 | Password: 69 | 74 | {zo.errors.password((err) => ( 75 | {err.message} 76 | ))} 77 |
78 | 79 |
80 | 86 | 89 | {zo.errors.terms((err) => ( 90 | {err.message} 91 | ))} 92 |
93 | 94 | 97 | 98 | {formResponse?.ok ? ( 99 |
User created!
100 | ) : null} 101 |
102 |
103 |
104 | The exists@test.invalid email is validated to be reserved on the 105 | server. Just submit the form the see it in action. Checkout the 106 | devtools network tab and the source of this{" "} 107 | 108 | here 109 | 110 | . 111 |
112 |
113 | ); 114 | } 115 | 116 | export async function action({ request }: ActionArgs) { 117 | // Read the form data and parse it with Zorm's parseForm() helper 118 | const form = await request.formData(); 119 | const data = parseForm(SignupSchema, form); 120 | 121 | const issues = createCustomIssues(SignupSchema); 122 | 123 | console.log("Validating..."); 124 | // Simulate slower database/network connection 125 | await new Promise((r) => setTimeout(r, 1000)); 126 | 127 | // In reality you would make a real database check here or capture a 128 | // constraint error from user insertion 129 | if (data.email === "exists@test.invalid") { 130 | // Add an issue the email field. This generates a ZodCustomIssue 131 | issues.email("Account already exists with " + data.email, { 132 | anything: "Any extra params you want to pass to ZodCustomIssue", 133 | }); 134 | } 135 | 136 | // Respond with the issues if we have any 137 | if (issues.hasIssues()) { 138 | return json( 139 | { ok: false, serverIssues: issues.toArray() }, 140 | { status: 400 }, 141 | ); 142 | } 143 | 144 | console.log("Form ok. Saving..."); 145 | 146 | return json({ ok: true, serverIssues: [] }); 147 | } 148 | 149 | function Err(props: { children: string }) { 150 | return
{props.children}
; 151 | } 152 | -------------------------------------------------------------------------------- /packages/remix-example/app/styles.css: -------------------------------------------------------------------------------- 1 | input[type="text"], 2 | input[type="email"], 3 | input[type="password"], 4 | button { 5 | display: block; 6 | } 7 | 8 | input[type="checkbox"] { 9 | margin-top: 1rem; 10 | } 11 | 12 | button { 13 | margin-top: 1rem; 14 | } 15 | 16 | .error { 17 | color: red; 18 | } 19 | 20 | .ok { 21 | color: rgb(153, 255, 0); 22 | margin-top: 2rem; 23 | font-size: 20pt; 24 | } 25 | 26 | footer { 27 | max-width: 30rem; 28 | padding: 1rem; 29 | color: gray; 30 | } 31 | -------------------------------------------------------------------------------- /packages/remix-example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "remix-template-vercel", 3 | "private": true, 4 | "description": "", 5 | "license": "", 6 | "sideEffects": false, 7 | "scripts": { 8 | "build": "remix setup node && remix build", 9 | "postinstall": "remix setup node", 10 | "dev": "remix dev" 11 | }, 12 | "dependencies": { 13 | "@remix-run/node": "^1.14.3", 14 | "@remix-run/react": "^1.14.3", 15 | "@remix-run/vercel": "^1.14.3", 16 | "@vercel/node": "^1.15.4", 17 | "prettier": "2.5.1", 18 | "react": "18.2.0", 19 | "react-dom": "18.2.0", 20 | "react-zorm": "0.7.0", 21 | "remix": "^1.14.3", 22 | "zod": "3.21.4" 23 | }, 24 | "devDependencies": { 25 | "@remix-run/dev": "^1.14.3", 26 | "@remix-run/eslint-config": "^1.14.3", 27 | "@remix-run/serve": "^1.14.3", 28 | "@types/react": "18.0.21", 29 | "@types/react-dom": "18.0.6", 30 | "eslint": "^8.36.0", 31 | "typescript": "4.5.5" 32 | }, 33 | "engines": { 34 | "node": ">=14" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /packages/remix-example/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/esamattis/react-zorm/7364171d8eea12e38371762241f251f7547452db/packages/remix-example/public/favicon.ico -------------------------------------------------------------------------------- /packages/remix-example/remix.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('@remix-run/dev').AppConfig} 3 | */ 4 | module.exports = { 5 | serverBuildTarget: "vercel", 6 | // When running locally in development mode, we use the built in remix 7 | // server. This does not understand the vercel lambda module format, 8 | // so we default back to the standard build output. 9 | server: process.env.NODE_ENV === "development" ? undefined : "./server.js", 10 | ignoredRouteFiles: [".*"], 11 | // appDirectory: "app", 12 | // assetsBuildDirectory: "public/build", 13 | // serverBuildPath: "api/index.js", 14 | // publicPath: "/build/", 15 | }; 16 | -------------------------------------------------------------------------------- /packages/remix-example/remix.env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /packages/remix-example/server.js: -------------------------------------------------------------------------------- 1 | import { createRequestHandler } from "@remix-run/vercel"; 2 | import * as build from "@remix-run/dev/server-build"; 3 | 4 | export default createRequestHandler({ build, mode: process.env.NODE_ENV }); 5 | -------------------------------------------------------------------------------- /packages/remix-example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"], 3 | "compilerOptions": { 4 | "lib": ["DOM", "DOM.Iterable", "ES2019"], 5 | "isolatedModules": true, 6 | "esModuleInterop": true, 7 | "jsx": "react-jsx", 8 | "moduleResolution": "node", 9 | "resolveJsonModule": true, 10 | "target": "ES2019", 11 | "strict": true, 12 | "baseUrl": ".", 13 | "paths": { 14 | "~/*": ["./app/*"] 15 | }, 16 | 17 | // Remix takes care of building everything in `remix build`. 18 | "noEmit": true 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/remix-example/vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "build": { 3 | "env": { 4 | "ENABLE_FILE_SYSTEM_API": "1" 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'packages/*' -------------------------------------------------------------------------------- /react-zorm.svg: -------------------------------------------------------------------------------- 1 | --------------------------------------------------------------------------------