Skip to content

Commit

Permalink
fix: Initial FieldAPI tests, made FieldAPI typings much more strict (#…
Browse files Browse the repository at this point in the history
…405)

* fix: initial work at fixing the typescript typings for fieldapi to be more strict

* chore(form-core): change implementation of strict tdata

* fix(form-core): insertValue should now be typed properly

* test(form-core): validate array helpers

* fix(form-core): make types for getSubField more narrow

* chore: autoformat with prettier

* chore: fix tests, eslint

* chore: upgrade eslint deps

* chore: upgrade nx and concurrent

* chore: remove svelte from prettier plugins

* chore: upgrade TypeScript version to avoid a TS codegen bug

* chore: remove React 17 CI script temporarily

* chore: fix build and formatter
  • Loading branch information
crutchcorn authored Aug 28, 2023
1 parent 25237e4 commit 7ee5524
Show file tree
Hide file tree
Showing 11 changed files with 2,959 additions and 2,246 deletions.
1 change: 1 addition & 0 deletions .eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ const config = {
'import/no-unresolved': ['error', { ignore: ['^@tanstack/'] }],
'import/no-unused-modules': ['off', { unusedExports: true }],
'no-redeclare': 'off',
'@typescript-eslint/no-unused-vars': 'off',
},
overrides: [
{
Expand Down
26 changes: 0 additions & 26 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -88,32 +88,6 @@ jobs:
- name: Install dependencies
run: pnpm --filter "./packages/**" --filter form --prefer-offline install --no-frozen-lockfile
- run: pnpm run test:format --base=${{ github.event.pull_request.base.sha }}
test-react-17:
name: 'Test React 17'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
ref: ${{ github.head_ref }}
repository: ${{github.event.pull_request.head.repo.full_name}}
- uses: pnpm/[email protected]
with:
version: 7
- uses: actions/setup-node@v3
with:
node-version: 16.14.2
cache: 'pnpm'
- name: Install dependencies
run: pnpm --filter "./packages/**" --filter form --prefer-offline install --no-frozen-lockfile
- name: Run Tests
uses: nick-fields/[email protected]
with:
timeout_minutes: 5
max_attempts: 3
command: pnpm run test:react:17 --base=${{ github.event.pull_request.base.sha }}
env:
REACTJS_VERSION: 17
test-build:
name: 'Test Build'
runs-on: ubuntu-latest
Expand Down
5 changes: 1 addition & 4 deletions .prettierrc
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
{
"semi": false,
"singleQuote": true,
"trailingComma": "all",
"pluginSearchDirs": false,
"plugins": ["prettier-plugin-svelte"],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
"trailingComma": "all"
}
25 changes: 12 additions & 13 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"build:all": "nx run-many --exclude=examples/** --target=build",
"watch": "pnpm run build:all && nx watch --all -- pnpm run build:all",
"dev": "pnpm run watch",
"prettier": "prettier --plugin-search-dir . \"{packages,examples,scripts}/**/*.{md,js,jsx,cjs,ts,tsx,json,vue,svelte}\"",
"prettier": "prettier \"{packages,examples,scripts}/**/*.{md,js,jsx,cjs,ts,tsx,json,vue}\"",
"prettier:write": "pnpm run prettier --write",
"cipublish": "node scripts/publish.js"
},
Expand Down Expand Up @@ -51,33 +51,32 @@
"@types/react-dom": "^18.0.5",
"@types/semver": "^7.3.13",
"@types/testing-library__jest-dom": "^5.14.5",
"@typescript-eslint/eslint-plugin": "^5.41.0",
"@typescript-eslint/parser": "^5.41.0",
"@typescript-eslint/eslint-plugin": "^6.4.1",
"@typescript-eslint/parser": "^6.4.1",
"@vitest/coverage-istanbul": "^0.27.1",
"axios": "^0.26.1",
"babel-eslint": "^10.1.0",
"babel-jest": "^27.5.1",
"babel-preset-solid": "^1.5.4",
"bundlewatch": "^0.3.2",
"chalk": "^4.1.2",
"concurrently": "^8.2.0",
"concurrently": "^8.2.1",
"cpy-cli": "^5.0.0",
"current-git-branch": "^1.1.0",
"eslint": "^8.34.0",
"eslint-config-prettier": "^8.8.0",
"eslint-import-resolver-typescript": "^3.5.5",
"eslint": "^8.48.0",
"eslint-config-prettier": "^9.0.0",
"eslint-import-resolver-typescript": "^3.6.0",
"eslint-plugin-compat": "^4.1.4",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-react": "^7.32.2",
"eslint-plugin-import": "^2.28.1",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"git-log-parser": "^1.2.0",
"jsdom": "^22.0.0",
"jsonfile": "^6.1.0",
"luxon": "^3.3.0",
"nx": "^16.4.2",
"nx": "^16.7.4",
"nx-cloud": "^16.0.5",
"prettier": "^2.8.8",
"prettier-plugin-svelte": "^2.10.0",
"prettier": "^3.0.2",
"publint": "^0.1.15",
"react": "^18.2.0",
"react-17": "npm:react@^17.0.2",
Expand All @@ -96,7 +95,7 @@
"stream-to-array": "^2.3.0",
"tsup": "^7.0.0",
"type-fest": "^3.11.0",
"typescript": "^5.0.4",
"typescript": "^5.2.2",
"vitest": "^0.27.1",
"vue": "^3.2.47"
},
Expand Down
91 changes: 59 additions & 32 deletions packages/form-core/src/FieldApi.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
//
import type { DeepKeys, DeepValue, RequiredByKey, Updater } from './utils'
import type { DeepKeys, DeepValue, Updater } from './utils'
import type { FormApi, ValidationError } from './FormApi'
import { Store } from '@tanstack/store'
import { setBy } from './utils'

export type ValidationCause = 'change' | 'blur' | 'submit'

Expand Down Expand Up @@ -76,14 +74,27 @@ export type FieldState<TData> = {
meta: FieldMeta
}

/**
* TData may not 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> {
uid: number
form: FormApi<TFormData>
name!: DeepKeys<TFormData>
store!: Store<FieldState<TData>>
state!: FieldState<TData>
prevState!: FieldState<TData>
options: FieldOptions<TData, TFormData> = {} as any
// This is a hack that allows us to use `GetTData` without calling it everywhere
_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>) {
this.form = opts.form
Expand All @@ -96,7 +107,7 @@ export class FieldApi<TData, TFormData> {

this.name = opts.name as any

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

if (state.value !== this.prevState.value) {
this.validate('change', state.value)
this.validate('change', state.value as never)
}

this.prevState = state
Expand All @@ -126,7 +137,7 @@ export class FieldApi<TData, TFormData> {

this.state = this.store.state
this.prevState = this.state
this.update(opts)
this.update(opts as never)
}

mount = () => {
Expand All @@ -148,7 +159,7 @@ export class FieldApi<TData, TFormData> {
})
})

this.options.onMount?.(this)
this.options.onMount?.(this as never)

return () => {
unsubscribe()
Expand All @@ -159,18 +170,19 @@ export class FieldApi<TData, TFormData> {
}
}

update = (opts: FieldApiOptions<TData, TFormData>) => {
update = (opts: FieldApiOptions<typeof this._tdata, TFormData>) => {
this.options = {
asyncDebounceMs: this.form.options.asyncDebounceMs ?? 0,
onChangeAsyncDebounceMs: this.form.options.onChangeAsyncDebounceMs ?? 0,
onBlurAsyncDebounceMs: this.form.options.onBlurAsyncDebounceMs ?? 0,
...opts,
}
} as never

// Default Value
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (this.state.value === undefined) {
if (this.options.defaultValue !== undefined) {
this.setValue(this.options.defaultValue)
this.setValue(this.options.defaultValue as never)
} else if (
opts.form.options.defaultValues?.[
this.options.name as keyof TFormData
Expand All @@ -179,7 +191,7 @@ export class FieldApi<TData, TFormData> {
this.setValue(
opts.form.options.defaultValues[
this.options.name as keyof TFormData
] as TData,
] as never,
)
}
}
Expand All @@ -191,11 +203,11 @@ export class FieldApi<TData, TFormData> {
}
}

getValue = (): TData => {
getValue = (): typeof this._tdata => {
return this.form.getFieldValue(this.name)
}
setValue = (
updater: Updater<TData>,
updater: Updater<typeof this._tdata>,
options?: { touch?: boolean; notify?: boolean },
) => {
this.form.setFieldValue(this.name, updater as any, options)
Expand All @@ -208,22 +220,33 @@ export class FieldApi<TData, TFormData> {
}

getMeta = (): FieldMeta => this.form.getFieldMeta(this.name)

setMeta = (updater: Updater<FieldMeta>) =>
this.form.setFieldMeta(this.name, updater)

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

pushValue = (value: TData extends any[] ? TData[number] : never) =>
this.form.pushFieldValue(this.name, value as any)
insertValue = (index: number, value: TData) =>
this.form.insertFieldValue(this.name, index, value as any)
pushValue = (
value: typeof this._tdata extends any[]
? (typeof this._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,
) => 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<TData>>(name: TName) =>
new FieldApi<DeepValue<TData, TName>, TFormData>({
name: `${this.name}.${name}` as any,
getSubField = <TName extends DeepKeys<typeof this._tdata>>(name: TName) =>
new FieldApi<DeepValue<typeof this._tdata, TName>, TFormData>({
name: `${this.name}.${name}` as never,
form: this.form,
})

Expand All @@ -238,7 +261,7 @@ export class FieldApi<TData, TFormData> {
// track freshness of the validation
const validationCount = (this.getInfo().validationCount || 0) + 1
this.getInfo().validationCount = validationCount
const error = normalizeError(validate(value, this))
const error = normalizeError(validate(value, this as never))

if (this.state.meta.error !== error) {
this.setMeta((prev) => ({
Expand Down Expand Up @@ -321,7 +344,7 @@ export class FieldApi<TData, TFormData> {
// Only kick off validation if this validation is the latest attempt
if (checkLatest()) {
try {
const rawError = await validate(value, this)
const rawError = await validate(value, this as never)

if (checkLatest()) {
const error = normalizeError(rawError)
Expand Down Expand Up @@ -351,7 +374,7 @@ export class FieldApi<TData, TFormData> {

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

getChangeProps = <T extends UserChangeProps<any>>(
props: T = {} as T,
): ChangeProps<TData> & Omit<T, keyof ChangeProps<TData>> => {
): ChangeProps<typeof this._tdata> &
Omit<T, keyof ChangeProps<typeof this._tdata>> => {
return {
...props,
value: this.state.value,
onChange: (value) => {
this.setValue(value)
this.setValue(value as never)
props.onChange?.(value)
},
onBlur: (e) => {
Expand All @@ -388,12 +412,14 @@ export class FieldApi<TData, TFormData> {
}
this.validate('blur')
},
} as ChangeProps<TData> & Omit<T, keyof ChangeProps<TData>>
} as ChangeProps<typeof this._tdata> &
Omit<T, keyof ChangeProps<typeof this._tdata>>
}

getInputProps = <T extends UserInputProps>(
props: T = {} as T,
): InputProps<TData> & Omit<T, keyof InputProps<TData>> => {
): InputProps<typeof this._tdata> &
Omit<T, keyof InputProps<typeof this._tdata>> => {
return {
...props,
value: this.state.value,
Expand All @@ -402,7 +428,8 @@ export class FieldApi<TData, TFormData> {
props.onChange?.(e.target.value)
},
onBlur: this.getChangeProps(props).onBlur,
}
} as InputProps<typeof this._tdata> &
Omit<T, keyof InputProps<typeof this._tdata>>
}
}

Expand Down
9 changes: 6 additions & 3 deletions packages/form-core/src/FormApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ export class FormApi<TFormData> {
const shouldUpdateState =
options.defaultState !== this.options.defaultState

// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!shouldUpdateValues || !shouldUpdateValues) {
return
}
Expand All @@ -179,7 +180,9 @@ export class FormApi<TFormData> {
getDefaultFormState(
Object.assign(
{},
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
shouldUpdateState ? options.defaultState : {},
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
shouldUpdateValues
? {
values: options.defaultValues,
Expand All @@ -196,9 +199,8 @@ export class FormApi<TFormData> {
reset = () =>
this.store.setState(() =>
getDefaultFormState({
...this.options?.defaultState,
values:
this.options?.defaultValues ?? this.options?.defaultState?.values,
...this.options.defaultState,
values: this.options.defaultValues ?? this.options.defaultState?.values,
}),
)

Expand Down Expand Up @@ -298,6 +300,7 @@ export class FormApi<TFormData> {
}

getFieldInfo = <TField extends DeepKeys<TFormData>>(field: TField) => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
return (this.fieldInfo[field] ||= {
instances: {},
})
Expand Down
Loading

0 comments on commit 7ee5524

Please sign in to comment.