diff --git a/examples/preact/multi-step-wizard/README.md b/examples/preact/multi-step-wizard/README.md new file mode 100644 index 000000000..dec170278 --- /dev/null +++ b/examples/preact/multi-step-wizard/README.md @@ -0,0 +1,6 @@ +# Example + +To run this example: + +- `pnpm install` +- `pnpm --filter @tanstack/form-example-preact-simple dev` diff --git a/examples/preact/multi-step-wizard/index.html b/examples/preact/multi-step-wizard/index.html new file mode 100644 index 000000000..2b9d36aed --- /dev/null +++ b/examples/preact/multi-step-wizard/index.html @@ -0,0 +1,12 @@ + + + + + + TanStack Form Preact Simple Example App + + +
+ + + diff --git a/examples/preact/multi-step-wizard/package.json b/examples/preact/multi-step-wizard/package.json new file mode 100644 index 000000000..4e4e91138 --- /dev/null +++ b/examples/preact/multi-step-wizard/package.json @@ -0,0 +1,32 @@ +{ + "name": "@tanstack/form-example-preact-multi-step-wizard", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --port=3001", + "build": "vite build", + "preview": "vite preview", + "test:types": "tsc" + }, + "dependencies": { + "@tanstack/preact-form": "^1.29.4", + "preact": "^10.26.4", + "zod": "^3.25.76" + }, + "devDependencies": { + "@preact/preset-vite": "^2.10.2", + "vite": "^7.2.2" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + } +} diff --git a/examples/preact/multi-step-wizard/src/App.tsx b/examples/preact/multi-step-wizard/src/App.tsx new file mode 100644 index 000000000..22fdb05ea --- /dev/null +++ b/examples/preact/multi-step-wizard/src/App.tsx @@ -0,0 +1,5 @@ +import { WizardPage } from './features/wizard/page' + +export default function App() { + return +} diff --git a/examples/preact/multi-step-wizard/src/components/text-fields.tsx b/examples/preact/multi-step-wizard/src/components/text-fields.tsx new file mode 100644 index 000000000..0b0409a95 --- /dev/null +++ b/examples/preact/multi-step-wizard/src/components/text-fields.tsx @@ -0,0 +1,26 @@ +import { useStore } from '@tanstack/preact-form' +import { useFieldContext } from '../hooks/form-context' + +export function TextField({ label }: { label: string }) { + const field = useFieldContext() + + const errors = useStore(field.store, (state) => state.meta.errors) + + return ( +
+ + {errors.map((error: { message: string }) => ( +
+ {error.message} +
+ ))} +
+ ) +} diff --git a/examples/preact/multi-step-wizard/src/features/wizard/page.tsx b/examples/preact/multi-step-wizard/src/features/wizard/page.tsx new file mode 100644 index 000000000..8edb81b73 --- /dev/null +++ b/examples/preact/multi-step-wizard/src/features/wizard/page.tsx @@ -0,0 +1,34 @@ +import { revalidateLogic } from '@tanstack/preact-form' +import { z } from 'zod' +import { useState } from 'preact/hooks' +import { useAppForm } from '../../hooks/form' +import { step1Schema, step2Schema, wizardFormOpts } from './shared-form' +import { Step2Form } from './step2-subform' +import { Step1Form } from './step1-subform' + +export const WizardPage = () => { + const [step, setStep] = useState(0) + const form = useAppForm({ + ...wizardFormOpts, + validationLogic: revalidateLogic(), + validators: { + // onDynamic is only used when `form.handleSubmit` is called itself. + // When `form.FormGroup`'s `handleSubmit` is called, it will only validate the current step's schema. + // This means that this schema will not be called when the user submits the form group, but instead when they submit the entire form. + onDynamic: z.object({ + step1: step1Schema, + step2: step2Schema, + }), + }, + onSubmit: ({ value }) => { + alert(`Form submitted: ${JSON.stringify(value)}`) + }, + }) + + return ( + <> + {step === 0 && } + {step === 1 && } + + ) +} diff --git a/examples/preact/multi-step-wizard/src/features/wizard/shared-form.tsx b/examples/preact/multi-step-wizard/src/features/wizard/shared-form.tsx new file mode 100644 index 000000000..6c8923517 --- /dev/null +++ b/examples/preact/multi-step-wizard/src/features/wizard/shared-form.tsx @@ -0,0 +1,21 @@ +import { formOptions } from '@tanstack/preact-form' +import z from 'zod' + +export const step1Schema = z.object({ + name: z.string().min(2, 'Name must be at least 2 characters'), +}) + +export const step2Schema = z.object({ + name: z.string().min(3, 'Name must be at least 3 characters'), +}) + +export const wizardFormOpts = formOptions({ + defaultValues: { + step1: { + name: '', + }, + step2: { + name: '', + }, + }, +}) diff --git a/examples/preact/multi-step-wizard/src/features/wizard/step1-subform.tsx b/examples/preact/multi-step-wizard/src/features/wizard/step1-subform.tsx new file mode 100644 index 000000000..2eb2e04c1 --- /dev/null +++ b/examples/preact/multi-step-wizard/src/features/wizard/step1-subform.tsx @@ -0,0 +1,46 @@ +import { withForm } from '../../hooks/form' +import { step1Schema, wizardFormOpts } from './shared-form' + +export const Step1Form = withForm({ + ...wizardFormOpts, + props: { + step: 0, + setStep: (_step: number) => {}, + }, + render: function Render({ form, step, setStep }) { + return ( + { + setStep(step + 1) + }} + onGroupSubmitInvalid={() => { + // Just like a form, you can also handle invalid submits at the group level, which is useful for multi-step wizards to prevent going to the next step if the current step is invalid + }} + > + {(formGroup) => ( +
{ + e.preventDefault() + e.stopPropagation() + formGroup.handleSubmit() + }} + > + + {(field) => } + + + + + + {/* formGroup contains errorMaps and errors, just like forms and fields */} +
{JSON.stringify(formGroup.state.meta.errorMap, null, 2)}
+
+ )} +
+ ) + }, +}) diff --git a/examples/preact/multi-step-wizard/src/features/wizard/step2-subform.tsx b/examples/preact/multi-step-wizard/src/features/wizard/step2-subform.tsx new file mode 100644 index 000000000..5e95c42c9 --- /dev/null +++ b/examples/preact/multi-step-wizard/src/features/wizard/step2-subform.tsx @@ -0,0 +1,42 @@ +import { withForm } from '../../hooks/form' +import { step2Schema, wizardFormOpts } from './shared-form' + +export const Step2Form = withForm({ + ...wizardFormOpts, + props: { + step: 1, + setStep: (_step: number) => {}, + }, + render: function Render({ form, step, setStep }) { + return ( + { + form.handleSubmit() + }} + > + {(formGroup) => ( +
{ + e.preventDefault() + e.stopPropagation() + formGroup.handleSubmit() + }} + > + + {(field) => } + + + + + + +
+ )} +
+ ) + }, +}) diff --git a/examples/preact/multi-step-wizard/src/hooks/form-context.tsx b/examples/preact/multi-step-wizard/src/hooks/form-context.tsx new file mode 100644 index 000000000..459a1ab26 --- /dev/null +++ b/examples/preact/multi-step-wizard/src/hooks/form-context.tsx @@ -0,0 +1,4 @@ +import { createFormHookContexts } from '@tanstack/preact-form' + +export const { fieldContext, useFieldContext, formContext, useFormContext } = + createFormHookContexts() diff --git a/examples/preact/multi-step-wizard/src/hooks/form.tsx b/examples/preact/multi-step-wizard/src/hooks/form.tsx new file mode 100644 index 000000000..5a5a4e75e --- /dev/null +++ b/examples/preact/multi-step-wizard/src/hooks/form.tsx @@ -0,0 +1,27 @@ +import { createFormHook } from '@tanstack/preact-form' +import { TextField } from '../components/text-fields' +import { fieldContext, formContext, useFormContext } from './form-context' + +function SubscribeButton({ label }: { label: string }) { + const form = useFormContext() + return ( + state.isSubmitting}> + {(isSubmitting) => ( + + )} + + ) +} + +export const { useAppForm, withForm, withFieldGroup } = createFormHook({ + fieldComponents: { + TextField, + }, + formComponents: { + SubscribeButton, + }, + fieldContext, + formContext, +}) diff --git a/examples/preact/multi-step-wizard/src/index.tsx b/examples/preact/multi-step-wizard/src/index.tsx new file mode 100644 index 000000000..f324b5c1b --- /dev/null +++ b/examples/preact/multi-step-wizard/src/index.tsx @@ -0,0 +1,10 @@ +import { render } from 'preact' +import App from './App' + +const rootElement = document.getElementById('root') + +if (!rootElement) { + throw new Error('Root element not found') +} + +render(, rootElement) diff --git a/examples/preact/multi-step-wizard/tsconfig.json b/examples/preact/multi-step-wizard/tsconfig.json new file mode 100644 index 000000000..9dfa26665 --- /dev/null +++ b/examples/preact/multi-step-wizard/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "preact", + "moduleResolution": "Bundler" + }, + "include": ["src", "vite.config.ts"] +} diff --git a/examples/preact/multi-step-wizard/vite.config.ts b/examples/preact/multi-step-wizard/vite.config.ts new file mode 100644 index 000000000..bfe110c05 --- /dev/null +++ b/examples/preact/multi-step-wizard/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from 'vite' +import preact from '@preact/preset-vite' + +export default defineConfig({ + plugins: [preact()], +}) diff --git a/examples/react/multi-step-wizard/.eslintrc.cjs b/examples/react/multi-step-wizard/.eslintrc.cjs new file mode 100644 index 000000000..35853b617 --- /dev/null +++ b/examples/react/multi-step-wizard/.eslintrc.cjs @@ -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 diff --git a/examples/react/multi-step-wizard/.gitignore b/examples/react/multi-step-wizard/.gitignore new file mode 100644 index 000000000..4673b022e --- /dev/null +++ b/examples/react/multi-step-wizard/.gitignore @@ -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* diff --git a/examples/react/multi-step-wizard/README.md b/examples/react/multi-step-wizard/README.md new file mode 100644 index 000000000..1cf889265 --- /dev/null +++ b/examples/react/multi-step-wizard/README.md @@ -0,0 +1,6 @@ +# Example + +To run this example: + +- `npm install` +- `npm run dev` diff --git a/examples/react/multi-step-wizard/index.html b/examples/react/multi-step-wizard/index.html new file mode 100644 index 000000000..5d0e76cd4 --- /dev/null +++ b/examples/react/multi-step-wizard/index.html @@ -0,0 +1,16 @@ + + + + + + + + + TanStack Form React Simple Example App + + + +
+ + + diff --git a/examples/react/multi-step-wizard/package.json b/examples/react/multi-step-wizard/package.json new file mode 100644 index 000000000..0d3539e26 --- /dev/null +++ b/examples/react/multi-step-wizard/package.json @@ -0,0 +1,37 @@ +{ + "name": "@tanstack/form-example-react-multi-step-wizard", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --port=3001", + "build": "vite build", + "preview": "vite preview", + "test:types": "tsc" + }, + "dependencies": { + "@tanstack/react-form": "^1.29.3", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "zod": "^3.25.76" + }, + "devDependencies": { + "@tanstack/react-devtools": "^0.9.7", + "@tanstack/react-form-devtools": "^0.2.24", + "@types/react": "^19.0.7", + "@types/react-dom": "^19.0.3", + "@vitejs/plugin-react": "^5.1.1", + "vite": "^7.2.2" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + } +} diff --git a/examples/react/multi-step-wizard/public/emblem-light.svg b/examples/react/multi-step-wizard/public/emblem-light.svg new file mode 100644 index 000000000..a58e69ad5 --- /dev/null +++ b/examples/react/multi-step-wizard/public/emblem-light.svg @@ -0,0 +1,13 @@ + + + + emblem-light + Created with Sketch. + + + + + + + + \ No newline at end of file diff --git a/examples/react/multi-step-wizard/src/App.tsx b/examples/react/multi-step-wizard/src/App.tsx new file mode 100644 index 000000000..9b047365d --- /dev/null +++ b/examples/react/multi-step-wizard/src/App.tsx @@ -0,0 +1,5 @@ +import { WizardPage } from './features/wizard/page.tsx' + +export default function App() { + return +} diff --git a/examples/react/multi-step-wizard/src/components/text-fields.tsx b/examples/react/multi-step-wizard/src/components/text-fields.tsx new file mode 100644 index 000000000..de74aaad6 --- /dev/null +++ b/examples/react/multi-step-wizard/src/components/text-fields.tsx @@ -0,0 +1,26 @@ +import { useStore } from '@tanstack/react-form' +import { useFieldContext } from '../hooks/form-context.tsx' + +export function TextField({ label }: { label: string }) { + const field = useFieldContext() + + const errors = useStore(field.store, (state) => state.meta.errors) + + return ( +
+ + {errors.map((error: { message: string }) => ( +
+ {error.message} +
+ ))} +
+ ) +} diff --git a/examples/react/multi-step-wizard/src/features/wizard/page.tsx b/examples/react/multi-step-wizard/src/features/wizard/page.tsx new file mode 100644 index 000000000..1bf726928 --- /dev/null +++ b/examples/react/multi-step-wizard/src/features/wizard/page.tsx @@ -0,0 +1,34 @@ +import { revalidateLogic } from '@tanstack/react-form' +import { useAppForm } from '../../hooks/form.tsx' +import { step1Schema, step2Schema, wizardFormOpts } from './shared-form.tsx' +import { z } from 'zod' +import { Step2Form } from './step2-subform.tsx' +import { useState } from 'react' +import { Step1Form } from './step1-subform.tsx' + +export const WizardPage = () => { + const [step, setStep] = useState(0) + const form = useAppForm({ + ...wizardFormOpts, + validationLogic: revalidateLogic(), + validators: { + // onDynamic is only used when `form.handleSubmit` is called itself. + // When `form.FormGroup`'s `handleSubmit` is called, it will only validate the current step's schema. + // This means that this schema will not be called when the user submits the form group, but instead when they submit the entire form. + onDynamic: z.object({ + step1: step1Schema, + step2: step2Schema, + }), + }, + onSubmit: ({ value }) => { + alert(`Form submitted: ${JSON.stringify(value)}`) + }, + }) + + return ( + <> + {step === 0 && } + {step === 1 && } + + ) +} diff --git a/examples/react/multi-step-wizard/src/features/wizard/shared-form.tsx b/examples/react/multi-step-wizard/src/features/wizard/shared-form.tsx new file mode 100644 index 000000000..6f059c02b --- /dev/null +++ b/examples/react/multi-step-wizard/src/features/wizard/shared-form.tsx @@ -0,0 +1,21 @@ +import { formOptions } from '@tanstack/react-form' +import z from 'zod' + +export const step1Schema = z.object({ + name: z.string().min(2, 'Name must be at least 2 characters'), +}) + +export const step2Schema = z.object({ + name: z.string().min(3, 'Name must be at least 3 characters'), +}) + +export const wizardFormOpts = formOptions({ + defaultValues: { + step1: { + name: '', + }, + step2: { + name: '', + }, + }, +}) diff --git a/examples/react/multi-step-wizard/src/features/wizard/step1-subform.tsx b/examples/react/multi-step-wizard/src/features/wizard/step1-subform.tsx new file mode 100644 index 000000000..2eb2e04c1 --- /dev/null +++ b/examples/react/multi-step-wizard/src/features/wizard/step1-subform.tsx @@ -0,0 +1,46 @@ +import { withForm } from '../../hooks/form' +import { step1Schema, wizardFormOpts } from './shared-form' + +export const Step1Form = withForm({ + ...wizardFormOpts, + props: { + step: 0, + setStep: (_step: number) => {}, + }, + render: function Render({ form, step, setStep }) { + return ( + { + setStep(step + 1) + }} + onGroupSubmitInvalid={() => { + // Just like a form, you can also handle invalid submits at the group level, which is useful for multi-step wizards to prevent going to the next step if the current step is invalid + }} + > + {(formGroup) => ( +
{ + e.preventDefault() + e.stopPropagation() + formGroup.handleSubmit() + }} + > + + {(field) => } + + + + + + {/* formGroup contains errorMaps and errors, just like forms and fields */} +
{JSON.stringify(formGroup.state.meta.errorMap, null, 2)}
+
+ )} +
+ ) + }, +}) diff --git a/examples/react/multi-step-wizard/src/features/wizard/step2-subform.tsx b/examples/react/multi-step-wizard/src/features/wizard/step2-subform.tsx new file mode 100644 index 000000000..5e95c42c9 --- /dev/null +++ b/examples/react/multi-step-wizard/src/features/wizard/step2-subform.tsx @@ -0,0 +1,42 @@ +import { withForm } from '../../hooks/form' +import { step2Schema, wizardFormOpts } from './shared-form' + +export const Step2Form = withForm({ + ...wizardFormOpts, + props: { + step: 1, + setStep: (_step: number) => {}, + }, + render: function Render({ form, step, setStep }) { + return ( + { + form.handleSubmit() + }} + > + {(formGroup) => ( +
{ + e.preventDefault() + e.stopPropagation() + formGroup.handleSubmit() + }} + > + + {(field) => } + + + + + + +
+ )} +
+ ) + }, +}) diff --git a/examples/react/multi-step-wizard/src/hooks/form-context.tsx b/examples/react/multi-step-wizard/src/hooks/form-context.tsx new file mode 100644 index 000000000..1a6b376d7 --- /dev/null +++ b/examples/react/multi-step-wizard/src/hooks/form-context.tsx @@ -0,0 +1,4 @@ +import { createFormHookContexts } from '@tanstack/react-form' + +export const { fieldContext, useFieldContext, formContext, useFormContext } = + createFormHookContexts() diff --git a/examples/react/multi-step-wizard/src/hooks/form.tsx b/examples/react/multi-step-wizard/src/hooks/form.tsx new file mode 100644 index 000000000..4dbc203e8 --- /dev/null +++ b/examples/react/multi-step-wizard/src/hooks/form.tsx @@ -0,0 +1,27 @@ +import { createFormHook } from '@tanstack/react-form' +import { fieldContext, formContext, useFormContext } from './form-context.tsx' +import { TextField } from '../components/text-fields.tsx' + +function SubscribeButton({ label }: { label: string }) { + const form = useFormContext() + return ( + state.isSubmitting}> + {(isSubmitting) => ( + + )} + + ) +} + +export const { useAppForm, withForm, withFieldGroup } = createFormHook({ + fieldComponents: { + TextField, + }, + formComponents: { + SubscribeButton, + }, + fieldContext, + formContext, +}) diff --git a/examples/react/multi-step-wizard/src/index.tsx b/examples/react/multi-step-wizard/src/index.tsx new file mode 100644 index 000000000..ff73d4079 --- /dev/null +++ b/examples/react/multi-step-wizard/src/index.tsx @@ -0,0 +1,20 @@ +import * as React from 'react' +import { createRoot } from 'react-dom/client' + +import { TanStackDevtools } from '@tanstack/react-devtools' +import { formDevtoolsPlugin } from '@tanstack/react-form-devtools' + +import App from './App.tsx' + +const rootElement = document.getElementById('root')! + +createRoot(rootElement).render( + + + + + , +) diff --git a/examples/react/multi-step-wizard/tsconfig.json b/examples/react/multi-step-wizard/tsconfig.json new file mode 100644 index 000000000..22b43163b --- /dev/null +++ b/examples/react/multi-step-wizard/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ESNext", + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/examples/react/next-server-actions-zod/package.json b/examples/react/next-server-actions-zod/package.json index 85e1824e1..8b8f926b0 100644 --- a/examples/react/next-server-actions-zod/package.json +++ b/examples/react/next-server-actions-zod/package.json @@ -9,7 +9,7 @@ }, "dependencies": { "@tanstack/react-form-nextjs": "^1.29.3", - "@tanstack/react-store": "^0.9.1", + "@tanstack/react-store": "^0.11.0", "next": "16.0.5", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/examples/react/next-server-actions/package.json b/examples/react/next-server-actions/package.json index 1e1cc1f60..5eedb71dc 100644 --- a/examples/react/next-server-actions/package.json +++ b/examples/react/next-server-actions/package.json @@ -9,7 +9,7 @@ }, "dependencies": { "@tanstack/react-form-nextjs": "^1.29.3", - "@tanstack/react-store": "^0.9.1", + "@tanstack/react-store": "^0.11.0", "next": "16.0.5", "react": "^19.0.0", "react-dom": "^19.0.0" diff --git a/examples/react/remix/package.json b/examples/react/remix/package.json index b36b30325..3114bd584 100644 --- a/examples/react/remix/package.json +++ b/examples/react/remix/package.json @@ -12,7 +12,7 @@ "@remix-run/react": "^2.17.1", "@remix-run/serve": "^2.17.1", "@tanstack/react-form-remix": "^1.29.3", - "@tanstack/react-store": "^0.9.1", + "@tanstack/react-store": "^0.11.0", "isbot": "^5.1.30", "react": "^19.0.0", "react-dom": "^19.0.0" diff --git a/examples/react/tanstack-start/package.json b/examples/react/tanstack-start/package.json index e9c8ef9b0..87da653e8 100644 --- a/examples/react/tanstack-start/package.json +++ b/examples/react/tanstack-start/package.json @@ -12,7 +12,7 @@ "@tanstack/react-form-start": "^1.29.3", "@tanstack/react-router": "^1.134.9", "@tanstack/react-start": "^1.134.9", - "@tanstack/react-store": "^0.9.1", + "@tanstack/react-store": "^0.11.0", "react": "^19.0.0", "react-dom": "^19.0.0" }, diff --git a/examples/solid/multi-step-wizard/.gitignore b/examples/solid/multi-step-wizard/.gitignore new file mode 100644 index 000000000..a547bf36d --- /dev/null +++ b/examples/solid/multi-step-wizard/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/examples/solid/multi-step-wizard/README.md b/examples/solid/multi-step-wizard/README.md new file mode 100644 index 000000000..99613fc0a --- /dev/null +++ b/examples/solid/multi-step-wizard/README.md @@ -0,0 +1,28 @@ +## Usage + +```bash +$ npm install # or pnpm install or yarn install +``` + +### Learn more on the [Solid Website](https://solidjs.com) and come chat with us on our [Discord](https://discord.com/invite/solidjs) + +## Available Scripts + +In the project directory, you can run: + +### `npm run dev` + +Runs the app in the development mode.
+Open [http://localhost:5173](http://localhost:5173) to view it in the browser. + +### `npm run build` + +Builds the app for production to the `dist` folder.
+It correctly bundles Solid in production mode and optimizes the build for the best performance. + +The build is minified and the filenames include the hashes.
+Your app is ready to be deployed! + +## Deployment + +Learn more about deploying your application with the [documentations](https://vitejs.dev/guide/static-deploy.html) diff --git a/examples/solid/multi-step-wizard/index.html b/examples/solid/multi-step-wizard/index.html new file mode 100644 index 000000000..f49678fa5 --- /dev/null +++ b/examples/solid/multi-step-wizard/index.html @@ -0,0 +1,12 @@ + + + + + + TanStack Form Solid Simple Example App + + +
+ + + diff --git a/examples/solid/multi-step-wizard/package.json b/examples/solid/multi-step-wizard/package.json new file mode 100644 index 000000000..8507eab3c --- /dev/null +++ b/examples/solid/multi-step-wizard/package.json @@ -0,0 +1,21 @@ +{ + "name": "@tanstack/form-example-solid-multi-step-wizard", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "test:types": "tsc", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/solid-form": "^1.29.3", + "solid-js": "^1.9.9", + "zod": "^3.25.76" + }, + "devDependencies": { + "typescript": "5.8.2", + "vite": "^7.2.2", + "vite-plugin-solid": "^2.11.8" + } +} diff --git a/examples/solid/multi-step-wizard/src/components/text-fields.tsx b/examples/solid/multi-step-wizard/src/components/text-fields.tsx new file mode 100644 index 000000000..cf40ad96c --- /dev/null +++ b/examples/solid/multi-step-wizard/src/components/text-fields.tsx @@ -0,0 +1,24 @@ +import { For } from 'solid-js' +import { useStore } from '@tanstack/solid-form' +import { useFieldContext } from '../hooks/form-context.tsx' + +export function TextField(props: { label: string }) { + const field = useFieldContext() + + const errors = useStore(field().store, (state) => state.meta.errors) + + return ( +
+ + + {(error) =>
{error}
} +
+
+ ) +} diff --git a/examples/solid/multi-step-wizard/src/features/wizard/page.tsx b/examples/solid/multi-step-wizard/src/features/wizard/page.tsx new file mode 100644 index 000000000..312780b4e --- /dev/null +++ b/examples/solid/multi-step-wizard/src/features/wizard/page.tsx @@ -0,0 +1,38 @@ +import { createSignal, Show } from 'solid-js' +import { revalidateLogic } from '@tanstack/solid-form' +import { z } from 'zod' +import { useAppForm } from '../../hooks/form.tsx' +import { step1Schema, step2Schema, wizardFormOpts } from './shared-form.tsx' +import { Step1Form } from './step1-subform.tsx' +import { Step2Form } from './step2-subform.tsx' + +export const WizardPage = () => { + const [step, setStep] = createSignal(0) + const form = useAppForm(() => ({ + ...wizardFormOpts, + validationLogic: revalidateLogic(), + validators: { + // onDynamic is only used when `form.handleSubmit` is called itself. + // When `form.FormGroup`'s `handleSubmit` is called, it will only validate the current step's schema. + // This means that this schema will not be called when the user submits the form group, but instead when they submit the entire form. + onDynamic: z.object({ + step1: step1Schema, + step2: step2Schema, + }), + }, + onSubmit: ({ value }) => { + alert(`Form submitted: ${JSON.stringify(value)}`) + }, + })) + + return ( + <> + + + + + + + + ) +} diff --git a/examples/solid/multi-step-wizard/src/features/wizard/shared-form.tsx b/examples/solid/multi-step-wizard/src/features/wizard/shared-form.tsx new file mode 100644 index 000000000..513587bd4 --- /dev/null +++ b/examples/solid/multi-step-wizard/src/features/wizard/shared-form.tsx @@ -0,0 +1,21 @@ +import { formOptions } from '@tanstack/solid-form' +import z from 'zod' + +export const step1Schema = z.object({ + name: z.string().min(2, 'Name must be at least 2 characters'), +}) + +export const step2Schema = z.object({ + name: z.string().min(3, 'Name must be at least 3 characters'), +}) + +export const wizardFormOpts = formOptions({ + defaultValues: { + step1: { + name: '', + }, + step2: { + name: '', + }, + }, +}) diff --git a/examples/solid/multi-step-wizard/src/features/wizard/step1-subform.tsx b/examples/solid/multi-step-wizard/src/features/wizard/step1-subform.tsx new file mode 100644 index 000000000..15bf8a3f7 --- /dev/null +++ b/examples/solid/multi-step-wizard/src/features/wizard/step1-subform.tsx @@ -0,0 +1,48 @@ +import { withForm } from '../../hooks/form.tsx' +import { step1Schema, wizardFormOpts } from './shared-form.tsx' + +export const Step1Form = withForm({ + ...wizardFormOpts, + props: { + step: 0, + setStep: (_step: number) => {}, + }, + render: (props) => { + return ( + { + props.setStep(props.step + 1) + }} + onGroupSubmitInvalid={() => { + // Just like a form, you can also handle invalid submits at the group level, which is useful for multi-step wizards to prevent going to the next step if the current step is invalid + }} + > + {(formGroup) => ( +
{ + e.preventDefault() + e.stopPropagation() + formGroup().handleSubmit() + }} + > + + {(field) => } + + + + + + {/* formGroup contains errorMaps and errors, just like forms and fields */} +
+              {JSON.stringify(formGroup().state.meta.errorMap, null, 2)}
+            
+
+ )} +
+ ) + }, +}) diff --git a/examples/solid/multi-step-wizard/src/features/wizard/step2-subform.tsx b/examples/solid/multi-step-wizard/src/features/wizard/step2-subform.tsx new file mode 100644 index 000000000..6132c884c --- /dev/null +++ b/examples/solid/multi-step-wizard/src/features/wizard/step2-subform.tsx @@ -0,0 +1,42 @@ +import { withForm } from '../../hooks/form.tsx' +import { step2Schema, wizardFormOpts } from './shared-form.tsx' + +export const Step2Form = withForm({ + ...wizardFormOpts, + props: { + step: 1, + setStep: (_step: number) => {}, + }, + render: (props) => { + return ( + { + props.form.handleSubmit() + }} + > + {(formGroup) => ( +
{ + e.preventDefault() + e.stopPropagation() + formGroup().handleSubmit() + }} + > + + {(field) => } + + + + + + +
+ )} +
+ ) + }, +}) diff --git a/examples/solid/multi-step-wizard/src/hooks/form-context.tsx b/examples/solid/multi-step-wizard/src/hooks/form-context.tsx new file mode 100644 index 000000000..04455937a --- /dev/null +++ b/examples/solid/multi-step-wizard/src/hooks/form-context.tsx @@ -0,0 +1,4 @@ +import { createFormHookContexts } from '@tanstack/solid-form' + +export const { fieldContext, useFieldContext, formContext, useFormContext } = + createFormHookContexts() diff --git a/examples/solid/multi-step-wizard/src/hooks/form.tsx b/examples/solid/multi-step-wizard/src/hooks/form.tsx new file mode 100644 index 000000000..2f98ff59a --- /dev/null +++ b/examples/solid/multi-step-wizard/src/hooks/form.tsx @@ -0,0 +1,25 @@ +import { createFormHook } from '@tanstack/solid-form' +import { fieldContext, formContext, useFormContext } from './form-context.tsx' +import { TextField } from '../components/text-fields.tsx' + +function SubscribeButton(props: { label: string }) { + const form = useFormContext() + return ( + state.isSubmitting}> + {(isSubmitting) => ( + + )} + + ) +} + +export const { useAppForm, withForm, withFieldGroup } = createFormHook({ + fieldComponents: { + TextField, + }, + formComponents: { + SubscribeButton, + }, + fieldContext, + formContext, +}) diff --git a/examples/solid/multi-step-wizard/src/index.tsx b/examples/solid/multi-step-wizard/src/index.tsx new file mode 100644 index 000000000..88bb10c6c --- /dev/null +++ b/examples/solid/multi-step-wizard/src/index.tsx @@ -0,0 +1,11 @@ +/* @refresh reload */ +import { render } from 'solid-js/web' +import { WizardPage } from './features/wizard/page.tsx' + +function App() { + return +} + +const root = document.getElementById('root') + +render(() => , root!) diff --git a/examples/solid/multi-step-wizard/src/vite-env.d.ts b/examples/solid/multi-step-wizard/src/vite-env.d.ts new file mode 100644 index 000000000..11f02fe2a --- /dev/null +++ b/examples/solid/multi-step-wizard/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/solid/multi-step-wizard/tsconfig.json b/examples/solid/multi-step-wizard/tsconfig.json new file mode 100644 index 000000000..576a7f010 --- /dev/null +++ b/examples/solid/multi-step-wizard/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "preserve", + "jsxImportSource": "solid-js", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src", "vite.config.ts"] +} diff --git a/examples/solid/multi-step-wizard/vite.config.ts b/examples/solid/multi-step-wizard/vite.config.ts new file mode 100644 index 000000000..4095d9be5 --- /dev/null +++ b/examples/solid/multi-step-wizard/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from 'vite' +import solid from 'vite-plugin-solid' + +export default defineConfig({ + plugins: [solid()], +}) diff --git a/packages/angular-form/src/tanstack-field.ts b/packages/angular-form/src/tanstack-field.ts index 17a3b222f..55ab5cd2f 100644 --- a/packages/angular-form/src/tanstack-field.ts +++ b/packages/angular-form/src/tanstack-field.ts @@ -22,8 +22,8 @@ import { injectStore } from '@tanstack/angular-store' import type { DeepKeys, DeepValue, + FieldLikeMeta, FieldListeners, - FieldMeta, FieldValidators, FormAsyncValidateOrFn, FormValidateOrFn, @@ -117,7 +117,7 @@ export class TanStackField< defaultMeta = input< Partial< - FieldMeta< + FieldLikeMeta< TParentData, TName, TData, diff --git a/packages/form-core/package.json b/packages/form-core/package.json index 935958a93..82b5b0097 100644 --- a/packages/form-core/package.json +++ b/packages/form-core/package.json @@ -53,7 +53,7 @@ "dependencies": { "@tanstack/devtools-event-client": "^0.4.1", "@tanstack/pacer-lite": "^0.1.1", - "@tanstack/store": "^0.9.1" + "@tanstack/store": "^0.11.0" }, "devDependencies": { "arktype": "^2.1.22", diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts index 7d4a37b4c..e06f891fc 100644 --- a/packages/form-core/src/FieldApi.ts +++ b/packages/form-core/src/FieldApi.ts @@ -12,67 +12,32 @@ import { mergeOpts, } from './utils' import { defaultValidationLogic } from './ValidationLogic' -import type { ReadonlyStore } from '@tanstack/store' -import type { DeepKeys, DeepValue, UnwrapOneLevelOfArray } from './util-types' -import type { - StandardSchemaV1, - StandardSchemaV1Issue, - TStandardSchemaValidatorValue, -} from './standardSchemaValidator' +import type { AnyFormGroupApi } from './FormGroupApi' import type { + FieldErrorMapFromValidator, FieldInfo, - FormApi, - FormAsyncValidateOrFn, - FormValidateAsyncFn, - FormValidateFn, - FormValidateOrFn, -} from './FormApi' -import type { + FieldLikeAPI, + FieldLikeApiOptions, + FieldLikeMetaBase, + FieldLikeOptions, + FieldLikeState, ListenerCause, + UnwrapFieldAsyncValidateOrFn, + UnwrapFieldValidateOrFn, UpdateMetaOptions, ValidationCause, ValidationError, ValidationErrorMap, - ValidationErrorMapSource, } from './types' +import type { ReadonlyStore } from '@tanstack/store' +import type { DeepKeys, DeepValue } from './util-types' +import type { + StandardSchemaV1, + TStandardSchemaValidatorValue, +} from './standardSchemaValidator' +import type { FormAsyncValidateOrFn, FormValidateOrFn } from './FormApi' import type { AsyncValidator, SyncValidator, Updater } from './utils' -/** - * @private - */ -// TODO: Add the `Unwrap` type to the errors -type FieldErrorMapFromValidator< - TFormData, - TName extends DeepKeys, - TData extends DeepValue, - TOnMount extends undefined | FieldValidateOrFn, - TOnChange extends undefined | FieldValidateOrFn, - TOnChangeAsync extends - | undefined - | FieldAsyncValidateOrFn, - TOnBlur extends undefined | FieldValidateOrFn, - TOnBlurAsync extends - | undefined - | FieldAsyncValidateOrFn, - TOnSubmit extends undefined | FieldValidateOrFn, - TOnSubmitAsync extends - | undefined - | FieldAsyncValidateOrFn, -> = Partial< - Record< - DeepKeys, - ValidationErrorMap< - TOnMount, - TOnChange, - TOnChangeAsync, - TOnBlur, - TOnBlurAsync, - TOnSubmit, - TOnSubmitAsync - > - > -> - /** * @private */ @@ -111,55 +76,6 @@ export type FieldValidateFn< > }) => unknown -/** - * @private - */ -export type FieldValidateOrFn< - TParentData, - TName extends DeepKeys, - TData extends DeepValue = DeepValue, -> = - | FieldValidateFn - | StandardSchemaV1 - -type StandardBrandedSchemaV1 = T & { __standardSchemaV1: true } - -type UnwrapFormValidateOrFnForInner< - TValidateOrFn extends undefined | FormValidateOrFn, -> = [TValidateOrFn] extends [FormValidateFn] - ? ReturnType - : [TValidateOrFn] extends [StandardSchemaV1] - ? StandardBrandedSchemaV1 - : undefined - -export type UnwrapFieldValidateOrFn< - TName extends string, - TValidateOrFn extends undefined | FieldValidateOrFn, - TFormValidateOrFn extends undefined | FormValidateOrFn, -> = - | ([TFormValidateOrFn] extends [StandardSchemaV1] - ? TName extends keyof TStandardOut - ? StandardSchemaV1Issue[] - : undefined - : undefined) - | (UnwrapFormValidateOrFnForInner extends infer TFormValidateVal - ? TFormValidateVal extends { __standardSchemaV1: true } - ? [DeepValue] extends [never] - ? undefined - : StandardSchemaV1Issue[] - : TFormValidateVal extends { fields: any } - ? TName extends keyof TFormValidateVal['fields'] - ? TFormValidateVal['fields'][TName] - : undefined - : undefined - : never) - | ([TValidateOrFn] extends [FieldValidateFn] - ? ReturnType - : [TValidateOrFn] extends [StandardSchemaV1] - ? // TODO: Check if `disableErrorFlat` is enabled, if so, return StandardSchemaV1Issue[][] - StandardSchemaV1Issue[] - : undefined) - /** * @private */ @@ -199,53 +115,6 @@ export type FieldValidateAsyncFn< signal: AbortSignal }) => unknown | Promise -/** - * @private - */ -export type FieldAsyncValidateOrFn< - TParentData, - TName extends DeepKeys, - TData extends DeepValue = DeepValue, -> = - | FieldValidateAsyncFn - | StandardSchemaV1 - -type UnwrapFormAsyncValidateOrFnForInner< - TValidateOrFn extends undefined | FormAsyncValidateOrFn, -> = [TValidateOrFn] extends [FormValidateAsyncFn] - ? Awaited> - : [TValidateOrFn] extends [StandardSchemaV1] - ? StandardBrandedSchemaV1 - : undefined - -export type UnwrapFieldAsyncValidateOrFn< - TName extends string, - TValidateOrFn extends undefined | FieldAsyncValidateOrFn, - TFormValidateOrFn extends undefined | FormAsyncValidateOrFn, -> = - | ([TFormValidateOrFn] extends [StandardSchemaV1] - ? TName extends keyof TStandardOut - ? StandardSchemaV1Issue[] - : undefined - : undefined) - | (UnwrapFormAsyncValidateOrFnForInner extends infer TFormValidateVal - ? TFormValidateVal extends { __standardSchemaV1: true } - ? [DeepValue] extends [never] - ? undefined - : StandardSchemaV1Issue[] - : TFormValidateVal extends { fields: any } - ? TName extends keyof TFormValidateVal['fields'] - ? TFormValidateVal['fields'][TName] - : undefined - : undefined - : never) - | ([TValidateOrFn] extends [FieldValidateAsyncFn] - ? Awaited> - : [TValidateOrFn] extends [StandardSchemaV1] - ? // TODO: Check if `disableErrorFlat` is enabled, if so, return StandardSchemaV1Issue[][] - StandardSchemaV1Issue[] - : undefined) - /** * @private */ @@ -284,6 +153,28 @@ export type FieldListenerFn< > }) => void +/** + * @private + */ +export type FieldValidateOrFn< + TParentData, + TName extends DeepKeys, + TData extends DeepValue = DeepValue, +> = + | FieldValidateFn + | StandardSchemaV1 + +/** + * @private + */ +export type FieldAsyncValidateOrFn< + TParentData, + TName extends DeepKeys, + TData extends DeepValue = DeepValue, +> = + | FieldValidateAsyncFn + | StandardSchemaV1 + export interface FieldValidators< TParentData, TName extends DeepKeys, @@ -362,514 +253,32 @@ export interface FieldValidators< */ onSubmit?: TOnSubmit /** - * An optional property similar to `onSubmit` but async validation. - * - * @example z.string().refine(async (val) => val.length > 3, { message: 'Testing 123' }) - */ - onSubmitAsync?: TOnSubmitAsync - onDynamic?: TOnDynamic - onDynamicAsync?: TOnDynamicAsync - onDynamicAsyncDebounceMs?: number -} - -export interface FieldListeners< - TParentData, - TName extends DeepKeys, - TData extends DeepValue = DeepValue, -> { - onChange?: FieldListenerFn - onChangeDebounceMs?: number - onBlur?: FieldListenerFn - onBlurDebounceMs?: number - onMount?: FieldListenerFn - onUnmount?: FieldListenerFn - onSubmit?: FieldListenerFn -} - -/** - * An object type representing the options for a field in a form. - */ -export interface FieldOptions< - TParentData, - TName extends DeepKeys, - TData extends DeepValue, - TOnMount extends undefined | FieldValidateOrFn, - TOnChange extends undefined | FieldValidateOrFn, - TOnChangeAsync extends - | undefined - | FieldAsyncValidateOrFn, - TOnBlur extends undefined | FieldValidateOrFn, - TOnBlurAsync extends - | undefined - | FieldAsyncValidateOrFn, - TOnSubmit extends undefined | FieldValidateOrFn, - TOnSubmitAsync extends - | undefined - | FieldAsyncValidateOrFn, - TOnDynamic extends undefined | FieldValidateOrFn, - TOnDynamicAsync extends - | undefined - | FieldAsyncValidateOrFn, -> { - /** - * The field name. The type will be `DeepKeys` to ensure your name is a deep key of the parent dataset. - */ - name: TName - /** - * An optional default value for the field. - */ - defaultValue?: NoInfer - /** - * The default time to debounce async validation if there is not a more specific debounce time passed. - */ - asyncDebounceMs?: number - /** - * If `true`, always run async validation, even if there are errors emitted during synchronous validation. - */ - asyncAlways?: boolean - /** - * A list of validators to pass to the field - */ - validators?: FieldValidators< - TParentData, - TName, - TData, - TOnMount, - TOnChange, - TOnChangeAsync, - TOnBlur, - TOnBlurAsync, - TOnSubmit, - TOnSubmitAsync, - TOnDynamic, - TOnDynamicAsync - > - /** - * An optional object with default metadata for the field. - */ - defaultMeta?: Partial< - FieldMeta< - TParentData, - TName, - TData, - TOnMount, - TOnChange, - TOnChangeAsync, - TOnBlur, - TOnBlurAsync, - TOnSubmit, - TOnSubmitAsync, - TOnDynamic, - TOnDynamicAsync, - any, - any, - any, - any, - any, - any, - any, - any, - any - > - > - /** - * A list of listeners which attach to the corresponding events - */ - listeners?: FieldListeners - /** - * Disable the `flat(1)` operation on `field.errors`. This is useful if you want to keep the error structure as is. Not suggested for most use-cases. - */ - disableErrorFlat?: boolean -} - -/** - * An object type representing the required options for the FieldApi class. - */ -export interface FieldApiOptions< - in out TParentData, - in out TName extends DeepKeys, - in out TData extends DeepValue, - in out TOnMount extends - | undefined - | FieldValidateOrFn, - in out TOnChange extends - | undefined - | FieldValidateOrFn, - in out TOnChangeAsync extends - | undefined - | FieldAsyncValidateOrFn, - in out TOnBlur extends - | undefined - | FieldValidateOrFn, - in out TOnBlurAsync extends - | undefined - | FieldAsyncValidateOrFn, - in out TOnSubmit extends - | undefined - | FieldValidateOrFn, - in out TOnSubmitAsync extends - | undefined - | FieldAsyncValidateOrFn, - in out TOnDynamic extends - | undefined - | FieldValidateOrFn, - in out TOnDynamicAsync extends - | undefined - | FieldAsyncValidateOrFn, - in out TFormOnMount extends undefined | FormValidateOrFn, - in out TFormOnChange extends undefined | FormValidateOrFn, - in out TFormOnChangeAsync extends - | undefined - | FormAsyncValidateOrFn, - in out TFormOnBlur extends undefined | FormValidateOrFn, - in out TFormOnBlurAsync extends - | undefined - | FormAsyncValidateOrFn, - in out TFormOnSubmit extends undefined | FormValidateOrFn, - in out TFormOnSubmitAsync extends - | undefined - | FormAsyncValidateOrFn, - in out TFormOnDynamic extends undefined | FormValidateOrFn, - in out TFormOnDynamicAsync extends - | undefined - | FormAsyncValidateOrFn, - in out TFormOnServer extends undefined | FormAsyncValidateOrFn, - in out TParentSubmitMeta, -> extends FieldOptions< - TParentData, - TName, - TData, - TOnMount, - TOnChange, - TOnChangeAsync, - TOnBlur, - TOnBlurAsync, - TOnSubmit, - TOnSubmitAsync, - TOnDynamic, - TOnDynamicAsync -> { - form: FormApi< - TParentData, - TFormOnMount, - TFormOnChange, - TFormOnChangeAsync, - TFormOnBlur, - TFormOnBlurAsync, - TFormOnSubmit, - TFormOnSubmitAsync, - TFormOnDynamic, - TFormOnDynamicAsync, - TFormOnServer, - TParentSubmitMeta - > -} - -export type FieldMetaBase< - TParentData, - TName extends DeepKeys, - TData extends DeepValue, - TOnMount extends undefined | FieldValidateOrFn, - TOnChange extends undefined | FieldValidateOrFn, - TOnChangeAsync extends - | undefined - | FieldAsyncValidateOrFn, - TOnBlur extends undefined | FieldValidateOrFn, - TOnBlurAsync extends - | undefined - | FieldAsyncValidateOrFn, - TOnSubmit extends undefined | FieldValidateOrFn, - TOnSubmitAsync extends - | undefined - | FieldAsyncValidateOrFn, - TOnDynamic extends undefined | FieldValidateOrFn, - TOnDynamicAsync extends - | undefined - | FieldAsyncValidateOrFn, - TFormOnMount extends undefined | FormValidateOrFn, - TFormOnChange extends undefined | FormValidateOrFn, - TFormOnChangeAsync extends undefined | FormAsyncValidateOrFn, - TFormOnBlur extends undefined | FormValidateOrFn, - TFormOnBlurAsync extends undefined | FormAsyncValidateOrFn, - TFormOnSubmit extends undefined | FormValidateOrFn, - TFormOnSubmitAsync extends undefined | FormAsyncValidateOrFn, - TFormOnDynamic extends undefined | FormValidateOrFn, - TFormOnDynamicAsync extends undefined | FormAsyncValidateOrFn, -> = { - /** - * A flag indicating whether the field has been touched. - */ - isTouched: boolean - /** - * A flag indicating whether the field has been blurred. - */ - isBlurred: boolean - /** - * A flag that is `true` if the field's value has been modified by the user. Opposite of `isPristine`. - */ - isDirty: boolean - /** - * A map of errors related to the field value. - */ - errorMap: ValidationErrorMap< - UnwrapFieldValidateOrFn, - UnwrapFieldValidateOrFn, - UnwrapFieldAsyncValidateOrFn, - UnwrapFieldValidateOrFn, - UnwrapFieldAsyncValidateOrFn, - UnwrapFieldValidateOrFn, - UnwrapFieldAsyncValidateOrFn, - UnwrapFieldValidateOrFn, - UnwrapFieldAsyncValidateOrFn - > - - /** - * @private allows tracking the source of the errors in the error map - */ - errorSourceMap: ValidationErrorMapSource - /** - * A flag indicating whether the field is currently being validated. - */ - isValidating: boolean -} - -export type AnyFieldMetaBase = FieldMetaBase< - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any -> - -export type FieldMetaDerived< - TParentData, - TName extends DeepKeys, - TData extends DeepValue, - TOnMount extends undefined | FieldValidateOrFn, - TOnChange extends undefined | FieldValidateOrFn, - TOnChangeAsync extends - | undefined - | FieldAsyncValidateOrFn, - TOnBlur extends undefined | FieldValidateOrFn, - TOnBlurAsync extends - | undefined - | FieldAsyncValidateOrFn, - TOnSubmit extends undefined | FieldValidateOrFn, - TOnSubmitAsync extends - | undefined - | FieldAsyncValidateOrFn, - TOnDynamic extends undefined | FieldValidateOrFn, - TOnDynamicAsync extends - | undefined - | FieldAsyncValidateOrFn, - TFormOnMount extends undefined | FormValidateOrFn, - TFormOnChange extends undefined | FormValidateOrFn, - TFormOnChangeAsync extends undefined | FormAsyncValidateOrFn, - TFormOnBlur extends undefined | FormValidateOrFn, - TFormOnBlurAsync extends undefined | FormAsyncValidateOrFn, - TFormOnSubmit extends undefined | FormValidateOrFn, - TFormOnSubmitAsync extends undefined | FormAsyncValidateOrFn, - TFormOnDynamic extends undefined | FormValidateOrFn, - TFormOnDynamicAsync extends undefined | FormAsyncValidateOrFn, -> = { - /** - * An array of errors related to the field value. - */ - errors: Array< - | UnwrapOneLevelOfArray< - UnwrapFieldValidateOrFn - > - | UnwrapOneLevelOfArray< - UnwrapFieldValidateOrFn - > - | UnwrapOneLevelOfArray< - UnwrapFieldAsyncValidateOrFn - > - | UnwrapOneLevelOfArray< - UnwrapFieldValidateOrFn - > - | UnwrapOneLevelOfArray< - UnwrapFieldAsyncValidateOrFn - > - | UnwrapOneLevelOfArray< - UnwrapFieldValidateOrFn - > - | UnwrapOneLevelOfArray< - UnwrapFieldAsyncValidateOrFn - > - | UnwrapOneLevelOfArray< - UnwrapFieldValidateOrFn - > - | UnwrapOneLevelOfArray< - UnwrapFieldAsyncValidateOrFn< - TName, - TOnDynamicAsync, - TFormOnDynamicAsync - > - > - > - /** - * A flag that is `true` if the field's value has not been modified by the user. Opposite of `isDirty`. - */ - isPristine: boolean - /** - * A boolean indicating if the field is valid. Evaluates `true` if there are no field errors. - */ - isValid: boolean - /** - * A flag indicating whether the field's current value is the default value - */ - isDefaultValue: boolean -} - -export type AnyFieldMetaDerived = FieldMetaDerived< - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any -> - -/** - * An object type representing the metadata of a field in a form. - */ -export type FieldMeta< - TParentData, - TName extends DeepKeys, - TData extends DeepValue, - TOnMount extends undefined | FieldValidateOrFn, - TOnChange extends undefined | FieldValidateOrFn, - TOnChangeAsync extends - | undefined - | FieldAsyncValidateOrFn, - TOnBlur extends undefined | FieldValidateOrFn, - TOnBlurAsync extends - | undefined - | FieldAsyncValidateOrFn, - TOnSubmit extends undefined | FieldValidateOrFn, - TOnSubmitAsync extends - | undefined - | FieldAsyncValidateOrFn, - TOnDynamic extends undefined | FieldValidateOrFn, - TOnDynamicAsync extends - | undefined - | FieldAsyncValidateOrFn, - TFormOnMount extends undefined | FormValidateOrFn, - TFormOnChange extends undefined | FormValidateOrFn, - TFormOnChangeAsync extends undefined | FormAsyncValidateOrFn, - TFormOnBlur extends undefined | FormValidateOrFn, - TFormOnBlurAsync extends undefined | FormAsyncValidateOrFn, - TFormOnSubmit extends undefined | FormValidateOrFn, - TFormOnSubmitAsync extends undefined | FormAsyncValidateOrFn, - TFormOnDynamic extends undefined | FormValidateOrFn, - TFormOnDynamicAsync extends undefined | FormAsyncValidateOrFn, -> = FieldMetaBase< - TParentData, - TName, - TData, - TOnMount, - TOnChange, - TOnChangeAsync, - TOnBlur, - TOnBlurAsync, - TOnSubmit, - TOnSubmitAsync, - TOnDynamic, - TOnDynamicAsync, - TFormOnMount, - TFormOnChange, - TFormOnChangeAsync, - TFormOnBlur, - TFormOnBlurAsync, - TFormOnSubmit, - TFormOnSubmitAsync, - TFormOnDynamic, - TFormOnDynamicAsync -> & - FieldMetaDerived< - TParentData, - TName, - TData, - TOnMount, - TOnChange, - TOnChangeAsync, - TOnBlur, - TOnBlurAsync, - TOnSubmit, - TOnSubmitAsync, - TOnDynamic, - TOnDynamicAsync, - TFormOnMount, - TFormOnChange, - TFormOnChangeAsync, - TFormOnBlur, - TFormOnBlurAsync, - TFormOnSubmit, - TFormOnSubmitAsync, - TFormOnDynamic, - TFormOnDynamicAsync - > - -export type AnyFieldMeta = FieldMeta< - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any -> + * An optional property similar to `onSubmit` but async validation. + * + * @example z.string().refine(async (val) => val.length > 3, { message: 'Testing 123' }) + */ + onSubmitAsync?: TOnSubmitAsync + onDynamic?: TOnDynamic + onDynamicAsync?: TOnDynamicAsync + onDynamicAsyncDebounceMs?: number +} -/** - * An object type representing the state of a field. - */ -export type FieldState< +export interface FieldListeners< + TParentData, + TName extends DeepKeys, + TData extends DeepValue = DeepValue, +> { + onChange?: FieldListenerFn + onChangeDebounceMs?: number + onBlur?: FieldListenerFn + onBlurDebounceMs?: number + onMount?: FieldListenerFn + onUnmount?: FieldListenerFn + onSubmit?: FieldListenerFn + onGroupSubmit?: FieldListenerFn +} + +interface FieldExtraOptions< TParentData, TName extends DeepKeys, TData extends DeepValue, @@ -890,24 +299,11 @@ export type FieldState< TOnDynamicAsync extends | undefined | FieldAsyncValidateOrFn, - TFormOnMount extends undefined | FormValidateOrFn, - TFormOnChange extends undefined | FormValidateOrFn, - TFormOnChangeAsync extends undefined | FormAsyncValidateOrFn, - TFormOnBlur extends undefined | FormValidateOrFn, - TFormOnBlurAsync extends undefined | FormAsyncValidateOrFn, - TFormOnSubmit extends undefined | FormValidateOrFn, - TFormOnSubmitAsync extends undefined | FormAsyncValidateOrFn, - TFormOnDynamic extends undefined | FormValidateOrFn, - TFormOnDynamicAsync extends undefined | FormAsyncValidateOrFn, -> = { - /** - * The current value of the field. - */ - value: TData +> { /** - * The current metadata of the field. + * A list of validators to pass to the field */ - meta: FieldMeta< + validators?: FieldValidators< TParentData, TName, TData, @@ -919,19 +315,162 @@ export type FieldState< TOnSubmit, TOnSubmitAsync, TOnDynamic, - TOnDynamicAsync, - TFormOnMount, - TFormOnChange, - TFormOnChangeAsync, - TFormOnBlur, - TFormOnBlurAsync, - TFormOnSubmit, - TFormOnSubmitAsync, - TFormOnDynamic, - TFormOnDynamicAsync + TOnDynamicAsync > + + /** + * A list of listeners which attach to the corresponding events + */ + listeners?: FieldListeners } +/** + * An object type representing the options for a field in a form. + */ +export interface FieldOptions< + TParentData, + TName extends DeepKeys, + TData extends DeepValue, + TOnMount extends undefined | FieldValidateOrFn, + TOnChange extends undefined | FieldValidateOrFn, + TOnChangeAsync extends + | undefined + | FieldAsyncValidateOrFn, + TOnBlur extends undefined | FieldValidateOrFn, + TOnBlurAsync extends + | undefined + | FieldAsyncValidateOrFn, + TOnSubmit extends undefined | FieldValidateOrFn, + TOnSubmitAsync extends + | undefined + | FieldAsyncValidateOrFn, + TOnDynamic extends undefined | FieldValidateOrFn, + TOnDynamicAsync extends + | undefined + | FieldAsyncValidateOrFn, +> + extends + FieldExtraOptions< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync + >, + FieldLikeOptions< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync + > {} + +export interface FieldApiOptions< + in out TParentData, + in out TName extends DeepKeys, + in out TData extends DeepValue, + in out TOnMount extends + | undefined + | FieldValidateOrFn, + in out TOnChange extends + | undefined + | FieldValidateOrFn, + in out TOnChangeAsync extends + | undefined + | FieldAsyncValidateOrFn, + in out TOnBlur extends + | undefined + | FieldValidateOrFn, + in out TOnBlurAsync extends + | undefined + | FieldAsyncValidateOrFn, + in out TOnSubmit extends + | undefined + | FieldValidateOrFn, + in out TOnSubmitAsync extends + | undefined + | FieldAsyncValidateOrFn, + in out TOnDynamic extends + | undefined + | FieldValidateOrFn, + in out TOnDynamicAsync extends + | undefined + | FieldAsyncValidateOrFn, + in out TFormOnMount extends undefined | FormValidateOrFn, + in out TFormOnChange extends undefined | FormValidateOrFn, + in out TFormOnChangeAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnBlur extends undefined | FormValidateOrFn, + in out TFormOnBlurAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnSubmit extends undefined | FormValidateOrFn, + in out TFormOnSubmitAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnDynamic extends undefined | FormValidateOrFn, + in out TFormOnDynamicAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnServer extends undefined | FormAsyncValidateOrFn, + in out TParentSubmitMeta, +> + extends + FieldLikeApiOptions< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta + >, + FieldExtraOptions< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync + > {} + /** * @public * @@ -1027,6 +566,44 @@ export class FieldApi< | FormAsyncValidateOrFn, in out TFormOnServer extends undefined | FormAsyncValidateOrFn, in out TParentSubmitMeta, +> implements FieldLikeAPI< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta, + FieldExtraOptions< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync + > > { /** * A reference to the form API instance. @@ -1092,7 +669,7 @@ export class FieldApi< * The field state store. */ store!: ReadonlyStore< - FieldState< + FieldLikeState< TParentData, TName, TData, @@ -1171,7 +748,7 @@ export class FieldApi< this.store = createStore( ( prevVal: - | FieldState< + | FieldLikeState< TParentData, TName, TData, @@ -1221,7 +798,7 @@ export class FieldApi< return { value, meta, - } as FieldState< + } as FieldLikeState< TParentData, TName, TData, @@ -1490,7 +1067,7 @@ export class FieldApi< */ setMeta = ( updater: Updater< - FieldMetaBase< + FieldLikeMetaBase< TParentData, TName, TData, @@ -1653,6 +1230,10 @@ export class FieldApi< const linkedFields: AnyFieldApi[] = [] for (const field of fields) { if (!field.instance) continue + // TODO: How to handle FieldGroups? Do we need to? IDK. + if (!(field.instance instanceof FieldApi)) { + continue + } const { onChangeListenTo, onBlurListenTo } = field.instance.options.validators || {} if (cause === 'change' && onChangeListenTo?.includes(this.name)) { @@ -1994,32 +1575,115 @@ export class FieldApi< */ validate = ( cause: ValidationCause, - opts?: { skipFormValidation?: boolean }, + opts?: { skipFormValidation?: boolean; skipGroupValidation?: boolean }, ): ValidationError[] | Promise => { // If the field is pristine, do not validate if (!this.state.meta.isTouched) return [] + // Cascade into any encompassing `FormGroupApi`'s own validators so + // group-scoped strategies (e.g. `revalidateLogic` gated on the group's + // own `submissionAttempts`) get a chance to react to this field change. + // Mirror the field's sync→short-circuit-on-error→async semantics for + // each group: only kick off async validators if the group's sync pass + // was clean (or its `asyncAlways` flag is set). + const encompassingGroups = opts?.skipGroupValidation + ? [] + : Array.from(this.form.formGroupApis).filter((group) => + this.name.startsWith(group.name), + ) + // Attempt to sync validate first - const { fieldsErrorMap } = opts?.skipFormValidation + const formSyncResult = opts?.skipFormValidation ? { fieldsErrorMap: {} as never } : this.form.validateSync(cause) - const { hasErrored } = this.validateSync( - cause, - fieldsErrorMap[this.name] ?? {}, - ) + let fieldsErrorMap = (formSyncResult.fieldsErrorMap[this.name] ?? + {}) as ValidationErrorMap + + // For each encompassing group whose own submission has been attempted, + // also re-run the parent form's validators with that group as the + // gating context. This ensures form-level errors (e.g. those produced + // by a form-level z.object onDynamic during a group submit) are kept + // fresh on subsequent field changes — even though the form itself + // hasn't been submitted directly. + if (!opts?.skipFormValidation) { + for (const group of encompassingGroups) { + if (group.formState.submissionAttempts === 0) continue + const { fieldsErrorMap: groupFormErrors } = this.form.validateSync( + cause, + { + group, + dontUpdateFormErrorMap: true, + filterFieldNames: (fieldName) => fieldName.startsWith(group.name), + }, + ) + fieldsErrorMap = { + ...fieldsErrorMap, + ...(groupFormErrors[this.name] ?? {}), + } + } + } + + const { hasErrored } = this.validateSync(cause, fieldsErrorMap) + + const groupHasErroredWeakMap = new WeakMap() + for (const group of encompassingGroups) { + const { hasErrored: groupHasErrored } = group.validateSync( + cause, + {}, + { skipRelatedFieldValidation: true }, + ) + + groupHasErroredWeakMap.set(group, groupHasErrored) + } if (hasErrored && !this.options.asyncAlways) { this.getInfo().validationMetaMap[ getErrorMapKey(cause) ]?.lastAbortController.abort() - return this.state.meta.errors + + const groupErrors = [] as ValidationError[][] + + for (const group of encompassingGroups) { + group + .getInfo() + .validationMetaMap[getErrorMapKey(cause)]?.lastAbortController.abort() + + groupErrors.push(group.state.meta.errors) + } + + return [...this.state.meta.errors, ...groupErrors.flat()] } // No error? Attempt async validation const formValidationResultPromise = opts?.skipFormValidation ? Promise.resolve({}) : this.form.validateAsync(cause) - return this.validateAsync(cause, formValidationResultPromise) + + const fieldAsyncResults = this.validateAsync( + cause, + formValidationResultPromise, + ) + + const groupAsyncResults: Promise[] = [] + for (const group of encompassingGroups) { + if (groupHasErroredWeakMap.get(group) && !group.options.asyncAlways) { + continue + } + + groupAsyncResults.push( + group.validateAsync(cause, formValidationResultPromise, { + skipRelatedFieldValidation: true, + }), + ) + } + + if (groupAsyncResults.length === 0) { + return fieldAsyncResults + } + + return Promise.all([fieldAsyncResults, ...groupAsyncResults]).then( + (results) => results.flat(), + ) } /** @@ -2094,7 +1758,7 @@ export class FieldApi< ) } - private triggerOnBlurListener() { + private triggerOnBlurListener = () => { const formDebounceMs = this.form.options.listeners?.onBlurDebounceMs if (formDebounceMs && formDebounceMs > 0) { if (this.timeoutIds.formListeners.blur) { @@ -2176,6 +1840,16 @@ export class FieldApi< }) } } + + /** + * @private + */ + triggerOnSubmitListener = () => { + this.options.listeners?.onSubmit?.({ + value: this.state.value, + fieldApi: this, + }) + } } function normalizeError(rawError?: ValidationError) { diff --git a/packages/form-core/src/FieldGroupApi.ts b/packages/form-core/src/FieldGroupApi.ts index 6cd9d0976..a7fac9951 100644 --- a/packages/form-core/src/FieldGroupApi.ts +++ b/packages/form-core/src/FieldGroupApi.ts @@ -1,5 +1,11 @@ import { createStore } from '@tanstack/store' import { concatenatePaths, getBy, makePathArray } from './utils' +import type { + AnyFieldLikeMetaBase, + FormLikeAPI, + UpdateMetaOptions, + ValidationCause, +} from './types' import type { ReadonlyStore } from '@tanstack/store' import type { Updater } from './utils' import type { @@ -7,18 +13,13 @@ import type { FormAsyncValidateOrFn, FormValidateOrFn, } from './FormApi' -import type { AnyFieldMetaBase, FieldOptions } from './FieldApi' +import type { FieldOptions } from './FieldApi' import type { DeepKeys, DeepKeysOfType, DeepValue, FieldsMap, } from './util-types' -import type { - FieldManipulator, - UpdateMetaOptions, - ValidationCause, -} from './types' export type AnyFieldGroupApi = FieldGroupApi< any, @@ -127,7 +128,7 @@ export class FieldGroupApi< in out TOnDynamicAsync extends undefined | FormAsyncValidateOrFn, in out TOnServer extends undefined | FormAsyncValidateOrFn, in out TSubmitMeta = never, -> implements FieldManipulator { +> implements FormLikeAPI { /** * The form that called this field group. */ @@ -368,7 +369,7 @@ export class FieldGroupApi< */ setFieldMeta = >( field: TField, - updater: Updater, + updater: Updater, ) => { return this.form.setFieldMeta(this.getFormFieldName(field), updater) } diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index 2317fb2b9..e6cecb443 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -21,24 +21,12 @@ import { } from './standardSchemaValidator' import { defaultFieldMeta, metaHelper } from './metaHelper' import { formEventClient } from './EventClient' -import type { ReadonlyStore, Store } from '@tanstack/store' - -// types -import type { ValidationLogicFn } from './ValidationLogic' -import type { - StandardSchemaV1, - StandardSchemaV1Issue, - TStandardSchemaValidatorValue, -} from './standardSchemaValidator' -import type { - AnyFieldApi, - AnyFieldMeta, - AnyFieldMetaBase, - FieldApi, -} from './FieldApi' import type { + AnyFieldLikeMeta, + AnyFieldLikeMetaBase, ExtractGlobalFormError, - FieldManipulator, + FieldInfo, + FormLikeAPI, FormValidationError, FormValidationErrorMap, GlobalFormValidationError, @@ -49,6 +37,17 @@ import type { ValidationErrorMap, ValidationErrorMapKeys, } from './types' +import type { ReadonlyStore, Store } from '@tanstack/store' + +// types +import type { ValidationLogicFn } from './ValidationLogic' +import type { + StandardSchemaV1, + StandardSchemaV1Issue, + TStandardSchemaValidatorValue, +} from './standardSchemaValidator' +import type { AnyFieldApi } from './FieldApi' +import type { AnyFormGroupApi } from './FormGroupApi' import type { DeepKeys, DeepKeysOfType, DeepValue } from './util-types' import type { Updater } from './utils' @@ -503,44 +502,6 @@ export type ValidationMeta = { lastAbortController: AbortController } -/** - * An object representing the field information for a specific field within the form. - */ -export type FieldInfo = { - /** - * An instance of the FieldAPI. - */ - instance: FieldApi< - TFormData, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any - > | null - /** - * A record of field validation internal handling. - */ - validationMetaMap: Record -} - /** * An object representing the current state of the form. */ @@ -583,7 +544,7 @@ export type BaseFormState< /** * A record of field metadata for each field in the form, not including the derived properties, like `errors` and such */ - fieldMetaBase: Partial, AnyFieldMetaBase>> + fieldMetaBase: Partial, AnyFieldLikeMetaBase>> /** * A boolean indicating if the form is currently in the process of being submitted after `handleSubmit` is called. * @@ -712,7 +673,7 @@ export type DerivedFormState< /** * A record of field metadata for each field in the form. */ - fieldMeta: Partial, AnyFieldMeta>> + fieldMeta: Partial, AnyFieldLikeMeta>> } export interface FormState< @@ -851,6 +812,18 @@ export type AnyFormApi = FormApi< any > +interface ValidateOpts { + // Useful in FormGroup where validation doesn't update form error map + dontUpdateFormErrorMap?: boolean + // Filter which field names to validate, useful for FormGroup validation to filter out fields that don't start with the FormGroup name + filterFieldNames?: (fieldName: DeepKeys) => boolean + // When form-level validators are run on behalf of a `FormGroupApi` (e.g. + // because a field inside that group is revalidating), pass the group so + // strategies like `revalidateLogic` can gate on the group's own + // `submissionAttempts` instead of the parent form's. + group?: AnyFormGroupApi +} + /** * We cannot use methods and must use arrow functions. Otherwise, our React adapters * will break due to loss of the method when using spread. @@ -876,7 +849,7 @@ export class FormApi< in out TOnDynamicAsync extends undefined | FormAsyncValidateOrFn, in out TOnServer extends undefined | FormAsyncValidateOrFn, in out TSubmitMeta = never, -> implements FieldManipulator { +> implements FormLikeAPI { /** * The options for the form. */ @@ -944,7 +917,12 @@ export class FormApi< * A record of field information for each field in the form. */ fieldInfo: Partial, FieldInfo>> = {} - + /** + * The set of currently-mounted `FormGroupApi` instances belonging to + * this form. Used by `FieldApi.validate` to cascade field-level changes + * into the validators of any group that encompasses the field. + */ + formGroupApis: Set = new Set() get state() { return this.store.state } @@ -1035,7 +1013,7 @@ export class FormApi< } const existingFieldMeta = baseStoreVal.fieldMetaBase[ fieldName as never - ] as AnyFieldMetaBase | undefined + ] as AnyFieldLikeMetaBase | undefined baseStoreVal.fieldMetaBase[fieldName as never] = { isTouched: false, isValidating: false, @@ -1050,7 +1028,7 @@ export class FormApi< ...(existingFieldMeta?.['errorMap'] ?? {}), [errKey as never]: fieldErr, }, - } satisfies AnyFieldMetaBase as never + } satisfies AnyFieldLikeMetaBase as never } } } @@ -1074,7 +1052,7 @@ export class FormApi< | undefined = undefined this.fieldMetaDerived = createStore( - (prevVal: Record, AnyFieldMeta> | undefined) => { + (prevVal: Record, AnyFieldLikeMeta> | undefined) => { const currBaseStore = this.baseStore.get() let originalMetaCount = 0 @@ -1098,11 +1076,11 @@ export class FormApi< ) as Array) { const currBaseMeta = currBaseStore.fieldMetaBase[ fieldName as never - ] as AnyFieldMetaBase + ] as AnyFieldLikeMetaBase const prevBaseMeta = prevBaseStore?.fieldMetaBase[ fieldName as never - ] as AnyFieldMetaBase | undefined + ] as AnyFieldLikeMetaBase | undefined const prevFieldInfo = prevVal?.[fieldName as never as keyof typeof prevVal] @@ -1160,7 +1138,7 @@ export class FormApi< isPristine: isFieldPristine, isValid: isFieldValid, isDefaultValue: isDefaultValue, - } satisfies AnyFieldMeta as AnyFieldMeta + } satisfies AnyFieldLikeMeta as AnyFieldLikeMeta } if (!Object.keys(currBaseStore.fieldMetaBase).length) return fieldMeta @@ -1215,7 +1193,7 @@ export class FormApi< // Computed state const fieldMetaValues = Object.values(currFieldMeta).filter( Boolean, - ) as AnyFieldMeta[] + ) as AnyFieldLikeMeta[] const isFieldsValidating = fieldMetaValues.some( (field) => field.isValidating, @@ -1560,12 +1538,15 @@ export class FormApi< fieldValidationPromises.push( // Remember, `validate` is either a sync operation or a promise Promise.resolve().then(() => - fieldInstance.validate(cause, { skipFormValidation: true }), + fieldInstance.validate(cause, { + skipFormValidation: true, + skipGroupValidation: true, + }), ), ) // If any fields are not touched - if (!field.instance.state.meta.isTouched) { + if (!field.instance.store.state.meta.isTouched) { // Mark them as touched field.instance.setMeta((prev) => ({ ...prev, isTouched: true })) } @@ -1640,7 +1621,7 @@ export class FormApi< } // If the field is not touched (same logic as in validateAllFields) - if (!fieldInstance.state.meta.isTouched) { + if (!fieldInstance.store.state.meta.isTouched) { // Mark it as touched fieldInstance.setMeta((prev) => ({ ...prev, isTouched: true })) } @@ -1654,6 +1635,7 @@ export class FormApi< */ validateSync = ( cause: ValidationCause, + validateOpts?: ValidateOpts, ): { hasErrored: boolean fieldsErrorMap: FormErrorMapFromValidator< @@ -1672,6 +1654,7 @@ export class FormApi< const validates = getSyncValidatorArray(cause, { ...this.options, form: this, + group: validateOpts?.group, validationLogic: this.options.validationLogic || defaultValidationLogic, }) @@ -1709,11 +1692,17 @@ export class FormApi< const errorMapKey = getErrorMapKey(validateObj.cause) - const allFieldsToProcess = new Set([ + let allFieldsToProcess = new Set([ ...Object.keys(this.state.fieldMeta), ...Object.keys(fieldErrors || {}), ] as DeepKeys[]) + if (validateOpts?.filterFieldNames) { + allFieldsToProcess = new Set( + [...allFieldsToProcess].filter(validateOpts.filterFieldNames), + ) + } + for (const field of allFieldsToProcess) { if ( this.baseStore.state.fieldMetaBase[field] === undefined && @@ -1765,15 +1754,17 @@ export class FormApi< } } - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (this.state.errorMap?.[errorMapKey] !== formError) { - this.baseStore.setState((prev) => ({ - ...prev, - errorMap: { - ...prev.errorMap, - [errorMapKey]: formError, - }, - })) + if (!validateOpts?.dontUpdateFormErrorMap) { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (this.state.errorMap?.[errorMapKey] !== formError) { + this.baseStore.setState((prev) => ({ + ...prev, + errorMap: { + ...prev.errorMap, + [errorMapKey]: formError, + }, + })) + } } if (formError || fieldErrors) { @@ -1781,6 +1772,10 @@ export class FormApi< } } + if (validateOpts?.dontUpdateFormErrorMap) { + return + } + /** * when we have an error for onSubmit in the state, we want * to clear the error as soon as the user enters a valid value in the field @@ -1830,6 +1825,7 @@ export class FormApi< */ validateAsync = async ( cause: ValidationCause, + validateOpts?: ValidateOpts, ): Promise< FormErrorMapFromValidator< TFormData, @@ -1847,6 +1843,7 @@ export class FormApi< const validates = getAsyncValidatorArray(cause, { ...this.options, form: this, + group: validateOpts?.group, validationLogic: this.options.validationLogic || defaultValidationLogic, }) @@ -1920,9 +1917,13 @@ export class FormApi< } const errorMapKey = getErrorMapKey(validateObj.cause) - for (const field of Object.keys( - this.state.fieldMeta, - ) as DeepKeys[]) { + let fields: DeepKeys[] = Object.keys(this.state.fieldMeta) + + if (validateOpts?.filterFieldNames) { + fields = fields.filter(validateOpts.filterFieldNames) + } + + for (const field of fields) { if (this.baseStore.state.fieldMetaBase[field] === undefined) { continue } @@ -1947,10 +1948,8 @@ export class FormApi< previousErrorValue: currentErrorMap?.[errorMapKey], }) - if ( - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - currentErrorMap?.[errorMapKey] !== newErrorValue - ) { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (currentErrorMap?.[errorMapKey] !== newErrorValue) { this.setFieldMeta(field, (prev) => ({ ...prev, errorMap: { @@ -1965,13 +1964,15 @@ export class FormApi< } } - this.baseStore.setState((prev) => ({ - ...prev, - errorMap: { - ...prev.errorMap, - [errorMapKey]: formError, - }, - })) + if (!validateOpts?.dontUpdateFormErrorMap) { + this.baseStore.setState((prev) => ({ + ...prev, + errorMap: { + ...prev.errorMap, + [errorMapKey]: formError, + }, + })) + } resolve( fieldErrorsFromFormValidators @@ -2030,6 +2031,7 @@ export class FormApi< */ validate = ( cause: ValidationCause, + validateOpts?: ValidateOpts, ): | FormErrorMapFromValidator< TFormData, @@ -2058,14 +2060,17 @@ export class FormApi< > > => { // Attempt to sync validate first - const { hasErrored, fieldsErrorMap } = this.validateSync(cause) + const { hasErrored, fieldsErrorMap } = this.validateSync( + cause, + validateOpts, + ) if (hasErrored && !this.options.asyncAlways) { return fieldsErrorMap } // No error? Attempt async validation - return this.validateAsync(cause) + return this.validateAsync(cause, validateOpts) } // Needs to edgecase in the React adapter specifically to avoid type errors @@ -2093,7 +2098,7 @@ export class FormApi< (field) => { if (!field.instance) return // If any fields are not touched - if (!field.instance.state.meta.isTouched) { + if (!field.instance.store.state.meta.isTouched) { // Mark them as touched field.instance.setMeta((prev) => ({ ...prev, isTouched: true })) } @@ -2135,8 +2140,8 @@ export class FormApi< submissionAttempt: this.state.submissionAttempts, successful: false, stage: 'validateAllFields', - errors: (Object.values(this.state.fieldMeta) as AnyFieldMeta[]) - .map((meta: AnyFieldMeta) => meta.errors) + errors: (Object.values(this.state.fieldMeta) as AnyFieldLikeMeta[]) + .map((meta) => meta.errors) .flat(), }) return @@ -2168,10 +2173,7 @@ export class FormApi< batch(() => { void (Object.values(this.fieldInfo) as FieldInfo[]).forEach( (field) => { - field.instance?.options.listeners?.onSubmit?.({ - value: field.instance.state.value, - fieldApi: field.instance, - }) + field.instance?.triggerOnSubmitListener() }, ) }) @@ -2233,7 +2235,7 @@ export class FormApi< */ getFieldMeta = >( field: TField, - ): AnyFieldMeta | undefined => { + ): AnyFieldLikeMeta | undefined => { return this.state.fieldMeta[field] } @@ -2261,7 +2263,7 @@ export class FormApi< */ setFieldMeta = >( field: TField, - updater: Updater, + updater: Updater, ) => { this.baseStore.setState((prev) => { return { @@ -2281,15 +2283,15 @@ export class FormApi< * resets every field's meta */ resetFieldMeta = >( - fieldMeta: Partial>, - ): Partial> => { + fieldMeta: Partial>, + ): Partial> => { return Object.keys(fieldMeta).reduce( (acc, key) => { const fieldKey = key as TField acc[fieldKey] = defaultFieldMeta return acc }, - {} as Partial>, + {} as Partial>, ) } @@ -2699,12 +2701,12 @@ export class FormApi< fields: Object.entries(this.state.fieldMeta).reduce( (acc, [fieldName, fieldMeta]) => { if ( - Object.keys(fieldMeta as AnyFieldMeta).length && - (fieldMeta as AnyFieldMeta).errors.length + Object.keys(fieldMeta as AnyFieldLikeMeta).length && + (fieldMeta as AnyFieldLikeMeta).errors.length ) { acc[fieldName as DeepKeys] = { - errors: (fieldMeta as AnyFieldMeta).errors, - errorMap: (fieldMeta as AnyFieldMeta).errorMap, + errors: (fieldMeta as AnyFieldLikeMeta).errors, + errorMap: (fieldMeta as AnyFieldLikeMeta).errorMap, } } diff --git a/packages/form-core/src/FormGroupApi.ts b/packages/form-core/src/FormGroupApi.ts new file mode 100644 index 000000000..16e911ecd --- /dev/null +++ b/packages/form-core/src/FormGroupApi.ts @@ -0,0 +1,2094 @@ +import { batch, createStore } from '@tanstack/store' +import { + determineFieldLevelErrorSourceAndValue, + evaluate, + getAsyncValidatorArray, + getSyncValidatorArray, + isGlobalFormValidationError, + mergeOpts, +} from './utils' +import { defaultValidationLogic } from './ValidationLogic' +import { + isStandardSchemaValidator, + standardSchemaValidators, +} from './standardSchemaValidator' +import { defaultFieldMeta } from './metaHelper' +import { FieldApi } from './FieldApi' +import { FieldLikeApiOptions } from './types' +import type { + AnyFieldLikeMeta, + AnyFieldLikeMetaBase, + FieldErrorMapFromValidator, + FieldInfo, + FieldLikeAPI, + FieldLikeMetaBase, + FieldLikeOptions, + FieldLikeState, + FormLikeAPI, + ListenerCause, + UnwrapFieldAsyncValidateOrFn, + UnwrapFieldValidateOrFn, + UpdateMetaOptions, + ValidationCause, + ValidationError, + ValidationErrorMap, +} from './types' +import type { + FormApi, + FormAsyncValidateOrFn, + FormValidateOrFn, +} from './FormApi' +import type { AnyFieldApi } from './FieldApi' +import type { + StandardSchemaV1, + TStandardSchemaValidatorValue, +} from './standardSchemaValidator' +import type { AsyncValidator, SyncValidator, Updater } from './utils' +import type { ReadonlyStore, Store } from '@tanstack/store' +import type { DeepKeys, DeepKeysOfType, DeepValue } from './util-types' + +/** + * @private + */ +export type FormGroupValidateFn< + TParentData, + TName extends DeepKeys, + TData extends DeepValue = DeepValue, +> = (props: { + value: TData + groupApi: FormGroupApi< + TParentData, + TName, + TData, + // This is technically an edge-type; which we try to keep non-`any`, but in this case + // It's referring to an inaccessible type from the group validate function inner types, so it's not a big deal + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any + > +}) => unknown + +/** + * @private + */ +export type FormGroupValidateOrFn< + TParentData, + TName extends DeepKeys, + TData extends DeepValue = DeepValue, +> = + | FormGroupValidateFn + | StandardSchemaV1 + +/** + * @private + */ +export type FormGroupValidateAsyncFn< + TParentData, + TName extends DeepKeys, + TData extends DeepValue = DeepValue, +> = (options: { + value: TData + groupApi: FormGroupApi< + TParentData, + TName, + TData, + // This is technically an edge-type; which we try to keep non-`any`, but in this case + // It's referring to an inaccessible type from the group validate function inner types, so it's not a big deal + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any + > + signal: AbortSignal +}) => unknown | Promise + +/** + * @private + */ +export type FormGroupAsyncValidateOrFn< + TParentData, + TName extends DeepKeys, + TData extends DeepValue = DeepValue, +> = + | FormGroupValidateAsyncFn + | StandardSchemaV1 + +/** + * @private + */ +export type FormGroupListenerFn< + TParentData, + TName extends DeepKeys, + TData extends DeepValue = DeepValue, +> = (props: { + value: TData + groupApi: FormGroupApi< + TParentData, + TName, + TData, + // This is technically an edge-type; which we try to keep non-`any`, but in this case + // It's referring to an inaccessible type from the group listener function inner types, so it's not a big deal + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any + > +}) => void + +export interface FormGroupValidators< + TParentData, + TName extends DeepKeys, + TData extends DeepValue, + TOnMount extends undefined | FormGroupValidateOrFn, + TOnChange extends + | undefined + | FormGroupValidateOrFn, + TOnChangeAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnBlur extends undefined | FormGroupValidateOrFn, + TOnBlurAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnSubmit extends + | undefined + | FormGroupValidateOrFn, + TOnSubmitAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnDynamic extends + | undefined + | FormGroupValidateOrFn, + TOnDynamicAsync extends + | undefined + | FormGroupAsyncValidateOrFn, +> { + /** + * An optional function, that runs on the mount event of input. + */ + onMount?: TOnMount + /** + * An optional function, that runs on the change event of input. + * + * @example z.string().min(1) + */ + onChange?: TOnChange + /** + * An optional property similar to `onChange` but async validation + * + * @example z.string().refine(async (val) => val.length > 3, { message: 'Testing 123' }) + */ + onChangeAsync?: TOnChangeAsync + /** + * An optional number to represent how long the `onChangeAsync` should wait before running + * + * If set to a number larger than 0, will debounce the async validation event by this length of time in milliseconds + */ + onChangeAsyncDebounceMs?: number + /** + * An optional list of field names that should trigger this field's `onChange` and `onChangeAsync` events when its value changes + */ + onChangeListenTo?: DeepKeys[] + /** + * An optional function, that runs on the blur event of input. + * + * @example z.string().min(1) + */ + onBlur?: TOnBlur + /** + * An optional property similar to `onBlur` but async validation. + * + * @example z.string().refine(async (val) => val.length > 3, { message: 'Testing 123' }) + */ + onBlurAsync?: TOnBlurAsync + + /** + * An optional number to represent how long the `onBlurAsync` should wait before running + * + * If set to a number larger than 0, will debounce the async validation event by this length of time in milliseconds + */ + onBlurAsyncDebounceMs?: number + /** + * An optional list of field names that should trigger this field's `onBlur` and `onBlurAsync` events when its value changes + */ + onBlurListenTo?: DeepKeys[] + /** + * An optional function, that runs on the submit event of form. + * + * @example z.string().min(1) + */ + onSubmit?: TOnSubmit + /** + * An optional property similar to `onSubmit` but async validation. + * + * @example z.string().refine(async (val) => val.length > 3, { message: 'Testing 123' }) + */ + onSubmitAsync?: TOnSubmitAsync + onDynamic?: TOnDynamic + onDynamicAsync?: TOnDynamicAsync + onDynamicAsyncDebounceMs?: number +} + +export interface FormGroupListeners< + TParentData, + TName extends DeepKeys, + TData extends DeepValue = DeepValue, +> { + onChange?: FormGroupListenerFn + onChangeDebounceMs?: number + onBlur?: FormGroupListenerFn + onBlurDebounceMs?: number + onMount?: FormGroupListenerFn + onUnmount?: FormGroupListenerFn + onSubmit?: FormGroupListenerFn + onGroupSubmit?: FormGroupListenerFn +} + +interface FormGroupExtraOptions< + in out TParentData, + in out TName extends DeepKeys, + in out TData extends DeepValue, + in out TOnMount extends + | undefined + | FormGroupValidateOrFn, + in out TOnChange extends + | undefined + | FormGroupValidateOrFn, + in out TOnChangeAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + in out TOnBlur extends + | undefined + | FormGroupValidateOrFn, + in out TOnBlurAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + in out TOnSubmit extends + | undefined + | FormGroupValidateOrFn, + in out TOnSubmitAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + in out TOnDynamic extends + | undefined + | FormGroupValidateOrFn, + in out TOnDynamicAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + in out TSubmitMeta, + in out TFormOnMount extends undefined | FormValidateOrFn, + in out TFormOnChange extends undefined | FormValidateOrFn, + in out TFormOnChangeAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnBlur extends undefined | FormValidateOrFn, + in out TFormOnBlurAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnSubmit extends undefined | FormValidateOrFn, + in out TFormOnSubmitAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnDynamic extends undefined | FormValidateOrFn, + in out TFormOnDynamicAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnServer extends undefined | FormAsyncValidateOrFn, + in out TParentSubmitMeta, +> { + /** + * A list of validators to pass to the field + */ + validators?: FormGroupValidators< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync + > + + /** + * If true, allows the form to be submitted in an invalid state i.e. canSubmit will remain true regardless of validation errors. Defaults to undefined. + */ + canSubmitWhenInvalid?: boolean + + /** + * A list of listeners which attach to the corresponding events + */ + listeners?: FormGroupListeners + + defaultState?: FormGroupState + /** + * onSubmitMeta, the data passed from the handleSubmit handler, to the onSubmit function props + */ + onSubmitMeta?: TSubmitMeta + + /** + * A function to be called when the form is submitted, what should happen once the user submits a valid form returns `any` or a promise `Promise` + */ + onGroupSubmit?: (props: { + value: TData + groupApi: FormGroupApi< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TSubmitMeta, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta + > + meta: TSubmitMeta + }) => any | Promise + /** + * Specify an action for scenarios where the user tries to submit an invalid form. + */ + onGroupSubmitInvalid?: (props: { + value: TData + groupApi: FormGroupApi< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TSubmitMeta, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta + > + meta: TSubmitMeta + }) => void +} + +export interface FormGroupOptions< + in out TParentData, + in out TName extends DeepKeys, + in out TData extends DeepValue, + in out TOnMount extends + | undefined + | FormGroupValidateOrFn, + in out TOnChange extends + | undefined + | FormGroupValidateOrFn, + in out TOnChangeAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + in out TOnBlur extends + | undefined + | FormGroupValidateOrFn, + in out TOnBlurAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + in out TOnSubmit extends + | undefined + | FormGroupValidateOrFn, + in out TOnSubmitAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + in out TOnDynamic extends + | undefined + | FormGroupValidateOrFn, + in out TOnDynamicAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + in out TSubmitMeta, + in out TFormOnMount extends undefined | FormValidateOrFn, + in out TFormOnChange extends undefined | FormValidateOrFn, + in out TFormOnChangeAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnBlur extends undefined | FormValidateOrFn, + in out TFormOnBlurAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnSubmit extends undefined | FormValidateOrFn, + in out TFormOnSubmitAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnDynamic extends undefined | FormValidateOrFn, + in out TFormOnDynamicAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnServer extends undefined | FormAsyncValidateOrFn, + in out TParentSubmitMeta, +> + extends + FieldLikeOptions< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync + >, + FormGroupExtraOptions< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TSubmitMeta, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta + > {} + +export interface FormGroupApiOptions< + in out TParentData, + in out TName extends DeepKeys, + in out TData extends DeepValue, + in out TOnMount extends + | undefined + | FormGroupValidateOrFn, + in out TOnChange extends + | undefined + | FormGroupValidateOrFn, + in out TOnChangeAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + in out TOnBlur extends + | undefined + | FormGroupValidateOrFn, + in out TOnBlurAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + in out TOnSubmit extends + | undefined + | FormGroupValidateOrFn, + in out TOnSubmitAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + in out TOnDynamic extends + | undefined + | FormGroupValidateOrFn, + in out TOnDynamicAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + in out TSubmitMeta, + in out TFormOnMount extends undefined | FormValidateOrFn, + in out TFormOnChange extends undefined | FormValidateOrFn, + in out TFormOnChangeAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnBlur extends undefined | FormValidateOrFn, + in out TFormOnBlurAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnSubmit extends undefined | FormValidateOrFn, + in out TFormOnSubmitAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnDynamic extends undefined | FormValidateOrFn, + in out TFormOnDynamicAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnServer extends undefined | FormAsyncValidateOrFn, + in out TParentSubmitMeta, +> extends FormGroupOptions< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TSubmitMeta, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta +> { + form: FormApi< + TParentData, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta + > +} + +interface FormGroupState { + /** + * A boolean indicating if the form is currently in the process of being submitted after `handleSubmit` is called. + * + * Goes back to `false` when submission completes for one of the following reasons: + * - the validation step returned errors. + * - the `onSubmit` function has completed. + * + * Note: if you're running async operations in your `onSubmit` function make sure to await them to ensure `isSubmitting` is set to `false` only when the async operation completes. + * + * This is useful for displaying loading indicators or disabling form inputs during submission. + * + */ + isSubmitting: boolean + /** + * A boolean indicating if the `onSubmit` function has completed successfully. + * + * Goes back to `false` at each new submission attempt. + * + * Note: you can use isSubmitting to check if the form is currently submitting. + */ + isSubmitted: boolean + /** + * A boolean indicating if the form or any of its fields are currently validating. + */ + isValidating: boolean + /** + * A counter for tracking the number of submission attempts. + */ + submissionAttempts: number + /** + * A boolean indicating if the last submission was successful. + */ + isSubmitSuccessful: boolean +} + +function getDefaultFormGroupState( + defaultState: Partial, +): FormGroupState { + return { + isSubmitted: defaultState.isSubmitted ?? false, + isSubmitting: defaultState.isSubmitting ?? false, + isValidating: defaultState.isValidating ?? false, + submissionAttempts: defaultState.submissionAttempts ?? 0, + isSubmitSuccessful: defaultState.isSubmitSuccessful ?? false, + } +} + +/** + * @public + * + * A type representing the FormGroup API with all generics set to `any` for convenience. + */ +export type AnyFormGroupApi = FormGroupApi< + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any +> + +interface FormGroupStoreState extends AnyFieldLikeMeta { + isFieldsValidating: boolean + isFieldsValid: boolean + isGroupValid: boolean + canSubmit: boolean +} + +export class FormGroupApi< + in out TParentData, + in out TName extends DeepKeys, + in out TData extends DeepValue, + in out TOnMount extends + | undefined + | FormGroupValidateOrFn, + in out TOnChange extends + | undefined + | FormGroupValidateOrFn, + in out TOnChangeAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + in out TOnBlur extends + | undefined + | FormGroupValidateOrFn, + in out TOnBlurAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + in out TOnSubmit extends + | undefined + | FormGroupValidateOrFn, + in out TOnSubmitAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + in out TOnDynamic extends + | undefined + | FormGroupValidateOrFn, + in out TOnDynamicAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + in out TSubmitMeta, + in out TFormOnMount extends undefined | FormValidateOrFn, + in out TFormOnChange extends undefined | FormValidateOrFn, + in out TFormOnChangeAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnBlur extends undefined | FormValidateOrFn, + in out TFormOnBlurAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnSubmit extends undefined | FormValidateOrFn, + in out TFormOnSubmitAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnDynamic extends undefined | FormValidateOrFn, + in out TFormOnDynamicAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnServer extends undefined | FormAsyncValidateOrFn, + in out TParentSubmitMeta, +> + implements + FormLikeAPI, + FieldLikeAPI< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta, + FormGroupExtraOptions< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TSubmitMeta, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta + > + > +{ + /** + * A reference to the form API instance. + */ + form: FormGroupApiOptions< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TSubmitMeta, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta + >['form'] + /** + * The field name. + */ + name: TName + /** + * The field options. + */ + options: FormGroupApiOptions< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TSubmitMeta, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta + > = {} as any + /** + * The field state store. + */ + store!: ReadonlyStore< + FieldLikeState< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync + > + > + /** + * The current field state. + */ + get state() { + return this.store.state + } + + formStateStore: Store + + get formState() { + return this.formStateStore.state + } + + timeoutIds: { + validations: Record | null> + listeners: Record | null> + formListeners: Record | null> + } + + constructor( + opts: FormGroupApiOptions< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TSubmitMeta, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta + >, + ) { + this.form = opts.form + this.name = opts.name + this.options = opts + + this.timeoutIds = { + validations: {} as Record, + listeners: {} as Record, + formListeners: {} as Record, + } + + const formStateStoreVal: FormGroupState = getDefaultFormGroupState({ + ...(opts.defaultState as any), + }) + + this.formStateStore = createStore(formStateStoreVal) as never + + let prevMeta: AnyFieldLikeMeta | undefined = undefined + + this.store = createStore( + ( + prevVal: + | (FormGroupStoreState & + FieldLikeState< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync + >) + | undefined, + ) => { + // Temp hack to subscribe to form.store + this.form.store.get() + + const meta = this.form.getFieldMeta(this.name) ?? { + ...defaultFieldMeta, + ...opts.defaultMeta, + } + + let value = this.form.getFieldValue(this.name) + if ( + !meta.isTouched && + (value as unknown) === undefined && + this.options.defaultValue !== undefined && + !evaluate(value, this.options.defaultValue) + ) { + value = this.options.defaultValue + } + + const relatedFieldMeta = this.getRelatedFieldMetasDerived() + + const isFieldsValidating = relatedFieldMeta.some( + (field) => field.isValidating, + ) + + const isFieldsValid = relatedFieldMeta.every((field) => field.isValid) + + const isTouched = relatedFieldMeta.some((field) => field.isTouched) + const isBlurred = relatedFieldMeta.some((field) => field.isBlurred) + const isDefaultValue = relatedFieldMeta.every( + (field) => field.isDefaultValue, + ) + + const isDirty = relatedFieldMeta.some((field) => field.isDirty) + const isPristine = !isDirty + + const isValidating = !!isFieldsValidating + + // As `errors` is not a primitive, we need to aggressively persist the same referencial value for performance reasons + let errors = prevVal?.errors ?? [] + if (!prevMeta || meta.errorMap !== prevMeta.errorMap) { + errors = Object.values(meta.errorMap).reduce< + Array< + | UnwrapFieldValidateOrFn + | UnwrapFieldValidateOrFn + | UnwrapFieldAsyncValidateOrFn< + TName, + TOnChangeAsync, + TFormOnChangeAsync + > + | UnwrapFieldValidateOrFn + | UnwrapFieldAsyncValidateOrFn< + TName, + TOnBlurAsync, + TFormOnBlurAsync + > + | UnwrapFieldValidateOrFn + | UnwrapFieldAsyncValidateOrFn< + TName, + TOnSubmitAsync, + TFormOnSubmitAsync + > + > + >((prev, curr) => { + if (curr === undefined) return prev + + if (curr && isGlobalFormValidationError(curr)) { + prev.push(curr.form as never) + return prev + } + prev.push(curr as never) + return prev + }, []) + } + + const isGroupValid = errors.length === 0 + const isValid = isFieldsValid && isGroupValid + const submitInvalid = this.options.canSubmitWhenInvalid ?? false + const canSubmit = + (this.formStateStore.state.submissionAttempts === 0 && + !isTouched) /* && + !hasOnMountError */ || + (!isValidating && + !this.formStateStore.state.isSubmitting && + isValid) || + submitInvalid + + const errorMap = meta.errorMap + // TODO: Handle this + /* + if (shouldInvalidateOnMount) { + errors = errors.filter( + (err) => err !== currBaseStore.errorMap.onMount, + ) + errorMap = Object.assign(errorMap, { onMount: undefined }) + } + */ + + if ( + prevVal && + prevMeta && + prevVal.value === value && + prevVal.meta === meta && + prevVal.errorMap === errorMap && + prevVal.errors === errors && + prevVal.isFieldsValidating === isFieldsValidating && + prevVal.isFieldsValid === isFieldsValid && + prevVal.isGroupValid === isGroupValid && + prevVal.isValid === isValid && + prevVal.canSubmit === canSubmit && + prevVal.isTouched === isTouched && + prevVal.isBlurred === isBlurred && + prevVal.isPristine === isPristine && + prevVal.isDefaultValue === isDefaultValue && + prevVal.isDirty === isDirty && + evaluate(prevMeta, meta) + ) { + return prevVal + } + + const state = { + ...this.formStateStore.state, + value, + meta, + errorMap, + errors, + canSubmit, + isFieldsValidating, + isFieldsValid, + isGroupValid, + isValid, + isTouched, + isBlurred, + isPristine, + isDefaultValue, + isDirty, + errorSourceMap: {}, + } as FormGroupStoreState & + FieldLikeState< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync + > + + prevMeta = meta + + return state + }, + ) + + this.handleSubmit = this.handleSubmit.bind(this) + } + + /** + * Updates the field instance with new options. + */ + update = ( + opts: FormGroupApiOptions< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TSubmitMeta, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta + >, + ) => { + this.options = opts + this.name = opts.name + + // Default Value + if (!this.state.meta.isTouched && this.options.defaultValue !== undefined) { + const formField = this.form.getFieldValue(this.name) + if (!evaluate(formField, opts.defaultValue)) { + this.form.setFieldValue(this.name, opts.defaultValue as never, { + dontUpdateMeta: true, + dontValidate: true, + dontRunListeners: true, + }) + } + } + + if (!this.form.getFieldMeta(this.name)) { + this.form.setFieldMeta(this.name, this.state.meta) + } + } + + /** + * @private + */ + runValidator< + TValue extends TStandardSchemaValidatorValue & { + groupApi: AnyFormGroupApi + }, + TType extends 'validate' | 'validateAsync', + >(props: { + validate: TType extends 'validate' + ? FormGroupValidateOrFn + : FormGroupAsyncValidateOrFn + value: TValue + type: TType + // When `api` is 'field', the return type cannot be `FormValidationError` + }): unknown { + if (isStandardSchemaValidator(props.validate)) { + return standardSchemaValidators[props.type]( + props.value, + props.validate, + ) as never + } + + return (props.validate as FormGroupValidateFn)( + props.value, + ) as never + } + + mount = () => { + this.update(this.options as never) + this.form.formGroupApis.add(this) + return () => { + this.form.formGroupApis.delete(this) + } + } + + /** + * Sets the field value and run the `change` validator. + */ + setValue = (updater: Updater, options?: UpdateMetaOptions) => { + this.form.setFieldValue( + this.name, + updater as never, + mergeOpts(options, { dontRunListeners: true, dontValidate: true }), + ) + + if (!options?.dontRunListeners) { + this.triggerOnChangeListener() + } + + if (!options?.dontValidate) { + this.validate('change') + } + } + + getMeta = () => this.store.state.meta + + /** + * Sets the field metadata. + */ + setMeta = ( + updater: Updater< + FieldLikeMetaBase< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync + > + >, + ) => this.form.setFieldMeta(this.name, updater) + + /** + * Gets the field information object. + */ + getInfo = () => this.form.getFieldInfo(this.name) + + /** + * @private + */ + getRelatedFields = () => { + const fields = Object.values(this.form.fieldInfo) as FieldInfo[] + + const relatedFields: AnyFieldApi[] = [] + for (const field of fields) { + if (!field.instance) continue + // TODO: How to handle FormGroups? + if (!(field.instance instanceof FieldApi)) continue + if (field.instance.name.startsWith(this.name)) { + relatedFields.push(field.instance) + } + } + + return relatedFields + } + /** + * @private + */ + getRelatedFieldMetasDerived = () => { + const fields = Object.entries(this.form.fieldMetaDerived.state) as [ + string, + AnyFieldLikeMeta, + ][] + + const relatedFieldMetas: (AnyFieldLikeMeta & { name: string })[] = [] + for (const [fieldName, fieldMeta] of fields) { + if (fieldName.startsWith(this.name)) { + relatedFieldMetas.push({ ...fieldMeta, name: fieldName }) + } + } + + return relatedFieldMetas + } + + /** + * @private + */ + validateSync = ( + cause: ValidationCause, + errorFromForm: ValidationErrorMap, + opts: { + skipRelatedFieldValidation?: boolean + } = {}, + ) => { + const validates = getSyncValidatorArray(cause, { + ...this.options, + form: this.form, + group: this, + validationLogic: + this.form.options.validationLogic || defaultValidationLogic, + }) + + const relatedFields = opts.skipRelatedFieldValidation + ? [] + : this.getRelatedFields() + const relatedFieldValidates = relatedFields.reduce( + (acc, field) => { + const fieldValidates = getSyncValidatorArray(cause, { + ...field.options, + form: field.form, + validationLogic: + field.form.options.validationLogic || defaultValidationLogic, + }) + fieldValidates.forEach((validate) => { + ;(validate as any).field = field + }) + return acc.concat(fieldValidates as never) + }, + [] as Array< + SyncValidator & { + field: AnyFieldApi + } + >, + ) + + // Needs type cast as eslint errantly believes this is always falsy + let hasErrored = false as boolean + + batch(() => { + const validateFieldOrGroupFn = ( + fieldOrGroup: AnyFieldApi | AnyFormGroupApi, + validateObj: SyncValidator, + ) => { + const errorMapKey = getErrorMapKey(validateObj.cause) + + const fieldLevelError = validateObj.validate + ? normalizeError( + // TODO: Remove `any` cast + (fieldOrGroup as any).runValidator({ + validate: validateObj.validate, + value: { + value: fieldOrGroup.store.state.value, + validationSource: 'field', + ...(fieldOrGroup instanceof FormGroupApi + ? { + groupApi: fieldOrGroup, + } + : { fieldApi: fieldOrGroup }), + } as never, + type: 'validate', + }), + ) + : undefined + + const formLevelError = errorFromForm[errorMapKey] + + const { newErrorValue, newSource } = + determineFieldLevelErrorSourceAndValue({ + formLevelError, + fieldLevelError, + }) + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (fieldOrGroup.state.meta.errorMap?.[errorMapKey] !== newErrorValue) { + fieldOrGroup.setMeta((prev) => ({ + ...prev, + errorMap: { + ...prev.errorMap, + [errorMapKey]: newErrorValue, + }, + errorSourceMap: { + ...prev.errorSourceMap, + [errorMapKey]: newSource, + }, + })) + } + if (newErrorValue) { + hasErrored = true + } + } + + for (const validateObj of validates) { + validateFieldOrGroupFn(this, validateObj) + } + for (const fieldValidateObj of relatedFieldValidates) { + if (!fieldValidateObj.validate) continue + validateFieldOrGroupFn(fieldValidateObj.field, fieldValidateObj) + } + }) + + /** + * when we have an error for onSubmit in the state, we want + * to clear the error as soon as the user enters a valid value in the field + */ + const submitErrKey = getErrorMapKey('submit') + + if ( + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + this.state.meta.errorMap?.[submitErrKey] && + cause !== 'submit' && + !hasErrored + ) { + this.setMeta((prev) => ({ + ...prev, + errorMap: { + ...prev.errorMap, + [submitErrKey]: undefined, + }, + errorSourceMap: { + ...prev.errorSourceMap, + [submitErrKey]: undefined, + }, + })) + } + + return { hasErrored } + } + + /** + * @private + */ + validateAsync = async ( + cause: ValidationCause, + formValidationResultPromise: Promise< + FieldErrorMapFromValidator< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync + > + >, + opts: { + skipRelatedFieldValidation?: boolean + } = {}, + ) => { + const validates = getAsyncValidatorArray(cause, { + ...this.options, + form: this.form, + group: this, + validationLogic: + this.form.options.validationLogic || defaultValidationLogic, + }) + + // Get the field-specific error messages that are coming from the form's validator + const asyncFormValidationResults = await formValidationResultPromise + + const relatedFields = opts.skipRelatedFieldValidation + ? [] + : this.getRelatedFields() + const relatedFieldValidates = relatedFields.reduce( + (acc, field) => { + const fieldValidates = getAsyncValidatorArray(cause, { + ...field.options, + form: field.form, + validationLogic: + field.form.options.validationLogic || defaultValidationLogic, + }) + fieldValidates.forEach((validate) => { + ;(validate as any).field = field + }) + return acc.concat(fieldValidates as never) + }, + [] as Array< + AsyncValidator & { + field: AnyFieldApi + } + >, + ) + + /** + * We have to use a for loop and generate our promises this way, otherwise it won't be sync + * when there are no validators needed to be run + */ + const validatesPromises: Promise[] = [] + const linkedPromises: Promise[] = [] + + // Check if there are actual async validators to run before setting isValidating + // This prevents unnecessary re-renders when there are no async validators + // See: https://github.com/TanStack/form/issues/1130 + const hasAsyncValidators = + validates.some((v) => v.validate) || + relatedFieldValidates.some((v) => v.validate) + + if (hasAsyncValidators) { + if (!this.state.meta.isValidating) { + this.setMeta((prev) => ({ ...prev, isValidating: true })) + } + + for (const linkedField of relatedFields) { + linkedField.setMeta((prev) => ({ ...prev, isValidating: true })) + } + } + + const validateFieldOrGroupAsyncFn = ( + fieldOrGroup: AnyFieldApi | AnyFormGroupApi, + validateObj: AsyncValidator, + promises: Promise[], + ) => { + const errorMapKey = getErrorMapKey(validateObj.cause) + const fieldInfo = fieldOrGroup.getInfo() + const fieldValidatorMeta = fieldInfo.validationMetaMap[errorMapKey] + + fieldValidatorMeta?.lastAbortController.abort() + const controller = new AbortController() + + fieldInfo.validationMetaMap[errorMapKey] = { + lastAbortController: controller, + } + + promises.push( + new Promise(async (resolve) => { + let rawError!: ValidationError | undefined + try { + rawError = await new Promise((rawResolve, rawReject) => { + if (fieldOrGroup.timeoutIds.validations[validateObj.cause]) { + clearTimeout( + fieldOrGroup.timeoutIds.validations[validateObj.cause]!, + ) + } + + fieldOrGroup.timeoutIds.validations[validateObj.cause] = + setTimeout(async () => { + if (controller.signal.aborted) return rawResolve(undefined) + try { + rawResolve( + await this.runValidator({ + validate: validateObj.validate, + value: { + value: fieldOrGroup.store.state.value, + signal: controller.signal, + validationSource: 'field', + ...(fieldOrGroup instanceof FormGroupApi + ? { + groupApi: fieldOrGroup, + } + : { fieldApi: fieldOrGroup }), + } as never, + type: 'validateAsync', + }), + ) + } catch (e) { + rawReject(e) + } + }, validateObj.debounceMs) + }) + } catch (e: unknown) { + rawError = e as ValidationError + } + if (controller.signal.aborted) return resolve(undefined) + + const fieldLevelError = normalizeError(rawError) + const formLevelError = + asyncFormValidationResults[ + fieldOrGroup.name as keyof typeof asyncFormValidationResults + ]?.[errorMapKey] + + const { newErrorValue, newSource } = + determineFieldLevelErrorSourceAndValue({ + formLevelError, + fieldLevelError, + }) + + if (fieldOrGroup.getInfo().instance !== fieldOrGroup) { + return resolve(undefined) + } + + fieldOrGroup.setMeta((prev) => { + return { + ...prev, + errorMap: { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + ...prev?.errorMap, + [errorMapKey]: newErrorValue, + }, + errorSourceMap: { + ...prev.errorSourceMap, + [errorMapKey]: newSource, + }, + } + }) + + resolve(newErrorValue) + }), + ) + } + + // TODO: Dedupe this logic to reduce bundle size + for (const validateObj of validates) { + if (!validateObj.validate) continue + validateFieldOrGroupAsyncFn(this, validateObj, validatesPromises) + } + for (const fieldValitateObj of relatedFieldValidates) { + if (!fieldValitateObj.validate) continue + validateFieldOrGroupAsyncFn( + fieldValitateObj.field, + fieldValitateObj, + linkedPromises, + ) + } + + let results: ValidationError[] = [] + if (validatesPromises.length || linkedPromises.length) { + results = await Promise.all(validatesPromises) + await Promise.all(linkedPromises) + } + + // Only reset isValidating if we set it to true earlier + if (hasAsyncValidators) { + this.setMeta((prev) => ({ ...prev, isValidating: false })) + + for (const linkedField of relatedFields) { + linkedField.setMeta((prev) => ({ ...prev, isValidating: false })) + } + } + + return results.filter(Boolean) + } + + /** + * Validates all fields according to the FIELD level validators. + * This will ignore FORM level validators, use form.validate({ValidationCause}) for a complete validation + */ + validateAllFields = async (cause: ValidationCause) => { + const fieldValidationPromises: Promise[] = [] as any + + batch(() => { + void Object.values(this.getRelatedFields()).forEach((fieldInstance) => { + // Validate the field + fieldValidationPromises.push( + // Remember, `validate` is either a sync operation or a promise + Promise.resolve().then(() => + fieldInstance.validate(cause, { + skipFormValidation: true, + skipGroupValidation: true, + }), + ), + ) + + // If any fields are not touched + if (!fieldInstance.store.state.meta.isTouched) { + // Mark them as touched + fieldInstance.setMeta((prev) => ({ ...prev, isTouched: true })) + } + }) + }) + + const fieldErrorMapMap = await Promise.all(fieldValidationPromises) + return fieldErrorMapMap.flat() + } + + validateArrayFieldsStartingFrom = < + TField extends DeepKeysOfType, + >( + field: TField, + index: number, + cause: ValidationCause, + ) => { + return this.form.validateArrayFieldsStartingFrom(field, index, cause) + } + + validateField = >( + field: TField, + cause: ValidationCause, + ) => { + return this.form.validateField(field, cause) + } + + getFieldValue = >( + field: TField, + ) => { + return this.form.getFieldValue(field) + } + + getFieldMeta = >( + field: TField, + ) => { + return this.form.getFieldMeta(field) + } + + setFieldMeta = >( + field: TField, + updater: Updater, + ) => { + return this.form.setFieldMeta(field, updater) + } + + setFieldValue = >( + field: TField, + value: any, + ) => { + return this.form.setFieldValue(field, value) + } + + deleteField = >( + field: TField, + ) => { + return this.form.deleteField(field) + } + + pushFieldValue = >( + field: TField, + value: any, + ) => { + return this.form.pushFieldValue(field, value) + } + + insertFieldValue = >( + field: TField, + index: number, + value: any, + ) => { + return this.form.insertFieldValue(field, index, value) + } + + replaceFieldValue = >( + field: TField, + index: number, + value: any, + ) => { + return this.form.replaceFieldValue(field, index, value) + } + + swapFieldValues = >( + field: TField, + index1: number, + index2: number, + ) => { + return this.form.swapFieldValues(field, index1, index2) + } + + moveFieldValues = >( + field: TField, + fromIndex: number, + toIndex: number, + ) => { + return this.form.moveFieldValues(field, fromIndex, toIndex) + } + + clearFieldValues = >( + field: TField, + ) => { + return this.form.clearFieldValues(field) + } + + resetField = >( + field: TField, + ) => { + return this.form.resetField(field) + } + + removeFieldValue = >( + field: TField, + index: number, + ) => { + return this.form.removeFieldValue(field, index) + } + + areRelatedFieldsValid = () => { + return Object.values(this.getRelatedFields()).every( + (field) => field.state.meta.isValid, + ) + } + + /** + * Validates the form group and all related children. + */ + validate = ( + cause: ValidationCause, + opts?: { + skipFormValidation?: boolean + skipRelatedFieldValidation?: boolean + }, + ): ValidationError[] | Promise => { + // Attempt to sync validate first + const { fieldsErrorMap } = opts?.skipFormValidation + ? { fieldsErrorMap: {} as never } + : this.form.validateSync(cause, { + dontUpdateFormErrorMap: true, + filterFieldNames: (fieldName) => fieldName.startsWith(this.name), + }) + const { hasErrored } = this.validateSync( + cause, + fieldsErrorMap[this.name] ?? {}, + { skipRelatedFieldValidation: opts?.skipRelatedFieldValidation }, + ) + + if (hasErrored && !this.options.asyncAlways) { + this.getInfo().validationMetaMap[ + getErrorMapKey(cause) + ]?.lastAbortController.abort() + return this.state.meta.errors + } + + // No error? Attempt async validation + const formValidationResultPromise = opts?.skipFormValidation + ? Promise.resolve({}) + : this.form.validateAsync(cause, { + dontUpdateFormErrorMap: true, + filterFieldNames: (fieldName) => fieldName.startsWith(this.name), + }) + return this.validateAsync(cause, formValidationResultPromise, { + skipRelatedFieldValidation: opts?.skipRelatedFieldValidation, + }) + } + + /** + * @private + */ + triggerOnChangeListener = () => { + // // TODO: Solve typings with formListener getting a fieldApi vs a groupApi + // const formDebounceMs = this.form.options.listeners?.onChangeDebounceMs + // if (formDebounceMs && formDebounceMs > 0) { + // if (this.timeoutIds.formListeners.change) { + // clearTimeout(this.timeoutIds.formListeners.change) + // } + // + // this.timeoutIds.formListeners.change = setTimeout(() => { + // this.form.options.listeners?.onChange?.({ + // formApi: this.form, + // groupApi: this, + // }) + // }, formDebounceMs) + // } else { + // this.form.options.listeners?.onChange?.({ + // formApi: this.form, + // groupApi: this, + // }) + // } + + const fieldDebounceMs = this.options.listeners?.onChangeDebounceMs + if (fieldDebounceMs && fieldDebounceMs > 0) { + if (this.timeoutIds.listeners.change) { + clearTimeout(this.timeoutIds.listeners.change) + } + + this.timeoutIds.listeners.change = setTimeout(() => { + this.options.listeners?.onChange?.({ + value: this.state.value, + groupApi: this, + }) + }, fieldDebounceMs) + } else { + this.options.listeners?.onChange?.({ + value: this.state.value, + groupApi: this, + }) + } + } + + /** + * @private + */ + triggerOnSubmitListener = () => { + this.options.listeners?.onSubmit?.({ + value: this.state.value, + groupApi: this, + }) + } + + // Needs to edgecase in the React adapter specifically to avoid type errors + handleSubmit(): Promise + handleSubmit(submitMeta: TSubmitMeta): Promise + handleSubmit(submitMeta?: TSubmitMeta): Promise { + return this._handleSubmit(submitMeta) + } + + /** + * Handles the form submission, performs validation, and calls the appropriate onSubmit or onSubmitInvalid callbacks. + */ + _handleSubmit = async (submitMeta?: TSubmitMeta): Promise => { + this.formStateStore.setState((old) => ({ + ...old, + // Submission attempts mark the form as not submitted + isSubmitted: false, + // Count submission attempts + submissionAttempts: old.submissionAttempts + 1, + isSubmitSuccessful: false, // Reset isSubmitSuccessful at the start of submission + })) + + batch(() => { + void Object.values(this.getRelatedFields()).forEach((field) => { + // If any fields are not touched + if (!field.state.meta.isTouched) { + // Mark them as touched + field.setMeta((prev) => ({ ...prev, isTouched: true })) + } + }) + }) + + const submitMetaArg = + submitMeta ?? (this.options.onSubmitMeta as TSubmitMeta) + + this.formStateStore.setState((d) => ({ ...d, isSubmitting: true })) + + const done = () => { + this.formStateStore.setState((prev) => ({ ...prev, isSubmitting: false })) + } + + await this.validateAllFields('submit') + + // Fields are invalid, do not submit + if (!this.areRelatedFieldsValid()) { + done() + + this.options.onGroupSubmitInvalid?.({ + value: this.state.value, + groupApi: this, + meta: submitMetaArg, + }) + + return + } + + await this.validate('submit', { + // This has already happened in the previous step + skipRelatedFieldValidation: true, + }) + + // Group (or related fields) is invalid, do not submit. Mirrors + // `FormApi._handleSubmit`'s check against the derived `state.isValid`, + // which includes both the group's own validators and any form-level + // errors propagated onto related fields by `validate('submit')` above + // (e.g. `onDynamic` errors via `revalidateLogic`). + + if (!this.areRelatedFieldsValid() || !this.state.meta.isValid) { + done() + + this.options.onGroupSubmitInvalid?.({ + value: this.state.value, + groupApi: this, + meta: submitMetaArg, + }) + + return + } + + batch(() => { + void Object.values(this.getRelatedFields()).forEach((field) => { + field.options.listeners?.onGroupSubmit?.({ + value: field.state.value, + fieldApi: field, + }) + }) + }) + + this.options.listeners?.onSubmit?.({ + groupApi: this, + value: this.state.value, + }) + + try { + // Run the submit code + await this.options.onGroupSubmit?.({ + value: this.state.value, + groupApi: this, + meta: submitMetaArg, + }) + + batch(() => { + this.formStateStore.setState((prev) => ({ + ...prev, + isSubmitted: true, + isSubmitSuccessful: true, // Set isSubmitSuccessful to true on successful submission + })) + + done() + }) + } catch (err) { + this.formStateStore.setState((prev) => ({ + ...prev, + isSubmitSuccessful: false, // Ensure isSubmitSuccessful is false if an error occurs + })) + + done() + + throw err + } + } +} + +function normalizeError(rawError?: ValidationError) { + if (rawError) { + return rawError + } + + return undefined +} + +function getErrorMapKey(cause: ValidationCause) { + switch (cause) { + case 'submit': + return 'onSubmit' + case 'blur': + return 'onBlur' + case 'mount': + return 'onMount' + case 'server': + return 'onServer' + case 'dynamic': + return 'onDynamic' + case 'change': + default: + return 'onChange' + } +} diff --git a/packages/form-core/src/ValidationLogic.ts b/packages/form-core/src/ValidationLogic.ts index dbf2818fc..ea587c22c 100644 --- a/packages/form-core/src/ValidationLogic.ts +++ b/packages/form-core/src/ValidationLogic.ts @@ -1,4 +1,5 @@ import type { AnyFormApi, FormValidators } from './FormApi' +import type { AnyFormGroupApi } from './FormGroupApi' export interface ValidationLogicValidatorsFn { // TODO: Type this properly @@ -20,6 +21,12 @@ export interface ValidationLogicValidatorsFn { export interface ValidationLogicProps { // TODO: Type this properly form: AnyFormApi + /** + * Set when the validators being processed belong to a `FormGroupApi`. + * Allows validation strategies (e.g. `revalidateLogic`) to gate their + * behavior on the group's own state instead of the parent form's. + */ + group?: AnyFormGroupApi // TODO: Type this properly validators: | FormValidators @@ -88,8 +95,15 @@ export const revalidateLogic = const validatorsToAdd = [] as ValidationLogicValidatorsFn[] - const modeToWatch = - props.form.state.submissionAttempts === 0 ? mode : modeAfterSubmission + // When validating a `FormGroupApi`'s own validators, gate on the group's + // submission attempts so a group's `onDynamic` validator only flips into + // `modeAfterSubmission` after that group itself has been submitted. + // Otherwise (form-level validators), gate on the parent form. + const submissionAttempts = props.group + ? props.group.formState.submissionAttempts + : props.form.state.submissionAttempts + + const modeToWatch = submissionAttempts === 0 ? mode : modeAfterSubmission if ([modeToWatch, 'submit'].includes(props.event.type)) { validatorsToAdd.push(dynamicValidator) diff --git a/packages/form-core/src/index.ts b/packages/form-core/src/index.ts index 94c0f3eea..b7fd096ce 100644 --- a/packages/form-core/src/index.ts +++ b/packages/form-core/src/index.ts @@ -1,5 +1,6 @@ export * from './FormApi' export * from './FieldApi' +export * from './FormGroupApi' export * from './utils' export * from './util-types' export * from './types' diff --git a/packages/form-core/src/metaHelper.ts b/packages/form-core/src/metaHelper.ts index a990c0c0a..f8a20ad7b 100644 --- a/packages/form-core/src/metaHelper.ts +++ b/packages/form-core/src/metaHelper.ts @@ -3,12 +3,12 @@ import type { FormAsyncValidateOrFn, FormValidateOrFn, } from './FormApi' -import type { AnyFieldMeta } from './FieldApi' import type { DeepKeys } from './util-types' +import type { AnyFieldLikeMeta } from './types' type ValueFieldMode = 'insert' | 'remove' | 'swap' | 'move' -export const defaultFieldMeta: AnyFieldMeta = { +export const defaultFieldMeta: AnyFieldLikeMeta = { isValidating: false, isTouched: false, isBlurred: false, @@ -77,7 +77,7 @@ export function metaHelper< } return fieldMap }, - new Map, AnyFieldMeta | undefined>(), + new Map, AnyFieldLikeMeta | undefined>(), ) shiftMeta(affectedFields, fromIndex < toIndex ? 'up' : 'down') @@ -226,7 +226,7 @@ export function metaHelper< }) } - const getEmptyFieldMeta = (): AnyFieldMeta => defaultFieldMeta + const getEmptyFieldMeta = (): AnyFieldLikeMeta => defaultFieldMeta return { handleArrayMove, diff --git a/packages/form-core/src/standardSchemaValidator.ts b/packages/form-core/src/standardSchemaValidator.ts index ae4b93c35..764852856 100644 --- a/packages/form-core/src/standardSchemaValidator.ts +++ b/packages/form-core/src/standardSchemaValidator.ts @@ -86,8 +86,9 @@ export const standardSchemaValidators = { if (!result.issues) return - if (validationSource === 'field') + if (validationSource === 'field') { return result.issues as TStandardSchemaValidatorIssue + } return transformFormIssues(result.issues, value) }, async validateAsync( @@ -101,8 +102,9 @@ export const standardSchemaValidators = { if (!result.issues) return - if (validationSource === 'field') + if (validationSource === 'field') { return result.issues as TStandardSchemaValidatorIssue + } return transformFormIssues(result.issues, value) }, } diff --git a/packages/form-core/src/types.ts b/packages/form-core/src/types.ts index ff5824283..9e4814aa2 100644 --- a/packages/form-core/src/types.ts +++ b/packages/form-core/src/types.ts @@ -1,6 +1,36 @@ -import type { AnyFieldMeta, AnyFieldMetaBase } from './FieldApi' -import type { DeepKeys, DeepKeysOfType, DeepValue } from './util-types' +import type { + FieldAsyncValidateOrFn, + FieldValidateAsyncFn, + FieldValidateFn, + FieldValidateOrFn, +} from './FieldApi' +import type { + DeepKeys, + DeepKeysOfType, + DeepValue, + UnwrapOneLevelOfArray, +} from './util-types' import type { Updater } from './utils' +import type { + AnyFormApi, + FormApi, + FormAsyncValidateOrFn, + FormValidateAsyncFn, + FormValidateFn, + FormValidateOrFn, + ValidationMeta, +} from './FormApi' +import type { ReadonlyStore } from '@tanstack/store' +import type { + FormGroupAsyncValidateOrFn, + FormGroupValidateAsyncFn, + FormGroupValidateFn, + FormGroupValidateOrFn, +} from './FormGroupApi' +import type { + StandardSchemaV1, + StandardSchemaV1Issue, +} from './standardSchemaValidator' export type ValidationError = unknown @@ -146,9 +176,8 @@ export interface UpdateMetaOptions { /** * @private - * A list of field manipulation methods that a form-like API must implement. */ -export interface FieldManipulator { +export interface FormLikeAPI { /** * Validates all fields using the correct handlers for a given validation cause. */ @@ -191,14 +220,14 @@ export interface FieldManipulator { */ getFieldMeta: >( field: TField, - ) => AnyFieldMeta | undefined + ) => AnyFieldLikeMeta | undefined /** * Updates the metadata of the specified field. */ setFieldMeta: >( field: TField, - updater: Updater, + updater: Updater, ) => void /** @@ -292,3 +321,941 @@ export interface FieldManipulator { */ resetField: >(field: TField) => void } + +type UnwrapFormAsyncValidateOrFnForInner< + TValidateOrFn extends undefined | FormAsyncValidateOrFn, +> = [TValidateOrFn] extends [FormValidateAsyncFn] + ? Awaited> + : [TValidateOrFn] extends [StandardSchemaV1] + ? StandardBrandedSchemaV1 + : undefined + +export type UnwrapFieldAsyncValidateOrFn< + TName extends string, + TValidateOrFn extends + | undefined + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, + TFormValidateOrFn extends undefined | FormAsyncValidateOrFn, +> = + | ([TFormValidateOrFn] extends [StandardSchemaV1] + ? TName extends keyof TStandardOut + ? StandardSchemaV1Issue[] + : undefined + : undefined) + | (UnwrapFormAsyncValidateOrFnForInner extends infer TFormValidateVal + ? TFormValidateVal extends { __standardSchemaV1: true } + ? [DeepValue] extends [never] + ? undefined + : StandardSchemaV1Issue[] + : TFormValidateVal extends { fields: any } + ? TName extends keyof TFormValidateVal['fields'] + ? TFormValidateVal['fields'][TName] + : undefined + : undefined + : never) + | ([TValidateOrFn] extends [FieldValidateAsyncFn] + ? Awaited> + : [TValidateOrFn] extends [StandardSchemaV1] + ? // TODO: Check if `disableErrorFlat` is enabled, if so, return StandardSchemaV1Issue[][] + StandardSchemaV1Issue[] + : undefined) + | ([TValidateOrFn] extends [FormGroupValidateAsyncFn] + ? Awaited> + : [TValidateOrFn] extends [StandardSchemaV1] + ? // TODO: Check if `disableErrorFlat` is enabled, if so, return StandardSchemaV1Issue[][] + StandardSchemaV1Issue[] + : undefined) + +/** + * @private + */ +// TODO: Add the `Unwrap` type to the errors +export type FieldErrorMapFromValidator< + TParentData, + TName extends DeepKeys, + TData extends DeepValue, + TOnMount extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, + TOnChange extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, + TOnChangeAsync extends + | undefined + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, + TOnBlur extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, + TOnBlurAsync extends + | undefined + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, + TOnSubmit extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, + TOnSubmitAsync extends + | undefined + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, +> = Partial< + Record< + DeepKeys, + ValidationErrorMap< + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync + > + > +> + +type StandardBrandedSchemaV1 = T & { __standardSchemaV1: true } + +type UnwrapFormValidateOrFnForInner< + TValidateOrFn extends undefined | FormValidateOrFn, +> = [TValidateOrFn] extends [FormValidateFn] + ? ReturnType + : [TValidateOrFn] extends [StandardSchemaV1] + ? StandardBrandedSchemaV1 + : undefined + +export type UnwrapFieldValidateOrFn< + TName extends string, + TValidateOrFn extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, + TFormValidateOrFn extends undefined | FormValidateOrFn, +> = + | ([TFormValidateOrFn] extends [StandardSchemaV1] + ? TName extends keyof TStandardOut + ? StandardSchemaV1Issue[] + : undefined + : undefined) + | (UnwrapFormValidateOrFnForInner extends infer TFormValidateVal + ? TFormValidateVal extends { __standardSchemaV1: true } + ? [DeepValue] extends [never] + ? undefined + : StandardSchemaV1Issue[] + : TFormValidateVal extends { fields: any } + ? TName extends keyof TFormValidateVal['fields'] + ? TFormValidateVal['fields'][TName] + : undefined + : undefined + : never) + | ([TValidateOrFn] extends [FieldValidateFn] + ? ReturnType + : [TValidateOrFn] extends [StandardSchemaV1] + ? // TODO: Check if `disableErrorFlat` is enabled, if so, return StandardSchemaV1Issue[][] + StandardSchemaV1Issue[] + : undefined) + | ([TValidateOrFn] extends [FormGroupValidateFn] + ? ReturnType + : [TValidateOrFn] extends [StandardSchemaV1] + ? // TODO: Check if `disableErrorFlat` is enabled, if so, return StandardSchemaV1Issue[][] + StandardSchemaV1Issue[] + : undefined) + +/** + * @private + */ +export type FieldLikeMetaBase< + TParentData, + TName extends DeepKeys, + TData extends DeepValue, + TOnMount extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, + TOnChange extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, + TOnChangeAsync extends + | undefined + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, + TOnBlur extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, + TOnBlurAsync extends + | undefined + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, + TOnSubmit extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, + TOnSubmitAsync extends + | undefined + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, + TOnDynamic extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, + TOnDynamicAsync extends + | undefined + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, + TFormOnMount extends undefined | FormValidateOrFn, + TFormOnChange extends undefined | FormValidateOrFn, + TFormOnChangeAsync extends undefined | FormAsyncValidateOrFn, + TFormOnBlur extends undefined | FormValidateOrFn, + TFormOnBlurAsync extends undefined | FormAsyncValidateOrFn, + TFormOnSubmit extends undefined | FormValidateOrFn, + TFormOnSubmitAsync extends undefined | FormAsyncValidateOrFn, + TFormOnDynamic extends undefined | FormValidateOrFn, + TFormOnDynamicAsync extends undefined | FormAsyncValidateOrFn, +> = { + /** + * A flag indicating whether the field has been touched. + */ + isTouched: boolean + /** + * A flag indicating whether the field has been blurred. + */ + isBlurred: boolean + /** + * A flag that is `true` if the field's value has been modified by the user. Opposite of `isPristine`. + */ + isDirty: boolean + /** + * A map of errors related to the field value. + */ + errorMap: ValidationErrorMap< + UnwrapFieldValidateOrFn, + UnwrapFieldValidateOrFn, + UnwrapFieldAsyncValidateOrFn, + UnwrapFieldValidateOrFn, + UnwrapFieldAsyncValidateOrFn, + UnwrapFieldValidateOrFn, + UnwrapFieldAsyncValidateOrFn, + UnwrapFieldValidateOrFn, + UnwrapFieldAsyncValidateOrFn + > + + /** + * @private allows tracking the source of the errors in the error map + */ + errorSourceMap: ValidationErrorMapSource + /** + * A flag indicating whether the field is currently being validated. + */ + isValidating: boolean +} + +/** + * @private + */ +export type AnyFieldLikeMetaBase = FieldLikeMetaBase< + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any +> + +/** + * @private + */ +export type FieldLikeMetaDerived< + TParentData, + TName extends DeepKeys, + TData extends DeepValue, + TOnMount extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, + TOnChange extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, + TOnChangeAsync extends + | undefined + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, + TOnBlur extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, + TOnBlurAsync extends + | undefined + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, + TOnSubmit extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, + TOnSubmitAsync extends + | undefined + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, + TOnDynamic extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, + TOnDynamicAsync extends + | undefined + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, + TFormOnMount extends undefined | FormValidateOrFn, + TFormOnChange extends undefined | FormValidateOrFn, + TFormOnChangeAsync extends undefined | FormAsyncValidateOrFn, + TFormOnBlur extends undefined | FormValidateOrFn, + TFormOnBlurAsync extends undefined | FormAsyncValidateOrFn, + TFormOnSubmit extends undefined | FormValidateOrFn, + TFormOnSubmitAsync extends undefined | FormAsyncValidateOrFn, + TFormOnDynamic extends undefined | FormValidateOrFn, + TFormOnDynamicAsync extends undefined | FormAsyncValidateOrFn, +> = { + /** + * An array of errors related to the field value. + */ + errors: Array< + | UnwrapOneLevelOfArray< + UnwrapFieldValidateOrFn + > + | UnwrapOneLevelOfArray< + UnwrapFieldValidateOrFn + > + | UnwrapOneLevelOfArray< + UnwrapFieldAsyncValidateOrFn + > + | UnwrapOneLevelOfArray< + UnwrapFieldValidateOrFn + > + | UnwrapOneLevelOfArray< + UnwrapFieldAsyncValidateOrFn + > + | UnwrapOneLevelOfArray< + UnwrapFieldValidateOrFn + > + | UnwrapOneLevelOfArray< + UnwrapFieldAsyncValidateOrFn + > + | UnwrapOneLevelOfArray< + UnwrapFieldValidateOrFn + > + | UnwrapOneLevelOfArray< + UnwrapFieldAsyncValidateOrFn< + TName, + TOnDynamicAsync, + TFormOnDynamicAsync + > + > + > + /** + * A flag that is `true` if the field's value has not been modified by the user. Opposite of `isDirty`. + */ + isPristine: boolean + /** + * A boolean indicating if the field is valid. Evaluates `true` if there are no field errors. + */ + isValid: boolean + /** + * A flag indicating whether the field's current value is the default value + */ + isDefaultValue: boolean +} + +/** + * @private + * An object type representing the metadata of a field in a form. + */ +export type FieldLikeMeta< + TParentData, + TName extends DeepKeys, + TData extends DeepValue, + TOnMount extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, + TOnChange extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, + TOnChangeAsync extends + | undefined + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, + TOnBlur extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, + TOnBlurAsync extends + | undefined + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, + TOnSubmit extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, + TOnSubmitAsync extends + | undefined + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, + TOnDynamic extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, + TOnDynamicAsync extends + | undefined + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, + TFormOnMount extends undefined | FormValidateOrFn, + TFormOnChange extends undefined | FormValidateOrFn, + TFormOnChangeAsync extends undefined | FormAsyncValidateOrFn, + TFormOnBlur extends undefined | FormValidateOrFn, + TFormOnBlurAsync extends undefined | FormAsyncValidateOrFn, + TFormOnSubmit extends undefined | FormValidateOrFn, + TFormOnSubmitAsync extends undefined | FormAsyncValidateOrFn, + TFormOnDynamic extends undefined | FormValidateOrFn, + TFormOnDynamicAsync extends undefined | FormAsyncValidateOrFn, +> = FieldLikeMetaBase< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync +> & + FieldLikeMetaDerived< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync + > + +/** + * @private + */ +export type AnyFieldLikeMeta = FieldLikeMeta< + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any +> + +export type AnyFieldMeta = AnyFieldLikeMeta + +/** + * @private + * An object type representing the state of a field. + */ +export type FieldLikeState< + TParentData, + TName extends DeepKeys, + TData extends DeepValue, + TOnMount extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, + TOnChange extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, + TOnChangeAsync extends + | undefined + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, + TOnBlur extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, + TOnBlurAsync extends + | undefined + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, + TOnSubmit extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, + TOnSubmitAsync extends + | undefined + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, + TOnDynamic extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, + TOnDynamicAsync extends + | undefined + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, + TFormOnMount extends undefined | FormValidateOrFn, + TFormOnChange extends undefined | FormValidateOrFn, + TFormOnChangeAsync extends undefined | FormAsyncValidateOrFn, + TFormOnBlur extends undefined | FormValidateOrFn, + TFormOnBlurAsync extends undefined | FormAsyncValidateOrFn, + TFormOnSubmit extends undefined | FormValidateOrFn, + TFormOnSubmitAsync extends undefined | FormAsyncValidateOrFn, + TFormOnDynamic extends undefined | FormValidateOrFn, + TFormOnDynamicAsync extends undefined | FormAsyncValidateOrFn, +> = { + /** + * The current value of the field. + */ + value: TData + /** + * The current metadata of the field. + */ + meta: FieldLikeMeta< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync + > +} + +/** + * @private + * An object type representing the options for a field in a form. + */ +export interface FieldLikeOptions< + TParentData, + TName extends DeepKeys, + TData extends DeepValue, + in out TOnMount extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, + in out TOnChange extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, + in out TOnChangeAsync extends + | undefined + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, + in out TOnBlur extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, + in out TOnBlurAsync extends + | undefined + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, + in out TOnSubmit extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, + in out TOnSubmitAsync extends + | undefined + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, + in out TOnDynamic extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, + in out TOnDynamicAsync extends + | undefined + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, +> { + /** + * The field name. The type will be `DeepKeys` to ensure your name is a deep key of the parent dataset. + */ + name: TName + /** + * An optional default value for the field. + */ + defaultValue?: NoInfer + /** + * The default time to debounce async validation if there is not a more specific debounce time passed. + */ + asyncDebounceMs?: number + /** + * If `true`, always run async validation, even if there are errors emitted during synchronous validation. + */ + asyncAlways?: boolean + /** + * An optional object with default metadata for the field. + */ + defaultMeta?: Partial< + FieldLikeMeta< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + any, + any, + any, + any, + any, + any, + any, + any, + any + > + > + /** + * Disable the `flat(1)` operation on `field.errors`. This is useful if you want to keep the error structure as is. Not suggested for most use-cases. + */ + disableErrorFlat?: boolean +} + +/** + * @private + * An object type representing the required options for the FieldApi class. + */ +export interface FieldLikeApiOptions< + in out TParentData, + in out TName extends DeepKeys, + in out TData extends DeepValue, + in out TOnMount extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, + in out TOnChange extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, + in out TOnChangeAsync extends + | undefined + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, + in out TOnBlur extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, + in out TOnBlurAsync extends + | undefined + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, + in out TOnSubmit extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, + in out TOnSubmitAsync extends + | undefined + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, + in out TOnDynamic extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, + in out TOnDynamicAsync extends + | undefined + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, + in out TFormOnMount extends undefined | FormValidateOrFn, + in out TFormOnChange extends undefined | FormValidateOrFn, + in out TFormOnChangeAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnBlur extends undefined | FormValidateOrFn, + in out TFormOnBlurAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnSubmit extends undefined | FormValidateOrFn, + in out TFormOnSubmitAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnDynamic extends undefined | FormValidateOrFn, + in out TFormOnDynamicAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnServer extends undefined | FormAsyncValidateOrFn, + in out TParentSubmitMeta, +> extends FieldLikeOptions< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync +> { + form: FormApi< + TParentData, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta + > +} + +/** + * @private + */ +export interface FieldLikeAPI< + in out TParentData, + in out TName extends DeepKeys, + in out TData extends DeepValue, + in out TOnMount extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, + in out TOnChange extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, + in out TOnChangeAsync extends + | undefined + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, + in out TOnBlur extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, + in out TOnBlurAsync extends + | undefined + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, + in out TOnSubmit extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, + in out TOnSubmitAsync extends + | undefined + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, + in out TOnDynamic extends + | undefined + | FieldValidateOrFn + | FormGroupValidateOrFn, + in out TOnDynamicAsync extends + | undefined + | FieldAsyncValidateOrFn + | FormGroupAsyncValidateOrFn, + in out TFormOnMount extends undefined | FormValidateOrFn, + in out TFormOnChange extends undefined | FormValidateOrFn, + in out TFormOnChangeAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnBlur extends undefined | FormValidateOrFn, + in out TFormOnBlurAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnSubmit extends undefined | FormValidateOrFn, + in out TFormOnSubmitAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnDynamic extends undefined | FormValidateOrFn, + in out TFormOnDynamicAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnServer extends undefined | FormAsyncValidateOrFn, + in out TParentSubmitMeta, + TExtraOptions = {}, +> { + form: AnyFormApi + options: FieldLikeApiOptions< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta + > & + TExtraOptions + store: ReadonlyStore< + FieldLikeState< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync + > + > + /** + * The field name. + */ + name: TName + mount: () => () => void + + setValue: (updater: Updater, options?: UpdateMetaOptions) => void + getMeta: () => AnyFieldLikeMeta + setMeta: (updater: Updater) => void + getInfo: () => FieldInfo + validate: ( + cause: ValidationCause, + opts?: { skipFormValidation?: boolean; skipGroupValidation?: boolean }, + ) => ValidationError[] | Promise + /** + * @private + */ + triggerOnChangeListener: () => void + /** + * @private + */ + triggerOnSubmitListener: () => void +} + +/** + * @private + */ +export interface FieldInfo { + instance: FieldLikeAPI< + TParentData, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any + > | null + validationMetaMap: Record +} diff --git a/packages/form-core/src/utils.ts b/packages/form-core/src/utils.ts index 7cabb65b5..467ff70cd 100644 --- a/packages/form-core/src/utils.ts +++ b/packages/form-core/src/utils.ts @@ -251,6 +251,7 @@ export function getSyncValidatorArray( options: SyncValidatorArrayPartialOptions & { validationLogic?: any form?: any + group?: any fieldName?: string }, ): T extends FieldValidators< @@ -300,6 +301,7 @@ export function getSyncValidatorArray( return options.validationLogic({ form: options.form, + group: options.group, validators: options.validators, event: { type: cause, fieldName: options.fieldName, async: false }, runValidation, @@ -314,6 +316,7 @@ export function getAsyncValidatorArray( options: AsyncValidatorArrayPartialOptions & { validationLogic?: any form?: any + group?: any fieldName?: string }, ): T extends FieldValidators< @@ -411,6 +414,7 @@ export function getAsyncValidatorArray( return options.validationLogic({ form: options.form, + group: options.group, validators: options.validators, event: { type: cause, fieldName: options.fieldName, async: true }, runValidation, diff --git a/packages/form-core/tests/FieldApi.spec.ts b/packages/form-core/tests/FieldApi.spec.ts index 1fa3d4d65..e54cccb52 100644 --- a/packages/form-core/tests/FieldApi.spec.ts +++ b/packages/form-core/tests/FieldApi.spec.ts @@ -550,13 +550,15 @@ describe('field api', () => { expect(subField2.state.meta.errorMap.onChange).toStrictEqual('Required') expect(subField3.state.value).toBe('world') expect(subField3.state.meta.errorMap.onChange).toStrictEqual(undefined) - expect(form.getFieldInfo('people[0].name').instance?.state.value).toBe( - 'hello', - ) - expect(form.getFieldInfo('people[1].name').instance?.state.value).toBe('') - expect(form.getFieldInfo('people[2].name').instance?.state.value).toBe( - 'world', - ) + expect( + form.getFieldInfo('people[0].name').instance?.store.state.value, + ).toBe('hello') + expect( + form.getFieldInfo('people[1].name').instance?.store.state.value, + ).toBe('') + expect( + form.getFieldInfo('people[2].name').instance?.store.state.value, + ).toBe('world') }) it('should remove remove the last subfield from an array field correctly', async () => { diff --git a/packages/form-core/tests/FormGroupApi.spec.ts b/packages/form-core/tests/FormGroupApi.spec.ts new file mode 100644 index 000000000..8ba340766 --- /dev/null +++ b/packages/form-core/tests/FormGroupApi.spec.ts @@ -0,0 +1,478 @@ +import { describe, expect, it, vi } from 'vitest' +import { z } from 'zod' +import { FieldApi, FormApi, FormGroupApi } from '../src/index' +import { revalidateLogic } from '../src/ValidationLogic' + +describe('form group api', () => { + it('should allow a submission without submitting the form', async () => { + const onSubmit = vi.fn() + + const form = new FormApi({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + onSubmit, + }) + + const onGroupSubmit = vi.fn() + + const step1Group = new FormGroupApi({ + name: 'step1', + form, + onGroupSubmit, + }) + + const step1NameField = new FieldApi({ + name: 'step1.name', + form, + }) + + form.mount() + step1Group.mount() + step1NameField.mount() + + await step1Group.handleSubmit() + + expect(onGroupSubmit).toHaveBeenCalled() + expect(onSubmit).not.toHaveBeenCalled() + }) + + it('should handle invalid submissions with form validator', async () => { + const onSubmit = vi.fn() + + const form = new FormApi({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + onSubmit, + validators: { + onSubmit: () => { + return { + fields: { + step1: { + name: { + required: true, + }, + }, + }, + } + }, + }, + }) + + const onGroupSubmit = vi.fn() + const onGroupSubmitInvalid = vi.fn() + + const step1Group = new FormGroupApi({ + name: 'step1', + form, + onGroupSubmit, + onGroupSubmitInvalid, + }) + + const step1NameField = new FieldApi({ + name: 'step1.name', + form, + }) + + form.mount() + step1Group.mount() + step1NameField.mount() + + await step1Group.handleSubmit() + + expect(onGroupSubmitInvalid).toHaveBeenCalled() + expect(onGroupSubmit).not.toHaveBeenCalled() + expect(onSubmit).not.toHaveBeenCalled() + }) + + it('should handle invalid submissions with form validator and throw away other unrelated fields errors', async () => { + const onSubmit = vi.fn() + + const form = new FormApi({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + onSubmit, + validators: { + onSubmit: () => { + return { + fields: { + step1: { + name: { + required: true, + }, + }, + step2: { + name: { + required: true, + }, + }, + }, + } + }, + }, + }) + + const onGroupSubmit = vi.fn() + const onGroupSubmitInvalid = vi.fn() + + const step1Group = new FormGroupApi({ + name: 'step1', + form, + onGroupSubmit, + onGroupSubmitInvalid, + }) + + const step1NameField = new FieldApi({ + name: 'step1.name', + form, + }) + + form.mount() + step1Group.mount() + step1NameField.mount() + + await step1Group.handleSubmit() + + expect(onGroupSubmitInvalid).toHaveBeenCalled() + expect(onGroupSubmit).not.toHaveBeenCalled() + expect(onSubmit).not.toHaveBeenCalled() + }) + + it('Should handle validations on form groups themselves', async () => { + const onSubmit = vi.fn() + + const form = new FormApi({ + defaultValues: { + step1: { name: '' }, + step2: { name: 'test2' }, + }, + onSubmit, + }) + + const onGroupSubmit = vi.fn() + const onGroupSubmitInvalid = vi.fn() + + const step1Group = new FormGroupApi({ + name: 'step1', + form, + onGroupSubmit, + onGroupSubmitInvalid, + validators: { + onSubmit: ({ value }) => { + if (!value.name) { + return 'Name is required' + } + return undefined + }, + }, + }) + + const step1NameField = new FieldApi({ + name: 'step1.name', + form, + }) + + form.mount() + step1Group.mount() + step1NameField.mount() + + await step1Group.handleSubmit() + + expect(onGroupSubmitInvalid).toHaveBeenCalled() + expect(onGroupSubmit).not.toHaveBeenCalled() + expect(onSubmit).not.toHaveBeenCalled() + expect(step1Group.state.meta.errorMap.onSubmit).toBe('Name is required') + }) + it('Should handle submit meta args', async () => { + const onSubmit = vi.fn() + + const form = new FormApi({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + onSubmit, + }) + + const onGroupSubmit = vi.fn() + + const step1Group = new FormGroupApi({ + name: 'step1', + form, + onGroupSubmit, + onSubmitMeta: {} as { source: string }, + }) + + const step1NameField = new FieldApi({ + name: 'step1.name', + form, + }) + + form.mount() + step1Group.mount() + step1NameField.mount() + + await step1Group.handleSubmit({ source: 'button' }) + + expect(onGroupSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + value: { name: 'test' }, + meta: { source: 'button' }, + }), + ) + expect(onSubmit).not.toHaveBeenCalled() + }) + it.todo('Should handle onXListenTo from fields') + it.todo('Should handle onXListenTo from other groups') + + it('should re-run validation on subsequent submissions after fixing the value', async () => { + const onSubmit = vi.fn() + const onGroupSubmit = vi.fn() + const onGroupSubmitInvalid = vi.fn() + + const form = new FormApi({ + defaultValues: { + step1: { name: '' }, + step2: { name: 'test2' }, + }, + onSubmit, + }) + + const step1Group = new FormGroupApi({ + name: 'step1', + form, + onGroupSubmit, + onGroupSubmitInvalid, + validators: { + onSubmit: ({ value }) => { + if (!value.name) { + return 'Name is required' + } + return undefined + }, + }, + }) + + const step1NameField = new FieldApi({ + name: 'step1.name', + form, + }) + + form.mount() + step1Group.mount() + step1NameField.mount() + + // First submit fails + await step1Group.handleSubmit() + expect(onGroupSubmitInvalid).toHaveBeenCalledTimes(1) + expect(onGroupSubmit).not.toHaveBeenCalled() + expect(step1Group.state.meta.errorMap.onSubmit).toBe('Name is required') + + // Fix the value + step1NameField.setValue('valid name') + + // Second submit should re-validate and succeed + await step1Group.handleSubmit() + expect(onGroupSubmit).toHaveBeenCalledTimes(1) + expect(onGroupSubmitInvalid).toHaveBeenCalledTimes(1) + expect(step1Group.state.meta.errorMap.onSubmit).toBeUndefined() + }) + + it('should clear stale group-level onDynamic errors on subsequent group submissions when using revalidateLogic', async () => { + const onSubmit = vi.fn() + const onGroupSubmit = vi.fn() + const onGroupSubmitInvalid = vi.fn() + + const form = new FormApi({ + defaultValues: { + step1: { name: '' }, + step2: { name: 'test2' }, + }, + onSubmit, + validationLogic: revalidateLogic(), + }) + + const step1Group = new FormGroupApi({ + name: 'step1', + form, + onGroupSubmit, + onGroupSubmitInvalid, + validators: { + onDynamic: ({ value }) => { + if (!value.name) { + return 'Name is required' + } + return undefined + }, + }, + }) + + const step1NameField = new FieldApi({ + name: 'step1.name', + form, + }) + + form.mount() + step1Group.mount() + step1NameField.mount() + + // First submit fails: group-level onDynamic surfaces an error on the group + await step1Group.handleSubmit() + expect(onGroupSubmitInvalid).toHaveBeenCalledTimes(1) + expect(onGroupSubmit).not.toHaveBeenCalled() + expect(step1Group.state.meta.errorMap.onDynamic).toBe('Name is required') + + // Fix the value: when revalidateLogic runs the group's own validators it + // gates on the group's `submissionAttempts` (now > 0), so onDynamic + // re-runs on `change` and the stale group error clears. + step1NameField.setValue('valid name') + expect(step1Group.state.meta.errorMap.onDynamic).toBeUndefined() + + // Second submit should succeed + await step1Group.handleSubmit() + expect(onGroupSubmit).toHaveBeenCalledTimes(1) + expect(onGroupSubmitInvalid).toHaveBeenCalledTimes(1) + }) + + it('group submissions should not bump the parent form-level submissionAttempts', async () => { + const formDynamic = vi.fn(() => undefined) + + const form = new FormApi({ + defaultValues: { step1: { name: '' }, step2: { name: '' } }, + onSubmit: vi.fn(), + validationLogic: revalidateLogic(), + validators: { + onDynamic: formDynamic, + }, + }) + + const step1Group = new FormGroupApi({ + name: 'step1', + form, + onGroupSubmit: vi.fn(), + }) + + const step1NameField = new FieldApi({ + name: 'step1.name', + form, + }) + const step2NameField = new FieldApi({ + name: 'step2.name', + form, + }) + + form.mount() + step1Group.mount() + step1NameField.mount() + step2NameField.mount() + + const initialFormAttempts = form.state.submissionAttempts + await step1Group.handleSubmit() + + // Group submission must not bump the form's submissionAttempts + expect(form.state.submissionAttempts).toBe(initialFormAttempts) + + formDynamic.mockClear() + + // A change to a field OUTSIDE any submitted group should still leave + // the form's onDynamic gated off (since form.submissionAttempts === 0 + // and step2 has not been submitted). + step2NameField.setValue('valid name') + expect(formDynamic).not.toHaveBeenCalled() + }) + + it('field changes inside a submitted group re-run the form-level onDynamic with the group as gating context', async () => { + const formDynamic = vi.fn(() => undefined) + + const form = new FormApi({ + defaultValues: { step1: { name: '' } }, + onSubmit: vi.fn(), + validationLogic: revalidateLogic(), + validators: { + onDynamic: formDynamic, + }, + }) + + const step1Group = new FormGroupApi({ + name: 'step1', + form, + onGroupSubmit: vi.fn(), + }) + + const step1NameField = new FieldApi({ + name: 'step1.name', + form, + }) + + form.mount() + step1Group.mount() + step1NameField.mount() + + await step1Group.handleSubmit() + formDynamic.mockClear() + + // After the group has been submitted, a change to a field inside it + // re-runs the form-level onDynamic gated on the group's + // submissionAttempts so stale form-level errors stay in sync. + step1NameField.setValue('valid name') + expect(formDynamic).toHaveBeenCalled() + }) + + it('repro: field error from form-level onDynamic should clear after group submit + fix', async () => { + const form = new FormApi({ + defaultValues: { + step1: { name: '' }, + step2: { name: '' }, + }, + onSubmit: vi.fn(), + validationLogic: revalidateLogic(), + validators: { + onDynamic: z.object({ + step1: z.object({ + name: z.string().min(2, 'Name must be at least 2 characters'), + }), + step2: z.object({ + name: z.string().min(3, 'Name must be at least 3 characters'), + }), + }), + }, + }) + + const step1Group = new FormGroupApi({ + name: 'step1', + form, + onGroupSubmit: vi.fn(), + onGroupSubmitInvalid: vi.fn(), + validators: { + onDynamic: z.object({ + name: z.string().min(2, 'Name must be at least 2 characters'), + }), + }, + }) + + const step1NameField = new FieldApi({ + name: 'step1.name', + form, + }) + + form.mount() + step1Group.mount() + step1NameField.mount() + + // First submit fails: group surfaces an error and the form-level + // z.object also propagates a per-field onDynamic error onto the field. + await step1Group.handleSubmit() + expect(step1Group.state.meta.errorMap.onDynamic).toBeDefined() + expect(step1NameField.state.meta.errors.length).toBeGreaterThan(0) + + // Fix the value: both the group AND the field should clear. + step1NameField.setValue('valid name') + expect(step1Group.state.meta.errorMap.onDynamic).toBeUndefined() + expect(step1NameField.state.meta.errors).toEqual([]) + }) +}) diff --git a/packages/form-core/tests/FormGroupApi.test-d.ts b/packages/form-core/tests/FormGroupApi.test-d.ts new file mode 100644 index 000000000..7d6871a67 --- /dev/null +++ b/packages/form-core/tests/FormGroupApi.test-d.ts @@ -0,0 +1,281 @@ +import { expectTypeOf, it } from 'vitest' +import { FormApi, FormGroupApi } from '../src/index' + +it('should type value properly', () => { + const form = new FormApi({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + } as const) + + const group = new FormGroupApi({ + name: 'step1', + form, + onGroupSubmit: () => {}, + }) + + expectTypeOf(group.state.value).toEqualTypeOf<{ readonly name: 'test' }>() +}) + +it('should type the name property', () => { + const form = new FormApi({ + defaultValues: { + step1: { name: 'test' }, + step2: { age: 10 }, + }, + }) + + const group = new FormGroupApi({ + name: 'step1', + form, + onGroupSubmit: () => {}, + }) + + expectTypeOf(group.name).toEqualTypeOf<'step1'>() +}) + +it('should type the validator onChange value properly', () => { + const form = new FormApi({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + }) + + const group = new FormGroupApi({ + name: 'step1', + form, + onGroupSubmit: () => {}, + validators: { + onChange: ({ value }) => { + expectTypeOf(value).toEqualTypeOf<{ name: string }>() + return undefined + }, + }, + }) +}) + +it('should type the validator onSubmit value properly', () => { + const form = new FormApi({ + defaultValues: { + step1: { name: 'test', age: 20 }, + step2: { name: 'test2' }, + }, + }) + + const group = new FormGroupApi({ + name: 'step1', + form, + onGroupSubmit: () => {}, + validators: { + onSubmit: ({ value }) => { + expectTypeOf(value).toEqualTypeOf<{ name: string; age: number }>() + return undefined + }, + }, + }) +}) + +it('should type the errorMap from group validators', () => { + const form = new FormApi({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + }) + + const group = new FormGroupApi({ + name: 'step1', + form, + onGroupSubmit: () => {}, + validators: { + onSubmit: () => 'submit-error' as const, + }, + }) + + expectTypeOf(group.state.meta.errorMap.onSubmit).toEqualTypeOf< + 'submit-error' | undefined + >() +}) + +it('should type errors array from group validators', () => { + const form = new FormApi({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + }) + + const group = new FormGroupApi({ + name: 'step1', + form, + onGroupSubmit: () => {}, + validators: { + onChange: () => 'change-error' as const, + }, + }) + + expectTypeOf(group.state.meta.errors).toEqualTypeOf< + Array<'change-error' | undefined> + >() +}) + +it('should type handleSubmit return as Promise', () => { + const form = new FormApi({ + defaultValues: { + step1: { name: 'test' }, + }, + }) + + const group = new FormGroupApi({ + name: 'step1', + form, + onGroupSubmit: () => {}, + }) + + expectTypeOf(group.handleSubmit()).toEqualTypeOf>() +}) + +it('should type handleSubmit with the correct meta type when onSubmitMeta is provided', () => { + type SubmitMeta = { source: string } + + const form = new FormApi({ + defaultValues: { + step1: { name: 'test' }, + }, + }) + + const group = new FormGroupApi({ + name: 'step1', + form, + onGroupSubmit: () => {}, + onSubmitMeta: {} as SubmitMeta, + }) + + expectTypeOf(group.handleSubmit).toEqualTypeOf<{ + (): Promise + (submitMeta: SubmitMeta): Promise + }>() +}) + +it('should type onGroupSubmit callback value and meta properly', () => { + type SubmitMeta = { source: string } + + const form = new FormApi({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + }) + + const group = new FormGroupApi({ + name: 'step1', + form, + onSubmitMeta: {} as SubmitMeta, + onGroupSubmit: ({ value, meta }) => { + expectTypeOf(value).toEqualTypeOf<{ name: string }>() + expectTypeOf(meta).toEqualTypeOf() + }, + }) +}) + +it('should type onGroupSubmitInvalid callback value and meta properly', () => { + type SubmitMeta = { source: string } + + const form = new FormApi({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + }) + + const group = new FormGroupApi({ + name: 'step1', + form, + onSubmitMeta: {} as SubmitMeta, + onGroupSubmit: () => {}, + onGroupSubmitInvalid: ({ value, meta }) => { + expectTypeOf(value).toEqualTypeOf<{ name: string }>() + expectTypeOf(meta).toEqualTypeOf() + }, + }) +}) + +it('should type errorMap with both sync and async validator return types', () => { + const form = new FormApi({ + defaultValues: { + step1: { name: 'test' }, + }, + }) + + const group = new FormGroupApi({ + name: 'step1', + form, + onGroupSubmit: () => {}, + validators: { + onChange: () => 'sync-change' as const, + onChangeAsync: async () => 'async-change' as const, + onBlur: () => 'sync-blur' as const, + onBlurAsync: async () => 'async-blur' as const, + onSubmit: () => 'sync-submit' as const, + onSubmitAsync: async () => 'async-submit' as const, + }, + }) + + expectTypeOf(group.state.meta.errorMap.onChange).toEqualTypeOf< + 'sync-change' | 'async-change' | undefined + >() + + expectTypeOf(group.state.meta.errorMap.onBlur).toEqualTypeOf< + 'sync-blur' | 'async-blur' | undefined + >() + + expectTypeOf(group.state.meta.errorMap.onSubmit).toEqualTypeOf< + 'sync-submit' | 'async-submit' | undefined + >() +}) + +it('should type the listener onChange callback value', () => { + const form = new FormApi({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + }) + + const group = new FormGroupApi({ + name: 'step1', + form, + onGroupSubmit: () => {}, + listeners: { + onChange: ({ value }) => { + expectTypeOf(value).toEqualTypeOf<{ name: string }>() + }, + }, + }) +}) + +it('should type setValue updater properly', () => { + const form = new FormApi({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + }) + + const group = new FormGroupApi({ + name: 'step1', + form, + onGroupSubmit: () => {}, + }) + + // Should accept the correct value type + group.setValue({ name: 'new name' }) + + // Should accept an updater function + group.setValue((prev) => { + expectTypeOf(prev).toEqualTypeOf<{ name: string }>() + return { name: 'updated' } + }) +}) diff --git a/packages/preact-form/package.json b/packages/preact-form/package.json index 7d9bfe9bb..f03f72f4d 100644 --- a/packages/preact-form/package.json +++ b/packages/preact-form/package.json @@ -52,7 +52,7 @@ ], "dependencies": { "@tanstack/form-core": "workspace:*", - "@tanstack/preact-store": "^0.10.2" + "@tanstack/preact-store": "^0.13.1" }, "devDependencies": { "@preact/preset-vite": "^2.10.2", diff --git a/packages/preact-form/src/createFormHook.tsx b/packages/preact-form/src/createFormHook.tsx index 3b4e40846..5f759fa52 100644 --- a/packages/preact-form/src/createFormHook.tsx +++ b/packages/preact-form/src/createFormHook.tsx @@ -17,8 +17,8 @@ import type { import type { ComponentType, Context, FunctionComponent } from 'preact' import type { PropsWithChildren } from './types' import type { FieldComponent } from './useField' -import type { ReactFormExtendedApi } from './useForm' -import type { AppFieldExtendedReactFieldGroupApi } from './useFieldGroup' +import type { PreactFormExtendedApi } from './useForm' +import type { AppFieldExtendedPreactFieldGroupApi } from './useFieldGroup' // We should never hit the `null` case here const FieldContext = createContext(null as never) @@ -71,7 +71,7 @@ function useFormContext() { ) } - return form as ReactFormExtendedApi< + return form as PreactFormExtendedApi< // If you need access to the form data, you need to use `withForm` instead Record, any, @@ -147,7 +147,7 @@ interface CreateFormHookProps< /** * @private */ -export type AppFieldExtendedReactFormApi< +export type AppFieldExtendedPreactFormApi< TFormData, TOnMount extends undefined | FormValidateOrFn, TOnChange extends undefined | FormValidateOrFn, @@ -162,7 +162,7 @@ export type AppFieldExtendedReactFormApi< TSubmitMeta, TFieldComponents extends Record>, TFormComponents extends Record>, -> = ReactFormExtendedApi< +> = PreactFormExtendedApi< TFormData, TOnMount, TOnChange, @@ -233,7 +233,7 @@ export interface WithFormProps< render: FunctionComponent< PropsWithChildren< NoInfer & { - form: AppFieldExtendedReactFormApi< + form: AppFieldExtendedPreactFormApi< TFormData, TOnMount, TOnChange, @@ -266,7 +266,7 @@ export interface WithFieldGroupProps< render: FunctionComponent< PropsWithChildren< NoInfer & { - group: AppFieldExtendedReactFieldGroupApi< + group: AppFieldExtendedPreactFieldGroupApi< unknown, TFieldGroupData, string | FieldsMap, @@ -328,7 +328,7 @@ export function createFormHook< TOnServer, TSubmitMeta >, - ): AppFieldExtendedReactFormApi< + ): AppFieldExtendedPreactFormApi< TFormData, TOnMount, TOnChange, @@ -485,7 +485,7 @@ export function createFormHook< params: PropsWithChildren< NoInfer & { form: - | AppFieldExtendedReactFormApi< + | AppFieldExtendedPreactFormApi< TFormData, TOnMount, TOnChange, @@ -501,7 +501,7 @@ export function createFormHook< TComponents, TFormComponents > - | AppFieldExtendedReactFieldGroupApi< + | AppFieldExtendedPreactFieldGroupApi< // Since this only occurs if you nest it within other field groups, it can be more // lenient with the types. unknown, @@ -573,7 +573,7 @@ export function createFormHook< TOnServer, TSubmitMeta >, - ): AppFieldExtendedReactFormApi< + ): AppFieldExtendedPreactFormApi< TFormData, TOnMount, TOnChange, diff --git a/packages/preact-form/src/index.ts b/packages/preact-form/src/index.ts index c5878a608..56dd2c4d5 100644 --- a/packages/preact-form/src/index.ts +++ b/packages/preact-form/src/index.ts @@ -1,10 +1,11 @@ export * from '@tanstack/form-core' -export { useStore } from './useStore' +export { useStore } from '@tanstack/preact-store' export * from './createFormHook' export * from './types' export * from './useField' export * from './useFieldGroup' +export * from './useFormGroup' export * from './useForm' export * from './useIsomorphicLayoutEffect' diff --git a/packages/preact-form/src/useField.tsx b/packages/preact-form/src/useField.tsx index 2162b5bc4..d76ed7823 100644 --- a/packages/preact-form/src/useField.tsx +++ b/packages/preact-form/src/useField.tsx @@ -1,7 +1,7 @@ import { useMemo, useState } from 'preact/hooks' import { FieldApi, functionalUpdate } from '@tanstack/form-core' +import { useStore } from '@tanstack/preact-store' import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect' -import { useStore } from './useStore' import type { AnyFieldApi, AnyFieldMeta, @@ -16,39 +16,6 @@ import type { import type { ComponentChild, FunctionComponent } from 'preact' import type { UseFieldOptions, UseFieldOptionsBound } from './types' -interface ReactFieldApi< - TParentData, - TFormOnMount extends undefined | FormValidateOrFn, - TFormOnChange extends undefined | FormValidateOrFn, - TFormOnChangeAsync extends undefined | FormAsyncValidateOrFn, - TFormOnBlur extends undefined | FormValidateOrFn, - TFormOnBlurAsync extends undefined | FormAsyncValidateOrFn, - TFormOnSubmit extends undefined | FormValidateOrFn, - TFormOnSubmitAsync extends undefined | FormAsyncValidateOrFn, - TFormOnDynamic extends undefined | FormValidateOrFn, - TFormOnDynamicAsync extends undefined | FormAsyncValidateOrFn, - TFormOnServer extends undefined | FormAsyncValidateOrFn, - TPatentSubmitMeta, -> { - /** - * A pre-bound and type-safe sub-field component using this field as a root. - */ - Field: FieldComponent< - TParentData, - TFormOnMount, - TFormOnChange, - TFormOnChangeAsync, - TFormOnBlur, - TFormOnBlurAsync, - TFormOnSubmit, - TFormOnSubmitAsync, - TFormOnDynamic, - TFormOnDynamicAsync, - TFormOnServer, - TPatentSubmitMeta - > -} - /** * A type representing a hook for using a field in a form with the given form data type. * @@ -303,23 +270,7 @@ export function useField< TFormOnDynamicAsync, TFormOnServer, TPatentSubmitMeta - > & - ReactFieldApi< - TParentData, - TFormOnMount, - TFormOnChange, - TFormOnChangeAsync, - TFormOnBlur, - TFormOnBlurAsync, - TFormOnSubmit, - TFormOnSubmitAsync, - TFormOnDynamic, - TFormOnDynamicAsync, - TFormOnServer, - TPatentSubmitMeta - > = reactiveFieldApi as never - - extendedApi.Field = Field as never + > = reactiveFieldApi as never return extendedApi }, [ diff --git a/packages/preact-form/src/useFieldGroup.tsx b/packages/preact-form/src/useFieldGroup.tsx index f8e14af2a..f81f35b12 100644 --- a/packages/preact-form/src/useFieldGroup.tsx +++ b/packages/preact-form/src/useFieldGroup.tsx @@ -1,7 +1,7 @@ import { useState } from 'preact/hooks' import { FieldGroupApi, functionalUpdate } from '@tanstack/form-core' +import { useStore } from '@tanstack/preact-store' import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect' -import { useStore } from './useStore' import type { AnyFieldGroupApi, DeepKeysOfType, @@ -10,7 +10,7 @@ import type { FormAsyncValidateOrFn, FormValidateOrFn, } from '@tanstack/form-core' -import type { AppFieldExtendedReactFormApi } from './createFormHook' +import type { AppFieldExtendedPreactFormApi } from './createFormHook' import type { ComponentChild, ComponentType, FunctionComponent } from 'preact' import type { LensFieldComponent } from './useField' import type { PropsWithChildren } from './types' @@ -31,7 +31,7 @@ function LocalSubscribe({ /** * @private */ -export type AppFieldExtendedReactFieldGroupApi< +export type AppFieldExtendedPreactFieldGroupApi< TFormData, TFieldGroupData, TFields extends @@ -108,7 +108,7 @@ export function useFieldGroup< TSubmitMeta = never, >(opts: { form: - | AppFieldExtendedReactFormApi< + | AppFieldExtendedPreactFormApi< TFormData, TOnMount, TOnChange, @@ -124,7 +124,7 @@ export function useFieldGroup< TComponents, TFormComponents > - | AppFieldExtendedReactFieldGroupApi< + | AppFieldExtendedPreactFieldGroupApi< // Since this only occurs if you nest it within other form lenses, it can be more // lenient with the types. unknown, @@ -148,7 +148,7 @@ export function useFieldGroup< defaultValues?: TFieldGroupData onSubmitMeta?: TSubmitMeta formComponents: TFormComponents -}): AppFieldExtendedReactFieldGroupApi< +}): AppFieldExtendedPreactFieldGroupApi< TFormData, TFieldGroupData, TFields, @@ -170,7 +170,7 @@ export function useFieldGroup< const api = new FieldGroupApi(opts) const form = opts.form instanceof FieldGroupApi - ? (opts.form.form as AppFieldExtendedReactFormApi< + ? (opts.form.form as AppFieldExtendedPreactFormApi< TFormData, TOnMount, TOnChange, @@ -188,7 +188,7 @@ export function useFieldGroup< >) : opts.form - const extendedApi: AppFieldExtendedReactFieldGroupApi< + const extendedApi: AppFieldExtendedPreactFieldGroupApi< TFormData, TFieldGroupData, TFields, @@ -233,7 +233,7 @@ export function useFieldGroup< return Object.assign(extendedApi, { ...opts.formComponents, - }) as AppFieldExtendedReactFieldGroupApi< + }) as AppFieldExtendedPreactFieldGroupApi< TFormData, TFieldGroupData, TFields, diff --git a/packages/preact-form/src/useForm.tsx b/packages/preact-form/src/useForm.tsx index aed79b755..dcf8f7660 100644 --- a/packages/preact-form/src/useForm.tsx +++ b/packages/preact-form/src/useForm.tsx @@ -1,9 +1,11 @@ import { FormApi, functionalUpdate, mergeAndUpdate } from '@tanstack/form-core' import { useMemo, useRef, useState } from 'preact/hooks' +import { useStore } from '@tanstack/preact-store' import { Field } from './useField' import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect' import { useFormId } from './useFormId' -import { useStore } from './useStore' +import { FormGroup } from './useFormGroup' +import type { FormGroupComponent } from './useFormGroup' import type { AnyFormApi, AnyFormState, @@ -15,12 +17,11 @@ import type { import type { ComponentChild, FunctionComponent } from 'preact' import type { FieldComponent } from './useField' import type { PropsWithChildren } from './types' -import type { NoInfer } from './useStore' /** * Fields that are added onto the `FormAPI` from `@tanstack/form-core` and returned from `useForm` */ -export interface ReactFormApi< +export interface PreactFormApi< in out TFormData, in out TOnMount extends undefined | FormValidateOrFn, in out TOnChange extends undefined | FormValidateOrFn, @@ -35,7 +36,7 @@ export interface ReactFormApi< in out TSubmitMeta, > { /** - * A React component to render form fields. With this, you can render and manage individual form fields. + * A Preact component to render form fields. With this, you can render and manage individual form fields. */ Field: FieldComponent< TFormData, @@ -51,6 +52,20 @@ export interface ReactFormApi< TOnServer, TSubmitMeta > + FormGroup: FormGroupComponent< + TFormData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TOnServer, + TSubmitMeta + > /** * A `Subscribe` function that allows you to listen and react to changes in the form's state. It's especially useful when you need to execute side effects or render specific components in response to state updates. */ @@ -93,9 +108,9 @@ export interface ReactFormApi< } /** - * An extended version of the `FormApi` class that includes React-specific functionalities from `ReactFormApi` + * An extended version of the `FormApi` class that includes Preact-specific functionalities from `PreactFormApi` */ -export type ReactFormExtendedApi< +export type PreactFormExtendedApi< TFormData, TOnMount extends undefined | FormValidateOrFn, TOnChange extends undefined | FormValidateOrFn, @@ -122,7 +137,7 @@ export type ReactFormExtendedApi< TOnServer, TSubmitMeta > & - ReactFormApi< + PreactFormApi< TFormData, TOnMount, TOnChange, @@ -211,7 +226,7 @@ export function useForm< } const extendedFormApi = useMemo(() => { - const extendedApi: ReactFormExtendedApi< + const extendedApi: PreactFormExtendedApi< TFormData, TOnMount, TOnChange, @@ -242,6 +257,10 @@ export function useForm< return } + extendedApi.FormGroup = function APIFormGroup(props) { + return + } + extendedApi.Subscribe = function Subscribe(props: any) { return ( , + TFormOnChange extends undefined | FormValidateOrFn, + TFormOnChangeAsync extends undefined | FormAsyncValidateOrFn, + TFormOnBlur extends undefined | FormValidateOrFn, + TFormOnBlurAsync extends undefined | FormAsyncValidateOrFn, + TFormOnSubmit extends undefined | FormValidateOrFn, + TFormOnSubmitAsync extends undefined | FormAsyncValidateOrFn, + TFormOnDynamic extends undefined | FormValidateOrFn, + TFormOnDynamicAsync extends undefined | FormAsyncValidateOrFn, + TFormOnServer extends undefined | FormAsyncValidateOrFn, + TParentSubmitMeta, +> = < + TName extends DeepKeys, + TData extends DeepValue, + TOnMount extends undefined | FormGroupValidateOrFn, + TOnChange extends + | undefined + | FormGroupValidateOrFn, + TOnChangeAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnBlur extends undefined | FormGroupValidateOrFn, + TOnBlurAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnSubmit extends + | undefined + | FormGroupValidateOrFn, + TOnSubmitAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnDynamic extends + | undefined + | FormGroupValidateOrFn, + TOnDynamicAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TSubmitMeta, +>( + opts: FormGroupApiOptions< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TSubmitMeta, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta + >, +) => FormGroupApi< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TSubmitMeta, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta +> + +export function useFormGroup< + TParentData, + TName extends DeepKeys, + TData extends DeepValue, + TOnMount extends undefined | FormGroupValidateOrFn, + TOnChange extends + | undefined + | FormGroupValidateOrFn, + TOnChangeAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnBlur extends undefined | FormGroupValidateOrFn, + TOnBlurAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnSubmit extends + | undefined + | FormGroupValidateOrFn, + TOnSubmitAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnDynamic extends + | undefined + | FormGroupValidateOrFn, + TOnDynamicAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TSubmitMeta, + TFormOnMount extends undefined | FormValidateOrFn, + TFormOnChange extends undefined | FormValidateOrFn, + TFormOnChangeAsync extends undefined | FormAsyncValidateOrFn, + TFormOnBlur extends undefined | FormValidateOrFn, + TFormOnBlurAsync extends undefined | FormAsyncValidateOrFn, + TFormOnSubmit extends undefined | FormValidateOrFn, + TFormOnSubmitAsync extends undefined | FormAsyncValidateOrFn, + TFormOnDynamic extends undefined | FormValidateOrFn, + TFormOnDynamicAsync extends undefined | FormAsyncValidateOrFn, + TFormOnServer extends undefined | FormAsyncValidateOrFn, + TParentSubmitMeta, +>( + opts: FormGroupApiOptions< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TSubmitMeta, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta + >, +) { + // Keep a snapshot of options so that React Compiler doesn't + // wrongly optimize formGroupApi. + const [prevOptions, setPrevOptions] = useState(() => ({ + form: opts.form, + name: opts.name, + })) + + const [formGroupApi, setFormGroupApi] = useState(() => { + return new FormGroupApi({ + ...opts, + }) + }) + + // We only want to + // update on name changes since those are at risk of becoming stale. The field + // state must be up to date for the internal JSX render. + // The other options can freely be in `fieldApi.update` + if (prevOptions.form !== opts.form || prevOptions.name !== opts.name) { + setFormGroupApi( + new FormGroupApi({ + ...opts, + }), + ) + setPrevOptions({ form: opts.form, name: opts.name }) + } + + const reactiveStateValue = useStore( + formGroupApi.store, + (state) => state.value, + ) + + const reactiveMetaIsTouched = useStore( + formGroupApi.store, + (state) => state.meta.isTouched, + ) + const reactiveMetaIsBlurred = useStore( + formGroupApi.store, + (state) => state.meta.isBlurred, + ) + const reactiveMetaIsDirty = useStore( + formGroupApi.store, + (state) => state.meta.isDirty, + ) + const reactiveMetaErrorMap = useStore( + formGroupApi.store, + (state) => state.meta.errorMap, + ) + const reactiveMetaErrorSourceMap = useStore( + formGroupApi.store, + (state) => state.meta.errorSourceMap, + ) + const reactiveMetaIsValidating = useStore( + formGroupApi.store, + (state) => state.meta.isValidating, + ) + + // This makes me sad, but if I understand correctly, this is what we have to do for reactivity to work properly with React compiler. + const extendedFieldApi = useMemo(() => { + const reactiveFieldApi = { + ...formGroupApi, + handleSubmit: ((...props: never[]) => { + return formGroupApi._handleSubmit(...props) + }) as typeof formGroupApi.handleSubmit, + get formState() { + return formGroupApi.formState + }, + get state() { + return { + value: reactiveStateValue, + get meta() { + return { + ...formGroupApi.state.meta, + isTouched: reactiveMetaIsTouched, + isBlurred: reactiveMetaIsBlurred, + isDirty: reactiveMetaIsDirty, + errorMap: reactiveMetaErrorMap, + errorSourceMap: reactiveMetaErrorSourceMap, + isValidating: reactiveMetaIsValidating, + } satisfies typeof formGroupApi.state.meta + }, + } satisfies typeof formGroupApi.state + }, + } + + const extendedApi: FormGroupApi< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TSubmitMeta, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta + > = reactiveFieldApi as never + + return extendedApi + }, [ + formGroupApi, + reactiveStateValue, + reactiveMetaIsTouched, + reactiveMetaIsBlurred, + reactiveMetaIsDirty, + reactiveMetaErrorMap, + reactiveMetaErrorSourceMap, + reactiveMetaIsValidating, + ]) + + useIsomorphicLayoutEffect(formGroupApi.mount, [formGroupApi]) + + useIsomorphicLayoutEffect(() => { + formGroupApi.update(opts) + }) + + return extendedFieldApi +} + +interface FormGroupComponentProps< + TParentData, + TName extends DeepKeys, + TData extends DeepValue, + TOnMount extends undefined | FormGroupValidateOrFn, + TOnChange extends + | undefined + | FormGroupValidateOrFn, + TOnChangeAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnBlur extends undefined | FormGroupValidateOrFn, + TOnBlurAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnSubmit extends + | undefined + | FormGroupValidateOrFn, + TOnSubmitAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnDynamic extends + | undefined + | FormGroupValidateOrFn, + TOnDynamicAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TSubmitMeta, + TFormOnMount extends undefined | FormValidateOrFn, + TFormOnChange extends undefined | FormValidateOrFn, + TFormOnChangeAsync extends undefined | FormAsyncValidateOrFn, + TFormOnBlur extends undefined | FormValidateOrFn, + TFormOnBlurAsync extends undefined | FormAsyncValidateOrFn, + TFormOnSubmit extends undefined | FormValidateOrFn, + TFormOnSubmitAsync extends undefined | FormAsyncValidateOrFn, + TFormOnDynamic extends undefined | FormValidateOrFn, + TFormOnDynamicAsync extends undefined | FormAsyncValidateOrFn, + TFormOnServer extends undefined | FormAsyncValidateOrFn, + TParentSubmitMeta, + ExtendedApi = {}, +> extends FormGroupApiOptions< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TSubmitMeta, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta +> { + children: ( + formGroupApi: FormGroupApi< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TSubmitMeta, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta + > & + ExtendedApi, + ) => ComponentChild +} + +interface FormGroupComponentBoundProps< + TParentData, + TName extends DeepKeys, + TData extends DeepValue, + TOnMount extends undefined | FormGroupValidateOrFn, + TOnChange extends + | undefined + | FormGroupValidateOrFn, + TOnChangeAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnBlur extends undefined | FormGroupValidateOrFn, + TOnBlurAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnSubmit extends + | undefined + | FormGroupValidateOrFn, + TOnSubmitAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnDynamic extends + | undefined + | FormGroupValidateOrFn, + TOnDynamicAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TSubmitMeta, + TFormOnMount extends undefined | FormValidateOrFn, + TFormOnChange extends undefined | FormValidateOrFn, + TFormOnChangeAsync extends undefined | FormAsyncValidateOrFn, + TFormOnBlur extends undefined | FormValidateOrFn, + TFormOnBlurAsync extends undefined | FormAsyncValidateOrFn, + TFormOnSubmit extends undefined | FormValidateOrFn, + TFormOnSubmitAsync extends undefined | FormAsyncValidateOrFn, + TFormOnDynamic extends undefined | FormValidateOrFn, + TFormOnDynamicAsync extends undefined | FormAsyncValidateOrFn, + TFormOnServer extends undefined | FormAsyncValidateOrFn, + TParentSubmitMeta, + ExtendedApi = {}, +> extends FormGroupOptions< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TSubmitMeta, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta +> { + children: ( + formGroupApi: FormGroupApi< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TSubmitMeta, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta + > & + ExtendedApi, + ) => ComponentChild +} + +export type FormGroupComponent< + in out TParentData, + in out TFormOnMount extends undefined | FormValidateOrFn, + in out TFormOnChange extends undefined | FormValidateOrFn, + in out TFormOnChangeAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnBlur extends undefined | FormValidateOrFn, + in out TFormOnBlurAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnSubmit extends undefined | FormValidateOrFn, + in out TFormOnSubmitAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnDynamic extends undefined | FormValidateOrFn, + in out TFormOnDynamicAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnServer extends undefined | FormAsyncValidateOrFn, + in out TParentSubmitMeta, + in out ExtendedApi = {}, +> = < + const TName extends DeepKeys, + TData extends DeepValue, + TOnMount extends undefined | FormGroupValidateOrFn, + TOnChange extends + | undefined + | FormGroupValidateOrFn, + TOnChangeAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnBlur extends undefined | FormGroupValidateOrFn, + TOnBlurAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnSubmit extends + | undefined + | FormGroupValidateOrFn, + TOnSubmitAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnDynamic extends + | undefined + | FormGroupValidateOrFn, + TOnDynamicAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TSubmitMeta, +>({ + children, + ...formGroupOptions +}: FormGroupComponentBoundProps< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TSubmitMeta, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta, + ExtendedApi +>) => ReturnType + +export const FormGroup = (< + TParentData, + TName extends DeepKeys, + TData extends DeepValue, + TOnMount extends undefined | FormGroupValidateOrFn, + TOnChange extends + | undefined + | FormGroupValidateOrFn, + TOnChangeAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnBlur extends undefined | FormGroupValidateOrFn, + TOnBlurAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnSubmit extends + | undefined + | FormGroupValidateOrFn, + TOnSubmitAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnDynamic extends + | undefined + | FormGroupValidateOrFn, + TOnDynamicAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TSubmitMeta, + TFormOnMount extends undefined | FormValidateOrFn, + TFormOnChange extends undefined | FormValidateOrFn, + TFormOnChangeAsync extends undefined | FormAsyncValidateOrFn, + TFormOnBlur extends undefined | FormValidateOrFn, + TFormOnBlurAsync extends undefined | FormAsyncValidateOrFn, + TFormOnSubmit extends undefined | FormValidateOrFn, + TFormOnSubmitAsync extends undefined | FormAsyncValidateOrFn, + TFormOnDynamic extends undefined | FormValidateOrFn, + TFormOnDynamicAsync extends undefined | FormAsyncValidateOrFn, + TFormOnServer extends undefined | FormAsyncValidateOrFn, + TParentSubmitMeta, +>({ + children, + ...formGroupOptions +}: FormGroupComponentProps< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TSubmitMeta, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta +>): ReturnType => { + const formGroupApi = useFormGroup(formGroupOptions as any) + + const jsxToDisplay = useMemo( + () => functionalUpdate(children, formGroupApi as any), + [children, formGroupApi], + ) + return (<>{jsxToDisplay}) as never +}) satisfies FunctionComponent< + FormGroupComponentProps< + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any + > +> diff --git a/packages/preact-form/src/useStore.ts b/packages/preact-form/src/useStore.ts deleted file mode 100644 index b604961e1..000000000 --- a/packages/preact-form/src/useStore.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { useStore as usePreactStore } from '@tanstack/preact-store' - -export type NoInfer = [T][T extends any ? 0 : never] - -type EqualityFn = (objA: T, objB: T) => boolean - -interface UseStoreOptions { - equal?: EqualityFn -} - -type StoreLike = { - subscribe: ( - listener: (...args: Array) => void, - ) => { unsubscribe: () => void } | (() => void) - readonly state: TState -} - -function bridgeStore(store: StoreLike) { - return { - get state() { - return store.state - }, - // @tanstack/preact-store currently calls subscribe unbound. - subscribe: (listener: () => void) => { - const subscription = store.subscribe(listener) - - return typeof subscription === 'function' - ? subscription - : () => subscription.unsubscribe() - }, - } -} - -export function useStore>( - store: StoreLike, - selector?: (state: NoInfer) => TSelected, - options?: UseStoreOptions, -): TSelected { - return usePreactStore( - bridgeStore(store) as never, - selector as never, - options as never, - ) as TSelected -} diff --git a/packages/preact-form/tests/createFormHook.test.tsx b/packages/preact-form/tests/createFormHook.test.tsx index a7efefb2c..70c731c20 100644 --- a/packages/preact-form/tests/createFormHook.test.tsx +++ b/packages/preact-form/tests/createFormHook.test.tsx @@ -2,7 +2,8 @@ import { beforeEach, describe, expect, it } from 'vitest' import { render } from '@testing-library/preact' import { formOptions } from '@tanstack/form-core' import userEvent from '@testing-library/user-event' -import { createFormHook, createFormHookContexts, useStore } from '../src' +import { useStore } from '@tanstack/preact-store' +import { createFormHook, createFormHookContexts } from '../src' let user: ReturnType diff --git a/packages/preact-form/tests/useField.test.tsx b/packages/preact-form/tests/useField.test.tsx index a414be3b3..adf79f2be 100644 --- a/packages/preact-form/tests/useField.test.tsx +++ b/packages/preact-form/tests/useField.test.tsx @@ -1,10 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { render, waitFor, within } from '@testing-library/preact' import { userEvent } from '@testing-library/user-event' -import { useState } from 'preact/hooks' -import { useForm, useStore } from '../src/index' +import { useStore } from '@tanstack/preact-store' +import { useForm } from '../src/index' import { sleep } from './utils' -import type { AnyFieldApi } from '../src/index' let user: ReturnType diff --git a/packages/preact-form/tests/useForm.test-d.tsx b/packages/preact-form/tests/useForm.test-d.tsx index 2e92cf29c..e052a249a 100644 --- a/packages/preact-form/tests/useForm.test-d.tsx +++ b/packages/preact-form/tests/useForm.test-d.tsx @@ -1,7 +1,7 @@ import { describe, expectTypeOf, it } from 'vitest' import { formOptions, useForm } from '../src/index' import type { FormAsyncValidateOrFn, FormValidateOrFn } from '../src/index' -import type { ReactFormExtendedApi } from '../src/useForm' +import type { PreactFormExtendedApi } from '../src/useForm' describe('useForm', () => { it('should type onSubmit properly', () => { @@ -52,7 +52,7 @@ describe('useForm', () => { TOnServer extends undefined | FormAsyncValidateOrFn, TSubmitMeta, >( - f: ReactFormExtendedApi< + f: PreactFormExtendedApi< TFormData, TOnMount, TOnChange, diff --git a/packages/preact-form/tests/useForm.test.tsx b/packages/preact-form/tests/useForm.test.tsx index d792e129d..a6fcc1e63 100644 --- a/packages/preact-form/tests/useForm.test.tsx +++ b/packages/preact-form/tests/useForm.test.tsx @@ -2,7 +2,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { render, waitFor } from '@testing-library/preact' import { userEvent } from '@testing-library/user-event' import { useCallback, useEffect, useState } from 'preact/hooks' -import { mergeForm, useForm, useStore } from '../src/index' +import { useStore } from '@tanstack/preact-store' +import { mergeForm, useForm } from '../src/index' import { sleep } from './utils' let user: ReturnType diff --git a/packages/preact-form/tests/useFormGroup.test-d.tsx b/packages/preact-form/tests/useFormGroup.test-d.tsx new file mode 100644 index 000000000..765ca1378 --- /dev/null +++ b/packages/preact-form/tests/useFormGroup.test-d.tsx @@ -0,0 +1,389 @@ +import { describe, expectTypeOf, it } from 'vitest' +import { useForm, useFormGroup } from '../src/index' +import type { FormGroupApi } from '../src/index' + +describe('useFormGroup form-like surface', () => { + it('should type state.value based on the selected field', () => { + function Comp() { + const form = useForm({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + }) + + const group = useFormGroup({ + form, + name: 'step1', + onGroupSubmit: () => {}, + }) + + expectTypeOf(group.state.value).toEqualTypeOf<{ name: string }>() + expectTypeOf(group.name).toEqualTypeOf<'step1'>() + } + }) + + it('should type onGroupSubmit value and meta', () => { + type SubmitMeta = { source: string } + + function Comp() { + const form = useForm({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + }) + + const group = useFormGroup({ + form, + name: 'step1', + onSubmitMeta: {} as SubmitMeta, + onGroupSubmit: ({ value, meta }) => { + expectTypeOf(value).toEqualTypeOf<{ name: string }>() + expectTypeOf(meta).toEqualTypeOf() + }, + }) + } + }) + + it('should type onGroupSubmitInvalid value and meta', () => { + type SubmitMeta = { source: string } + + function Comp() { + const form = useForm({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + }) + + const group = useFormGroup({ + form, + name: 'step1', + onSubmitMeta: {} as SubmitMeta, + onGroupSubmit: () => {}, + onGroupSubmitInvalid: ({ value, meta }) => { + expectTypeOf(value).toEqualTypeOf<{ name: string }>() + expectTypeOf(meta).toEqualTypeOf() + }, + }) + } + }) + + it('should type validators with the scoped value', () => { + function Comp() { + const form = useForm({ + defaultValues: { + step1: { name: 'test', age: 10 }, + step2: { name: 'test2' }, + }, + }) + + const group = useFormGroup({ + form, + name: 'step1', + onGroupSubmit: () => {}, + validators: { + onChange: ({ value }) => { + expectTypeOf(value).toEqualTypeOf<{ name: string; age: number }>() + return undefined + }, + onSubmit: ({ value }) => { + expectTypeOf(value).toEqualTypeOf<{ name: string; age: number }>() + return undefined + }, + }, + }) + } + }) + + it('should type listeners with the scoped value', () => { + function Comp() { + const form = useForm({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + }) + + const group = useFormGroup({ + form, + name: 'step1', + onGroupSubmit: () => {}, + listeners: { + onChange: ({ value }) => { + expectTypeOf(value).toEqualTypeOf<{ name: string }>() + }, + }, + }) + } + }) + + it('should type handleSubmit return as Promise', () => { + function Comp() { + const form = useForm({ + defaultValues: { step1: { name: 'test' } }, + }) + + const group = useFormGroup({ + form, + name: 'step1', + onGroupSubmit: () => {}, + }) + + expectTypeOf(group.handleSubmit()).toEqualTypeOf>() + } + }) + + it('should type handleSubmit overload when onSubmitMeta is provided', () => { + type SubmitMeta = { source: string } + + function Comp() { + const form = useForm({ + defaultValues: { step1: { name: 'test' } }, + }) + + const group = useFormGroup({ + form, + name: 'step1', + onSubmitMeta: {} as SubmitMeta, + onGroupSubmit: () => {}, + }) + + expectTypeOf(group.handleSubmit).toEqualTypeOf<{ + (): Promise + (submitMeta: SubmitMeta): Promise + }>() + } + }) + + it('should type setValue updater', () => { + function Comp() { + const form = useForm({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + }) + + const group = useFormGroup({ + form, + name: 'step1', + onGroupSubmit: () => {}, + }) + + group.setValue({ name: 'new name' }) + group.setValue((prev) => { + expectTypeOf(prev).toEqualTypeOf<{ name: string }>() + return { name: 'updated' } + }) + } + }) + + it('should infer the FormGroupApi instance type for useFormGroup', () => { + function Comp() { + const form = useForm({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + }) + + const group = useFormGroup({ + form, + name: 'step1', + onGroupSubmit: () => {}, + }) + + expectTypeOf(group).toMatchTypeOf< + FormGroupApi< + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any + > + >() + } + }) +}) + +describe('useFormGroup field-like meta surface', () => { + it('should type meta booleans', () => { + function Comp() { + const form = useForm({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + }) + + const group = useFormGroup({ + form, + name: 'step1', + onGroupSubmit: () => {}, + }) + + expectTypeOf(group.state.meta.isTouched).toEqualTypeOf() + expectTypeOf(group.state.meta.isBlurred).toEqualTypeOf() + expectTypeOf(group.state.meta.isDirty).toEqualTypeOf() + expectTypeOf(group.state.meta.isValidating).toEqualTypeOf() + } + }) + + it('should type errorMap entries based on validator return types', () => { + function Comp() { + const form = useForm({ + defaultValues: { step1: { name: 'test' } }, + }) + + const group = useFormGroup({ + form, + name: 'step1', + onGroupSubmit: () => {}, + validators: { + onChange: () => 'sync-change' as const, + onChangeAsync: async () => 'async-change' as const, + onBlur: () => 'sync-blur' as const, + onBlurAsync: async () => 'async-blur' as const, + onSubmit: () => 'sync-submit' as const, + onSubmitAsync: async () => 'async-submit' as const, + }, + }) + + expectTypeOf(group.state.meta.errorMap.onChange).toEqualTypeOf< + 'sync-change' | 'async-change' | undefined + >() + expectTypeOf(group.state.meta.errorMap.onBlur).toEqualTypeOf< + 'sync-blur' | 'async-blur' | undefined + >() + expectTypeOf(group.state.meta.errorMap.onSubmit).toEqualTypeOf< + 'sync-submit' | 'async-submit' | undefined + >() + } + }) + + it('should type errors array from group validators', () => { + function Comp() { + const form = useForm({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + }) + + const group = useFormGroup({ + form, + name: 'step1', + onGroupSubmit: () => {}, + validators: { + onChange: () => 'change-error' as const, + }, + }) + + expectTypeOf(group.state.meta.errors).toEqualTypeOf< + Array<'change-error' | undefined> + >() + } + }) +}) + +describe('form.FormGroup component surface', () => { + it('should type the children render prop with the scoped value', () => { + function Comp() { + const form = useForm({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + }) + + return ( + {}}> + {(group) => { + expectTypeOf(group.state.value).toEqualTypeOf<{ name: string }>() + expectTypeOf(group.name).toEqualTypeOf<'step1'>() + return null + }} + + ) + } + }) + + it('should type the children render prop with onSubmitMeta', () => { + type SubmitMeta = { source: string } + + function Comp() { + const form = useForm({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + }) + + return ( + { + expectTypeOf(value).toEqualTypeOf<{ name: string }>() + expectTypeOf(meta).toEqualTypeOf() + }} + > + {(group) => { + expectTypeOf(group.handleSubmit).toEqualTypeOf<{ + (): Promise + (submitMeta: SubmitMeta): Promise + }>() + return null + }} + + ) + } + }) + + it('should type validators on the FormGroup component', () => { + function Comp() { + const form = useForm({ + defaultValues: { + step1: { name: 'test', age: 10 }, + step2: { name: 'test2' }, + }, + }) + + return ( + { + expectTypeOf(value).toEqualTypeOf<{ name: string; age: number }>() + return undefined + }, + }} + onGroupSubmit={() => {}} + > + {() => null} + + ) + } + }) +}) diff --git a/packages/preact-form/tests/useFormGroup.test.tsx b/packages/preact-form/tests/useFormGroup.test.tsx new file mode 100644 index 000000000..196271ba2 --- /dev/null +++ b/packages/preact-form/tests/useFormGroup.test.tsx @@ -0,0 +1,266 @@ +import { describe, expect, it, vi } from 'vitest' +import { render, waitFor } from '@testing-library/preact' +import { userEvent } from '@testing-library/user-event' +import { useForm } from '../src/index' + +const user = userEvent.setup() + +describe('form.FormGroup', () => { + it('should call onGroupSubmit but not the form onSubmit when submitting the group', async () => { + const onSubmit = vi.fn() + const onGroupSubmit = vi.fn() + + function Comp() { + const form = useForm({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + onSubmit, + }) + + return ( + + {(group) => ( +
{ + e.preventDefault() + e.stopPropagation() + group.handleSubmit() + }} + > + ( + field.handleChange(e.currentTarget.value)} + /> + )} + /> + + + )} +
+ ) + } + + const { getByTestId } = render() + await user.click(getByTestId('submit-group')) + + await waitFor(() => expect(onGroupSubmit).toHaveBeenCalledTimes(1)) + expect(onSubmit).not.toHaveBeenCalled() + }) + + it('should expose group state value reactively', async () => { + function Comp() { + const form = useForm({ + defaultValues: { + step1: { name: 'initial' }, + step2: { name: 'other' }, + }, + }) + + return ( + + {(group) => ( + <> + ( + field.handleChange(e.currentTarget.value)} + /> + )} + /> +
+                {JSON.stringify(group.state.value)}
+              
+ + )} +
+ ) + } + + const { getByTestId } = render() + expect(getByTestId('group-value').textContent).toBe('{"name":"initial"}') + + await user.clear(getByTestId('step1-name')) + await user.type(getByTestId('step1-name'), 'updated') + + await waitFor(() => + expect(getByTestId('group-value').textContent).toBe('{"name":"updated"}'), + ) + }) + + it('should call onGroupSubmitInvalid when group-level validation fails', async () => { + const onSubmit = vi.fn() + const onGroupSubmit = vi.fn() + const onGroupSubmitInvalid = vi.fn() + + function Comp() { + const form = useForm({ + defaultValues: { + step1: { name: '' }, + step2: { name: 'test2' }, + }, + onSubmit, + }) + + return ( + + !value.name ? 'Name is required' : undefined, + }} + onGroupSubmit={onGroupSubmit} + onGroupSubmitInvalid={onGroupSubmitInvalid} + > + {(group) => ( +
{ + e.preventDefault() + e.stopPropagation() + group.handleSubmit() + }} + > + ( + field.handleChange(e.currentTarget.value)} + /> + )} + /> + +
+                {String(group.state.meta.errorMap.onSubmit ?? '')}
+              
+ + )} +
+ ) + } + + const { getByTestId } = render() + await user.click(getByTestId('submit-group')) + + await waitFor(() => expect(onGroupSubmitInvalid).toHaveBeenCalledTimes(1)) + expect(onGroupSubmit).not.toHaveBeenCalled() + expect(onSubmit).not.toHaveBeenCalled() + await waitFor(() => + expect(getByTestId('group-error').textContent).toBe('Name is required'), + ) + }) + + it('should ignore form-level field errors outside the group when submitting the group', async () => { + const onGroupSubmit = vi.fn() + const onGroupSubmitInvalid = vi.fn() + + function Comp() { + const form = useForm({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + validators: { + onSubmit: () => ({ + fields: { + 'step2.name': 'Required', + }, + }), + }, + }) + + return ( + + {(group) => ( +
{ + e.preventDefault() + e.stopPropagation() + group.handleSubmit() + }} + > + ( + field.handleChange(e.currentTarget.value)} + /> + )} + /> + + + )} +
+ ) + } + + const { getByTestId } = render() + await user.click(getByTestId('submit-group')) + + await waitFor(() => expect(onGroupSubmit).toHaveBeenCalledTimes(1)) + expect(onGroupSubmitInvalid).not.toHaveBeenCalled() + }) + + it('should pass submit meta through handleSubmit', async () => { + const onGroupSubmit = vi.fn() + + function Comp() { + const form = useForm({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + }) + + return ( + + {(group) => ( + + )} + + ) + } + + const { getByTestId } = render() + await user.click(getByTestId('submit-group')) + + await waitFor(() => expect(onGroupSubmit).toHaveBeenCalledTimes(1)) + expect(onGroupSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + value: { name: 'test' }, + meta: { source: 'button' }, + }), + ) + }) +}) diff --git a/packages/react-form/package.json b/packages/react-form/package.json index eca53ac8b..9e25de1a2 100644 --- a/packages/react-form/package.json +++ b/packages/react-form/package.json @@ -52,7 +52,7 @@ ], "dependencies": { "@tanstack/form-core": "workspace:*", - "@tanstack/react-store": "^0.9.1" + "@tanstack/react-store": "^0.11.0" }, "devDependencies": { "@types/react": "^19.0.7", diff --git a/packages/react-form/src/index.ts b/packages/react-form/src/index.ts index e032da0e2..604315884 100644 --- a/packages/react-form/src/index.ts +++ b/packages/react-form/src/index.ts @@ -7,4 +7,5 @@ export * from './types' export * from './useField' export * from './useFieldGroup' export * from './useForm' +export * from './useFormGroup' export * from './useIsomorphicLayoutEffect' diff --git a/packages/react-form/src/useForm.tsx b/packages/react-form/src/useForm.tsx index d50651c02..1b11566f5 100644 --- a/packages/react-form/src/useForm.tsx +++ b/packages/react-form/src/useForm.tsx @@ -6,6 +6,8 @@ import { useMemo, useRef, useState } from 'react' import { Field } from './useField' import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect' import { useFormId } from './useFormId' +import { FormGroup } from './useFormGroup' +import type { FormGroupComponent } from './useFormGroup' import type { AnyFormApi, AnyFormState, @@ -51,6 +53,20 @@ export interface ReactFormApi< TOnServer, TSubmitMeta > + FormGroup: FormGroupComponent< + TFormData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TOnServer, + TSubmitMeta + > /** * A `Subscribe` function that allows you to listen and react to changes in the form's state. It's especially useful when you need to execute side effects or render specific components in response to state updates. */ @@ -242,6 +258,10 @@ export function useForm< return } + extendedApi.FormGroup = function APIFormGroup(props) { + return + } + extendedApi.Subscribe = function Subscribe(props: any) { return ( , + TFormOnChange extends undefined | FormValidateOrFn, + TFormOnChangeAsync extends undefined | FormAsyncValidateOrFn, + TFormOnBlur extends undefined | FormValidateOrFn, + TFormOnBlurAsync extends undefined | FormAsyncValidateOrFn, + TFormOnSubmit extends undefined | FormValidateOrFn, + TFormOnSubmitAsync extends undefined | FormAsyncValidateOrFn, + TFormOnDynamic extends undefined | FormValidateOrFn, + TFormOnDynamicAsync extends undefined | FormAsyncValidateOrFn, + TFormOnServer extends undefined | FormAsyncValidateOrFn, + TParentSubmitMeta, +> = < + TName extends DeepKeys, + TData extends DeepValue, + TOnMount extends undefined | FormGroupValidateOrFn, + TOnChange extends + | undefined + | FormGroupValidateOrFn, + TOnChangeAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnBlur extends undefined | FormGroupValidateOrFn, + TOnBlurAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnSubmit extends + | undefined + | FormGroupValidateOrFn, + TOnSubmitAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnDynamic extends + | undefined + | FormGroupValidateOrFn, + TOnDynamicAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TSubmitMeta, +>( + opts: FormGroupApiOptions< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TSubmitMeta, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta + >, +) => FormGroupApi< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TSubmitMeta, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta +> + +export function useFormGroup< + TParentData, + TName extends DeepKeys, + TData extends DeepValue, + TOnMount extends undefined | FormGroupValidateOrFn, + TOnChange extends + | undefined + | FormGroupValidateOrFn, + TOnChangeAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnBlur extends undefined | FormGroupValidateOrFn, + TOnBlurAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnSubmit extends + | undefined + | FormGroupValidateOrFn, + TOnSubmitAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnDynamic extends + | undefined + | FormGroupValidateOrFn, + TOnDynamicAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TSubmitMeta, + TFormOnMount extends undefined | FormValidateOrFn, + TFormOnChange extends undefined | FormValidateOrFn, + TFormOnChangeAsync extends undefined | FormAsyncValidateOrFn, + TFormOnBlur extends undefined | FormValidateOrFn, + TFormOnBlurAsync extends undefined | FormAsyncValidateOrFn, + TFormOnSubmit extends undefined | FormValidateOrFn, + TFormOnSubmitAsync extends undefined | FormAsyncValidateOrFn, + TFormOnDynamic extends undefined | FormValidateOrFn, + TFormOnDynamicAsync extends undefined | FormAsyncValidateOrFn, + TFormOnServer extends undefined | FormAsyncValidateOrFn, + TParentSubmitMeta, +>( + opts: FormGroupApiOptions< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TSubmitMeta, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta + >, +) { + // Keep a snapshot of options so that React Compiler doesn't + // wrongly optimize formGroupApi. + const [prevOptions, setPrevOptions] = useState(() => ({ + form: opts.form, + name: opts.name, + })) + + const [formGroupApi, setFormGroupApi] = useState(() => { + return new FormGroupApi({ + ...opts, + }) + }) + + // We only want to + // update on name changes since those are at risk of becoming stale. The field + // state must be up to date for the internal JSX render. + // The other options can freely be in `fieldApi.update` + if (prevOptions.form !== opts.form || prevOptions.name !== opts.name) { + setFormGroupApi( + new FormGroupApi({ + ...opts, + }), + ) + setPrevOptions({ form: opts.form, name: opts.name }) + } + + const reactiveStateValue = useStore( + formGroupApi.store, + (state) => state.value, + ) + + const reactiveMetaIsTouched = useStore( + formGroupApi.store, + (state) => state.meta.isTouched, + ) + const reactiveMetaIsBlurred = useStore( + formGroupApi.store, + (state) => state.meta.isBlurred, + ) + const reactiveMetaIsDirty = useStore( + formGroupApi.store, + (state) => state.meta.isDirty, + ) + const reactiveMetaErrorMap = useStore( + formGroupApi.store, + (state) => state.meta.errorMap, + ) + const reactiveMetaErrorSourceMap = useStore( + formGroupApi.store, + (state) => state.meta.errorSourceMap, + ) + const reactiveMetaIsValidating = useStore( + formGroupApi.store, + (state) => state.meta.isValidating, + ) + + // This makes me sad, but if I understand correctly, this is what we have to do for reactivity to work properly with React compiler. + const extendedFieldApi = useMemo(() => { + const reactiveFieldApi = { + ...formGroupApi, + handleSubmit: ((...props: never[]) => { + return formGroupApi._handleSubmit(...props) + }) as typeof formGroupApi.handleSubmit, + get formState() { + return formGroupApi.formState + }, + get state() { + return { + value: reactiveStateValue, + get meta() { + return { + ...formGroupApi.state.meta, + isTouched: reactiveMetaIsTouched, + isBlurred: reactiveMetaIsBlurred, + isDirty: reactiveMetaIsDirty, + errorMap: reactiveMetaErrorMap, + errorSourceMap: reactiveMetaErrorSourceMap, + isValidating: reactiveMetaIsValidating, + } satisfies typeof formGroupApi.state.meta + }, + } satisfies typeof formGroupApi.state + }, + } + + const extendedApi: FormGroupApi< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TSubmitMeta, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta + > = reactiveFieldApi as never + + return extendedApi + }, [ + formGroupApi, + reactiveStateValue, + reactiveMetaIsTouched, + reactiveMetaIsBlurred, + reactiveMetaIsDirty, + reactiveMetaErrorMap, + reactiveMetaErrorSourceMap, + reactiveMetaIsValidating, + ]) + + useIsomorphicLayoutEffect(formGroupApi.mount, [formGroupApi]) + + useIsomorphicLayoutEffect(() => { + formGroupApi.update(opts) + }) + + return extendedFieldApi +} + +interface FormGroupComponentProps< + TParentData, + TName extends DeepKeys, + TData extends DeepValue, + TOnMount extends undefined | FormGroupValidateOrFn, + TOnChange extends + | undefined + | FormGroupValidateOrFn, + TOnChangeAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnBlur extends undefined | FormGroupValidateOrFn, + TOnBlurAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnSubmit extends + | undefined + | FormGroupValidateOrFn, + TOnSubmitAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnDynamic extends + | undefined + | FormGroupValidateOrFn, + TOnDynamicAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TSubmitMeta, + TFormOnMount extends undefined | FormValidateOrFn, + TFormOnChange extends undefined | FormValidateOrFn, + TFormOnChangeAsync extends undefined | FormAsyncValidateOrFn, + TFormOnBlur extends undefined | FormValidateOrFn, + TFormOnBlurAsync extends undefined | FormAsyncValidateOrFn, + TFormOnSubmit extends undefined | FormValidateOrFn, + TFormOnSubmitAsync extends undefined | FormAsyncValidateOrFn, + TFormOnDynamic extends undefined | FormValidateOrFn, + TFormOnDynamicAsync extends undefined | FormAsyncValidateOrFn, + TFormOnServer extends undefined | FormAsyncValidateOrFn, + TParentSubmitMeta, + ExtendedApi = {}, +> extends FormGroupApiOptions< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TSubmitMeta, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta +> { + children: ( + formGroupApi: FormGroupApi< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TSubmitMeta, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta + > & + ExtendedApi, + ) => ReactNode +} + +interface FormGroupComponentBoundProps< + TParentData, + TName extends DeepKeys, + TData extends DeepValue, + TOnMount extends undefined | FormGroupValidateOrFn, + TOnChange extends + | undefined + | FormGroupValidateOrFn, + TOnChangeAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnBlur extends undefined | FormGroupValidateOrFn, + TOnBlurAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnSubmit extends + | undefined + | FormGroupValidateOrFn, + TOnSubmitAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnDynamic extends + | undefined + | FormGroupValidateOrFn, + TOnDynamicAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TSubmitMeta, + TFormOnMount extends undefined | FormValidateOrFn, + TFormOnChange extends undefined | FormValidateOrFn, + TFormOnChangeAsync extends undefined | FormAsyncValidateOrFn, + TFormOnBlur extends undefined | FormValidateOrFn, + TFormOnBlurAsync extends undefined | FormAsyncValidateOrFn, + TFormOnSubmit extends undefined | FormValidateOrFn, + TFormOnSubmitAsync extends undefined | FormAsyncValidateOrFn, + TFormOnDynamic extends undefined | FormValidateOrFn, + TFormOnDynamicAsync extends undefined | FormAsyncValidateOrFn, + TFormOnServer extends undefined | FormAsyncValidateOrFn, + TParentSubmitMeta, + ExtendedApi = {}, +> extends FormGroupOptions< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TSubmitMeta, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta +> { + children: ( + formGroupApi: FormGroupApi< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TSubmitMeta, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta + > & + ExtendedApi, + ) => ReactNode +} + +export type FormGroupComponent< + in out TParentData, + in out TFormOnMount extends undefined | FormValidateOrFn, + in out TFormOnChange extends undefined | FormValidateOrFn, + in out TFormOnChangeAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnBlur extends undefined | FormValidateOrFn, + in out TFormOnBlurAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnSubmit extends undefined | FormValidateOrFn, + in out TFormOnSubmitAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnDynamic extends undefined | FormValidateOrFn, + in out TFormOnDynamicAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnServer extends undefined | FormAsyncValidateOrFn, + in out TParentSubmitMeta, + in out ExtendedApi = {}, +> = < + const TName extends DeepKeys, + TData extends DeepValue, + TOnMount extends undefined | FormGroupValidateOrFn, + TOnChange extends + | undefined + | FormGroupValidateOrFn, + TOnChangeAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnBlur extends undefined | FormGroupValidateOrFn, + TOnBlurAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnSubmit extends + | undefined + | FormGroupValidateOrFn, + TOnSubmitAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnDynamic extends + | undefined + | FormGroupValidateOrFn, + TOnDynamicAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TSubmitMeta, +>({ + children, + ...formGroupOptions +}: FormGroupComponentBoundProps< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TSubmitMeta, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta, + ExtendedApi +>) => ReturnType + +export const FormGroup = (< + TParentData, + TName extends DeepKeys, + TData extends DeepValue, + TOnMount extends undefined | FormGroupValidateOrFn, + TOnChange extends + | undefined + | FormGroupValidateOrFn, + TOnChangeAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnBlur extends undefined | FormGroupValidateOrFn, + TOnBlurAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnSubmit extends + | undefined + | FormGroupValidateOrFn, + TOnSubmitAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnDynamic extends + | undefined + | FormGroupValidateOrFn, + TOnDynamicAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TSubmitMeta, + TFormOnMount extends undefined | FormValidateOrFn, + TFormOnChange extends undefined | FormValidateOrFn, + TFormOnChangeAsync extends undefined | FormAsyncValidateOrFn, + TFormOnBlur extends undefined | FormValidateOrFn, + TFormOnBlurAsync extends undefined | FormAsyncValidateOrFn, + TFormOnSubmit extends undefined | FormValidateOrFn, + TFormOnSubmitAsync extends undefined | FormAsyncValidateOrFn, + TFormOnDynamic extends undefined | FormValidateOrFn, + TFormOnDynamicAsync extends undefined | FormAsyncValidateOrFn, + TFormOnServer extends undefined | FormAsyncValidateOrFn, + TParentSubmitMeta, +>({ + children, + ...formGroupOptions +}: FormGroupComponentProps< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TSubmitMeta, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta +>): ReturnType => { + const formGroupApi = useFormGroup(formGroupOptions as any) + + const jsxToDisplay = useMemo( + () => functionalUpdate(children, formGroupApi as any), + [children, formGroupApi], + ) + return (<>{jsxToDisplay}) as never +}) satisfies FunctionComponent< + FormGroupComponentProps< + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any + > +> diff --git a/packages/react-form/tests/useFormGroup.test-d.tsx b/packages/react-form/tests/useFormGroup.test-d.tsx new file mode 100644 index 000000000..765ca1378 --- /dev/null +++ b/packages/react-form/tests/useFormGroup.test-d.tsx @@ -0,0 +1,389 @@ +import { describe, expectTypeOf, it } from 'vitest' +import { useForm, useFormGroup } from '../src/index' +import type { FormGroupApi } from '../src/index' + +describe('useFormGroup form-like surface', () => { + it('should type state.value based on the selected field', () => { + function Comp() { + const form = useForm({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + }) + + const group = useFormGroup({ + form, + name: 'step1', + onGroupSubmit: () => {}, + }) + + expectTypeOf(group.state.value).toEqualTypeOf<{ name: string }>() + expectTypeOf(group.name).toEqualTypeOf<'step1'>() + } + }) + + it('should type onGroupSubmit value and meta', () => { + type SubmitMeta = { source: string } + + function Comp() { + const form = useForm({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + }) + + const group = useFormGroup({ + form, + name: 'step1', + onSubmitMeta: {} as SubmitMeta, + onGroupSubmit: ({ value, meta }) => { + expectTypeOf(value).toEqualTypeOf<{ name: string }>() + expectTypeOf(meta).toEqualTypeOf() + }, + }) + } + }) + + it('should type onGroupSubmitInvalid value and meta', () => { + type SubmitMeta = { source: string } + + function Comp() { + const form = useForm({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + }) + + const group = useFormGroup({ + form, + name: 'step1', + onSubmitMeta: {} as SubmitMeta, + onGroupSubmit: () => {}, + onGroupSubmitInvalid: ({ value, meta }) => { + expectTypeOf(value).toEqualTypeOf<{ name: string }>() + expectTypeOf(meta).toEqualTypeOf() + }, + }) + } + }) + + it('should type validators with the scoped value', () => { + function Comp() { + const form = useForm({ + defaultValues: { + step1: { name: 'test', age: 10 }, + step2: { name: 'test2' }, + }, + }) + + const group = useFormGroup({ + form, + name: 'step1', + onGroupSubmit: () => {}, + validators: { + onChange: ({ value }) => { + expectTypeOf(value).toEqualTypeOf<{ name: string; age: number }>() + return undefined + }, + onSubmit: ({ value }) => { + expectTypeOf(value).toEqualTypeOf<{ name: string; age: number }>() + return undefined + }, + }, + }) + } + }) + + it('should type listeners with the scoped value', () => { + function Comp() { + const form = useForm({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + }) + + const group = useFormGroup({ + form, + name: 'step1', + onGroupSubmit: () => {}, + listeners: { + onChange: ({ value }) => { + expectTypeOf(value).toEqualTypeOf<{ name: string }>() + }, + }, + }) + } + }) + + it('should type handleSubmit return as Promise', () => { + function Comp() { + const form = useForm({ + defaultValues: { step1: { name: 'test' } }, + }) + + const group = useFormGroup({ + form, + name: 'step1', + onGroupSubmit: () => {}, + }) + + expectTypeOf(group.handleSubmit()).toEqualTypeOf>() + } + }) + + it('should type handleSubmit overload when onSubmitMeta is provided', () => { + type SubmitMeta = { source: string } + + function Comp() { + const form = useForm({ + defaultValues: { step1: { name: 'test' } }, + }) + + const group = useFormGroup({ + form, + name: 'step1', + onSubmitMeta: {} as SubmitMeta, + onGroupSubmit: () => {}, + }) + + expectTypeOf(group.handleSubmit).toEqualTypeOf<{ + (): Promise + (submitMeta: SubmitMeta): Promise + }>() + } + }) + + it('should type setValue updater', () => { + function Comp() { + const form = useForm({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + }) + + const group = useFormGroup({ + form, + name: 'step1', + onGroupSubmit: () => {}, + }) + + group.setValue({ name: 'new name' }) + group.setValue((prev) => { + expectTypeOf(prev).toEqualTypeOf<{ name: string }>() + return { name: 'updated' } + }) + } + }) + + it('should infer the FormGroupApi instance type for useFormGroup', () => { + function Comp() { + const form = useForm({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + }) + + const group = useFormGroup({ + form, + name: 'step1', + onGroupSubmit: () => {}, + }) + + expectTypeOf(group).toMatchTypeOf< + FormGroupApi< + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any + > + >() + } + }) +}) + +describe('useFormGroup field-like meta surface', () => { + it('should type meta booleans', () => { + function Comp() { + const form = useForm({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + }) + + const group = useFormGroup({ + form, + name: 'step1', + onGroupSubmit: () => {}, + }) + + expectTypeOf(group.state.meta.isTouched).toEqualTypeOf() + expectTypeOf(group.state.meta.isBlurred).toEqualTypeOf() + expectTypeOf(group.state.meta.isDirty).toEqualTypeOf() + expectTypeOf(group.state.meta.isValidating).toEqualTypeOf() + } + }) + + it('should type errorMap entries based on validator return types', () => { + function Comp() { + const form = useForm({ + defaultValues: { step1: { name: 'test' } }, + }) + + const group = useFormGroup({ + form, + name: 'step1', + onGroupSubmit: () => {}, + validators: { + onChange: () => 'sync-change' as const, + onChangeAsync: async () => 'async-change' as const, + onBlur: () => 'sync-blur' as const, + onBlurAsync: async () => 'async-blur' as const, + onSubmit: () => 'sync-submit' as const, + onSubmitAsync: async () => 'async-submit' as const, + }, + }) + + expectTypeOf(group.state.meta.errorMap.onChange).toEqualTypeOf< + 'sync-change' | 'async-change' | undefined + >() + expectTypeOf(group.state.meta.errorMap.onBlur).toEqualTypeOf< + 'sync-blur' | 'async-blur' | undefined + >() + expectTypeOf(group.state.meta.errorMap.onSubmit).toEqualTypeOf< + 'sync-submit' | 'async-submit' | undefined + >() + } + }) + + it('should type errors array from group validators', () => { + function Comp() { + const form = useForm({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + }) + + const group = useFormGroup({ + form, + name: 'step1', + onGroupSubmit: () => {}, + validators: { + onChange: () => 'change-error' as const, + }, + }) + + expectTypeOf(group.state.meta.errors).toEqualTypeOf< + Array<'change-error' | undefined> + >() + } + }) +}) + +describe('form.FormGroup component surface', () => { + it('should type the children render prop with the scoped value', () => { + function Comp() { + const form = useForm({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + }) + + return ( + {}}> + {(group) => { + expectTypeOf(group.state.value).toEqualTypeOf<{ name: string }>() + expectTypeOf(group.name).toEqualTypeOf<'step1'>() + return null + }} + + ) + } + }) + + it('should type the children render prop with onSubmitMeta', () => { + type SubmitMeta = { source: string } + + function Comp() { + const form = useForm({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + }) + + return ( + { + expectTypeOf(value).toEqualTypeOf<{ name: string }>() + expectTypeOf(meta).toEqualTypeOf() + }} + > + {(group) => { + expectTypeOf(group.handleSubmit).toEqualTypeOf<{ + (): Promise + (submitMeta: SubmitMeta): Promise + }>() + return null + }} + + ) + } + }) + + it('should type validators on the FormGroup component', () => { + function Comp() { + const form = useForm({ + defaultValues: { + step1: { name: 'test', age: 10 }, + step2: { name: 'test2' }, + }, + }) + + return ( + { + expectTypeOf(value).toEqualTypeOf<{ name: string; age: number }>() + return undefined + }, + }} + onGroupSubmit={() => {}} + > + {() => null} + + ) + } + }) +}) diff --git a/packages/react-form/tests/useFormGroup.test.tsx b/packages/react-form/tests/useFormGroup.test.tsx new file mode 100644 index 000000000..2da5158d8 --- /dev/null +++ b/packages/react-form/tests/useFormGroup.test.tsx @@ -0,0 +1,266 @@ +import { describe, expect, it, vi } from 'vitest' +import { render, waitFor } from '@testing-library/react' +import { userEvent } from '@testing-library/user-event' +import { useForm } from '../src/index' + +const user = userEvent.setup() + +describe('form.FormGroup', () => { + it('should call onGroupSubmit but not the form onSubmit when submitting the group', async () => { + const onSubmit = vi.fn() + const onGroupSubmit = vi.fn() + + function Comp() { + const form = useForm({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + onSubmit, + }) + + return ( + + {(group) => ( +
{ + e.preventDefault() + e.stopPropagation() + group.handleSubmit() + }} + > + ( + field.handleChange(e.target.value)} + /> + )} + /> + + + )} +
+ ) + } + + const { getByTestId } = render() + await user.click(getByTestId('submit-group')) + + await waitFor(() => expect(onGroupSubmit).toHaveBeenCalledTimes(1)) + expect(onSubmit).not.toHaveBeenCalled() + }) + + it('should expose group state value reactively', async () => { + function Comp() { + const form = useForm({ + defaultValues: { + step1: { name: 'initial' }, + step2: { name: 'other' }, + }, + }) + + return ( + + {(group) => ( + <> + ( + field.handleChange(e.target.value)} + /> + )} + /> +
+                {JSON.stringify(group.state.value)}
+              
+ + )} +
+ ) + } + + const { getByTestId } = render() + expect(getByTestId('group-value').textContent).toBe('{"name":"initial"}') + + await user.clear(getByTestId('step1-name')) + await user.type(getByTestId('step1-name'), 'updated') + + await waitFor(() => + expect(getByTestId('group-value').textContent).toBe('{"name":"updated"}'), + ) + }) + + it('should call onGroupSubmitInvalid when group-level validation fails', async () => { + const onSubmit = vi.fn() + const onGroupSubmit = vi.fn() + const onGroupSubmitInvalid = vi.fn() + + function Comp() { + const form = useForm({ + defaultValues: { + step1: { name: '' }, + step2: { name: 'test2' }, + }, + onSubmit, + }) + + return ( + + !value.name ? 'Name is required' : undefined, + }} + onGroupSubmit={onGroupSubmit} + onGroupSubmitInvalid={onGroupSubmitInvalid} + > + {(group) => ( +
{ + e.preventDefault() + e.stopPropagation() + group.handleSubmit() + }} + > + ( + field.handleChange(e.target.value)} + /> + )} + /> + +
+                {String(group.state.meta.errorMap.onSubmit ?? '')}
+              
+ + )} +
+ ) + } + + const { getByTestId } = render() + await user.click(getByTestId('submit-group')) + + await waitFor(() => expect(onGroupSubmitInvalid).toHaveBeenCalledTimes(1)) + expect(onGroupSubmit).not.toHaveBeenCalled() + expect(onSubmit).not.toHaveBeenCalled() + await waitFor(() => + expect(getByTestId('group-error').textContent).toBe('Name is required'), + ) + }) + + it('should ignore form-level field errors outside the group when submitting the group', async () => { + const onGroupSubmit = vi.fn() + const onGroupSubmitInvalid = vi.fn() + + function Comp() { + const form = useForm({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + validators: { + onSubmit: () => ({ + fields: { + 'step2.name': 'Required', + }, + }), + }, + }) + + return ( + + {(group) => ( +
{ + e.preventDefault() + e.stopPropagation() + group.handleSubmit() + }} + > + ( + field.handleChange(e.target.value)} + /> + )} + /> + + + )} +
+ ) + } + + const { getByTestId } = render() + await user.click(getByTestId('submit-group')) + + await waitFor(() => expect(onGroupSubmit).toHaveBeenCalledTimes(1)) + expect(onGroupSubmitInvalid).not.toHaveBeenCalled() + }) + + it('should pass submit meta through handleSubmit', async () => { + const onGroupSubmit = vi.fn() + + function Comp() { + const form = useForm({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + }) + + return ( + + {(group) => ( + + )} + + ) + } + + const { getByTestId } = render() + await user.click(getByTestId('submit-group')) + + await waitFor(() => expect(onGroupSubmit).toHaveBeenCalledTimes(1)) + expect(onGroupSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + value: { name: 'test' }, + meta: { source: 'button' }, + }), + ) + }) +}) diff --git a/packages/solid-form/package.json b/packages/solid-form/package.json index 6947a7201..16dc8cb1f 100644 --- a/packages/solid-form/package.json +++ b/packages/solid-form/package.json @@ -56,7 +56,7 @@ ], "dependencies": { "@tanstack/form-core": "workspace:*", - "@tanstack/solid-store": "^0.9.1" + "@tanstack/solid-store": "^0.11.0" }, "devDependencies": { "solid-js": "^1.9.9", diff --git a/packages/solid-form/src/createForm.tsx b/packages/solid-form/src/createForm.tsx index 0c01b193a..844dfa833 100644 --- a/packages/solid-form/src/createForm.tsx +++ b/packages/solid-form/src/createForm.tsx @@ -2,6 +2,7 @@ import { FormApi, functionalUpdate } from '@tanstack/form-core' import { createComputed, onMount } from 'solid-js' import { useStore } from '@tanstack/solid-store' import { Field, createField } from './createField' +import { FormGroup } from './createFormGroup' import type { FormAsyncValidateOrFn, FormOptions, @@ -10,6 +11,7 @@ import type { } from '@tanstack/form-core' import type { JSXElement } from 'solid-js' import type { FieldComponent } from './createField' +import type { FormGroupComponent } from './createFormGroup' export interface SolidFormApi< TParentData, @@ -39,6 +41,20 @@ export interface SolidFormApi< TFormOnServer, TSubmitMeta > + FormGroup: FormGroupComponent< + TParentData, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TSubmitMeta + > useStore: < TSelected = NoInfer< FormState< @@ -217,6 +233,7 @@ export function createForm< > = api as never extendedApi.Field = (props) => + extendedApi.FormGroup = (props) => extendedApi.useStore = (selector) => useStore(api.store, selector) extendedApi.Subscribe = (props) => functionalUpdate(props.children, useStore(api.store, props.selector)) diff --git a/packages/solid-form/src/createFormGroup.tsx b/packages/solid-form/src/createFormGroup.tsx new file mode 100644 index 000000000..47467a88a --- /dev/null +++ b/packages/solid-form/src/createFormGroup.tsx @@ -0,0 +1,622 @@ +import { FormGroupApi, functionalUpdate } from '@tanstack/form-core' +import { + createComponent, + createComputed, + createSignal, + onCleanup, + onMount, +} from 'solid-js' +import { useStore } from '@tanstack/solid-store' +import type { + DeepKeys, + DeepValue, + FormAsyncValidateOrFn, + FormGroupApiOptions, + FormGroupAsyncValidateOrFn, + FormGroupOptions, + FormGroupValidateOrFn, + FormValidateOrFn, +} from '@tanstack/form-core' +import type { Accessor, JSX, JSXElement } from 'solid-js' + +// ugly way to trick solid into triggering updates for changes on the formGroupApi +function makeFormGroupReactive< + TParentData, + TName extends DeepKeys, + TData extends DeepValue, + TOnMount extends undefined | FormGroupValidateOrFn, + TOnChange extends + | undefined + | FormGroupValidateOrFn, + TOnChangeAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnBlur extends undefined | FormGroupValidateOrFn, + TOnBlurAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnSubmit extends + | undefined + | FormGroupValidateOrFn, + TOnSubmitAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnDynamic extends + | undefined + | FormGroupValidateOrFn, + TOnDynamicAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TSubmitMeta, + TFormOnMount extends undefined | FormValidateOrFn, + TFormOnChange extends undefined | FormValidateOrFn, + TFormOnChangeAsync extends undefined | FormAsyncValidateOrFn, + TFormOnBlur extends undefined | FormValidateOrFn, + TFormOnBlurAsync extends undefined | FormAsyncValidateOrFn, + TFormOnSubmit extends undefined | FormValidateOrFn, + TFormOnSubmitAsync extends undefined | FormAsyncValidateOrFn, + TFormOnDynamic extends undefined | FormValidateOrFn, + TFormOnDynamicAsync extends undefined | FormAsyncValidateOrFn, + TFormOnServer extends undefined | FormAsyncValidateOrFn, + TParentSubmitMeta, +>( + formGroupApi: FormGroupApi< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TSubmitMeta, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta + >, +): () => FormGroupApi< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TSubmitMeta, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta +> { + const [group, setGroup] = createSignal(formGroupApi, { equals: false }) + // Handle shallow comparison to make sure that Derived doesn't create a new setGroup call every time + const store = useStore(formGroupApi.store, (store) => store) + // Run before initial render + createComputed(() => { + // Use the store to track dependencies + store() + setGroup(formGroupApi) + }) + return group +} + +export function createFormGroup< + TParentData, + TName extends DeepKeys, + TData extends DeepValue, + TOnMount extends undefined | FormGroupValidateOrFn, + TOnChange extends + | undefined + | FormGroupValidateOrFn, + TOnChangeAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnBlur extends undefined | FormGroupValidateOrFn, + TOnBlurAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnSubmit extends + | undefined + | FormGroupValidateOrFn, + TOnSubmitAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnDynamic extends + | undefined + | FormGroupValidateOrFn, + TOnDynamicAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TSubmitMeta, + TFormOnMount extends undefined | FormValidateOrFn, + TFormOnChange extends undefined | FormValidateOrFn, + TFormOnChangeAsync extends undefined | FormAsyncValidateOrFn, + TFormOnBlur extends undefined | FormValidateOrFn, + TFormOnBlurAsync extends undefined | FormAsyncValidateOrFn, + TFormOnSubmit extends undefined | FormValidateOrFn, + TFormOnSubmitAsync extends undefined | FormAsyncValidateOrFn, + TFormOnDynamic extends undefined | FormValidateOrFn, + TFormOnDynamicAsync extends undefined | FormAsyncValidateOrFn, + TFormOnServer extends undefined | FormAsyncValidateOrFn, + TParentSubmitMeta, +>( + opts: () => FormGroupApiOptions< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TSubmitMeta, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta + >, +) { + const options = opts() + + const api = new FormGroupApi(options) + + let mounted = false + // Instantiates form group meta and removes it when unrendered + onMount(() => { + const cleanupFn = api.mount() + mounted = true + onCleanup(() => { + cleanupFn() + mounted = false + }) + }) + + /** + * formGroupApi.update should not have any side effects. Think of it like a `useRef` + * that we need to keep updated every render with the most up-to-date information. + * + * createComputed to make sure this effect runs before render effects + */ + createComputed(() => { + if (!mounted) return + api.update(opts()) + }) + + return makeFormGroupReactive< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TSubmitMeta, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta + >(api) +} + +interface FormGroupComponentBoundProps< + TParentData, + TName extends DeepKeys, + TData extends DeepValue, + TOnMount extends undefined | FormGroupValidateOrFn, + TOnChange extends + | undefined + | FormGroupValidateOrFn, + TOnChangeAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnBlur extends undefined | FormGroupValidateOrFn, + TOnBlurAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnSubmit extends + | undefined + | FormGroupValidateOrFn, + TOnSubmitAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnDynamic extends + | undefined + | FormGroupValidateOrFn, + TOnDynamicAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TSubmitMeta, + TFormOnMount extends undefined | FormValidateOrFn, + TFormOnChange extends undefined | FormValidateOrFn, + TFormOnChangeAsync extends undefined | FormAsyncValidateOrFn, + TFormOnBlur extends undefined | FormValidateOrFn, + TFormOnBlurAsync extends undefined | FormAsyncValidateOrFn, + TFormOnSubmit extends undefined | FormValidateOrFn, + TFormOnSubmitAsync extends undefined | FormAsyncValidateOrFn, + TFormOnDynamic extends undefined | FormValidateOrFn, + TFormOnDynamicAsync extends undefined | FormAsyncValidateOrFn, + TFormOnServer extends undefined | FormAsyncValidateOrFn, + TParentSubmitMeta, + ExtendedApi = {}, +> extends FormGroupOptions< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TSubmitMeta, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta +> { + children: ( + formGroupApi: Accessor< + FormGroupApi< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TSubmitMeta, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta + > + > & + ExtendedApi, + ) => JSX.Element +} + +/** + * A type alias representing a form group component for a specific form data type. + */ +export type FormGroupComponent< + in out TParentData, + in out TFormOnMount extends undefined | FormValidateOrFn, + in out TFormOnChange extends undefined | FormValidateOrFn, + in out TFormOnChangeAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnBlur extends undefined | FormValidateOrFn, + in out TFormOnBlurAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnSubmit extends undefined | FormValidateOrFn, + in out TFormOnSubmitAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnDynamic extends undefined | FormValidateOrFn, + in out TFormOnDynamicAsync extends + | undefined + | FormAsyncValidateOrFn, + in out TFormOnServer extends undefined | FormAsyncValidateOrFn, + in out TParentSubmitMeta, + in out ExtendedApi = {}, +> = < + const TName extends DeepKeys, + TData extends DeepValue, + TOnMount extends undefined | FormGroupValidateOrFn, + TOnChange extends + | undefined + | FormGroupValidateOrFn, + TOnChangeAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnBlur extends undefined | FormGroupValidateOrFn, + TOnBlurAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnSubmit extends + | undefined + | FormGroupValidateOrFn, + TOnSubmitAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnDynamic extends + | undefined + | FormGroupValidateOrFn, + TOnDynamicAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TSubmitMeta, +>( + props: FormGroupComponentBoundProps< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TSubmitMeta, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta, + ExtendedApi + >, +) => JSXElement + +interface FormGroupComponentProps< + TParentData, + TName extends DeepKeys, + TData extends DeepValue, + TOnMount extends undefined | FormGroupValidateOrFn, + TOnChange extends + | undefined + | FormGroupValidateOrFn, + TOnChangeAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnBlur extends undefined | FormGroupValidateOrFn, + TOnBlurAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnSubmit extends + | undefined + | FormGroupValidateOrFn, + TOnSubmitAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnDynamic extends + | undefined + | FormGroupValidateOrFn, + TOnDynamicAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TSubmitMeta, + TFormOnMount extends undefined | FormValidateOrFn, + TFormOnChange extends undefined | FormValidateOrFn, + TFormOnChangeAsync extends undefined | FormAsyncValidateOrFn, + TFormOnBlur extends undefined | FormValidateOrFn, + TFormOnBlurAsync extends undefined | FormAsyncValidateOrFn, + TFormOnSubmit extends undefined | FormValidateOrFn, + TFormOnSubmitAsync extends undefined | FormAsyncValidateOrFn, + TFormOnDynamic extends undefined | FormValidateOrFn, + TFormOnDynamicAsync extends undefined | FormAsyncValidateOrFn, + TFormOnServer extends undefined | FormAsyncValidateOrFn, + TParentSubmitMeta, +> extends FormGroupApiOptions< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TSubmitMeta, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta +> { + children: ( + formGroupApi: () => FormGroupApi< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TSubmitMeta, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta + >, + ) => JSXElement +} + +export function FormGroup< + TParentData, + TName extends DeepKeys, + TData extends DeepValue, + TOnMount extends undefined | FormGroupValidateOrFn, + TOnChange extends + | undefined + | FormGroupValidateOrFn, + TOnChangeAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnBlur extends undefined | FormGroupValidateOrFn, + TOnBlurAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnSubmit extends + | undefined + | FormGroupValidateOrFn, + TOnSubmitAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnDynamic extends + | undefined + | FormGroupValidateOrFn, + TOnDynamicAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TSubmitMeta, + TFormOnMount extends undefined | FormValidateOrFn, + TFormOnChange extends undefined | FormValidateOrFn, + TFormOnChangeAsync extends undefined | FormAsyncValidateOrFn, + TFormOnBlur extends undefined | FormValidateOrFn, + TFormOnBlurAsync extends undefined | FormAsyncValidateOrFn, + TFormOnSubmit extends undefined | FormValidateOrFn, + TFormOnSubmitAsync extends undefined | FormAsyncValidateOrFn, + TFormOnDynamic extends undefined | FormValidateOrFn, + TFormOnDynamicAsync extends undefined | FormAsyncValidateOrFn, + TFormOnServer extends undefined | FormAsyncValidateOrFn, + TParentSubmitMeta, +>( + props: FormGroupComponentProps< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TSubmitMeta, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta + >, +) { + const formGroupApi = createFormGroup< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TSubmitMeta, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta + >(() => { + const { children, ...formGroupOptions } = props + return formGroupOptions + }) + + return <>{createComponent(() => props.children(formGroupApi), {})} +} diff --git a/packages/solid-form/src/index.tsx b/packages/solid-form/src/index.tsx index 22acfe2fd..185487856 100644 --- a/packages/solid-form/src/index.tsx +++ b/packages/solid-form/src/index.tsx @@ -5,5 +5,6 @@ export { useStore } from '@tanstack/solid-store' export * from './createField' export * from './createForm' export * from './createFieldGroup' +export * from './createFormGroup' export * from './createFormHook' export * from './types' diff --git a/packages/solid-form/tests/createFormGroup.test-d.tsx b/packages/solid-form/tests/createFormGroup.test-d.tsx new file mode 100644 index 000000000..4c0eb99bf --- /dev/null +++ b/packages/solid-form/tests/createFormGroup.test-d.tsx @@ -0,0 +1,389 @@ +import { describe, expectTypeOf, it } from 'vitest' +import { createForm, createFormGroup } from '../src/index' +import type { FormGroupApi } from '../src/index' + +describe('createFormGroup form-like surface', () => { + it('should type state.value based on the selected field', () => { + function Comp() { + const form = createForm(() => ({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + })) + + const group = createFormGroup(() => ({ + form, + name: 'step1', + onGroupSubmit: () => {}, + })) + + expectTypeOf(group().state.value).toEqualTypeOf<{ name: string }>() + expectTypeOf(group().name).toEqualTypeOf<'step1'>() + } + }) + + it('should type onGroupSubmit value and meta', () => { + type SubmitMeta = { source: string } + + function Comp() { + const form = createForm(() => ({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + })) + + const group = createFormGroup(() => ({ + form, + name: 'step1', + onSubmitMeta: {} as SubmitMeta, + onGroupSubmit: ({ value, meta }) => { + expectTypeOf(value).toEqualTypeOf<{ name: string }>() + expectTypeOf(meta).toEqualTypeOf() + }, + })) + } + }) + + it('should type onGroupSubmitInvalid value and meta', () => { + type SubmitMeta = { source: string } + + function Comp() { + const form = createForm(() => ({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + })) + + const group = createFormGroup(() => ({ + form, + name: 'step1', + onSubmitMeta: {} as SubmitMeta, + onGroupSubmit: () => {}, + onGroupSubmitInvalid: ({ value, meta }) => { + expectTypeOf(value).toEqualTypeOf<{ name: string }>() + expectTypeOf(meta).toEqualTypeOf() + }, + })) + } + }) + + it('should type validators with the scoped value', () => { + function Comp() { + const form = createForm(() => ({ + defaultValues: { + step1: { name: 'test', age: 10 }, + step2: { name: 'test2' }, + }, + })) + + const group = createFormGroup(() => ({ + form, + name: 'step1', + onGroupSubmit: () => {}, + validators: { + onChange: ({ value }) => { + expectTypeOf(value).toEqualTypeOf<{ name: string; age: number }>() + return undefined + }, + onSubmit: ({ value }) => { + expectTypeOf(value).toEqualTypeOf<{ name: string; age: number }>() + return undefined + }, + }, + })) + } + }) + + it('should type listeners with the scoped value', () => { + function Comp() { + const form = createForm(() => ({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + })) + + const group = createFormGroup(() => ({ + form, + name: 'step1', + onGroupSubmit: () => {}, + listeners: { + onChange: ({ value }) => { + expectTypeOf(value).toEqualTypeOf<{ name: string }>() + }, + }, + })) + } + }) + + it('should type handleSubmit return as Promise', () => { + function Comp() { + const form = createForm(() => ({ + defaultValues: { step1: { name: 'test' } }, + })) + + const group = createFormGroup(() => ({ + form, + name: 'step1', + onGroupSubmit: () => {}, + })) + + expectTypeOf(group().handleSubmit()).toEqualTypeOf>() + } + }) + + it('should type handleSubmit overload when onSubmitMeta is provided', () => { + type SubmitMeta = { source: string } + + function Comp() { + const form = createForm(() => ({ + defaultValues: { step1: { name: 'test' } }, + })) + + const group = createFormGroup(() => ({ + form, + name: 'step1', + onSubmitMeta: {} as SubmitMeta, + onGroupSubmit: () => {}, + })) + + expectTypeOf(group().handleSubmit).toEqualTypeOf<{ + (): Promise + (submitMeta: SubmitMeta): Promise + }>() + } + }) + + it('should type setValue updater', () => { + function Comp() { + const form = createForm(() => ({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + })) + + const group = createFormGroup(() => ({ + form, + name: 'step1', + onGroupSubmit: () => {}, + })) + + group().setValue({ name: 'new name' }) + group().setValue((prev) => { + expectTypeOf(prev).toEqualTypeOf<{ name: string }>() + return { name: 'updated' } + }) + } + }) + + it('should infer the FormGroupApi instance type for createFormGroup', () => { + function Comp() { + const form = createForm(() => ({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + })) + + const group = createFormGroup(() => ({ + form, + name: 'step1', + onGroupSubmit: () => {}, + })) + + expectTypeOf(group()).toMatchTypeOf< + FormGroupApi< + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any + > + >() + } + }) +}) + +describe('createFormGroup field-like meta surface', () => { + it('should type meta booleans', () => { + function Comp() { + const form = createForm(() => ({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + })) + + const group = createFormGroup(() => ({ + form, + name: 'step1', + onGroupSubmit: () => {}, + })) + + expectTypeOf(group().state.meta.isTouched).toEqualTypeOf() + expectTypeOf(group().state.meta.isBlurred).toEqualTypeOf() + expectTypeOf(group().state.meta.isDirty).toEqualTypeOf() + expectTypeOf(group().state.meta.isValidating).toEqualTypeOf() + } + }) + + it('should type errorMap entries based on validator return types', () => { + function Comp() { + const form = createForm(() => ({ + defaultValues: { step1: { name: 'test' } }, + })) + + const group = createFormGroup(() => ({ + form, + name: 'step1', + onGroupSubmit: () => {}, + validators: { + onChange: () => 'sync-change' as const, + onChangeAsync: async () => 'async-change' as const, + onBlur: () => 'sync-blur' as const, + onBlurAsync: async () => 'async-blur' as const, + onSubmit: () => 'sync-submit' as const, + onSubmitAsync: async () => 'async-submit' as const, + }, + })) + + expectTypeOf(group().state.meta.errorMap.onChange).toEqualTypeOf< + 'sync-change' | 'async-change' | undefined + >() + expectTypeOf(group().state.meta.errorMap.onBlur).toEqualTypeOf< + 'sync-blur' | 'async-blur' | undefined + >() + expectTypeOf(group().state.meta.errorMap.onSubmit).toEqualTypeOf< + 'sync-submit' | 'async-submit' | undefined + >() + } + }) + + it('should type errors array from group validators', () => { + function Comp() { + const form = createForm(() => ({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + })) + + const group = createFormGroup(() => ({ + form, + name: 'step1', + onGroupSubmit: () => {}, + validators: { + onChange: () => 'change-error' as const, + }, + })) + + expectTypeOf(group().state.meta.errors).toEqualTypeOf< + Array<'change-error' | undefined> + >() + } + }) +}) + +describe('form.FormGroup component surface', () => { + it('should type the children render prop with the scoped value', () => { + function Comp() { + const form = createForm(() => ({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + })) + + return ( + {}}> + {(group) => { + expectTypeOf(group().state.value).toEqualTypeOf<{ name: string }>() + expectTypeOf(group().name).toEqualTypeOf<'step1'>() + return null + }} + + ) + } + }) + + it('should type the children render prop with onSubmitMeta', () => { + type SubmitMeta = { source: string } + + function Comp() { + const form = createForm(() => ({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + })) + + return ( + { + expectTypeOf(value).toEqualTypeOf<{ name: string }>() + expectTypeOf(meta).toEqualTypeOf() + }} + > + {(group) => { + expectTypeOf(group().handleSubmit).toEqualTypeOf<{ + (): Promise + (submitMeta: SubmitMeta): Promise + }>() + return null + }} + + ) + } + }) + + it('should type validators on the FormGroup component', () => { + function Comp() { + const form = createForm(() => ({ + defaultValues: { + step1: { name: 'test', age: 10 }, + step2: { name: 'test2' }, + }, + })) + + return ( + { + expectTypeOf(value).toEqualTypeOf<{ name: string; age: number }>() + return undefined + }, + }} + onGroupSubmit={() => {}} + > + {() => null} + + ) + } + }) +}) diff --git a/packages/solid-form/tests/createFormGroup.test.tsx b/packages/solid-form/tests/createFormGroup.test.tsx new file mode 100644 index 000000000..0a97cd69b --- /dev/null +++ b/packages/solid-form/tests/createFormGroup.test.tsx @@ -0,0 +1,266 @@ +import { describe, expect, it, vi } from 'vitest' +import { render, waitFor } from '@solidjs/testing-library' +import { userEvent } from '@testing-library/user-event' +import { createForm } from '../src/index' + +const user = userEvent.setup() + +describe('form.FormGroup', () => { + it('should call onGroupSubmit but not the form onSubmit when submitting the group', async () => { + const onSubmit = vi.fn() + const onGroupSubmit = vi.fn() + + function Comp() { + const form = createForm(() => ({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + onSubmit, + })) + + return ( + + {(group) => ( +
{ + e.preventDefault() + e.stopPropagation() + group().handleSubmit() + }} + > + ( + field().handleChange(e.currentTarget.value)} + /> + )} + /> + + + )} +
+ ) + } + + const { getByTestId } = render(() => ) + await user.click(getByTestId('submit-group')) + + await waitFor(() => expect(onGroupSubmit).toHaveBeenCalledTimes(1)) + expect(onSubmit).not.toHaveBeenCalled() + }) + + it('should expose group state value reactively', async () => { + function Comp() { + const form = createForm(() => ({ + defaultValues: { + step1: { name: 'initial' }, + step2: { name: 'other' }, + }, + })) + + return ( + + {(group) => ( + <> + ( + field().handleChange(e.currentTarget.value)} + /> + )} + /> +
+                {JSON.stringify(group().state.value)}
+              
+ + )} +
+ ) + } + + const { getByTestId } = render(() => ) + expect(getByTestId('group-value').textContent).toBe('{"name":"initial"}') + + await user.clear(getByTestId('step1-name')) + await user.type(getByTestId('step1-name'), 'updated') + + await waitFor(() => + expect(getByTestId('group-value').textContent).toBe('{"name":"updated"}'), + ) + }) + + it('should call onGroupSubmitInvalid when group-level validation fails', async () => { + const onSubmit = vi.fn() + const onGroupSubmit = vi.fn() + const onGroupSubmitInvalid = vi.fn() + + function Comp() { + const form = createForm(() => ({ + defaultValues: { + step1: { name: '' }, + step2: { name: 'test2' }, + }, + onSubmit, + })) + + return ( + + !value.name ? 'Name is required' : undefined, + }} + onGroupSubmit={onGroupSubmit} + onGroupSubmitInvalid={onGroupSubmitInvalid} + > + {(group) => ( +
{ + e.preventDefault() + e.stopPropagation() + group().handleSubmit() + }} + > + ( + field().handleChange(e.currentTarget.value)} + /> + )} + /> + +
+                {String(group().state.meta.errorMap.onSubmit ?? '')}
+              
+ + )} +
+ ) + } + + const { getByTestId } = render(() => ) + await user.click(getByTestId('submit-group')) + + await waitFor(() => expect(onGroupSubmitInvalid).toHaveBeenCalledTimes(1)) + expect(onGroupSubmit).not.toHaveBeenCalled() + expect(onSubmit).not.toHaveBeenCalled() + await waitFor(() => + expect(getByTestId('group-error').textContent).toBe('Name is required'), + ) + }) + + it('should ignore form-level field errors outside the group when submitting the group', async () => { + const onGroupSubmit = vi.fn() + const onGroupSubmitInvalid = vi.fn() + + function Comp() { + const form = createForm(() => ({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + validators: { + onSubmit: () => ({ + fields: { + 'step2.name': 'Required', + }, + }), + }, + })) + + return ( + + {(group) => ( +
{ + e.preventDefault() + e.stopPropagation() + group().handleSubmit() + }} + > + ( + field().handleChange(e.currentTarget.value)} + /> + )} + /> + + + )} +
+ ) + } + + const { getByTestId } = render(() => ) + await user.click(getByTestId('submit-group')) + + await waitFor(() => expect(onGroupSubmit).toHaveBeenCalledTimes(1)) + expect(onGroupSubmitInvalid).not.toHaveBeenCalled() + }) + + it('should pass submit meta through handleSubmit', async () => { + const onGroupSubmit = vi.fn() + + function Comp() { + const form = createForm(() => ({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + })) + + return ( + + {(group) => ( + + )} + + ) + } + + const { getByTestId } = render(() => ) + await user.click(getByTestId('submit-group')) + + await waitFor(() => expect(onGroupSubmit).toHaveBeenCalledTimes(1)) + expect(onGroupSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + value: { name: 'test' }, + meta: { source: 'button' }, + }), + ) + }) +}) diff --git a/packages/svelte-form/package.json b/packages/svelte-form/package.json index 218921dc2..ec892ac9b 100644 --- a/packages/svelte-form/package.json +++ b/packages/svelte-form/package.json @@ -41,7 +41,7 @@ ], "dependencies": { "@tanstack/form-core": "workspace:*", - "@tanstack/svelte-store": "^0.10.1" + "@tanstack/svelte-store": "^0.12.0" }, "devDependencies": { "@sveltejs/package": "^2.5.3", diff --git a/packages/svelte-form/tsconfig.json b/packages/svelte-form/tsconfig.json index 97f8c9a49..d694ed82e 100644 --- a/packages/svelte-form/tsconfig.json +++ b/packages/svelte-form/tsconfig.json @@ -1,8 +1,8 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "module": "NodeNext", - "moduleResolution": "NodeNext" + "module": "esnext", + "moduleResolution": "bundler" }, "include": ["src", "tests", "*.config.js", "*.config.ts"] } diff --git a/packages/vue-form/package.json b/packages/vue-form/package.json index 31e34de3d..1931f4f9e 100644 --- a/packages/vue-form/package.json +++ b/packages/vue-form/package.json @@ -53,7 +53,7 @@ ], "dependencies": { "@tanstack/form-core": "workspace:*", - "@tanstack/vue-store": "^0.9.1" + "@tanstack/vue-store": "^0.11.0" }, "devDependencies": { "@vitejs/plugin-vue": "^5.2.4", diff --git a/packages/vue-form/src/index.ts b/packages/vue-form/src/index.ts index 50f1cbaad..071eb7d06 100644 --- a/packages/vue-form/src/index.ts +++ b/packages/vue-form/src/index.ts @@ -2,3 +2,4 @@ export * from '@tanstack/form-core' export { useStore } from '@tanstack/vue-store' export * from './useField' export * from './useForm' +export * from './useFormGroup' diff --git a/packages/vue-form/src/useForm.tsx b/packages/vue-form/src/useForm.tsx index b086a3e28..63d93e838 100644 --- a/packages/vue-form/src/useForm.tsx +++ b/packages/vue-form/src/useForm.tsx @@ -2,6 +2,7 @@ import { FormApi } from '@tanstack/form-core' import { useStore } from '@tanstack/vue-store' import { defineComponent, h, onMounted } from 'vue' import { Field } from './useField' +import { FormGroup } from './useFormGroup' import type { FormAsyncValidateOrFn, FormOptions, @@ -18,6 +19,7 @@ import type { SlotsType, } from 'vue' import type { FieldComponent } from './useField' +import type { FormGroupComponent } from './useFormGroup' type SubscribeComponent< TParentData, @@ -135,6 +137,20 @@ export interface VueFormApi< TFormOnServer, TSubmitMeta > + FormGroup: FormGroupComponent< + TParentData, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TSubmitMeta + > useStore: < TSelected = NoInfer< FormState< @@ -259,6 +275,20 @@ export function useForm< inheritAttrs: false, }, ) as never + extendedApi.FormGroup = defineComponent( + (props, context) => { + return () => + h( + FormGroup as never, + { ...props, ...context.attrs, form: api }, + context.slots, + ) + }, + { + name: 'APIFormGroup', + inheritAttrs: false, + }, + ) as never extendedApi.useStore = (selector) => { return useStore(api.store as never, selector as never) as never } diff --git a/packages/vue-form/src/useFormGroup.tsx b/packages/vue-form/src/useFormGroup.tsx new file mode 100644 index 000000000..105ea321b --- /dev/null +++ b/packages/vue-form/src/useFormGroup.tsx @@ -0,0 +1,532 @@ +import { FormGroupApi } from '@tanstack/form-core' +import { useStore } from '@tanstack/vue-store' +import { defineComponent, onMounted, onUnmounted, watch } from 'vue' +import type { + DeepKeys, + DeepValue, + FormAsyncValidateOrFn, + FormGroupApiOptions, + FormGroupAsyncValidateOrFn, + FormGroupOptions, + FormGroupValidateOrFn, + FormValidateOrFn, +} from '@tanstack/form-core' +import type { + ComponentOptionsMixin, + CreateComponentPublicInstanceWithMixins, + EmitsOptions, + EmitsToProps, + PublicProps, + SetupContext, + SlotsType, +} from 'vue' + +export type FormGroupComponent< + TParentData, + TFormOnMount extends undefined | FormValidateOrFn, + TFormOnChange extends undefined | FormValidateOrFn, + TFormOnChangeAsync extends undefined | FormAsyncValidateOrFn, + TFormOnBlur extends undefined | FormValidateOrFn, + TFormOnBlurAsync extends undefined | FormAsyncValidateOrFn, + TFormOnSubmit extends undefined | FormValidateOrFn, + TFormOnSubmitAsync extends undefined | FormAsyncValidateOrFn, + TFormOnDynamic extends undefined | FormValidateOrFn, + TFormOnDynamicAsync extends undefined | FormAsyncValidateOrFn, + TFormOnServer extends undefined | FormAsyncValidateOrFn, + TParentSubmitMeta, + // This complex type comes from Vue's return type for `DefineSetupFnComponent` but with our own types sprinkled in + // This allows us to pre-bind some generics while keeping the props type unbound generics for props-based inferencing +> = new < + TName extends DeepKeys, + TData extends DeepValue, + TOnMount extends undefined | FormGroupValidateOrFn, + TOnChange extends + | undefined + | FormGroupValidateOrFn, + TOnChangeAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnBlur extends undefined | FormGroupValidateOrFn, + TOnBlurAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnSubmit extends + | undefined + | FormGroupValidateOrFn, + TOnSubmitAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnDynamic extends + | undefined + | FormGroupValidateOrFn, + TOnDynamicAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TSubmitMeta, +>( + props: FormGroupComponentBoundProps< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TSubmitMeta, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta + > & + EmitsToProps & + PublicProps, +) => CreateComponentPublicInstanceWithMixins< + FormGroupComponentBoundProps< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TSubmitMeta, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta + >, + {}, + {}, + {}, + {}, + ComponentOptionsMixin, + ComponentOptionsMixin, + EmitsOptions, + PublicProps, + {}, + false, + {}, + SlotsType<{ + default: { + group: FormGroupApi< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TSubmitMeta, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta + > + state: FormGroupApi< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TSubmitMeta, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta + >['state'] + } + }> +> + +export interface VueFormGroupApi< + TParentData, + TFormOnMount extends undefined | FormValidateOrFn, + TFormOnChange extends undefined | FormValidateOrFn, + TFormOnChangeAsync extends undefined | FormAsyncValidateOrFn, + TFormOnBlur extends undefined | FormValidateOrFn, + TFormOnBlurAsync extends undefined | FormAsyncValidateOrFn, + TFormOnSubmit extends undefined | FormValidateOrFn, + TFormOnSubmitAsync extends undefined | FormAsyncValidateOrFn, + TFormOnDynamic extends undefined | FormValidateOrFn, + TFormOnDynamicAsync extends undefined | FormAsyncValidateOrFn, + TFormOnServer extends undefined | FormAsyncValidateOrFn, + TParentSubmitMeta, +> { + FormGroup: FormGroupComponent< + TParentData, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta + > +} + +export function useFormGroup< + TParentData, + TName extends DeepKeys, + TData extends DeepValue, + TOnMount extends undefined | FormGroupValidateOrFn, + TOnChange extends + | undefined + | FormGroupValidateOrFn, + TOnChangeAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnBlur extends undefined | FormGroupValidateOrFn, + TOnBlurAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnSubmit extends + | undefined + | FormGroupValidateOrFn, + TOnSubmitAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnDynamic extends + | undefined + | FormGroupValidateOrFn, + TOnDynamicAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TSubmitMeta, + TFormOnMount extends undefined | FormValidateOrFn, + TFormOnChange extends undefined | FormValidateOrFn, + TFormOnChangeAsync extends undefined | FormAsyncValidateOrFn, + TFormOnBlur extends undefined | FormValidateOrFn, + TFormOnBlurAsync extends undefined | FormAsyncValidateOrFn, + TFormOnSubmit extends undefined | FormValidateOrFn, + TFormOnSubmitAsync extends undefined | FormAsyncValidateOrFn, + TFormOnDynamic extends undefined | FormValidateOrFn, + TFormOnDynamicAsync extends undefined | FormAsyncValidateOrFn, + TFormOnServer extends undefined | FormAsyncValidateOrFn, + TParentSubmitMeta, +>( + opts: FormGroupApiOptions< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TSubmitMeta, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta + >, +) { + const formGroupApi = (() => { + const api = new FormGroupApi({ + ...opts, + }) + + return api + })() + + const groupState = useStore(formGroupApi.store, (state) => state) + + let cleanup!: () => void + onMounted(() => { + cleanup = formGroupApi.mount() + }) + + onUnmounted(() => { + cleanup() + }) + + watch( + () => opts, + () => { + // Keep options up to date as they are rendered + formGroupApi.update({ ...opts } as never) + }, + ) + + return { api: formGroupApi, state: groupState } as const +} + +export type FormGroupComponentProps< + TParentData, + TName extends DeepKeys, + TData extends DeepValue, + TOnMount extends undefined | FormGroupValidateOrFn, + TOnChange extends + | undefined + | FormGroupValidateOrFn, + TOnChangeAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnBlur extends undefined | FormGroupValidateOrFn, + TOnBlurAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnSubmit extends + | undefined + | FormGroupValidateOrFn, + TOnSubmitAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnDynamic extends + | undefined + | FormGroupValidateOrFn, + TOnDynamicAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TSubmitMeta, + TFormOnMount extends undefined | FormValidateOrFn, + TFormOnChange extends undefined | FormValidateOrFn, + TFormOnChangeAsync extends undefined | FormAsyncValidateOrFn, + TFormOnBlur extends undefined | FormValidateOrFn, + TFormOnBlurAsync extends undefined | FormAsyncValidateOrFn, + TFormOnSubmit extends undefined | FormValidateOrFn, + TFormOnSubmitAsync extends undefined | FormAsyncValidateOrFn, + TFormOnDynamic extends undefined | FormValidateOrFn, + TFormOnDynamicAsync extends undefined | FormAsyncValidateOrFn, + TFormOnServer extends undefined | FormAsyncValidateOrFn, + TParentSubmitMeta, +> = FormGroupApiOptions< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TSubmitMeta, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta +> + +export type FormGroupComponentBoundProps< + TParentData, + TName extends DeepKeys, + TData extends DeepValue, + TOnMount extends undefined | FormGroupValidateOrFn, + TOnChange extends + | undefined + | FormGroupValidateOrFn, + TOnChangeAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnBlur extends undefined | FormGroupValidateOrFn, + TOnBlurAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnSubmit extends + | undefined + | FormGroupValidateOrFn, + TOnSubmitAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnDynamic extends + | undefined + | FormGroupValidateOrFn, + TOnDynamicAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TSubmitMeta, + TFormOnMount extends undefined | FormValidateOrFn, + TFormOnChange extends undefined | FormValidateOrFn, + TFormOnChangeAsync extends undefined | FormAsyncValidateOrFn, + TFormOnBlur extends undefined | FormValidateOrFn, + TFormOnBlurAsync extends undefined | FormAsyncValidateOrFn, + TFormOnSubmit extends undefined | FormValidateOrFn, + TFormOnSubmitAsync extends undefined | FormAsyncValidateOrFn, + TFormOnDynamic extends undefined | FormValidateOrFn, + TFormOnDynamicAsync extends undefined | FormAsyncValidateOrFn, + TFormOnServer extends undefined | FormAsyncValidateOrFn, + TParentSubmitMeta, +> = FormGroupOptions< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TSubmitMeta, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta +> + +export const FormGroup = defineComponent( + < + TParentData, + TName extends DeepKeys, + TData extends DeepValue, + TOnMount extends + | undefined + | FormGroupValidateOrFn, + TOnChange extends + | undefined + | FormGroupValidateOrFn, + TOnChangeAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnBlur extends + | undefined + | FormGroupValidateOrFn, + TOnBlurAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnSubmit extends + | undefined + | FormGroupValidateOrFn, + TOnSubmitAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TOnDynamic extends + | undefined + | FormGroupValidateOrFn, + TOnDynamicAsync extends + | undefined + | FormGroupAsyncValidateOrFn, + TSubmitMeta, + TFormOnMount extends undefined | FormValidateOrFn, + TFormOnChange extends undefined | FormValidateOrFn, + TFormOnChangeAsync extends undefined | FormAsyncValidateOrFn, + TFormOnBlur extends undefined | FormValidateOrFn, + TFormOnBlurAsync extends undefined | FormAsyncValidateOrFn, + TFormOnSubmit extends undefined | FormValidateOrFn, + TFormOnSubmitAsync extends undefined | FormAsyncValidateOrFn, + TFormOnDynamic extends undefined | FormValidateOrFn, + TFormOnDynamicAsync extends undefined | FormAsyncValidateOrFn, + TFormOnServer extends undefined | FormAsyncValidateOrFn, + TParentSubmitMeta, + >( + formGroupOptions: FormGroupApiOptions< + TParentData, + TName, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TSubmitMeta, + TFormOnMount, + TFormOnChange, + TFormOnChangeAsync, + TFormOnBlur, + TFormOnBlurAsync, + TFormOnSubmit, + TFormOnSubmitAsync, + TFormOnDynamic, + TFormOnDynamicAsync, + TFormOnServer, + TParentSubmitMeta + >, + context: SetupContext, + ) => { + const groupApi = useFormGroup({ ...formGroupOptions, ...context.attrs }) + + return () => + context.slots.default!({ + group: groupApi.api, + state: groupApi.state.value, + }) + }, + { name: 'FormGroup', inheritAttrs: false }, +) diff --git a/packages/vue-form/tests/useFormGroup.test.tsx b/packages/vue-form/tests/useFormGroup.test.tsx new file mode 100644 index 000000000..751a0d730 --- /dev/null +++ b/packages/vue-form/tests/useFormGroup.test.tsx @@ -0,0 +1,272 @@ +import { describe, expect, it, vi } from 'vitest' +import { Fragment, defineComponent, h } from 'vue' +import { render, waitFor } from '@testing-library/vue' +import { userEvent } from '@testing-library/user-event' +import { useForm } from '../src' +import type { AnyFieldApi, AnyFormGroupApi } from '../src' + +const user = userEvent.setup() + +describe('form.FormGroup', () => { + it('should call onGroupSubmit but not the form onSubmit when submitting the group', async () => { + const onSubmit = vi.fn() + const onGroupSubmit = vi.fn() + + const Comp = defineComponent(() => { + const form = useForm({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + onSubmit, + }) + + return () => ( + + {({ group }: { group: AnyFormGroupApi }) => ( +
{ + e.preventDefault() + e.stopPropagation() + group.handleSubmit() + }} + > + + {({ field }: { field: AnyFieldApi }) => ( + + field.handleChange((e.target as HTMLInputElement).value) + } + /> + )} + + +
+ )} +
+ ) + }) + + const { getByTestId } = render(Comp) + await user.click(getByTestId('submit-group')) + + await waitFor(() => expect(onGroupSubmit).toHaveBeenCalledTimes(1)) + expect(onSubmit).not.toHaveBeenCalled() + }) + + it('should expose group state value reactively', async () => { + const Comp = defineComponent(() => { + const form = useForm({ + defaultValues: { + step1: { name: 'initial' }, + step2: { name: 'other' }, + }, + }) + + return () => ( + + {({ group }: { group: AnyFormGroupApi }) => ( + <> + + {({ field }: { field: AnyFieldApi }) => ( + + field.handleChange((e.target as HTMLInputElement).value) + } + /> + )} + +
+                {JSON.stringify(group.state.value)}
+              
+ + )} +
+ ) + }) + + const { getByTestId } = render(Comp) + expect(getByTestId('group-value').textContent).toBe('{"name":"initial"}') + + await user.clear(getByTestId('step1-name')) + await user.type(getByTestId('step1-name'), 'updated') + + await waitFor(() => + expect(getByTestId('group-value').textContent).toBe('{"name":"updated"}'), + ) + }) + + it('should call onGroupSubmitInvalid when group-level validation fails', async () => { + const onSubmit = vi.fn() + const onGroupSubmit = vi.fn() + const onGroupSubmitInvalid = vi.fn() + + const Comp = defineComponent(() => { + const form = useForm({ + defaultValues: { + step1: { name: '' }, + step2: { name: 'test2' }, + }, + onSubmit, + }) + + return () => ( + + !value.name ? 'Name is required' : undefined, + }} + onGroupSubmit={onGroupSubmit} + onGroupSubmitInvalid={onGroupSubmitInvalid} + > + {({ group }: { group: AnyFormGroupApi }) => ( +
{ + e.preventDefault() + e.stopPropagation() + group.handleSubmit() + }} + > + + {({ field }: { field: AnyFieldApi }) => ( + + field.handleChange((e.target as HTMLInputElement).value) + } + /> + )} + + +
+                {String(group.state.meta.errorMap.onSubmit ?? '')}
+              
+
+ )} +
+ ) + }) + + const { getByTestId } = render(Comp) + await user.click(getByTestId('submit-group')) + + await waitFor(() => expect(onGroupSubmitInvalid).toHaveBeenCalledTimes(1)) + expect(onGroupSubmit).not.toHaveBeenCalled() + expect(onSubmit).not.toHaveBeenCalled() + await waitFor(() => + expect(getByTestId('group-error').textContent).toBe('Name is required'), + ) + }) + + it('should ignore form-level field errors outside the group when submitting the group', async () => { + const onGroupSubmit = vi.fn() + const onGroupSubmitInvalid = vi.fn() + + const Comp = defineComponent(() => { + const form = useForm({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + validators: { + onSubmit: () => ({ + fields: { + 'step2.name': 'Required', + }, + }), + }, + }) + + return () => ( + + {({ group }: { group: AnyFormGroupApi }) => ( +
{ + e.preventDefault() + e.stopPropagation() + group.handleSubmit() + }} + > + + {({ field }: { field: AnyFieldApi }) => ( + + field.handleChange((e.target as HTMLInputElement).value) + } + /> + )} + + +
+ )} +
+ ) + }) + + const { getByTestId } = render(Comp) + await user.click(getByTestId('submit-group')) + + await waitFor(() => expect(onGroupSubmit).toHaveBeenCalledTimes(1)) + expect(onGroupSubmitInvalid).not.toHaveBeenCalled() + }) + + it('should pass submit meta through handleSubmit', async () => { + const onGroupSubmit = vi.fn() + + const Comp = defineComponent(() => { + const form = useForm({ + defaultValues: { + step1: { name: 'test' }, + step2: { name: 'test2' }, + }, + }) + + return () => ( + + {({ group }: { group: AnyFormGroupApi }) => ( + + )} + + ) + }) + + const { getByTestId } = render(Comp) + await user.click(getByTestId('submit-group')) + + await waitFor(() => expect(onGroupSubmit).toHaveBeenCalledTimes(1)) + expect(onGroupSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + value: { name: 'test' }, + meta: { source: 'button' }, + }), + ) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dbe1947ab..4e873d2ab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -386,6 +386,25 @@ importers: specifier: ^7.2.2 version: 7.2.2(@types/node@24.1.0)(jiti@2.6.1)(less@4.4.0)(sass@1.90.0)(sugarss@5.0.1(postcss@8.5.6))(terser@5.43.1)(tsx@4.19.4)(yaml@2.8.1) + examples/preact/multi-step-wizard: + dependencies: + '@tanstack/preact-form': + specifier: ^1.29.4 + version: link:../../../packages/preact-form + preact: + specifier: ^10.26.4 + version: 10.29.1 + zod: + specifier: ^3.25.76 + version: 3.25.76 + devDependencies: + '@preact/preset-vite': + specifier: ^2.10.2 + version: 2.10.5(@babel/core@7.28.5)(preact@10.29.1)(rollup@4.52.5)(vite@7.2.2(@types/node@24.1.0)(jiti@2.6.1)(less@4.4.0)(sass@1.90.0)(sugarss@5.0.1(postcss@8.5.6))(terser@5.43.1)(tsx@4.19.4)(yaml@2.8.1)) + vite: + specifier: ^7.2.2 + version: 7.2.2(@types/node@24.1.0)(jiti@2.6.1)(less@4.4.0)(sass@1.90.0)(sugarss@5.0.1(postcss@8.5.6))(terser@5.43.1)(tsx@4.19.4)(yaml@2.8.1) + examples/preact/simple: dependencies: '@tanstack/preact-form': @@ -622,14 +641,48 @@ importers: specifier: ^7.2.2 version: 7.2.2(@types/node@24.1.0)(jiti@2.6.1)(less@4.4.0)(sass@1.90.0)(sugarss@5.0.1(postcss@8.5.6))(terser@5.43.1)(tsx@4.19.4)(yaml@2.8.1) + examples/react/multi-step-wizard: + dependencies: + '@tanstack/react-form': + specifier: ^1.29.3 + version: link:../../../packages/react-form + react: + specifier: ^19.0.0 + version: 19.1.0 + react-dom: + specifier: ^19.0.0 + version: 19.1.0(react@19.1.0) + zod: + specifier: ^3.25.76 + version: 3.25.76 + devDependencies: + '@tanstack/react-devtools': + specifier: ^0.9.7 + version: 0.9.10(@types/react-dom@19.1.5(@types/react@19.1.6))(@types/react@19.1.6)(csstype@3.1.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(solid-js@1.9.11) + '@tanstack/react-form-devtools': + specifier: ^0.2.24 + version: link:../../../packages/react-form-devtools + '@types/react': + specifier: ^19.0.7 + version: 19.1.6 + '@types/react-dom': + specifier: ^19.0.3 + version: 19.1.5(@types/react@19.1.6) + '@vitejs/plugin-react': + specifier: ^5.1.1 + version: 5.1.1(vite@7.2.2(@types/node@24.1.0)(jiti@2.6.1)(less@4.4.0)(sass@1.90.0)(sugarss@5.0.1(postcss@8.5.6))(terser@5.43.1)(tsx@4.19.4)(yaml@2.8.1)) + vite: + specifier: ^7.2.2 + version: 7.2.2(@types/node@24.1.0)(jiti@2.6.1)(less@4.4.0)(sass@1.90.0)(sugarss@5.0.1(postcss@8.5.6))(terser@5.43.1)(tsx@4.19.4)(yaml@2.8.1) + examples/react/next-server-actions: dependencies: '@tanstack/react-form-nextjs': specifier: ^1.29.3 version: link:../../../packages/react-form-nextjs '@tanstack/react-store': - specifier: ^0.9.1 - version: 0.9.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + specifier: ^0.11.0 + version: 0.11.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) next: specifier: 16.0.5 version: 16.0.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.90.0) @@ -659,8 +712,8 @@ importers: specifier: ^1.29.3 version: link:../../../packages/react-form-nextjs '@tanstack/react-store': - specifier: ^0.9.1 - version: 0.9.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + specifier: ^0.11.0 + version: 0.11.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) next: specifier: 16.0.5 version: 16.0.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.90.0) @@ -736,8 +789,8 @@ importers: specifier: ^1.29.3 version: link:../../../packages/react-form-remix '@tanstack/react-store': - specifier: ^0.9.1 - version: 0.9.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + specifier: ^0.11.0 + version: 0.11.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) isbot: specifier: ^5.1.30 version: 5.1.31 @@ -853,8 +906,8 @@ importers: specifier: ^1.134.9 version: 1.135.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vite-plugin-solid@2.11.10(@testing-library/jest-dom@6.9.1)(solid-js@1.9.11)(vite@7.2.2(@types/node@24.1.0)(jiti@2.6.1)(less@4.4.0)(sass@1.90.0)(sugarss@5.0.1(postcss@8.5.6))(terser@5.43.1)(tsx@4.19.4)(yaml@2.8.1)))(vite@7.2.2(@types/node@24.1.0)(jiti@2.6.1)(less@4.4.0)(sass@1.90.0)(sugarss@5.0.1(postcss@8.5.6))(terser@5.43.1)(tsx@4.19.4)(yaml@2.8.1))(webpack@5.101.2(@swc/core@1.13.5)) '@tanstack/react-store': - specifier: ^0.9.1 - version: 0.9.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + specifier: ^0.11.0 + version: 0.11.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: specifier: ^19.0.0 version: 19.1.0 @@ -1017,6 +1070,28 @@ importers: specifier: ^2.11.8 version: 2.11.10(@testing-library/jest-dom@6.9.1)(solid-js@1.9.11)(vite@7.2.2(@types/node@24.1.0)(jiti@2.6.1)(less@4.4.0)(sass@1.90.0)(sugarss@5.0.1(postcss@8.5.6))(terser@5.43.1)(tsx@4.19.4)(yaml@2.8.1)) + examples/solid/multi-step-wizard: + dependencies: + '@tanstack/solid-form': + specifier: ^1.29.3 + version: link:../../../packages/solid-form + solid-js: + specifier: ^1.9.9 + version: 1.9.11 + zod: + specifier: ^3.25.76 + version: 3.25.76 + devDependencies: + typescript: + specifier: 5.8.2 + version: 5.8.2 + vite: + specifier: ^7.2.2 + version: 7.2.2(@types/node@24.1.0)(jiti@2.6.1)(less@4.4.0)(sass@1.90.0)(sugarss@5.0.1(postcss@8.5.6))(terser@5.43.1)(tsx@4.19.4)(yaml@2.8.1) + vite-plugin-solid: + specifier: ^2.11.8 + version: 2.11.10(@testing-library/jest-dom@6.9.1)(solid-js@1.9.11)(vite@7.2.2(@types/node@24.1.0)(jiti@2.6.1)(less@4.4.0)(sass@1.90.0)(sugarss@5.0.1(postcss@8.5.6))(terser@5.43.1)(tsx@4.19.4)(yaml@2.8.1)) + examples/solid/simple: dependencies: '@tanstack/solid-form': @@ -1318,8 +1393,8 @@ importers: specifier: ^0.1.1 version: 0.1.1 '@tanstack/store': - specifier: ^0.9.1 - version: 0.9.1 + specifier: ^0.11.0 + version: 0.11.0 devDependencies: arktype: specifier: ^2.1.22 @@ -1384,8 +1459,8 @@ importers: specifier: workspace:* version: link:../form-core '@tanstack/preact-store': - specifier: ^0.10.2 - version: 0.10.2(preact@10.29.1) + specifier: ^0.13.1 + version: 0.13.1(preact@10.29.1) devDependencies: '@preact/preset-vite': specifier: ^2.10.2 @@ -1406,8 +1481,8 @@ importers: specifier: workspace:* version: link:../form-core '@tanstack/react-store': - specifier: ^0.9.1 - version: 0.9.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + specifier: ^0.11.0 + version: 0.11.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) devDependencies: '@types/react': specifier: ^19.0.7 @@ -1555,8 +1630,8 @@ importers: specifier: workspace:* version: link:../form-core '@tanstack/solid-store': - specifier: ^0.9.1 - version: 0.9.1(solid-js@1.9.11) + specifier: ^0.11.0 + version: 0.11.0(solid-js@1.9.11) devDependencies: solid-js: specifier: ^1.9.9 @@ -1590,12 +1665,12 @@ importers: specifier: workspace:* version: link:../form-core '@tanstack/svelte-store': - specifier: ^0.10.1 - version: 0.10.1(svelte@5.41.1) + specifier: ^0.12.0 + version: 0.12.0(svelte@5.41.1) devDependencies: '@sveltejs/package': specifier: ^2.5.3 - version: 2.5.4(svelte@5.41.1)(typescript@5.9.3) + version: 2.5.4(svelte@5.41.1)(typescript@5.8.2) '@sveltejs/vite-plugin-svelte': specifier: ^5.1.1 version: 5.1.1(svelte@5.41.1)(vite@7.2.2(@types/node@24.1.0)(jiti@2.6.1)(less@4.4.0)(sass@1.90.0)(sugarss@5.0.1(postcss@8.5.6))(terser@5.43.1)(tsx@4.19.4)(yaml@2.8.1)) @@ -1607,7 +1682,7 @@ importers: version: 5.41.1 svelte-check: specifier: ^4.3.1 - version: 4.3.3(picomatch@4.0.3)(svelte@5.41.1)(typescript@5.9.3) + version: 4.3.3(picomatch@4.0.3)(svelte@5.41.1)(typescript@5.8.2) packages/vue-form: dependencies: @@ -1615,8 +1690,8 @@ importers: specifier: workspace:* version: link:../form-core '@tanstack/vue-store': - specifier: ^0.9.1 - version: 0.9.1(vue@3.5.16(typescript@5.9.3)) + specifier: ^0.11.0 + version: 0.11.0(vue@3.5.16(typescript@5.9.3)) devDependencies: '@vitejs/plugin-vue': specifier: ^5.2.4 @@ -5148,8 +5223,8 @@ packages: resolution: {integrity: sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w==} engines: {node: '>=18'} - '@tanstack/preact-store@0.10.2': - resolution: {integrity: sha512-fe2XUWlomNczbyMaOk4TtMRfIUq3Pn4S/jgGAS6jYOCMKGjHNrwhdTA4EtGeG86DMxPL7NyObOsNy/6rA4dqCw==} + '@tanstack/preact-store@0.13.1': + resolution: {integrity: sha512-0h8ku2LfJ/TtVtgx24CLgS2OgzY5wVsRWIGktE0yq6b6fHlsCcY55IEJXTDRs0RTYWFOXu7N/1kfb2SLEOasaw==} peerDependencies: preact: ^10.0.0 @@ -5199,14 +5274,14 @@ packages: react-dom: '>=18.0.0 || >=19.0.0' vite: '>=7.0.0' - '@tanstack/react-store@0.8.1': - resolution: {integrity: sha512-XItJt+rG8c5Wn/2L/bnxys85rBpm0BfMbhb4zmPVLXAKY9POrp1xd6IbU4PKoOI+jSEGc3vntPRfLGSgXfE2Ig==} + '@tanstack/react-store@0.11.0': + resolution: {integrity: sha512-tX4YXh3PDkmpvGQWkWqKpzs/MSqbtuwY9dWdWhtV9Q50PmO+jOkUKIWIX4G85dwt7lxdHLXsiaEKPdKmC8F41w==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@tanstack/react-store@0.9.1': - resolution: {integrity: sha512-YzJLnRvy5lIEFTLWBAZmcOjK3+2AepnBv/sr6NZmiqJvq7zTQggyK99Gw8fqYdMdHPQWXjz0epFKJXC+9V2xDA==} + '@tanstack/react-store@0.8.1': + resolution: {integrity: sha512-XItJt+rG8c5Wn/2L/bnxys85rBpm0BfMbhb4zmPVLXAKY9POrp1xd6IbU4PKoOI+jSEGc3vntPRfLGSgXfE2Ig==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -5254,8 +5329,8 @@ packages: peerDependencies: solid-js: '>=1.9.7' - '@tanstack/solid-store@0.9.1': - resolution: {integrity: sha512-gx7ToM+Yrkui36NIj0HjAufzv1Dg8usjtVFy5H3Ll52Xjuz+eliIJL+ihAr4LRuWh3nDPBR+nCLW0ShFrbE5yw==} + '@tanstack/solid-store@0.11.0': + resolution: {integrity: sha512-2isL0ZnnyI1iN0V+QPrxE3OcPndohBgVlBcHZYoAOIAiU1WoWjVy0q5gb0suPu1Id0h5cKC23JnwzQTxWDZD0w==} peerDependencies: solid-js: ^1.6.0 @@ -5277,14 +5352,17 @@ packages: resolution: {integrity: sha512-9pr5Ssp5EYcDSb35y5f+YcM2Z+IxexWjiBcrIu8OgN/jI52N98QooWrQED/VMOo7gHUC5/IeuWQAehpLH6TzNg==} engines: {node: '>=22.12.0'} + '@tanstack/store@0.11.0': + resolution: {integrity: sha512-WlzzCt3xi0G6pCAJu1U+2jiECwabETDpQDi3hfkFZvJii9AuZqEKbOiVarX1/bWhTNjU486yQtJCCasi/0q+Cw==} + '@tanstack/store@0.8.1': resolution: {integrity: sha512-PtOisLjUZPz5VyPRSCGjNOlwTvabdTBQ2K80DpVL1chGVr35WRxfeavAPdNq6pm/t7F8GhoR2qtmkkqtCEtHYw==} '@tanstack/store@0.9.1': resolution: {integrity: sha512-+qcNkOy0N1qSGsP7omVCW0SDrXtaDcycPqBDE726yryiA5eTDFpjBReaYjghVJwNf1pcPMyzIwTGlYjCSQR0Fg==} - '@tanstack/svelte-store@0.10.1': - resolution: {integrity: sha512-heeyV9bZQHbEJyJ7oWegQXmcyA8NSPP58JsZgRpvf8+lwEMfX+MW1IvPJbGZqmH+poULAz7DDxjC4JEe7l57LA==} + '@tanstack/svelte-store@0.12.0': + resolution: {integrity: sha512-XhXlU3jIO/WxikfeVczRdsAvRWzsLBh8Ic6sC7nzfzvMbPut7ZSdCbE7/usfm0bMjVGMmZmyzZy2xRu73QYWAA==} peerDependencies: svelte: ^5.0.0 @@ -5300,8 +5378,8 @@ packages: resolution: {integrity: sha512-FOl8EF6SAcljanKSm5aBeJaflFcxQAytTbxtNW8HC6D4x+UBW68IC4tBcrlrsI0wXHBmC/Gz4Ovvv8qCtiXSgQ==} engines: {node: '>=18'} - '@tanstack/vue-store@0.9.1': - resolution: {integrity: sha512-mXXZzPWom656MExX2gG1fqopJhToDbqGEl98WtJ5/hyouQHtQXiAgtsPNLzUcVcwU9okM/OCWv7QAgXf6C5ziQ==} + '@tanstack/vue-store@0.11.0': + resolution: {integrity: sha512-w1lE4D7oo6nIUhTQvL/RoTgcvoYJw+KvttWPNJd/Xu+MvNxMnjxFF/LgFp9vwmbNbiGhGCg3ZoPJptwjrfwNYw==} peerDependencies: '@vue/composition-api': ^1.2.1 vue: ^2.5.0 || ^3.0.0 @@ -6573,6 +6651,9 @@ packages: cookie-es@2.0.0: resolution: {integrity: sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg==} + cookie-es@2.0.1: + resolution: {integrity: sha512-aVf4A4hI2w70LnF7GG+7xDQUkliwiXWXFvTjkip4+b64ygDQ2sJPRSKFDHbxn8o0xu9QzPkMuuiWIXyFSE2slA==} + cookie-signature@1.0.6: resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} @@ -9765,8 +9846,8 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true - rou3@0.7.10: - resolution: {integrity: sha512-aoFj6f7MJZ5muJ+Of79nrhs9N3oLGqi2VEMe94Zbkjb6Wupha46EuoYgpWSOZlXww3bbd8ojgXTAA2mzimX5Ww==} + rou3@0.7.12: + resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==} router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} @@ -10773,6 +10854,7 @@ packages: uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true uvu@0.5.6: @@ -14927,14 +15009,14 @@ snapshots: dependencies: acorn: 8.15.0 - '@sveltejs/package@2.5.4(svelte@5.41.1)(typescript@5.9.3)': + '@sveltejs/package@2.5.4(svelte@5.41.1)(typescript@5.8.2)': dependencies: chokidar: 4.0.3 kleur: 4.1.5 sade: 1.8.1 semver: 7.7.2 svelte: 5.41.1 - svelte2tsx: 0.7.43(svelte@5.41.1)(typescript@5.9.3) + svelte2tsx: 0.7.43(svelte@5.41.1)(typescript@5.8.2) transitivePeerDependencies: - typescript @@ -15156,9 +15238,9 @@ snapshots: '@tanstack/pacer-lite@0.1.1': {} - '@tanstack/preact-store@0.10.2(preact@10.29.1)': + '@tanstack/preact-store@0.13.1(preact@10.29.1)': dependencies: - '@tanstack/store': 0.8.1 + '@tanstack/store': 0.11.0 preact: 10.29.1 '@tanstack/query-core@5.90.5': {} @@ -15234,16 +15316,16 @@ snapshots: - vite-plugin-solid - webpack - '@tanstack/react-store@0.8.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@tanstack/react-store@0.11.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: - '@tanstack/store': 0.8.1 + '@tanstack/store': 0.11.0 react: 19.1.0 react-dom: 19.1.0(react@19.1.0) use-sync-external-store: 1.6.0(react@19.1.0) - '@tanstack/react-store@0.9.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@tanstack/react-store@0.8.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: - '@tanstack/store': 0.9.1 + '@tanstack/store': 0.8.1 react: 19.1.0 react-dom: 19.1.0(react@19.1.0) use-sync-external-store: 1.6.0(react@19.1.0) @@ -15291,7 +15373,7 @@ snapshots: '@tanstack/react-router': 1.135.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0) vite: 7.2.2(@types/node@24.1.0)(jiti@2.6.1)(less@4.4.0)(sass@1.90.0)(sugarss@5.0.1(postcss@8.5.6))(terser@5.43.1)(tsx@4.19.4)(yaml@2.8.1) vite-plugin-solid: 2.11.10(@testing-library/jest-dom@6.9.1)(solid-js@1.9.11)(vite@7.2.2(@types/node@24.1.0)(jiti@2.6.1)(less@4.4.0)(sass@1.90.0)(sugarss@5.0.1(postcss@8.5.6))(terser@5.43.1)(tsx@4.19.4)(yaml@2.8.1)) - webpack: 5.101.2(@swc/core@1.13.5)(esbuild@0.25.9) + webpack: 5.101.2(@swc/core@1.13.5) transitivePeerDependencies: - supports-color @@ -15333,9 +15415,9 @@ snapshots: - csstype - utf-8-validate - '@tanstack/solid-store@0.9.1(solid-js@1.9.11)': + '@tanstack/solid-store@0.11.0(solid-js@1.9.11)': dependencies: - '@tanstack/store': 0.9.1 + '@tanstack/store': 0.11.0 solid-js: 1.9.11 '@tanstack/start-client-core@1.135.2': @@ -15394,13 +15476,15 @@ snapshots: dependencies: '@tanstack/router-core': 1.135.2 + '@tanstack/store@0.11.0': {} + '@tanstack/store@0.8.1': {} '@tanstack/store@0.9.1': {} - '@tanstack/svelte-store@0.10.1(svelte@5.41.1)': + '@tanstack/svelte-store@0.12.0(svelte@5.41.1)': dependencies: - '@tanstack/store': 0.9.1 + '@tanstack/store': 0.11.0 svelte: 5.41.1 '@tanstack/typedoc-config@0.3.1(typescript@5.8.2)': @@ -15426,9 +15510,9 @@ snapshots: - typescript - vite - '@tanstack/vue-store@0.9.1(vue@3.5.16(typescript@5.9.3))': + '@tanstack/vue-store@0.11.0(vue@3.5.16(typescript@5.9.3))': dependencies: - '@tanstack/store': 0.9.1 + '@tanstack/store': 0.11.0 vue: 3.5.16(typescript@5.9.3) vue-demi: 0.14.10(vue@3.5.16(typescript@5.9.3)) @@ -16954,6 +17038,8 @@ snapshots: cookie-es@2.0.0: {} + cookie-es@2.0.1: {} + cookie-signature@1.0.6: {} cookie-signature@1.2.2: {} @@ -18157,9 +18243,9 @@ snapshots: h3@2.0.0-beta.4: dependencies: - cookie-es: 2.0.0 + cookie-es: 2.0.1 fetchdts: 0.1.7 - rou3: 0.7.10 + rou3: 0.7.12 srvx: 0.8.16 handle-thing@2.0.1: {} @@ -20738,7 +20824,7 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.52.5 fsevents: 2.3.3 - rou3@0.7.10: {} + rou3@0.7.12: {} router@2.2.0: dependencies: @@ -21297,7 +21383,7 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - svelte-check@4.3.3(picomatch@4.0.3)(svelte@5.41.1)(typescript@5.9.3): + svelte-check@4.3.3(picomatch@4.0.3)(svelte@5.41.1)(typescript@5.8.2): dependencies: '@jridgewell/trace-mapping': 0.3.29 chokidar: 4.0.3 @@ -21305,16 +21391,16 @@ snapshots: picocolors: 1.1.1 sade: 1.8.1 svelte: 5.41.1 - typescript: 5.9.3 + typescript: 5.8.2 transitivePeerDependencies: - picomatch - svelte2tsx@0.7.43(svelte@5.41.1)(typescript@5.9.3): + svelte2tsx@0.7.43(svelte@5.41.1)(typescript@5.8.2): dependencies: dedent-js: 1.0.1 pascal-case: 3.1.2 svelte: 5.41.1 - typescript: 5.9.3 + typescript: 5.8.2 svelte@5.41.1: dependencies: @@ -21386,6 +21472,18 @@ snapshots: '@swc/core': 1.13.5 esbuild: 0.25.9 + terser-webpack-plugin@5.3.14(@swc/core@1.13.5)(webpack@5.101.2(@swc/core@1.13.5)(esbuild@0.25.9)): + dependencies: + '@jridgewell/trace-mapping': 0.3.29 + jest-worker: 27.5.1 + schema-utils: 4.3.2 + serialize-javascript: 6.0.2 + terser: 5.43.1 + webpack: 5.101.2(@swc/core@1.13.5) + optionalDependencies: + '@swc/core': 1.13.5 + optional: true + terser@5.43.1: dependencies: '@jridgewell/source-map': 0.3.6 @@ -21601,7 +21699,8 @@ snapshots: typescript@5.9.2: {} - typescript@5.9.3: {} + typescript@5.9.3: + optional: true uc.micro@2.1.0: {} @@ -22188,6 +22287,39 @@ snapshots: webpack-virtual-modules@0.6.2: {} + webpack@5.101.2(@swc/core@1.13.5): + dependencies: + '@types/eslint-scope': 3.7.7 + '@types/estree': 1.0.8 + '@types/json-schema': 7.0.15 + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/wasm-edit': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + acorn: 8.15.0 + acorn-import-phases: 1.0.4(acorn@8.15.0) + browserslist: 4.25.4 + chrome-trace-event: 1.0.4 + enhanced-resolve: 5.18.1 + es-module-lexer: 1.7.0 + eslint-scope: 5.1.1 + events: 3.3.0 + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + json-parse-even-better-errors: 2.3.1 + loader-runner: 4.3.0 + mime-types: 2.1.35 + neo-async: 2.6.2 + schema-utils: 4.3.2 + tapable: 2.2.2 + terser-webpack-plugin: 5.3.14(@swc/core@1.13.5)(webpack@5.101.2(@swc/core@1.13.5)(esbuild@0.25.9)) + watchpack: 2.4.4 + webpack-sources: 3.3.3 + transitivePeerDependencies: + - '@swc/core' + - esbuild + - uglify-js + optional: true + webpack@5.101.2(@swc/core@1.13.5)(esbuild@0.25.9): dependencies: '@types/eslint-scope': 3.7.7