From b820449d2cd02583d5715e470536f36184d35cb1 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Fri, 25 Apr 2025 03:17:00 -0700 Subject: [PATCH 1/3] feat: add withForm and createAppForm APIs --- packages/solid-form/src/createField.tsx | 10 +- packages/solid-form/src/createForm.tsx | 40 ++ packages/solid-form/src/index.tsx | 1 + packages/solid-form/src/makeFormCreate.tsx | 362 ++++++++++++++++++ .../tests/makeFormCreate.test-d.tsx | 252 ++++++++++++ .../solid-form/tests/makeFormCreate.test.tsx | 115 ++++++ 6 files changed, 777 insertions(+), 3 deletions(-) create mode 100644 packages/solid-form/src/makeFormCreate.tsx create mode 100644 packages/solid-form/tests/makeFormCreate.test-d.tsx create mode 100644 packages/solid-form/tests/makeFormCreate.test.tsx diff --git a/packages/solid-form/src/createField.tsx b/packages/solid-form/src/createField.tsx index 9113720de..0841f7e29 100644 --- a/packages/solid-form/src/createField.tsx +++ b/packages/solid-form/src/createField.tsx @@ -360,6 +360,7 @@ interface FieldComponentBoundProps< TFormOnSubmitAsync extends undefined | FormAsyncValidateOrFn, TFormOnServer extends undefined | FormAsyncValidateOrFn, TParentSubmitMeta, + ExtendedApi = {}, > extends CreateFieldOptionsBound< TParentData, TName, @@ -373,7 +374,7 @@ interface FieldComponentBoundProps< TOnSubmitAsync > { children: ( - fieldApi: () => FieldApi< + fieldApi: (() => FieldApi< TParentData, TName, TData, @@ -393,7 +394,8 @@ interface FieldComponentBoundProps< TFormOnSubmitAsync, TFormOnServer, TParentSubmitMeta - >, + >) & + ExtendedApi, ) => JSXElement } @@ -408,6 +410,7 @@ export type FieldComponent< TFormOnSubmitAsync extends undefined | FormAsyncValidateOrFn, TFormOnServer extends undefined | FormAsyncValidateOrFn, TParentSubmitMeta, + ExtendedApi = {}, > = < TName extends DeepKeys, TData extends DeepValue, @@ -446,7 +449,8 @@ export type FieldComponent< TFormOnSubmit, TFormOnSubmitAsync, TFormOnServer, - TParentSubmitMeta + TParentSubmitMeta, + ExtendedApi >) => JSXElement interface FieldComponentProps< diff --git a/packages/solid-form/src/createForm.tsx b/packages/solid-form/src/createForm.tsx index 3b4b52fc4..1dedde413 100644 --- a/packages/solid-form/src/createForm.tsx +++ b/packages/solid-form/src/createForm.tsx @@ -112,6 +112,46 @@ export interface SolidFormApi< }) => JSXElement } +/** + * An extended version of the `FormApi` class that includes Solid-specific functionalities from `SolidFormApi` + */ +export type SolidFormExtendedApi< + 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 +> & + SolidFormApi< + TFormData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnServer, + TSubmitMeta + > + + export function createForm< TParentData, TFormOnMount extends undefined | FormValidateOrFn, diff --git a/packages/solid-form/src/index.tsx b/packages/solid-form/src/index.tsx index dbe20bcd1..835316200 100644 --- a/packages/solid-form/src/index.tsx +++ b/packages/solid-form/src/index.tsx @@ -6,3 +6,4 @@ export { createForm, type SolidFormApi } from './createForm' export type { CreateField, FieldComponent } from './createField' export { createField, Field } from './createField' +export { makeFormCreate, makeFormCreateContexts } from './makeFormCreate' diff --git a/packages/solid-form/src/makeFormCreate.tsx b/packages/solid-form/src/makeFormCreate.tsx new file mode 100644 index 000000000..62b0d8c73 --- /dev/null +++ b/packages/solid-form/src/makeFormCreate.tsx @@ -0,0 +1,362 @@ +import { createContext, useContext } from 'solid-js' +import { createForm } from './createForm' +import type { Component, Context, JSX, ParentProps } from 'solid-js' +import type { + AnyFieldApi, + AnyFormApi, + FieldApi, + FormAsyncValidateOrFn, + FormOptions, + FormValidateOrFn, +} from '@tanstack/form-core' +import type { FieldComponent } from './createField' +import type { SolidFormExtendedApi } from './createForm' + +/** + * TypeScript inferencing is weird. + * + * If you have: + * + * @example + * + * interface Args { + * arg?: T + * } + * + * function test(arg?: Partial>): T { + * return 0 as any; + * } + * + * const a = test({}); + * + * Then `T` will default to `unknown`. + * + * However, if we change `test` to be: + * + * @example + * + * function test(arg?: Partial>): T; + * + * Then `T` becomes `undefined`. + * + * Here, we are checking if the passed type `T` extends `DefaultT` and **only** + * `DefaultT`, as if that's the case we assume that inferencing has not occured. + */ +type UnwrapOrAny = [unknown] extends [T] ? any : T +type UnwrapDefaultOrAny = [DefaultT] extends [T] + ? [T] extends [DefaultT] + ? any + : T + : T + +export function makeFormCreateContexts() { + // We should never hit the `null` case here + const fieldContext = createContext(null as never) + + function useFieldContext() { + const field = useContext(fieldContext) + + if (!field) { + throw new Error( + '`fieldContext` only works when within a `fieldComponent` passed to `makeFormCreate`', + ) + } + + return field as FieldApi< + any, + string, + TData, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any + > + } + + // We should never hit the `null` case here + const formContext = createContext(null as never) + + function useFormContext() { + const form = useContext(formContext) + + if (!form) { + throw new Error( + '`formContext` only works when within a `formComponent` passed to `makeFormCreate`', + ) + } + + return form as SolidFormExtendedApi< + // 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 { fieldContext, useFieldContext, useFormContext, formContext } +} + +interface makeFormCreateProps< + TFieldComponents extends Record>, + TFormComponents extends Record>, +> { + fieldComponents: TFieldComponents + fieldContext: Context + formComponents: TFormComponents + formContext: Context +} + +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>, +> = SolidFormExtendedApi< + 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: ParentProps< + NoInfer & { + form: AppFieldExtendedReactFormApi< + TFormData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnServer, + TSubmitMeta, + TFieldComponents, + TFormComponents + > + } + >, + ) => JSX.Element +} + +export function makeFormCreate< + const TComponents extends Record>, + const TFormComponents extends Record>, +>({ + fieldComponents, + fieldContext, + formContext, + formComponents, +}: makeFormCreateProps) { + function createAppForm< + 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 = createForm(props) + + const AppForm = ((props) => { + return ( + + {props.children} + + ) + }) as Component + + const AppField = ((props) => { + const { children, ...rest } = props + return ( + + {(field) => ( + + {children(Object.assign(field, fieldComponents))} + + )} + + ) + }) as FieldComponent< + TFormData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnServer, + TSubmitMeta, + TComponents + > + + const extendedForm = Object.assign(form, { + AppField, + AppForm, + ...formComponents, + }) + + return extendedForm + } + + function withForm< + 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, + TRenderProps extends Record = {}, + >({ + render, + props, + }: WithFormProps< + TFormData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnServer, + TSubmitMeta, + TComponents, + TFormComponents, + TRenderProps + >): WithFormProps< + UnwrapOrAny, + UnwrapDefaultOrAny, TOnMount>, + UnwrapDefaultOrAny, TOnChange>, + UnwrapDefaultOrAny, TOnChangeAsync>, + UnwrapDefaultOrAny, TOnBlur>, + UnwrapDefaultOrAny, TOnBlurAsync>, + UnwrapDefaultOrAny, TOnSubmit>, + UnwrapDefaultOrAny, TOnSubmitAsync>, + UnwrapDefaultOrAny, TOnServer>, + UnwrapOrAny, + UnwrapOrAny, + UnwrapOrAny, + UnwrapOrAny + >['render'] { + return (innerProps) => render({ ...props, ...innerProps }) + } + + return { + createAppForm, + withForm, + } +} diff --git a/packages/solid-form/tests/makeFormCreate.test-d.tsx b/packages/solid-form/tests/makeFormCreate.test-d.tsx new file mode 100644 index 000000000..c12ff925b --- /dev/null +++ b/packages/solid-form/tests/makeFormCreate.test-d.tsx @@ -0,0 +1,252 @@ +import { describe, expectTypeOf, it } from 'vitest' +import { formOptions } from '@tanstack/form-core' +import { makeFormCreate, makeFormCreateContexts } from '../src' +import type { JSX } from 'solid-js/jsx-runtime' + +const { fieldContext, useFieldContext, formContext, useFormContext } = + makeFormCreateContexts() + +function Test() { + return null +} + +const { createAppForm, withForm } = makeFormCreate({ + fieldComponents: { + Test, + }, + formComponents: { + Test, + }, + fieldContext, + formContext, +}) + +describe('makeFormCreate', () => { + it('should not break with an infinite type on large schemas', () => { + const ActivityKind0_Names = ['Work', 'Rest', 'OnCall'] as const + type ActivityKind0 = (typeof ActivityKind0_Names)[number] + + enum DayOfWeek { + Monday = 1, + Tuesday, + Wednesday, + Thursday, + Friday, + Saturday, + Sunday, + } + + interface Branding { + __type?: Brand + } + type Branded = T & Branding + type ActivityId = Branded + interface ActivitySelectorFormData { + includeAll: boolean + includeActivityIds: ActivityId[] + includeActivityKinds: Set + excludeActivityIds: ActivityId[] + } + + const GeneratedTypes0Visibility_Names = [ + 'Normal', + 'Advanced', + 'Hidden', + ] as const + type GeneratedTypes0Visibility = + (typeof GeneratedTypes0Visibility_Names)[number] + interface FormValuesBase { + key: string + visibility: GeneratedTypes0Visibility + } + + interface ActivityCountFormValues extends FormValuesBase { + _type: 'ActivityCount' + activitySelector: ActivitySelectorFormData + daysOfWeek: DayOfWeek[] + label: string + } + + interface PlanningTimesFormValues extends FormValuesBase { + _type: 'PlanningTimes' + showTarget: boolean + showPlanned: boolean + showDiff: boolean + } + + type EditorValues = ActivityCountFormValues | PlanningTimesFormValues + interface EditorFormValues { + editors: Record + ordering: string[] + } + + const ExampleUsage = withForm({ + props: { + initialValues: '' as keyof EditorFormValues['editors'], + }, + defaultValues: {} as EditorFormValues, + render: ({ form, initialValues }) => { + return ( +
+ + {(field) => { + expectTypeOf(field().state.value).toExtend() + return null + }} + + + + +
+ ) + }, + }) + }) + + it('types should be properly inferred when using formOptions', () => { + type Person = { + firstName: string + lastName: string + } + + const formOpts = formOptions({ + defaultValues: { + firstName: 'FirstName', + lastName: 'LastName', + } as Person, + }) + + const WithFormComponent = withForm({ + ...formOpts, + render: ({ form }) => { + expectTypeOf(form.state.values).toEqualTypeOf() + return + }, + }) + }) + + it('types should be properly inferred when passing args alongside formOptions', () => { + type Person = { + firstName: string + lastName: string + } + + const formOpts = formOptions({ + defaultValues: { + firstName: 'FirstName', + lastName: 'LastName', + } as Person, + }) + + const WithFormComponent = withForm({ + ...formOpts, + onSubmitMeta: { + test: 'test', + }, + render: ({ form }) => { + expectTypeOf(form.handleSubmit).toEqualTypeOf<{ + (): Promise + (submitMeta: { test: string }): Promise + }> + return + }, + }) + }) + + it('types should be properly inferred when formOptions are being overridden', () => { + type Person = { + firstName: string + lastName: string + } + + type PersonWithAge = Person & { + age: number + } + + const formOpts = formOptions({ + defaultValues: { + firstName: 'FirstName', + lastName: 'LastName', + } as Person, + }) + + const WithFormComponent = withForm({ + ...formOpts, + defaultValues: { + firstName: 'FirstName', + lastName: 'LastName', + age: 10, + }, + render: ({ form }) => { + expectTypeOf(form.state.values).toExtend() + return + }, + }) + }) + + it('withForm props should be properly inferred', () => { + const WithFormComponent = withForm({ + props: { + prop1: 'test', + prop2: 10, + }, + render: ({ form, ...props }) => { + expectTypeOf(props).toEqualTypeOf<{ + prop1: string + prop2: number + children?: JSX.Element + }>() + return + }, + }) + }) + + it("component made from withForm should have it's props properly typed", () => { + const formOpts = formOptions({ + defaultValues: { + firstName: 'FirstName', + lastName: 'LastName', + }, + }) + + const appForm = createAppForm(() => formOpts) + + const WithFormComponent = withForm({ + ...formOpts, + props: { + prop1: 'test', + prop2: 10, + }, + render: ({ form, children, ...props }) => { + expectTypeOf(props).toEqualTypeOf<{ + prop1: string + prop2: number + }>() + return + }, + }) + + const CorrectComponent = ( + + ) + + // @ts-expect-error Missing required props prop1 and prop2 + const MissingPropsComponent = + + const incorrectFormOpts = formOptions({ + defaultValues: { + firstName: 'FirstName', + lastName: 'LastName', + firstNameWrong: 'FirstName', + lastNameWrong: 'LastName', + }, + }) + + const incorrectAppForm = createAppForm(() => incorrectFormOpts) + + const IncorrectFormOptsComponent = ( + // @ts-expect-error Incorrect form opts + + ) + }) +}) diff --git a/packages/solid-form/tests/makeFormCreate.test.tsx b/packages/solid-form/tests/makeFormCreate.test.tsx new file mode 100644 index 000000000..be2e93a11 --- /dev/null +++ b/packages/solid-form/tests/makeFormCreate.test.tsx @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest' +import { render } from '@solidjs/testing-library' +import { formOptions } from '@tanstack/form-core' +import { makeFormCreate, makeFormCreateContexts } from '../src' + +const { fieldContext, useFieldContext, formContext, useFormContext } = + makeFormCreateContexts() + +function TextField({ label }: { label: string }) { + const field = useFieldContext() + return ( + + ) +} + +function SubscribeButton({ label }: { label: string }) { + const form = useFormContext() + return ( + state.isSubmitting}> + {(isSubmitting) => } + + ) +} + +const { createAppForm, withForm } = makeFormCreate({ + fieldComponents: { + TextField, + }, + formComponents: { + SubscribeButton, + }, + fieldContext, + formContext, +}) + +describe('makeFormCreate', () => { + it('should allow to set default value', () => { + type Person = { + firstName: string + lastName: string + } + + function Comp() { + const form = createAppForm(() => ({ + defaultValues: { + firstName: 'FirstName', + lastName: 'LastName', + } as Person, + })) + + return ( + <> + } + /> + + ) + } + + const { getByLabelText } = render(() => ) + const input = getByLabelText('Testing') + expect(input).toHaveValue('FirstName') + }) + + it('should handle withForm types properly', () => { + const formOpts = formOptions({ + defaultValues: { + firstName: 'John', + lastName: 'Doe', + }, + }) + + const ChildForm = withForm({ + ...formOpts, + // Optional, but adds props to the `render` function outside of `form` + props: { + title: 'Child Form', + }, + render: ({ form, title }) => { + return ( +
+

{title}

+ } + /> + + + +
+ ) + }, + }) + + const Parent = () => { + const form = createAppForm(() => ({ + ...formOpts, + })) + + return + } + + const { getByLabelText, getByText } = render(() => ) + const input = getByLabelText('First Name') + expect(input).toHaveValue('John') + expect(getByText('Testing')).toBeInTheDocument() + }) +}) From 7214ca4a91b0f89f3bf79b2fd4b37621e78cbfdf Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 25 Apr 2025 10:20:17 +0000 Subject: [PATCH 2/3] ci: apply automated fixes and generate docs --- packages/solid-form/src/createForm.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/solid-form/src/createForm.tsx b/packages/solid-form/src/createForm.tsx index 1dedde413..011d8af31 100644 --- a/packages/solid-form/src/createForm.tsx +++ b/packages/solid-form/src/createForm.tsx @@ -151,7 +151,6 @@ export type SolidFormExtendedApi< TSubmitMeta > - export function createForm< TParentData, TFormOnMount extends undefined | FormValidateOrFn, From 1a851ee901f6e70d8703f2347d3702c3b2aa1811 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Fri, 25 Apr 2025 03:25:29 -0700 Subject: [PATCH 3/3] chore: fix Knip --- packages/solid-form/src/makeFormCreate.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/solid-form/src/makeFormCreate.tsx b/packages/solid-form/src/makeFormCreate.tsx index 62b0d8c73..9bc29c2cd 100644 --- a/packages/solid-form/src/makeFormCreate.tsx +++ b/packages/solid-form/src/makeFormCreate.tsx @@ -167,7 +167,7 @@ type AppFieldExtendedReactFormApi< AppForm: Component } -export interface WithFormProps< +interface WithFormProps< TFormData, TOnMount extends undefined | FormValidateOrFn, TOnChange extends undefined | FormValidateOrFn,