diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts index 784debedb..f6ead0e5c 100644 --- a/packages/form-core/src/FieldApi.ts +++ b/packages/form-core/src/FieldApi.ts @@ -1656,12 +1656,24 @@ export class FieldApi< value: this.state.value, fieldApi: this, }) + + this.form.options.listeners?.onBlur?.({ + fieldName: this.name, + formApi: this.form, + fieldApi: this, + }) }, debounceMs) } else { this.options.listeners?.onBlur?.({ value: this.state.value, fieldApi: this, }) + + this.form.options.listeners?.onBlur?.({ + fieldName: this.name, + formApi: this.form, + fieldApi: this, + }) } } @@ -1678,12 +1690,24 @@ export class FieldApi< value: this.state.value, fieldApi: this, }) + + this.form.options.listeners?.onChange?.({ + fieldName: this.name as never, + formApi: this.form, + fieldApi: this, + }) }, debounceMs) } else { this.options.listeners?.onChange?.({ value: this.state.value, fieldApi: this, }) + + this.form.options.listeners?.onChange?.({ + fieldName: this.name as never, + formApi: this.form, + fieldApi: this, + }) } } } diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index 933808307..14d544861 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -21,7 +21,12 @@ import type { StandardSchemaV1Issue, TStandardSchemaValidatorValue, } from './standardSchemaValidator' -import type { AnyFieldMeta, AnyFieldMetaBase, FieldApi } from './FieldApi' +import type { + AnyFieldApi, + AnyFieldMeta, + AnyFieldMetaBase, + FieldApi, +} from './FieldApi' import type { FormValidationError, FormValidationErrorMap, @@ -232,6 +237,84 @@ export interface FormTransform< deps: unknown[] } +export interface FormListeners< + 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, + TField extends DeepKeys, + TSubmitMeta = never, +> { + onChange?: (props: { + fieldName: keyof TFormData + formApi: FormApi< + TFormData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnServer, + TSubmitMeta + > + fieldApi: AnyFieldApi + }) => void + + onBlur?: (props: { + fieldName: TField + formApi: FormApi< + TFormData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnServer, + TSubmitMeta + > + fieldApi: AnyFieldApi + }) => void + + onMount?: (props: { + formApi: FormApi< + TFormData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnServer, + TSubmitMeta + > + }) => void + + onSubmit?: (props: { + formApi: FormApi< + TFormData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnServer, + TSubmitMeta + > + }) => void +} + /** * An object representing the options for a form. */ @@ -298,6 +381,23 @@ export interface FormOptions< */ onSubmitMeta?: TSubmitMeta + /** + * form level listeners + */ + listeners?: FormListeners< + TFormData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnServer, + DeepKeys, + TSubmitMeta + > + /** * A function to be called when the form is submitted, what should happen once the user submits a valid form returns `any` or a promise `Promise` */ @@ -1064,6 +1164,9 @@ export class FormApi< cleanupFieldMetaDerived() cleanupStoreDerived() } + + this.options.listeners?.onMount?.({ formApi: this }) + const { onMount } = this.options.validators || {} if (!onMount) return cleanup this.validateSync('mount') @@ -1643,6 +1746,8 @@ export class FormApi< ) }) + this.options.listeners?.onSubmit?.({ formApi: this }) + try { // Run the submit code await this.options.onSubmit?.({ diff --git a/packages/form-core/tests/FormApi.spec.ts b/packages/form-core/tests/FormApi.spec.ts index 204fa4dba..b82df2562 100644 --- a/packages/form-core/tests/FormApi.spec.ts +++ b/packages/form-core/tests/FormApi.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { FieldApi, FormApi } from '../src/index' import { sleep } from './utils' +import type { AnyFieldApi, AnyFormApi } from '../src/index' describe('form api', () => { it('should get default form state when default values are passed', () => { @@ -1955,7 +1956,153 @@ describe('form api', () => { expect(form.state.errors).toStrictEqual(['first name is required']) }) - it('should run listener onSubmit', async () => { + it('should run the form listener onSubmit', async () => { + let triggered!: string + const form = new FormApi({ + defaultValues: { + name: 'test', + }, + listeners: { + onSubmit: ({ formApi }) => { + triggered = formApi.state.values.name + }, + }, + }) + + form.mount() + await form.handleSubmit() + + expect(triggered).toStrictEqual('test') + }) + + it('should run the form listener onMount', async () => { + let triggered!: string + const form = new FormApi({ + defaultValues: { + name: 'test', + }, + listeners: { + onMount: ({ formApi }) => { + triggered = formApi.state.values.name + }, + }, + }) + + form.mount() + + expect(triggered).toStrictEqual('test') + }) + + it('should run the form listener onChange', async () => { + let fieldNameCheck!: string + let fieldApiCheck!: AnyFieldApi + let formApiCheck!: AnyFormApi + + const form = new FormApi({ + defaultValues: { + name: '', + }, + listeners: { + onChange: ({ fieldName, fieldApi, formApi }) => { + fieldNameCheck = fieldName + fieldApiCheck = fieldApi + + formApiCheck = formApi as any + }, + }, + }) + form.mount() + + const field = new FieldApi({ + form, + name: 'name', + }) + field.mount() + field.setValue('newTest') + + expect(fieldNameCheck).toStrictEqual('name') + expect(fieldApiCheck.state.value).toStrictEqual('newTest') + expect(formApiCheck.state.values.name).toStrictEqual('newTest') + }) + + it('should run the form listener onChange when the field array is changed', () => { + let arr!: any + let name!: string + + const form = new FormApi({ + defaultValues: { + items: ['one', 'two'], + age: 0, + }, + listeners: { + onChange: ({ formApi, fieldName }) => { + arr = formApi.state.values[fieldName] + name = fieldName + }, + }, + }) + form.mount() + + const field = new FieldApi({ + form, + name: 'items', + }) + field.mount() + + field.removeValue(1) + expect(arr).toStrictEqual(['one']) + expect(name).toStrictEqual('items') + + field.replaceValue(0, 'start') + expect(arr).toStrictEqual(['start']) + + field.pushValue('end') + expect(arr).toStrictEqual(['start', 'end']) + + field.insertValue(1, 'middle') + expect(arr).toStrictEqual(['start', 'middle', 'end']) + + field.swapValues(0, 2) + expect(arr).toStrictEqual(['end', 'middle', 'start']) + + field.moveValue(0, 1) + expect(arr).toStrictEqual(['middle', 'end', 'start']) + }) + + it('should run the form listener onBlur', async () => { + let fieldNameCheck!: string + let fieldApiCheck!: AnyFieldApi + let formApiCheck!: AnyFormApi + + const form = new FormApi({ + defaultValues: { + name: 'test', + age: 0, + }, + listeners: { + onBlur: ({ fieldName, fieldApi, formApi }) => { + fieldNameCheck = fieldName + fieldApiCheck = fieldApi + + formApiCheck = formApi as any + }, + }, + }) + form.mount() + + const field = new FieldApi({ + form, + name: 'name', + }) + field.mount() + field.handleBlur() + + expect(fieldNameCheck).toStrictEqual('name') + expect(fieldApiCheck.state.value).toStrictEqual('test') + expect(formApiCheck.state.values.name).toStrictEqual('test') + }) + + it('should run the field listener onSubmit', async () => { const form = new FormApi({ defaultValues: { name: 'test',