Skip to content

Commit

Permalink
feat: set field errors from the form validators (#656)
Browse files Browse the repository at this point in the history
* Set field errors from the form's validators

* Fix some failing tests

* Fix a test (comment to be added later)

* Fix another test

* Shameful new line

* Update "pnpm-lock.yaml" after the rebase again

* ci: apply automated fixes

* Fix the sherif tests

* Update "pnpm-lock.yaml" after the rebase again

* Fix some type errors

* Fix a failing test after the rebase

* Update the field-errors-from-form-validators examples

* Reorganize the code a bit

* Update the docs

* Update pnpm-lock.yaml

* Rebase again

* ci: apply automated fixes

* Remove the doc pages that will be autogenerated

* Clean up the code

* Clean up the code around fake timers

* Clean up the tests

* Update the FieldApi tests

* Update the field-errors-from-form-validators example

* Fix an example in the docs

* chore: fix minor issues with demo

* docs: update docs

* chore: fix pnpm

* ci: apply automated fixes

* chore: fix sherif

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Corbin Crutchley <[email protected]>
  • Loading branch information
3 people authored Aug 27, 2024
1 parent 7c1d2a8 commit de60d5d
Show file tree
Hide file tree
Showing 16 changed files with 1,235 additions and 73 deletions.
103 changes: 103 additions & 0 deletions docs/framework/react/guides/validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,109 @@ export default function App() {
}
```

### Setting field-level errors from the form's validators

You can set errors on the fields from the form's validators. One common use case for this is validating all the fields on submit by calling a single API endpoint in the form's `onSubmitAsync` validator.

```tsx
export default function App() {
const form = useForm({
defaultValues: {
age: 0,
},
validators: {
onSubmitAsync: async ({ value }) => {
// Verify the age on the server
const isOlderThan13 = await verifyAgeOnServer(value.age)
if (!isOlderThan13) {
return {
form: 'Invalid data', // The `form` key is optional
fields: {
age: 'Must be 13 or older to sign',
},
}
}

return null
},
},
})

return (
<div>
<form
onSubmit={(e) => {
e.preventDefault()
e.stopPropagation()
void form.handleSubmit()
}}
>
<form.Field name="age">
{(field) => (
<>
<label htmlFor={field.name}>Age:</label>
<input
id={field.name}
name={field.name}
value={field.state.value}
type="number"
onChange={(e) => field.handleChange(e.target.valueAsNumber)}
/>
{field.state.meta.errors ? (
<em role="alert">{field.state.meta.errors.join(', ')}</em>
) : null}
</>
)}
</form.Field>
<form.Subscribe
selector={(state) => [state.errorMap]}
children={([errorMap]) =>
errorMap.onSubmit ? (
<div>
<em>There was an error on the form: {errorMap.onSubmit}</em>
</div>
) : null
}
/>
{/*...*/}
</form>
</div>
)
}
```

> Something worth mentioning is that if you have a form validation function that returns an error, that error may be overwritten by the field-specific validation.
>
> This means that:
>
> ```jsx
> const form = useForm({
> defaultValues: {
> age: 0,
> },
> validators: {
> onChange: ({ value }) => {
> return {
> fields: {
> age: value.age < 12 ? 'Too young!' : undefined,
> },
> }
> },
> },
> })
>
> // ...
>
> return <form.Field
> name="age"
> validators={{
> onChange: ({ value }) => value % 2 === 0 ? 'Must be odd!' : undefined,
> }}
> />
> ```
>
> Will only show `'Must be odd!` even if the 'Too young!' error is returned by the form-level validation.
## Asynchronous Functional Validation
While we suspect most validations will be synchronous, there are many instances where a network call or some other async operation would be useful to validate against.
Expand Down
11 changes: 11 additions & 0 deletions examples/react/field-errors-from-form-validators/.eslintrc.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// @ts-check

/** @type {import('eslint').Linter.Config} */
const config = {
extends: ['plugin:react/recommended', 'plugin:react-hooks/recommended'],
rules: {
'react/no-children-prop': 'off',
},
}

module.exports = config
27 changes: 27 additions & 0 deletions examples/react/field-errors-from-form-validators/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.

# dependencies
/node_modules
/.pnp
.pnp.js

# testing
/coverage

# production
/build

pnpm-lock.yaml
yarn.lock
package-lock.json

# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local

npm-debug.log*
yarn-debug.log*
yarn-error.log*
6 changes: 6 additions & 0 deletions examples/react/field-errors-from-form-validators/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Example

To run this example:

- `npm install`
- `npm run dev`
18 changes: 18 additions & 0 deletions examples/react/field-errors-from-form-validators/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" type="image/svg+xml" href="/emblem-light.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />

<title>
TanStack Form React Field Errors From Form Validators Example App
</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>
34 changes: 34 additions & 0 deletions examples/react/field-errors-from-form-validators/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"name": "@tanstack/field-errors-from-form-validators",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --port=3001",
"build": "vite build",
"preview": "vite preview",
"test:types": "tsc"
},
"dependencies": {
"@tanstack/react-form": "^0.29.2",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"vite": "^5.4.2"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
134 changes: 134 additions & 0 deletions examples/react/field-errors-from-form-validators/src/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { useForm } from '@tanstack/react-form'
import * as React from 'react'
import { createRoot } from 'react-dom/client'

async function sleep(ms: number) {
return new Promise((resolve) => {
setTimeout(resolve, ms)
})
}

async function verifyAgeOnServer(age: number) {
await sleep(Math.floor(Math.random() * 1000))
return age <= 13
}

async function checkIfUsernameIsTaken(name: string) {
await sleep(Math.floor(Math.random() * 500))
const usernames = ['user-1', 'user-2', 'user-3']
return !usernames.includes(name)
}

export default function App() {
const form = useForm({
defaultValues: {
username: '',
age: 0,
},
validators: {
onSubmitAsync: async ({ value }) => {
const [isRightAge, isUsernameAvailable] = await Promise.all([
// Verify the age on the server
verifyAgeOnServer(value.age),
// Verify the availability of the username on the server
checkIfUsernameIsTaken(value.username),
])

if (!isRightAge || !isUsernameAvailable) {
return {
// The `form` key is optional
form: 'Invalid data',
fields: {
...(!isRightAge ? { age: 'Must be 13 or older to sign' } : {}),
...(!isUsernameAvailable
? { username: 'Username is taken' }
: {}),
},
}
}

return null
},
},
})

return (
<div>
<h1>Field Errors From The Form's validators Example</h1>
<form
onSubmit={(e) => {
e.preventDefault()
e.stopPropagation()
void form.handleSubmit()
}}
>
<form.Field
name="username"
validators={{
onSubmit: ({ value }) => (!value ? 'Required field' : null),
}}
children={(field) => (
<div>
<label htmlFor={field.name}>Username:</label>
<input
id={field.name}
name={field.name}
value={field.state.value}
onChange={(e) => {
field.handleChange(e.target.value)
}}
/>
{field.state.meta.errors.length > 0 ? (
<em role="alert">{field.state.meta.errors.join(', ')}</em>
) : null}
</div>
)}
/>

<form.Field
name="age"
validators={{
onSubmit: ({ value }) => (!value ? 'Required field' : null),
}}
children={(field) => (
<div>
<label htmlFor={field.name}>Age:</label>
<input
id={field.name}
name={field.name}
value={field.state.value}
type="number"
onChange={(e) => field.handleChange(e.target.valueAsNumber)}
/>
{field.state.meta.errors.length > 0 ? (
<em role="alert">{field.state.meta.errors.join(', ')}</em>
) : null}
</div>
)}
/>
<form.Subscribe
selector={(state) => [state.errorMap]}
children={([errorMap]) =>
errorMap.onSubmit ? (
<div>
<em>There was an error on the form: {errorMap.onSubmit}</em>
</div>
) : null
}
/>
<form.Subscribe
selector={(state) => [state.canSubmit, state.isSubmitting]}
children={([canSubmit, isSubmitting]) => (
<button type="submit" disabled={!canSubmit}>
{isSubmitting ? '...' : 'Submit'}
</button>
)}
/>
</form>
</div>
)
}

const rootElement = document.getElementById('root')!

createRoot(rootElement).render(<App />)
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"compilerOptions": {
"jsx": "react",
"noEmit": true,
"strict": true,
"esModuleInterop": true,
"lib": ["DOM", "DOM.Iterable", "ES2020"]
}
}
Loading

0 comments on commit de60d5d

Please sign in to comment.