Skip to content

Commit

Permalink
fix: Field components should now infer state.value properly
Browse files Browse the repository at this point in the history
* chore: refactor TS typings for React

* fix: field should now infer state.value properly in React adapter

* chore: fix Vue package typings

* chore: fix linting

* chore: fix React adapter

* chore: improve performance of TData type in FieldApi

* chore: add back index and parent type

* chore: add Vue TSC dep on Vue example

* chore: fix lint and type test

* chore: update Vite stuff

* chore: add implicit dep for Vue and React examples

* chore: add type test pre-req

* chore: install deps from examples in PR CI

* chore: remove filter from more installation
  • Loading branch information
crutchcorn authored Sep 9, 2023
1 parent b5a768f commit 160f712
Show file tree
Hide file tree
Showing 14 changed files with 425 additions and 516 deletions.
10 changes: 5 additions & 5 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jobs:
node-version: 18.15.0
cache: 'pnpm'
- name: Install dependencies
run: pnpm --filter "./packages/**" --filter form --prefer-offline install --no-frozen-lockfile
run: pnpm --prefer-offline install --no-frozen-lockfile
- name: Run Tests
uses: nick-fields/[email protected]
with:
Expand All @@ -48,7 +48,7 @@ jobs:
node-version: 16.14.2
cache: 'pnpm'
- name: Install dependencies
run: pnpm --filter "./packages/**" --filter form --prefer-offline install --no-frozen-lockfile
run: pnpm --prefer-offline install --no-frozen-lockfile
- run: pnpm run test:eslint --base=${{ github.event.pull_request.base.sha }}
typecheck:
name: 'Typecheck'
Expand All @@ -67,7 +67,7 @@ jobs:
node-version: 16.14.2
cache: 'pnpm'
- name: Install dependencies
run: pnpm --filter "./packages/**" --filter form --prefer-offline install --no-frozen-lockfile
run: pnpm --prefer-offline install --no-frozen-lockfile
- run: pnpm run test:types --base=${{ github.event.pull_request.base.sha }}
format:
name: 'Format'
Expand All @@ -86,7 +86,7 @@ jobs:
node-version: 16.14.2
cache: 'pnpm'
- name: Install dependencies
run: pnpm --filter "./packages/**" --filter form --prefer-offline install --no-frozen-lockfile
run: pnpm --prefer-offline install --no-frozen-lockfile
- run: pnpm run test:format --base=${{ github.event.pull_request.base.sha }}
test-build:
name: 'Test Build'
Expand All @@ -105,7 +105,7 @@ jobs:
node-version: 16.14.2
cache: 'pnpm'
- name: Install dependencies
run: pnpm --filter "./packages/**" --filter form --prefer-offline install --no-frozen-lockfile
run: pnpm --prefer-offline install --no-frozen-lockfile
- name: Get appropriate base and head commits for `nx affected` commands
uses: nrwl/nx-set-shas@v3
with:
Expand Down
20 changes: 16 additions & 4 deletions examples/react/simple/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,11 @@
"react": "^18.0.0",
"react-dom": "^18.0.0",
"zod": "^3.21.4",
"@tanstack/form-core": "0.3.2",
"@tanstack/vue-form": "0.3.2"
"@tanstack/form-core": "0.3.2"
},
"devDependencies": {
"@vitejs/plugin-react": "^2.0.0",
"vite": "^3.0.0"
"@vitejs/plugin-react": "^4.0.4",
"vite": "^4.4.9"
},
"browserslist": {
"production": [
Expand All @@ -31,5 +30,18 @@
"last 1 firefox version",
"last 1 safari version"
]
},
"nx": {
"implicitDependencies": [
"@tanstack/form-core",
"@tanstack/react-form"
],
"targets": {
"test:types": {
"dependsOn": [
"build"
]
}
}
}
}
24 changes: 19 additions & 5 deletions examples/vue/simple/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,31 @@
"dev": "vite",
"build": "vite build",
"build:dev": "vite build -m development",
"test:types": "vue-tsc --noEmit",
"serve": "vite preview"
},
"dependencies": {
"@tanstack/vue-form": "0.3.2",
"vue": "^3.3.4",
"@tanstack/form-core": "0.3.2",
"@tanstack/react-form": "0.3.2"
"@tanstack/vue-form": "0.3.2",
"vue": "^3.3.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.2.3",
"@vitejs/plugin-vue": "^4.3.4",
"typescript": "^5.0.4",
"vite": "^4.4.4"
"vite": "^4.4.9",
"vue-tsc": "^1.8.10"
},
"nx": {
"implicitDependencies": [
"@tanstack/form-core",
"@tanstack/vue-form"
],
"targets": {
"test:types": {
"dependsOn": [
"build"
]
}
}
}
}
2 changes: 1 addition & 1 deletion examples/vue/simple/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const form = useForm({
form.provideFormContext()
async function onChangeFirstName(value) {
async function onChangeFirstName(value: string) {
await new Promise((resolve) => setTimeout(resolve, 1000))
return value.includes(`error`) && `No 'error' allowed in first name`
}
Expand Down
2 changes: 0 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,6 @@
"@types/testing-library__jest-dom": "^5.14.5",
"@typescript-eslint/eslint-plugin": "^6.4.1",
"@typescript-eslint/parser": "^6.4.1",
"@vitejs/plugin-vue": "^4.3.4",
"@vitejs/plugin-vue-jsx": "^3.0.2",
"@vitest/coverage-istanbul": "^0.34.3",
"axios": "^0.26.1",
"babel-eslint": "^10.1.0",
Expand Down
110 changes: 62 additions & 48 deletions packages/form-core/src/FieldApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,19 @@ export interface FieldOptions<
defaultMeta?: Partial<FieldMeta>
}

export type FieldApiOptions<TData, TFormData> = FieldOptions<
TData,
TFormData
> & {
export interface FieldApiOptions<
_TData,
TFormData,
/**
* This allows us to restrict the name to only be a valid field name while
* also assigning it to a generic
*/
TName = unknown extends TFormData ? string : DeepKeys<TFormData>,
/**
* If TData is unknown, we can use the TName generic to determine the type
*/
TData = unknown extends _TData ? DeepValue<TFormData, TName> : _TData,
> extends FieldOptions<_TData, TFormData, TName, TData> {
form: FormApi<TFormData>
}

Expand All @@ -65,35 +74,45 @@ export type FieldState<TData> = {
meta: FieldMeta
}

/**
* TData may not be known at the time of FieldApi construction, so we need to
* use a conditional type to determine if TData is known or not.
*
* If TData is not known, we use the TFormData type to determine the type of
* the field value based on the field name.
*/
type GetTData<Name, TData, TFormData> = unknown extends TData
? DeepValue<TFormData, Name>
: TData

export class FieldApi<TData, TFormData> {
type GetTData<
TData,
TFormData,
Opts extends FieldApiOptions<TData, TFormData>,
> = Opts extends FieldApiOptions<
infer _TData,
infer _TFormData,
infer _TName,
infer RealTData
>
? RealTData
: never

export class FieldApi<
_TData,
TFormData,
Opts extends FieldApiOptions<_TData, TFormData> = FieldApiOptions<
_TData,
TFormData
>,
TData extends GetTData<_TData, TFormData, Opts> = GetTData<
_TData,
TFormData,
Opts
>,
> {
uid: number
form: FormApi<TFormData>
form: Opts['form']
name!: DeepKeys<TFormData>
/**
* This is a hack that allows us to use `GetTData` without calling it everywhere
*
* Unfortunately this hack appears to be needed alongside the `TName` hack
* further up in this file. This properly types all of the internal methods,
* while the `TName` hack types the options properly
*/
_tdata!: GetTData<typeof this.name, TData, TFormData>
store!: Store<FieldState<typeof this._tdata>>
state!: FieldState<typeof this._tdata>
prevState!: FieldState<typeof this._tdata>
options: FieldOptions<typeof this._tdata, TFormData> = {} as any

constructor(opts: FieldApiOptions<TData, TFormData>) {
options: Opts = {} as any
store!: Store<FieldState<TData>>
state!: FieldState<TData>
prevState!: FieldState<TData>

constructor(
opts: Opts & {
form: FormApi<TFormData>
},
) {
this.form = opts.form
this.uid = uid++
// Support field prefixing from FieldScope
Expand All @@ -104,7 +123,7 @@ export class FieldApi<TData, TFormData> {

this.name = opts.name as any

this.store = new Store<FieldState<typeof this._tdata>>(
this.store = new Store<FieldState<TData>>(
{
value: this.getValue(),
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
Expand Down Expand Up @@ -138,7 +157,7 @@ export class FieldApi<TData, TFormData> {

mount = () => {
const info = this.getInfo()
info.instances[this.uid] = this
info.instances[this.uid] = this as never

const unsubscribe = this.form.store.subscribe(() => {
this.store.batch(() => {
Expand Down Expand Up @@ -167,7 +186,7 @@ export class FieldApi<TData, TFormData> {
}
}

update = (opts: FieldApiOptions<typeof this._tdata, TFormData>) => {
update = (opts: FieldApiOptions<TData, TFormData>) => {
// Default Value
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (this.state.value === undefined) {
Expand All @@ -189,12 +208,12 @@ export class FieldApi<TData, TFormData> {
this.options = opts as never
}

getValue = (): typeof this._tdata => {
getValue = (): TData => {
return this.form.getFieldValue(this.name)
}

setValue = (
updater: Updater<typeof this._tdata>,
updater: Updater<TData>,
options?: { touch?: boolean; notify?: boolean },
) => {
this.form.setFieldValue(this.name, updater as never, options)
Expand All @@ -218,26 +237,21 @@ export class FieldApi<TData, TFormData> {

getInfo = () => this.form.getFieldInfo(this.name)

pushValue = (
value: typeof this._tdata extends any[]
? (typeof this._tdata)[number]
: never,
) => this.form.pushFieldValue(this.name, value as any)
pushValue = (value: TData extends any[] ? TData[number] : never) =>
this.form.pushFieldValue(this.name, value as any)

insertValue = (
index: number,
value: typeof this._tdata extends any[]
? (typeof this._tdata)[number]
: never,
value: TData extends any[] ? TData[number] : never,
) => this.form.insertFieldValue(this.name, index, value as any)

removeValue = (index: number) => this.form.removeFieldValue(this.name, index)

swapValues = (aIndex: number, bIndex: number) =>
this.form.swapFieldValues(this.name, aIndex, bIndex)

getSubField = <TName extends DeepKeys<typeof this._tdata>>(name: TName) =>
new FieldApi<DeepValue<typeof this._tdata, TName>, TFormData>({
getSubField = <TName extends DeepKeys<TData>>(name: TName) =>
new FieldApi<DeepValue<TData, TName>, TFormData>({
name: `${this.name}.${name}` as never,
form: this.form,
})
Expand Down Expand Up @@ -371,7 +385,7 @@ export class FieldApi<TData, TFormData> {

validate = (
cause: ValidationCause,
value?: typeof this._tdata,
value?: TData,
): ValidationError[] | Promise<ValidationError[]> => {
// If the field is pristine and validatePristine is false, do not validate
if (!this.state.meta.isTouched) return []
Expand All @@ -389,7 +403,7 @@ export class FieldApi<TData, TFormData> {
return this.validateAsync(value, cause)
}

handleChange = (updater: Updater<typeof this._tdata>) => {
handleChange = (updater: Updater<TData>) => {
this.setValue(updater, { touch: true })
}

Expand Down
63 changes: 63 additions & 0 deletions packages/react-form/src/tests/useField.test-d.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { assertType } from 'vitest'
import * as React from 'react'
import { useForm } from '../useForm'

it('should type state.value properly', () => {
function Comp() {
const form = useForm({
defaultValues: {
firstName: 'test',
age: 84,
},
} as const)

return (
<form.Provider>
<form.Field
name="firstName"
children={(field) => {
assertType<'test'>(field.state.value)
}}
/>
<form.Field
name="age"
children={(field) => {
assertType<84>(field.state.value)
}}
/>
</form.Provider>
)
}
})

it('should type onChange properly', () => {
function Comp() {
const form = useForm({
defaultValues: {
firstName: 'test',
age: 84,
},
} as const)

return (
<form.Provider>
<form.Field
name="firstName"
onChange={(val) => {
assertType<'test'>(val)
return null
}}
children={(field) => null}
/>
<form.Field
name="age"
onChange={(val) => {
assertType<84>(val)
return null
}}
children={(field) => null}
/>
</form.Provider>
)
}
})
Loading

0 comments on commit 160f712

Please sign in to comment.