Skip to content

feat(form-core): Async field onChange with submition handling #1562

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion examples/react/simple/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export default function App() {
},
onSubmit: async ({ value }) => {
// Do something with form data
console.log(value)
console.log(value, 'Submitted')
},
})

Expand Down Expand Up @@ -76,6 +76,14 @@ export default function App() {
<div>
<form.Field
name="lastName"
listeners={{
onChangeAsyncDebounceMs: 5_000,
onChangeAsync: async ({ value, fieldApi }) => {
await new Promise((resolve) => setTimeout(resolve, 1000))
if (value === 'CHANGED') return
fieldApi.form.setFieldValue('lastName', 'CHANGED')
},
}}
children={(field) => (
<>
<label htmlFor={field.name}>Last Name:</label>
Expand Down
94 changes: 91 additions & 3 deletions packages/form-core/src/FieldApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,11 +238,11 @@
/**
* @private
*/
export type FieldListenerFn<
type FieldListenerFnProps<
TParentData,
TName extends DeepKeys<TParentData>,
TData extends DeepValue<TParentData, TName> = DeepValue<TParentData, TName>,
> = (props: {
> = {
value: TData
fieldApi: FieldApi<
TParentData,
Expand All @@ -267,7 +267,25 @@
any,
any
>
}) => void
}

/**
* @private
*/
export type FieldListenerFn<
TParentData,
TName extends DeepKeys<TParentData>,
TData extends DeepValue<TParentData, TName> = DeepValue<TParentData, TName>,
> = (props: FieldListenerFnProps<TParentData, TName, TData>) => void

/**
* @private
*/
export type FieldListenerAsyncFn<
TParentData,
TName extends DeepKeys<TParentData>,
TData extends DeepValue<TParentData, TName> = DeepValue<TParentData, TName>,
> = (props: FieldListenerFnProps<TParentData, TName, TData>) => Promise<void>

export interface FieldValidators<
TParentData,
Expand Down Expand Up @@ -357,6 +375,8 @@
> {
onChange?: FieldListenerFn<TParentData, TName, TData>
onChangeDebounceMs?: number
onChangeAsync?: FieldListenerAsyncFn<TParentData, TName, TData>
onChangeAsyncDebounceMs?: number
onBlur?: FieldListenerFn<TParentData, TName, TData>
onBlurDebounceMs?: number
onMount?: FieldListenerFn<TParentData, TName, TData>
Expand Down Expand Up @@ -983,12 +1003,17 @@
get state() {
return this.store.state
}

timeoutIds: {
validations: Record<ValidationCause, ReturnType<typeof setTimeout> | null>
listeners: Record<ListenerCause, ReturnType<typeof setTimeout> | null>
formListeners: Record<ListenerCause, ReturnType<typeof setTimeout> | null>
}

promises: {
listeners: Record<ListenerCause, Promise<void> | null>
}

/**
* Initializes a new `FieldApi` instance.
*/
Expand Down Expand Up @@ -1023,6 +1048,10 @@
formListeners: {} as Record<ListenerCause, never>,
}

this.promises = {
listeners: {} as Record<ListenerCause, Promise<void> | null>,
}

this.store = new Derived({
deps: [this.form.store],
fn: () => {
Expand Down Expand Up @@ -1788,6 +1817,7 @@
})
}

this.triggerOnChangeAsyncListener()
const fieldDebounceMs = this.options.listeners?.onChangeDebounceMs
if (fieldDebounceMs && fieldDebounceMs > 0) {
if (this.timeoutIds.listeners.change) {
Expand All @@ -1807,6 +1837,64 @@
})
}
}

private abortController: AbortController = new AbortController()
private collapseController: AbortController = new AbortController()
private triggerOnChangeAsyncListener() {
const fieldDebounceMs = this.options.listeners?.onChangeAsyncDebounceMs
if (fieldDebounceMs && fieldDebounceMs > 0) {
if (this.timeoutIds.listeners.change) {
clearTimeout(this.timeoutIds.listeners.change)
this.abortController.abort()

Check warning on line 1848 in packages/form-core/src/FieldApi.ts

View check run for this annotation

Codecov / codecov/patch

packages/form-core/src/FieldApi.ts#L1847-L1848

Added lines #L1847 - L1848 were not covered by tests
}

const debouncePromise = new Promise<void>((resolve) => {
this.abortController.signal.onabort = () => {
resolve()

Check warning on line 1853 in packages/form-core/src/FieldApi.ts

View check run for this annotation

Codecov / codecov/patch

packages/form-core/src/FieldApi.ts#L1851-L1853

Added lines #L1851 - L1853 were not covered by tests
}

this.collapseController.signal.onabort = () => {
this.options.listeners

Check warning on line 1857 in packages/form-core/src/FieldApi.ts

View check run for this annotation

Codecov / codecov/patch

packages/form-core/src/FieldApi.ts#L1856-L1857

Added lines #L1856 - L1857 were not covered by tests
?.onChangeAsync?.({
value: this.state.value,
fieldApi: this,
})
.finally(resolve)
}

this.timeoutIds.listeners.change = setTimeout(() => {
this.options.listeners

Check warning on line 1866 in packages/form-core/src/FieldApi.ts

View check run for this annotation

Codecov / codecov/patch

packages/form-core/src/FieldApi.ts#L1865-L1866

Added lines #L1865 - L1866 were not covered by tests
?.onChangeAsync?.({
value: this.state.value,
fieldApi: this,
})
.finally(resolve)
}, fieldDebounceMs)
}).finally(() => {
this.promises.listeners.change = null

Check warning on line 1874 in packages/form-core/src/FieldApi.ts

View check run for this annotation

Codecov / codecov/patch

packages/form-core/src/FieldApi.ts#L1873-L1874

Added lines #L1873 - L1874 were not covered by tests
})
this.promises.listeners.change = debouncePromise

Check warning on line 1876 in packages/form-core/src/FieldApi.ts

View check run for this annotation

Codecov / codecov/patch

packages/form-core/src/FieldApi.ts#L1876

Added line #L1876 was not covered by tests
} else {
const promise = this.options.listeners?.onChangeAsync?.({
value: this.state.value,
fieldApi: this,
})

if (promise) {
promise.finally(() => {
this.promises.listeners.change = null

Check warning on line 1885 in packages/form-core/src/FieldApi.ts

View check run for this annotation

Codecov / codecov/patch

packages/form-core/src/FieldApi.ts#L1884-L1885

Added lines #L1884 - L1885 were not covered by tests
})
this.promises.listeners.change = promise

Check warning on line 1887 in packages/form-core/src/FieldApi.ts

View check run for this annotation

Codecov / codecov/patch

packages/form-core/src/FieldApi.ts#L1887

Added line #L1887 was not covered by tests
}
}
}

collapseFieldOnChangeAsync = () => {
if (this.timeoutIds.listeners.change) {
clearTimeout(this.timeoutIds.listeners.change)

Check warning on line 1894 in packages/form-core/src/FieldApi.ts

View check run for this annotation

Codecov / codecov/patch

packages/form-core/src/FieldApi.ts#L1894

Added line #L1894 was not covered by tests
}
this.collapseController.abort()
}
}

function normalizeError(rawError?: ValidationError) {
Expand Down
19 changes: 19 additions & 0 deletions packages/form-core/src/FormApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1306,6 +1306,24 @@
return fieldErrorMapMap.flat()
}

collapseAllFieldAsyncOnChange = async () => {
const proimises: Promise<void>[] = []
batch(() => {
void (Object.values(this.fieldInfo) as FieldInfo<any>[]).forEach(
(field) => {
if (!field.instance) return
const promise = field.instance.promises.listeners.change
field.instance.collapseFieldOnChangeAsync()
if (promise) {
proimises.push(promise)
}

Check warning on line 1319 in packages/form-core/src/FormApi.ts

View check run for this annotation

Codecov / codecov/patch

packages/form-core/src/FormApi.ts#L1319

Added line #L1319 was not covered by tests
},
)
})

await Promise.all(proimises)
}

/**
* Validates the children of a specified array in the form starting from a given index until the end using the correct handlers for a given validation type.
*/
Expand Down Expand Up @@ -1770,6 +1788,7 @@
this.baseStore.setState((prev) => ({ ...prev, isSubmitting: false }))
}

await this.collapseAllFieldAsyncOnChange()
await this.validateAllFields('submit')

if (!this.state.isFieldsValid) {
Expand Down