From ac749b21d42d0bfa85a7b043639afa9e0e5c49ee Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Fri, 25 Apr 2025 04:47:39 -0700 Subject: [PATCH 1/3] feat: add useAppForm API to Vue --- .../vue-form/src/createFormComposition.tsx | 281 ++++++++++++++++++ packages/vue-form/src/index.ts | 1 + packages/vue-form/src/useField.tsx | 4 +- packages/vue-form/src/useForm.tsx | 39 +++ .../tests/createFormComposition.test.tsx | 86 ++++++ 5 files changed, 410 insertions(+), 1 deletion(-) create mode 100644 packages/vue-form/src/createFormComposition.tsx create mode 100644 packages/vue-form/tests/createFormComposition.test.tsx diff --git a/packages/vue-form/src/createFormComposition.tsx b/packages/vue-form/src/createFormComposition.tsx new file mode 100644 index 000000000..82336f5f3 --- /dev/null +++ b/packages/vue-form/src/createFormComposition.tsx @@ -0,0 +1,281 @@ +import { defineComponent, h, inject, provide } from 'vue' +import { useForm } from './useForm' +import type { Component, InjectionKey } from 'vue' +import type { + AnyFieldApi, + AnyFormApi, + FieldApi, + FormAsyncValidateOrFn, + FormOptions, + FormValidateOrFn, +} from '@tanstack/form-core' +import type { FieldComponent } from './useField' +import type { VueFormExtendedApi } from './useForm' + +export function createFormCompositionContexts() { + const fieldProviderKey = Symbol() as InjectionKey + + function injectField() { + const field = inject(fieldProviderKey) + + if (!field) { + throw new Error( + '`injectField` only works when within a `fieldComponent` passed to `createFormComposition`', + ) + } + + return field as FieldApi< + any, + string, + TData, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any + > + } + + const formProviderKey = Symbol() as InjectionKey + + function injectForm() { + const form = inject(formProviderKey) + + if (!form) { + throw new Error( + '`injectForm` only works when within a `formComponent` passed to `createFormHook`', + ) + } + + return form as VueFormExtendedApi< + // If you need access to the form data, you need to use `withForm` instead + Record, + any, + any, + any, + any, + any, + any, + any, + any, + any + > + } + + return { fieldProviderKey, injectField, formProviderKey, injectForm } +} + +interface CreateFormCompositionProps< + TFieldComponents extends Record, + TFormComponents extends Record, +> { + fieldComponents: TFieldComponents + fieldProviderKey: InjectionKey + formComponents: TFormComponents + formProviderKey: InjectionKey +} + +type AppFieldExtendedReactFormApi< + TFormData, + TOnMount extends undefined | FormValidateOrFn, + TOnChange extends undefined | FormValidateOrFn, + TOnChangeAsync extends undefined | FormAsyncValidateOrFn, + TOnBlur extends undefined | FormValidateOrFn, + TOnBlurAsync extends undefined | FormAsyncValidateOrFn, + TOnSubmit extends undefined | FormValidateOrFn, + TOnSubmitAsync extends undefined | FormAsyncValidateOrFn, + TOnServer extends undefined | FormAsyncValidateOrFn, + TSubmitMeta, + TFieldComponents extends Record, + TFormComponents extends Record, +> = VueFormExtendedApi< + TFormData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnServer, + TSubmitMeta +> & + NoInfer & { + AppField: FieldComponent< + TFormData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnServer, + TSubmitMeta, + NoInfer + > + AppForm: Component + } + +export interface WithFormProps< + TFormData, + TOnMount extends undefined | FormValidateOrFn, + TOnChange extends undefined | FormValidateOrFn, + TOnChangeAsync extends undefined | FormAsyncValidateOrFn, + TOnBlur extends undefined | FormValidateOrFn, + TOnBlurAsync extends undefined | FormAsyncValidateOrFn, + TOnSubmit extends undefined | FormValidateOrFn, + TOnSubmitAsync extends undefined | FormAsyncValidateOrFn, + TOnServer extends undefined | FormAsyncValidateOrFn, + TSubmitMeta, + TFieldComponents extends Record, + TFormComponents extends Record, + TRenderProps extends Record = Record, +> extends FormOptions< + TFormData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnServer, + TSubmitMeta + > { + // Optional, but adds props to the `render` function outside of `form` + props?: TRenderProps + render: ( + props: NoInfer & { + form: AppFieldExtendedReactFormApi< + TFormData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnServer, + TSubmitMeta, + TFieldComponents, + TFormComponents + > + }, + ) => JSX.Element +} + +export function createFormComposition< + const TComponents extends Record, + const TFormComponents extends Record, +>({ + fieldComponents, + fieldProviderKey, + formProviderKey, + formComponents, +}: CreateFormCompositionProps) { + function useAppForm< + TFormData, + TOnMount extends undefined | FormValidateOrFn, + TOnChange extends undefined | FormValidateOrFn, + TOnChangeAsync extends undefined | FormAsyncValidateOrFn, + TOnBlur extends undefined | FormValidateOrFn, + TOnBlurAsync extends undefined | FormAsyncValidateOrFn, + TOnSubmit extends undefined | FormValidateOrFn, + TOnSubmitAsync extends undefined | FormAsyncValidateOrFn, + TOnServer extends undefined | FormAsyncValidateOrFn, + TSubmitMeta, + >( + props: FormOptions< + TFormData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnServer, + TSubmitMeta + >, + ): AppFieldExtendedReactFormApi< + TFormData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnServer, + TSubmitMeta, + TComponents, + TFormComponents + > { + const form = useForm(props) + + const AppForm = defineComponent(() => { + provide(formProviderKey, form) + return () => { + return + } + }) + + const AppField = defineComponent((props, { slots }) => { + return () => { + return ( + + {({ field }: { field: AnyFieldApi }) => + h({ + setup: (_) => { + provide(fieldProviderKey, field) + }, + render: () => { + return slots.default({ + field: Object.assign(field, fieldComponents), + state: field.state, + }) + }, + }) + } + + ) + } + }) as FieldComponent< + TFormData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnServer, + TSubmitMeta, + TComponents + > + + const extendedForm = Object.assign(form, { + AppField, + AppForm, + ...formComponents, + }) + + return extendedForm + } + + return { + useAppForm, + } +} diff --git a/packages/vue-form/src/index.ts b/packages/vue-form/src/index.ts index 50f1cbaad..a984f05c3 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 './createFormComposition' diff --git a/packages/vue-form/src/useField.tsx b/packages/vue-form/src/useField.tsx index c63631431..021ad1352 100644 --- a/packages/vue-form/src/useField.tsx +++ b/packages/vue-form/src/useField.tsx @@ -32,6 +32,7 @@ export type FieldComponent< TFormOnSubmitAsync extends undefined | FormAsyncValidateOrFn, TFormOnServer extends undefined | FormAsyncValidateOrFn, TParentSubmitMeta, + ExtendedApi = {}, // 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 < @@ -64,7 +65,8 @@ export type FieldComponent< TOnSubmitAsync > & EmitsToProps & - PublicProps, + PublicProps & + ExtendedApi, ) => CreateComponentPublicInstanceWithMixins< FieldComponentBoundProps< TParentData, diff --git a/packages/vue-form/src/useForm.tsx b/packages/vue-form/src/useForm.tsx index 616fb0d58..d205b2a28 100644 --- a/packages/vue-form/src/useForm.tsx +++ b/packages/vue-form/src/useForm.tsx @@ -192,6 +192,45 @@ export interface VueFormApi< > } +/** + * An extended version of the `FormApi` class that includes Vue-specific functionalities from `VueFormApi` + */ +export type VueFormExtendedApi< + TFormData, + TOnMount extends undefined | FormValidateOrFn, + TOnChange extends undefined | FormValidateOrFn, + TOnChangeAsync extends undefined | FormAsyncValidateOrFn, + TOnBlur extends undefined | FormValidateOrFn, + TOnBlurAsync extends undefined | FormAsyncValidateOrFn, + TOnSubmit extends undefined | FormValidateOrFn, + TOnSubmitAsync extends undefined | FormAsyncValidateOrFn, + TOnServer extends undefined | FormAsyncValidateOrFn, + TSubmitMeta, +> = FormApi< + TFormData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnServer, + TSubmitMeta +> & + VueFormApi< + TFormData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnServer, + TSubmitMeta + > + export function useForm< TParentData, TFormOnMount extends undefined | FormValidateOrFn, diff --git a/packages/vue-form/tests/createFormComposition.test.tsx b/packages/vue-form/tests/createFormComposition.test.tsx new file mode 100644 index 000000000..bdb39d934 --- /dev/null +++ b/packages/vue-form/tests/createFormComposition.test.tsx @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest' +import { render } from '@testing-library/vue' +import { defineComponent, h } from 'vue' +import { createFormComposition, createFormCompositionContexts } from '../src' +import type { AnyFieldApi } from '@tanstack/form-core' + +const { injectField, fieldProviderKey, formProviderKey, injectForm } = + createFormCompositionContexts() + +const TextField = defineComponent<{ label: string }>( + ({ label }) => { + const field = injectField() + return () => { + return ( + + ) + } + }, + { + props: ['label'], + }, +) + +const SubscribeButton = defineComponent<{ label: string }>(({ label }) => { + const form = injectForm() + return () => { + return ( + state.isSubmitting}> + {(isSubmitting: boolean) => ( + + )} + + ) + } +}) + +const { useAppForm } = createFormComposition({ + fieldComponents: { + TextField, + }, + formComponents: { + SubscribeButton, + }, + fieldProviderKey, + formProviderKey, +}) + +describe('createFormComposition', () => { + it('should allow to set default value', () => { + type Person = { + firstName: string + lastName: string + } + + const Comp = defineComponent(() => { + const form = useAppForm({ + defaultValues: { + firstName: 'FirstName', + lastName: 'LastName', + } as Person, + }) + + return () => ( + + {({ field }: { field: AnyFieldApi & Record<'TextField', any> }) => ( + + )} + + ) + }) + + const { getByLabelText } = render() + const input = getByLabelText('Testing') + expect(input).toHaveValue('FirstName') + }) +}) From b170b68dc8ac446b018e0ae926b2abb2ee87966e Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Fri, 25 Apr 2025 06:28:28 -0700 Subject: [PATCH 2/3] chore: fix a few problems with TS and runtime --- packages/vue-form/src/createFormComposition.tsx | 4 ++-- packages/vue-form/src/useField.tsx | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/vue-form/src/createFormComposition.tsx b/packages/vue-form/src/createFormComposition.tsx index 82336f5f3..fd0981543 100644 --- a/packages/vue-form/src/createFormComposition.tsx +++ b/packages/vue-form/src/createFormComposition.tsx @@ -225,10 +225,10 @@ export function createFormComposition< > { const form = useForm(props) - const AppForm = defineComponent(() => { + const AppForm = defineComponent((_, { slots }) => { provide(formProviderKey, form) return () => { - return + return slots.default!() } }) diff --git a/packages/vue-form/src/useField.tsx b/packages/vue-form/src/useField.tsx index 021ad1352..e6a872f4b 100644 --- a/packages/vue-form/src/useField.tsx +++ b/packages/vue-form/src/useField.tsx @@ -65,8 +65,7 @@ export type FieldComponent< TOnSubmitAsync > & EmitsToProps & - PublicProps & - ExtendedApi, + PublicProps, ) => CreateComponentPublicInstanceWithMixins< FieldComponentBoundProps< TParentData, @@ -113,7 +112,8 @@ export type FieldComponent< TFormOnSubmitAsync, TFormOnServer, TParentSubmitMeta - > + > & + ExtendedApi state: FieldApi< TParentData, TName, From 973be0594bc1317ee31d50c22773386929b9b015 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Fri, 25 Apr 2025 07:19:47 -0700 Subject: [PATCH 3/3] chore: push broken example Blocked by: https://stackblitz.com/edit/vue-v-slot-comp-map-bug-repro --- examples/vue/field-components/.gitignore | 9 +++ examples/vue/field-components/README.md | 6 ++ examples/vue/field-components/index.html | 12 +++ examples/vue/field-components/package.json | 22 ++++++ examples/vue/field-components/src/App.vue | 74 +++++++++++++++++++ .../src/components/SubscribeButton.vue | 15 ++++ .../src/components/TextField.vue | 25 +++++++ .../src/compositions/form-providers.ts | 4 + .../field-components/src/compositions/form.ts | 15 ++++ examples/vue/field-components/src/main.ts | 5 ++ .../vue/field-components/src/shims-vue.d.ts | 5 ++ examples/vue/field-components/src/types.d.ts | 6 ++ examples/vue/field-components/tsconfig.json | 24 ++++++ examples/vue/field-components/vite.config.ts | 10 +++ pnpm-lock.yaml | 22 ++++++ 15 files changed, 254 insertions(+) create mode 100644 examples/vue/field-components/.gitignore create mode 100644 examples/vue/field-components/README.md create mode 100644 examples/vue/field-components/index.html create mode 100644 examples/vue/field-components/package.json create mode 100644 examples/vue/field-components/src/App.vue create mode 100644 examples/vue/field-components/src/components/SubscribeButton.vue create mode 100644 examples/vue/field-components/src/components/TextField.vue create mode 100644 examples/vue/field-components/src/compositions/form-providers.ts create mode 100644 examples/vue/field-components/src/compositions/form.ts create mode 100644 examples/vue/field-components/src/main.ts create mode 100644 examples/vue/field-components/src/shims-vue.d.ts create mode 100644 examples/vue/field-components/src/types.d.ts create mode 100644 examples/vue/field-components/tsconfig.json create mode 100644 examples/vue/field-components/vite.config.ts diff --git a/examples/vue/field-components/.gitignore b/examples/vue/field-components/.gitignore new file mode 100644 index 000000000..449e8098b --- /dev/null +++ b/examples/vue/field-components/.gitignore @@ -0,0 +1,9 @@ +node_modules +.DS_Store +dist +dist-ssr +*.local + +package-lock.json +yarn.lock +pnpm-lock.yaml diff --git a/examples/vue/field-components/README.md b/examples/vue/field-components/README.md new file mode 100644 index 000000000..28462a4ad --- /dev/null +++ b/examples/vue/field-components/README.md @@ -0,0 +1,6 @@ +# Basic example + +To run this example: + +- `npm install` or `yarn` or `pnpm i` +- `npm run dev` or `yarn dev` or `pnpm dev` diff --git a/examples/vue/field-components/index.html b/examples/vue/field-components/index.html new file mode 100644 index 000000000..1a850e19e --- /dev/null +++ b/examples/vue/field-components/index.html @@ -0,0 +1,12 @@ + + + + + + TanStack Form Vue Simple Example App + + +
+ + + diff --git a/examples/vue/field-components/package.json b/examples/vue/field-components/package.json new file mode 100644 index 000000000..b15093340 --- /dev/null +++ b/examples/vue/field-components/package.json @@ -0,0 +1,22 @@ +{ + "name": "@tanstack/form-example-vue-field-components", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "build:dev": "vite build -m development", + "test:types": "vue-tsc", + "serve": "vite preview" + }, + "dependencies": { + "@tanstack/vue-form": "^1.6.3", + "vue": "^3.5.13" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.3", + "typescript": "5.8.2", + "vite": "^6.3.2", + "vue-tsc": "^2.2.2" + } +} diff --git a/examples/vue/field-components/src/App.vue b/examples/vue/field-components/src/App.vue new file mode 100644 index 000000000..876f29dfe --- /dev/null +++ b/examples/vue/field-components/src/App.vue @@ -0,0 +1,74 @@ + + + diff --git a/examples/vue/field-components/src/components/SubscribeButton.vue b/examples/vue/field-components/src/components/SubscribeButton.vue new file mode 100644 index 000000000..910dbbeae --- /dev/null +++ b/examples/vue/field-components/src/components/SubscribeButton.vue @@ -0,0 +1,15 @@ + + + diff --git a/examples/vue/field-components/src/components/TextField.vue b/examples/vue/field-components/src/components/TextField.vue new file mode 100644 index 000000000..2f4469b7c --- /dev/null +++ b/examples/vue/field-components/src/components/TextField.vue @@ -0,0 +1,25 @@ + + + diff --git a/examples/vue/field-components/src/compositions/form-providers.ts b/examples/vue/field-components/src/compositions/form-providers.ts new file mode 100644 index 000000000..e748c35bc --- /dev/null +++ b/examples/vue/field-components/src/compositions/form-providers.ts @@ -0,0 +1,4 @@ +import { createFormCompositionContexts } from '@tanstack/vue-form' + +export const { fieldProviderKey, injectField, formProviderKey, injectForm } = + createFormCompositionContexts() diff --git a/examples/vue/field-components/src/compositions/form.ts b/examples/vue/field-components/src/compositions/form.ts new file mode 100644 index 000000000..5ffc914bf --- /dev/null +++ b/examples/vue/field-components/src/compositions/form.ts @@ -0,0 +1,15 @@ +import { createFormComposition } from '@tanstack/vue-form' +import SubscribeButton from '../components/SubscribeButton.vue' +import TextField from '../components/TextField.vue' +import { fieldProviderKey, formProviderKey } from './form-providers.ts' + +export const { useAppForm } = createFormComposition({ + fieldComponents: { + TextField, + }, + formComponents: { + SubscribeButton, + }, + fieldProviderKey, + formProviderKey, +}) diff --git a/examples/vue/field-components/src/main.ts b/examples/vue/field-components/src/main.ts new file mode 100644 index 000000000..912d54f8d --- /dev/null +++ b/examples/vue/field-components/src/main.ts @@ -0,0 +1,5 @@ +import { createApp } from 'vue' + +import App from './App.vue' + +createApp(App).mount('#app') diff --git a/examples/vue/field-components/src/shims-vue.d.ts b/examples/vue/field-components/src/shims-vue.d.ts new file mode 100644 index 000000000..ac1ded792 --- /dev/null +++ b/examples/vue/field-components/src/shims-vue.d.ts @@ -0,0 +1,5 @@ +declare module '*.vue' { + import { DefineComponent } from 'vue' + const component: DefineComponent<{}, {}, any> + export default component +} diff --git a/examples/vue/field-components/src/types.d.ts b/examples/vue/field-components/src/types.d.ts new file mode 100644 index 000000000..4851e8102 --- /dev/null +++ b/examples/vue/field-components/src/types.d.ts @@ -0,0 +1,6 @@ +export interface Post { + userId: number + id: number + title: string + body: string +} diff --git a/examples/vue/field-components/tsconfig.json b/examples/vue/field-components/tsconfig.json new file mode 100644 index 000000000..62eb2b161 --- /dev/null +++ b/examples/vue/field-components/tsconfig.json @@ -0,0 +1,24 @@ +{ + "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", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"] +} diff --git a/examples/vue/field-components/vite.config.ts b/examples/vue/field-components/vite.config.ts new file mode 100644 index 000000000..804a28720 --- /dev/null +++ b/examples/vue/field-components/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [vue()], + optimizeDeps: { + exclude: ['@tanstack/vue-form'], + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 742026abf..96fa9dd50 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -701,6 +701,28 @@ importers: specifier: ^2.2.2 version: 2.2.8(typescript@5.8.2) + examples/vue/field-components: + dependencies: + '@tanstack/vue-form': + specifier: ^1.6.3 + version: link:../../../packages/vue-form + vue: + specifier: ^3.5.13 + version: 3.5.13(typescript@5.8.2) + devDependencies: + '@vitejs/plugin-vue': + specifier: ^5.2.3 + version: 5.2.3(vite@6.3.2(@types/node@22.13.14)(jiti@2.4.2)(less@4.2.2)(sass@1.86.1)(sugarss@4.0.1(postcss@8.5.3))(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.1))(vue@3.5.13(typescript@5.8.2)) + typescript: + specifier: 5.8.2 + version: 5.8.2 + vite: + specifier: ^6.3.2 + version: 6.3.2(@types/node@22.13.14)(jiti@2.4.2)(less@4.2.2)(sass@1.86.1)(sugarss@4.0.1(postcss@8.5.3))(terser@5.39.0)(tsx@4.19.3)(yaml@2.7.1) + vue-tsc: + specifier: ^2.2.2 + version: 2.2.8(typescript@5.8.2) + examples/vue/simple: dependencies: '@tanstack/vue-form':