diff --git a/.changeset/mosaic-use-form.md b/.changeset/mosaic-use-form.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-use-form.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/components/form/form-submit-error.ts b/packages/mosaic/src/components/form/form-submit-error.ts new file mode 100644 index 00000000000..de83640f8c9 --- /dev/null +++ b/packages/mosaic/src/components/form/form-submit-error.ts @@ -0,0 +1,25 @@ +export type FieldFeedbackType = 'error' | 'warning' | 'success' | 'info'; + +export interface FieldFeedback { + type: FieldFeedbackType; + message: string; +} + +export type FormFieldErrors = Partial>; + +export interface FormError { + message?: string; + fields?: FormFieldErrors; +} + +export class FormSubmitError> extends Error { + readonly banner: string | undefined; + readonly fields: FormFieldErrors | undefined; + + constructor({ message, fields }: FormError) { + super(message ?? Object.values(fields ?? {}).join(' ')); + this.name = 'FormSubmitError'; + this.banner = message; + this.fields = fields; + } +} diff --git a/packages/mosaic/src/components/form/form.machine.ts b/packages/mosaic/src/components/form/form.machine.ts new file mode 100644 index 00000000000..6cca449f303 --- /dev/null +++ b/packages/mosaic/src/components/form/form.machine.ts @@ -0,0 +1,230 @@ +import { setup } from '../../machine/setup'; +import type { TransitionResult } from '../../machine/types'; +import { keysOf, mapKeys } from '../../utils/object'; +import type { FieldFeedback, FormError, FormFieldErrors } from './form-submit-error'; +import { FormSubmitError } from './form-submit-error'; + +export type FieldValidator = ( + value: TValue, + values: TValues, +) => FieldFeedback | undefined; + +export type AsyncFieldValidator = ( + value: TValue, + values: TValues, +) => Promise; + +export interface FieldConfig { + validate?: FieldValidator; + validateAsync?: AsyncFieldValidator; +} + +export type FieldsConfig = { [K in keyof TValues]?: FieldConfig }; + +export interface AsyncFieldState { + value: unknown; + feedback: FieldFeedback | undefined; + pending: boolean; +} + +export interface FormDeps { + initialValues: TValues; + fields: FieldsConfig | undefined; + onSubmit: (values: TValues) => Promise; + canSubmit: (values: TValues) => boolean; + fallbackMessage: string; +} + +export interface FormContext extends FormDeps { + values: TValues; + baseline: TValues | undefined; + touched: Partial>; + async: Partial>; + error: FormError | undefined; + submitQueued: boolean; +} + +export type FormEvent = + | { type: 'CHANGE'; name: keyof TValues; value: TValues[keyof TValues] } + | { type: 'TOUCH'; name: keyof TValues } + | { type: 'VALIDATED'; name: keyof TValues; value: unknown; feedback: FieldFeedback | undefined } + | { type: 'SUBMIT' } + | { type: 'RESET'; values?: TValues }; + +export function initialOf(context: FormContext): TValues { + return context.baseline ?? context.initialValues; +} + +function syncFeedback( + context: FormContext, + name: keyof TValues, +): FieldFeedback | undefined { + return context.fields?.[name]?.validate?.(context.values[name], context.values); +} + +function settledAsyncFeedback( + context: FormContext, + name: keyof TValues, +): FieldFeedback | undefined { + const state = context.async[name]; + return state?.pending === true ? undefined : state?.feedback; +} + +export function validatorFeedback( + context: FormContext, + name: keyof TValues, +): FieldFeedback | undefined { + return syncFeedback(context, name) ?? context.async[name]?.feedback; +} + +export function fieldFeedback( + context: FormContext, + name: keyof TValues, +): FieldFeedback | undefined { + const submitError = context.error?.fields?.[name]; + return submitError === undefined ? validatorFeedback(context, name) : { type: 'error', message: submitError }; +} + +export function firstInvalid(context: FormContext): keyof TValues | undefined { + return keysOf(context.values).find( + name => (syncFeedback(context, name) ?? settledAsyncFeedback(context, name))?.type === 'error', + ); +} + +export function isValid(context: FormContext): boolean { + return context.canSubmit(context.values) && firstInvalid(context) === undefined; +} + +function isValidating(context: FormContext): boolean { + return keysOf(context.values).some(name => context.async[name]?.pending === true); +} + +function submitOrStay( + context: FormContext, + patch: Partial>, +): TransitionResult, FormState> { + const next = { ...context, ...patch }; + if (!isValid(next)) { + return { context: { ...patch, submitQueued: false } }; + } + if (isValidating(next)) { + return { context: { ...patch, submitQueued: true } }; + } + return { target: 'submitting', context: { ...patch, submitQueued: false, error: undefined } }; +} + +function displayableFields( + context: FormContext, + fields: FormFieldErrors | undefined, +): FormFieldErrors | undefined { + if (fields === undefined) { + return undefined; + } + const result: FormFieldErrors = {}; + for (const name of keysOf(context.values)) { + const message = fields[name]; + if (message !== undefined && message !== '') { + result[name] = message; + } + } + return result; +} + +function toFormError(cause: unknown, context: FormContext): FormError { + const error: FormError = + cause instanceof FormSubmitError + ? { message: cause.banner, fields: displayableFields(context, cause.fields) } + : cause instanceof Error + ? { message: cause.message } + : {}; + const visible = (error.message ?? '') !== '' || keysOf(error.fields ?? {}).length > 0; + return visible ? error : { ...error, message: context.fallbackMessage }; +} + +function withoutField( + error: FormError | undefined, + name: keyof TValues, +): FormError | undefined { + if (error?.fields === undefined) { + return error; + } + const fields = { ...error.fields }; + delete fields[name]; + return { ...error, fields }; +} + +function asyncStateFor( + context: FormContext, + name: keyof TValues, + value: TValues[keyof TValues], +): AsyncFieldState | undefined { + if (context.fields?.[name]?.validateAsync === undefined || value === initialOf(context)[name]) { + return undefined; + } + return { value, feedback: context.async[name]?.feedback, pending: true }; +} + +type FormState = 'editing' | 'submitting'; + +export function createFormMachine(deps: FormDeps) { + const { createMachine, assign, fromPromise } = setup, FormEvent>(); + + return createMachine({ + id: 'form', + initial: 'editing', + context: { + ...deps, + values: deps.initialValues, + baseline: undefined, + touched: {}, + async: {}, + error: undefined, + submitQueued: false, + }, + states: { + editing: { + on: { + CHANGE: ({ context, event }) => ({ + context: { + values: { ...context.values, [event.name]: event.value }, + async: { ...context.async, [event.name]: asyncStateFor(context, event.name, event.value) }, + error: withoutField(context.error, event.name), + submitQueued: false, + }, + }), + TOUCH: ({ context, event }) => ({ context: { touched: { ...context.touched, [event.name]: true } } }), + VALIDATED: ({ context, event }) => { + if (context.async[event.name]?.value !== event.value) { + return undefined; + } + const async = { + ...context.async, + [event.name]: { value: event.value, feedback: event.feedback, pending: false }, + }; + return context.submitQueued ? submitOrStay(context, { async }) : { context: { async } }; + }, + SUBMIT: ({ context }) => submitOrStay(context, { touched: mapKeys(context.values, (): true => true) }), + RESET: ({ context, event }) => ({ + context: { + values: event.values ?? context.initialValues, + baseline: event.values, + touched: {}, + async: {}, + error: undefined, + submitQueued: false, + }, + }), + }, + }, + submitting: { + invoke: fromPromise(async ctx => ctx.onSubmit(ctx.values), { + onDone: 'editing', + onError: { + target: 'editing', + actions: assign((ctx, e) => ({ error: toFormError(e.error, ctx) })), + }, + }), + }, + }, + }); +} diff --git a/packages/mosaic/src/components/form/form.messages.ts b/packages/mosaic/src/components/form/form.messages.ts new file mode 100644 index 00000000000..96001b60ccb --- /dev/null +++ b/packages/mosaic/src/components/form/form.messages.ts @@ -0,0 +1,3 @@ +export const formMessages = { + error: 'Something went wrong. Please try again.', +}; diff --git a/packages/mosaic/src/components/form/index.ts b/packages/mosaic/src/components/form/index.ts new file mode 100644 index 00000000000..9728b9ad829 --- /dev/null +++ b/packages/mosaic/src/components/form/index.ts @@ -0,0 +1,12 @@ +export type { AsyncFieldValidator, FieldConfig, FieldsConfig, FieldValidator } from './form.machine'; +export { FormSubmitError } from './form-submit-error'; +export type { FieldFeedback, FieldFeedbackType, FormError, FormFieldErrors } from './form-submit-error'; +export { useForm } from './use-form'; +export type { + ControlledField, + FormField, + RegisteredField, + TextFieldName, + UseFormOptions, + UseFormResult, +} from './use-form'; diff --git a/packages/mosaic/src/components/form/use-form.edit-password.test.ts b/packages/mosaic/src/components/form/use-form.edit-password.test.ts new file mode 100644 index 00000000000..64f9c021936 --- /dev/null +++ b/packages/mosaic/src/components/form/use-form.edit-password.test.ts @@ -0,0 +1,131 @@ +import { act, renderHook } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { FieldFeedback } from './form-submit-error'; +import { FormSubmitError } from './form-submit-error'; +import { useForm } from './use-form'; + +const flush = () => new Promise(resolve => setTimeout(resolve, 0)); + +interface EditPasswordValues { + currentPassword: string; + newPassword: string; + confirmPassword: string; +} + +async function checkStrength(password: string): Promise { + await Promise.resolve(); + if (password.length < 8) { + return { type: 'error', message: 'Your password must contain 8 or more characters.' }; + } + if (!/[0-9]/.test(password)) { + return { type: 'warning', message: 'Your password works, but could be stronger.' }; + } + return { type: 'success', message: 'Your password meets all the necessary requirements.' }; +} + +function useEditPasswordForm(onSubmit: (values: EditPasswordValues) => Promise) { + return useForm({ + initialValues: { currentPassword: '', newPassword: '', confirmPassword: '' }, + fields: { + newPassword: { validateAsync: checkStrength }, + confirmPassword: { + validate: (value, values) => { + if (value === '') { + return undefined; + } + return value === values.newPassword + ? { type: 'success', message: 'Passwords match.' } + : { type: 'error', message: 'Passwords do not match.' }; + }, + }, + }, + onSubmit, + }); +} + +describe('useForm: edit password', () => { + it('walks a user from a weak password to a saved one', async () => { + const onSubmit = vi.fn(() => Promise.resolve()); + const { result } = renderHook(() => useEditPasswordForm(onSubmit)); + + act(() => result.current.setValue('currentPassword', 'old-secret')); + act(() => result.current.setValue('newPassword', 'short')); + await act(flush); + expect(result.current.fields.newPassword.feedback).toBeUndefined(); + expect(result.current.canSubmit).toBe(false); + + act(() => result.current.touch('newPassword')); + expect(result.current.fields.newPassword.feedback).toEqual({ + type: 'error', + message: 'Your password must contain 8 or more characters.', + }); + + act(() => result.current.setValue('newPassword', 'longenough')); + expect(result.current.fields.newPassword.isValidating).toBe(true); + await act(flush); + expect(result.current.fields.newPassword.feedback).toEqual({ + type: 'warning', + message: 'Your password works, but could be stronger.', + }); + + act(() => result.current.setValue('newPassword', 'longenough1')); + await act(flush); + expect(result.current.fields.newPassword.feedback).toEqual({ + type: 'success', + message: 'Your password meets all the necessary requirements.', + }); + + act(() => result.current.setValue('confirmPassword', 'longenough')); + expect(result.current.fields.confirmPassword.feedback).toBeUndefined(); + expect(result.current.canSubmit).toBe(false); + act(() => result.current.setValue('confirmPassword', 'longenough1')); + expect(result.current.fields.confirmPassword.feedback).toEqual({ type: 'success', message: 'Passwords match.' }); + expect(result.current.canSubmit).toBe(true); + + act(() => result.current.submit()); + expect(onSubmit).toHaveBeenCalledWith({ + currentPassword: 'old-secret', + newPassword: 'longenough1', + confirmPassword: 'longenough1', + }); + }); + + it('surfaces every error at once when the user submits early', () => { + const onSubmit = vi.fn(() => Promise.resolve()); + const { result } = renderHook(() => useEditPasswordForm(onSubmit)); + act(() => result.current.setValue('newPassword', 'abc')); + act(() => result.current.setValue('confirmPassword', 'abd')); + act(() => result.current.submit()); + expect(onSubmit).not.toHaveBeenCalled(); + expect(result.current.fields.confirmPassword.feedback).toEqual({ + type: 'error', + message: 'Passwords do not match.', + }); + }); + + it('shows the server rejection on the banner and under the field the model names', async () => { + const onSubmit = vi.fn(() => + Promise.reject( + new FormSubmitError({ + message: 'Password could not be changed.', + fields: { currentPassword: 'Incorrect password.' }, + }), + ), + ); + const { result } = renderHook(() => useEditPasswordForm(onSubmit)); + act(() => result.current.setValue('currentPassword', 'wrong')); + act(() => result.current.setValue('newPassword', 'longenough1')); + await act(flush); + act(() => result.current.setValue('confirmPassword', 'longenough1')); + await act(async () => { + result.current.submit(); + await flush(); + }); + expect(result.current.error).toBe('Password could not be changed.'); + expect(result.current.fields.currentPassword.feedback).toEqual({ type: 'error', message: 'Incorrect password.' }); + act(() => result.current.setValue('currentPassword', 'right')); + expect(result.current.fields.currentPassword.feedback).toBeUndefined(); + expect(result.current.canSubmit).toBe(true); + }); +}); diff --git a/packages/mosaic/src/components/form/use-form.test.ts b/packages/mosaic/src/components/form/use-form.test.ts new file mode 100644 index 00000000000..0c7a11fbcf0 --- /dev/null +++ b/packages/mosaic/src/components/form/use-form.test.ts @@ -0,0 +1,646 @@ +import { act, renderHook } from '@testing-library/react'; +import { describe, expect, expectTypeOf, it, vi } from 'vitest'; + +import type { FieldFeedback } from './form-submit-error'; +import { FormSubmitError } from './form-submit-error'; +import { useForm } from './use-form'; + +const flush = () => new Promise(resolve => setTimeout(resolve, 0)); +const resolved = () => Promise.resolve(); + +function deferred() { + let resolve: (value: T) => void = () => undefined; + const promise = new Promise(res => { + resolve = res; + }); + return { promise, resolve }; +} + +describe('useForm', () => { + it('starts from initialValues and updates one value at a time', () => { + const { result } = renderHook(() => useForm({ initialValues: { username: 'alex', bio: '' }, onSubmit: resolved })); + expect(result.current.values).toEqual({ username: 'alex', bio: '' }); + expect(result.current.id).toEqual(expect.any(String)); + act(() => result.current.setValue('bio', 'hello')); + expect(result.current.values).toEqual({ username: 'alex', bio: 'hello' }); + }); + + it('types values, fields, setValue, validators and reset from initialValues', () => { + const { result } = renderHook(() => + useForm({ + initialValues: { username: 'alex', age: 1 }, + fields: { + age: { + validate: (value, values) => { + expectTypeOf(value).toEqualTypeOf(); + expectTypeOf(values).toEqualTypeOf<{ username: string; age: number }>(); + return undefined; + }, + }, + }, + onSubmit: resolved, + }), + ); + expectTypeOf(result.current.values).toEqualTypeOf<{ username: string; age: number }>(); + expectTypeOf(result.current.setValue).parameter(0).toEqualTypeOf<'username' | 'age'>(); + expectTypeOf(result.current.touch).parameter(0).toEqualTypeOf<'username' | 'age'>(); + expectTypeOf(result.current.register).parameter(0).toEqualTypeOf<'username'>(); + expectTypeOf(result.current.control).parameter(0).toEqualTypeOf<'username' | 'age'>(); + expectTypeOf(result.current.fields.age.feedback).toEqualTypeOf(); + expectTypeOf(result.current.error).toEqualTypeOf(); + expectTypeOf(result.current.reset).parameter(0).toEqualTypeOf<{ username: string; age: number } | undefined>(); + }); + + it('submits the current values once and ignores submits while pending', async () => { + const request = deferred(); + const onSubmit = vi.fn(async (_values: { username: string }) => { + await request.promise; + }); + const { result } = renderHook(() => useForm({ initialValues: { username: 'alex' }, onSubmit })); + act(() => result.current.setValue('username', 'alexc')); + act(() => result.current.submit()); + expect(result.current.isSubmitting).toBe(true); + expect(result.current.canSubmit).toBe(false); + act(() => result.current.submit()); + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit).toHaveBeenCalledWith({ username: 'alexc' }); + await act(async () => { + request.resolve(); + await flush(); + }); + expect(result.current.isSubmitting).toBe(false); + expect(result.current.error).toBeUndefined(); + }); + + it('submits a value set in the same tick', () => { + const onSubmit = vi.fn(resolved); + const { result } = renderHook(() => useForm({ initialValues: { code: '' }, onSubmit })); + act(() => { + result.current.setValue('code', '123456'); + result.current.submit(); + }); + expect(onSubmit).toHaveBeenCalledWith({ code: '123456' }); + }); + + it('maps FormSubmitError onto the form message and field feedback, clearing the field on change', async () => { + const { result } = renderHook(() => + useForm({ + initialValues: { username: 'alex' }, + onSubmit: () => + Promise.reject(new FormSubmitError({ message: 'Could not save', fields: { username: 'Taken' } })), + }), + ); + await act(async () => { + result.current.submit(); + await flush(); + }); + expect(result.current.error).toBe('Could not save'); + expect(result.current.fields.username.feedback).toEqual({ type: 'error', message: 'Taken' }); + act(() => result.current.setValue('username', 'alexc')); + expect(result.current.error).toBe('Could not save'); + expect(result.current.fields.username.feedback).toBeUndefined(); + }); + + it('maps a fields-only FormSubmitError onto field feedback with no form message', async () => { + const failure = new FormSubmitError({ fields: { username: 'Taken', bio: 'Too long' } }); + expect(failure.message).toBe('Taken Too long'); + const { result } = renderHook(() => + useForm({ initialValues: { username: 'alex', bio: '' }, onSubmit: () => Promise.reject(failure) }), + ); + await act(async () => { + result.current.submit(); + await flush(); + }); + expect(result.current.error).toBeUndefined(); + expect(result.current.fields.username.feedback).toEqual({ type: 'error', message: 'Taken' }); + expect(result.current.fields.bio.feedback).toEqual({ type: 'error', message: 'Too long' }); + }); + + it('shows only the message for a plain Error and a generic message otherwise', async () => { + const plain = renderHook(() => + useForm({ initialValues: { username: '' }, onSubmit: () => Promise.reject(new Error('Nope')) }), + ); + await act(async () => { + plain.result.current.submit(); + await flush(); + }); + expect(plain.result.current.error).toBe('Nope'); + + const cause: unknown = 'boom'; + const unknown = renderHook(() => + useForm({ + initialValues: { username: '' }, + onSubmit: async () => { + await Promise.resolve(); + throw cause; + }, + }), + ); + await act(async () => { + unknown.result.current.submit(); + await flush(); + }); + expect(unknown.result.current.error).toBe('Something went wrong. Please try again.'); + }); + + it('falls back to the generic message when a submit error has nothing to show', async () => { + const empty = renderHook(() => + useForm({ initialValues: { username: '' }, onSubmit: () => Promise.reject(new Error()) }), + ); + await act(async () => { + empty.result.current.submit(); + await flush(); + }); + expect(empty.result.current.error).toBe('Something went wrong. Please try again.'); + + const blank = renderHook(() => + useForm({ initialValues: { username: '' }, onSubmit: () => Promise.reject(new FormSubmitError({})) }), + ); + await act(async () => { + blank.result.current.submit(); + await flush(); + }); + expect(blank.result.current.error).toBe('Something went wrong. Please try again.'); + + const undisplayable = renderHook(() => + useForm({ + initialValues: { username: '' }, + onSubmit: () => Promise.reject(new FormSubmitError({ fields: { username: '', server: 'Nope' } })), + }), + ); + await act(async () => { + undisplayable.result.current.submit(); + await flush(); + }); + expect(undisplayable.result.current.error).toBe('Something went wrong. Please try again.'); + expect(undisplayable.result.current.fields.username.feedback).toBeUndefined(); + }); + + it('recovers from an onSubmit that throws synchronously', async () => { + const { result } = renderHook(() => + useForm({ + initialValues: { username: '' }, + onSubmit: () => { + throw new Error('Nope'); + }, + }), + ); + await act(async () => { + result.current.submit(); + await flush(); + }); + expect(result.current.isSubmitting).toBe(false); + expect(result.current.error).toBe('Nope'); + }); + + it('reads canSubmit and validators from the current render', () => { + const { result, rerender } = renderHook( + ({ enabled }: { enabled: boolean }) => + useForm({ initialValues: { username: '' }, onSubmit: resolved, canSubmit: () => enabled }), + { initialProps: { enabled: true } }, + ); + expect(result.current.canSubmit).toBe(true); + rerender({ enabled: false }); + expect(result.current.canSubmit).toBe(false); + }); + + it('clears the message on the next submit', async () => { + let fail = true; + const { result } = renderHook(() => + useForm({ + initialValues: { username: '' }, + onSubmit: () => (fail ? Promise.reject(new Error('Nope')) : Promise.resolve()), + }), + ); + await act(async () => { + result.current.submit(); + await flush(); + }); + expect(result.current.error).toBe('Nope'); + fail = false; + act(() => result.current.submit()); + expect(result.current.error).toBeUndefined(); + }); + + it('hides validator errors until the field is touched, then updates them live', () => { + const onSubmit = vi.fn(resolved); + const { result } = renderHook(() => + useForm({ + initialValues: { password: '' }, + fields: { + password: { + validate: value => (value.length < 8 ? { type: 'error', message: 'Too short' } : undefined), + }, + }, + onSubmit, + }), + ); + act(() => result.current.setValue('password', 'abc')); + expect(result.current.fields.password.feedback).toBeUndefined(); + expect(result.current.fields.password.touched).toBe(false); + expect(result.current.canSubmit).toBe(false); + act(() => result.current.touch('password')); + expect(result.current.fields.password.touched).toBe(true); + expect(result.current.fields.password.feedback).toEqual({ type: 'error', message: 'Too short' }); + act(() => result.current.setValue('password', 'abcdefgh')); + expect(result.current.fields.password.feedback).toBeUndefined(); + expect(result.current.canSubmit).toBe(true); + }); + + it('shows success, warning and info feedback immediately', () => { + const { result } = renderHook(() => + useForm({ + initialValues: { password: '', confirm: '' }, + fields: { + password: { validate: value => (value ? { type: 'warning', message: 'Could be stronger' } : undefined) }, + confirm: { + validate: (value, values) => + value === '' ? undefined : { type: value === values.password ? 'success' : 'error', message: 'Match?' }, + }, + }, + onSubmit: resolved, + }), + ); + act(() => result.current.setValue('password', 'a')); + expect(result.current.fields.password.feedback).toEqual({ type: 'warning', message: 'Could be stronger' }); + act(() => result.current.setValue('confirm', 'a')); + expect(result.current.fields.confirm.feedback).toEqual({ type: 'success', message: 'Match?' }); + act(() => result.current.setValue('password', 'ab')); + expect(result.current.fields.confirm.feedback).toBeUndefined(); + expect(result.current.canSubmit).toBe(false); + }); + + it('touches every field on submit and does not call onSubmit while a validator fails', () => { + const onSubmit = vi.fn(resolved); + const { result } = renderHook(() => + useForm({ + initialValues: { a: '', b: '' }, + fields: { a: { validate: () => ({ type: 'error', message: 'Bad' }) } }, + onSubmit, + }), + ); + act(() => result.current.submit()); + expect(onSubmit).not.toHaveBeenCalled(); + expect(result.current.fields.a.touched).toBe(true); + expect(result.current.fields.b.touched).toBe(true); + expect(result.current.fields.a.feedback).toEqual({ type: 'error', message: 'Bad' }); + }); + + it('gates submit silently with canSubmit()', () => { + const onSubmit = vi.fn(resolved); + const { result } = renderHook(() => + useForm({ initialValues: { username: 'alex' }, onSubmit, canSubmit: values => values.username !== 'alex' }), + ); + expect(result.current.canSubmit).toBe(false); + expect(result.current.fields.username.feedback).toBeUndefined(); + act(() => result.current.submit()); + expect(onSubmit).not.toHaveBeenCalled(); + act(() => result.current.setValue('username', 'alexc')); + expect(result.current.canSubmit).toBe(true); + }); + + it('runs async validators on change with the latest result winning', async () => { + const checks = new Map>>(); + const validateAsync = vi.fn((value: string) => { + const check = deferred(); + checks.set(value, check); + return check.promise; + }); + const { result } = renderHook(() => + useForm({ initialValues: { password: '' }, fields: { password: { validateAsync } }, onSubmit: resolved }), + ); + expect(validateAsync).not.toHaveBeenCalled(); + expect(result.current.fields.password.isValidating).toBe(false); + act(() => result.current.setValue('password', 'a')); + act(() => result.current.setValue('password', 'ab')); + expect(validateAsync).toHaveBeenCalledTimes(2); + expect(result.current.fields.password.isValidating).toBe(true); + await act(async () => { + checks.get('ab')?.resolve({ type: 'success', message: 'Strong' }); + await flush(); + }); + expect(result.current.fields.password.feedback).toEqual({ type: 'success', message: 'Strong' }); + expect(result.current.fields.password.isValidating).toBe(false); + expect(result.current.canSubmit).toBe(true); + await act(async () => { + checks.get('a')?.resolve({ type: 'error', message: 'Weak' }); + await flush(); + }); + expect(result.current.fields.password.feedback).toEqual({ type: 'success', message: 'Strong' }); + }); + + it('keeps the last async feedback while the next check runs and lets a stale error queue a submit', async () => { + const checks = new Map>>(); + const onSubmit = vi.fn(resolved); + const { result } = renderHook(() => + useForm({ + initialValues: { username: '' }, + fields: { + username: { + validateAsync: (value: string) => { + const check = deferred(); + checks.set(value, check); + return check.promise; + }, + }, + }, + onSubmit, + }), + ); + act(() => result.current.setValue('username', 'ab')); + await act(async () => { + checks.get('ab')?.resolve({ type: 'success', message: 'Available' }); + await flush(); + }); + act(() => result.current.setValue('username', 'abc')); + expect(result.current.fields.username.isValidating).toBe(true); + expect(result.current.fields.username.feedback).toEqual({ type: 'success', message: 'Available' }); + await act(async () => { + checks.get('abc')?.resolve({ type: 'error', message: 'Taken' }); + await flush(); + }); + act(() => result.current.touch('username')); + expect(result.current.fields.username.feedback).toEqual({ type: 'error', message: 'Taken' }); + act(() => result.current.setValue('username', 'abcd')); + expect(result.current.fields.username.feedback).toEqual({ type: 'error', message: 'Taken' }); + expect(result.current.canSubmit).toBe(true); + act(() => result.current.submit()); + expect(result.current.isSubmitting).toBe(true); + await act(async () => { + checks.get('abcd')?.resolve(undefined); + await flush(); + }); + expect(onSubmit).toHaveBeenCalledWith({ username: 'abcd' }); + expect(result.current.fields.username.feedback).toBeUndefined(); + }); + + it('queues a submit while async validation is pending and runs it once the field validates', async () => { + const check = deferred(); + const onSubmit = vi.fn(resolved); + const { result } = renderHook(() => + useForm({ + initialValues: { password: '' }, + fields: { password: { validateAsync: () => check.promise } }, + onSubmit, + }), + ); + act(() => result.current.setValue('password', 'ab')); + expect(result.current.canSubmit).toBe(true); + act(() => result.current.submit()); + expect(onSubmit).not.toHaveBeenCalled(); + expect(result.current.isSubmitting).toBe(true); + expect(result.current.canSubmit).toBe(false); + await act(async () => { + check.resolve(undefined); + await flush(); + }); + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit).toHaveBeenCalledWith({ password: 'ab' }); + expect(result.current.isSubmitting).toBe(false); + }); + + it('focuses the first registered control in error when a queued submit is rejected by its check', async () => { + const check = deferred(); + const onSubmit = vi.fn(resolved); + const { result } = renderHook(() => + useForm({ + initialValues: { username: '' }, + fields: { username: { validateAsync: () => check.promise } }, + onSubmit, + }), + ); + const input = document.body.appendChild(document.createElement('input')); + result.current.register('username').ref(input); + act(() => result.current.setValue('username', 'ab')); + act(() => result.current.submit()); + expect(result.current.isSubmitting).toBe(true); + await act(async () => { + check.resolve({ type: 'error', message: 'Taken' }); + await flush(); + }); + expect(onSubmit).not.toHaveBeenCalled(); + expect(result.current.isSubmitting).toBe(false); + expect(result.current.fields.username.feedback).toEqual({ type: 'error', message: 'Taken' }); + expect(document.activeElement).toBe(input); + input.remove(); + }); + + it('drops a queued submit when the field changes or its validation fails', async () => { + const checks = new Map>>(); + const validateAsync = (value: string) => { + const check = deferred(); + checks.set(value, check); + return check.promise; + }; + const onSubmit = vi.fn(resolved); + const { result } = renderHook(() => + useForm({ initialValues: { password: '' }, fields: { password: { validateAsync } }, onSubmit }), + ); + act(() => result.current.setValue('password', 'a')); + act(() => result.current.submit()); + expect(result.current.isSubmitting).toBe(true); + act(() => result.current.setValue('password', 'ab')); + expect(result.current.isSubmitting).toBe(false); + act(() => result.current.submit()); + await act(async () => { + checks.get('ab')?.resolve({ type: 'error', message: 'Weak' }); + await flush(); + }); + expect(onSubmit).not.toHaveBeenCalled(); + expect(result.current.isSubmitting).toBe(false); + expect(result.current.fields.password.feedback).toEqual({ type: 'error', message: 'Weak' }); + }); + + it('treats a rejected async validator as no feedback', async () => { + const validateAsync = vi.fn(() => Promise.reject(new Error('Network'))); + const { result } = renderHook(() => + useForm({ initialValues: { password: '' }, fields: { password: { validateAsync } }, onSubmit: resolved }), + ); + act(() => result.current.setValue('password', 'ab')); + await act(flush); + expect(result.current.fields.password.isValidating).toBe(false); + expect(result.current.fields.password.feedback).toBeUndefined(); + expect(result.current.canSubmit).toBe(true); + }); + + it('treats an async validator that throws synchronously as no feedback', async () => { + const { result } = renderHook(() => + useForm({ + initialValues: { password: '' }, + fields: { + password: { + validateAsync: () => { + throw new Error('Nope'); + }, + }, + }, + onSubmit: resolved, + }), + ); + await act(async () => { + result.current.setValue('password', 'a'); + await flush(); + }); + expect(result.current.fields.password.isValidating).toBe(false); + expect(result.current.fields.password.feedback).toBeUndefined(); + }); + + it('skips async validation when the value returns to its initial value', async () => { + const validateAsync = vi.fn(() => Promise.resolve(undefined)); + const { result } = renderHook(() => + useForm({ initialValues: { username: 'alex' }, fields: { username: { validateAsync } }, onSubmit: resolved }), + ); + act(() => result.current.setValue('username', 'alexc')); + expect(validateAsync).toHaveBeenCalledTimes(1); + act(() => result.current.setValue('username', 'alex')); + await act(flush); + expect(validateAsync).toHaveBeenCalledTimes(1); + expect(result.current.fields.username.isValidating).toBe(false); + }); + + it('prefers a sync validator result over the async one for the same field', async () => { + const validateAsync = vi.fn(() => Promise.resolve({ type: 'success', message: 'Strong' })); + const { result } = renderHook(() => + useForm({ + initialValues: { password: '' }, + fields: { + password: { + validate: value => (value.length < 3 ? { type: 'info', message: 'Keep going' } : undefined), + validateAsync, + }, + }, + onSubmit: resolved, + }), + ); + act(() => result.current.setValue('password', 'ab')); + await act(flush); + expect(result.current.fields.password.feedback).toEqual({ type: 'info', message: 'Keep going' }); + act(() => result.current.setValue('password', 'abc')); + await act(flush); + expect(result.current.fields.password.feedback).toEqual({ type: 'success', message: 'Strong' }); + }); + + it('resets to the latest initialValues or to the given values, clearing errors, touched and feedback', async () => { + const { result, rerender } = renderHook( + ({ username }) => + useForm({ + initialValues: { username }, + fields: { username: { validate: () => ({ type: 'error', message: 'Bad' }) } }, + onSubmit: () => Promise.reject(new Error('Nope')), + }), + { initialProps: { username: 'alex' } }, + ); + act(() => result.current.setValue('username', 'draft')); + await act(async () => { + result.current.submit(); + await flush(); + }); + expect(result.current.fields.username.touched).toBe(true); + expect(result.current.fields.username.feedback).toEqual({ type: 'error', message: 'Bad' }); + rerender({ username: 'saved' }); + act(() => result.current.reset()); + expect(result.current.values).toEqual({ username: 'saved' }); + expect(result.current.error).toBeUndefined(); + expect(result.current.fields.username.touched).toBe(false); + expect(result.current.fields.username.feedback).toBeUndefined(); + act(() => result.current.reset({ username: 'given' })); + expect(result.current.values).toEqual({ username: 'given' }); + }); + + it('ignores changes and reset while submitting', async () => { + const request = deferred(); + const { result } = renderHook(() => + useForm({ + initialValues: { username: 'alex' }, + onSubmit: async () => { + await request.promise; + }, + }), + ); + act(() => result.current.submit()); + act(() => result.current.setValue('username', 'other')); + act(() => result.current.reset()); + expect(result.current.values).toEqual({ username: 'alex' }); + expect(result.current.isSubmitting).toBe(true); + await act(async () => { + request.resolve(); + await flush(); + }); + expect(result.current.isSubmitting).toBe(false); + }); + + it('registers a text control with its name, value, change and blur handlers', () => { + const { result } = renderHook(() => useForm({ initialValues: { username: 'alex' }, onSubmit: resolved })); + expect(result.current.register('username')).toMatchObject({ name: 'username', value: 'alex' }); + act(() => result.current.register('username').onChange({ target: { value: 'alexc' } })); + expect(result.current.values.username).toBe('alexc'); + expect(result.current.register('username').value).toBe('alexc'); + expect(result.current.fields.username.touched).toBe(false); + act(() => result.current.register('username').onBlur()); + expect(result.current.fields.username.touched).toBe(true); + }); + + it('controls a value-shaped field of any type with its name, value, value and blur handlers', () => { + const { result } = renderHook(() => useForm({ initialValues: { code: '', count: 0 }, onSubmit: resolved })); + expect(result.current.control('count')).toMatchObject({ name: 'count', value: 0 }); + act(() => result.current.control('count').onValueChange(2)); + expect(result.current.values.count).toBe(2); + act(() => result.current.control('code').onValueChange('123456')); + expect(result.current.control('code').value).toBe('123456'); + expect(result.current.fields.code.touched).toBe(false); + act(() => result.current.control('code').onBlur()); + expect(result.current.fields.code.touched).toBe(true); + }); + + it('focuses the first registered control with an error instead of submitting', () => { + const onSubmit = vi.fn(resolved); + const { result } = renderHook(() => + useForm({ + initialValues: { a: '', b: '' }, + fields: { b: { validate: value => (value === '' ? { type: 'error', message: 'Required' } : undefined) } }, + onSubmit, + }), + ); + const a = document.body.appendChild(document.createElement('input')); + const b = document.body.appendChild(document.createElement('input')); + result.current.register('a').ref(a); + result.current.register('b').ref(b); + act(() => result.current.submit()); + expect(onSubmit).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(b); + act(() => result.current.setValue('b', 'ok')); + a.focus(); + act(() => result.current.submit()); + expect(onSubmit).toHaveBeenCalledWith({ a: '', b: 'ok' }); + expect(document.activeElement).toBe(a); + a.remove(); + b.remove(); + }); + + it('marks fields and the form dirty against initialValues, or the values given to reset', () => { + const { result } = renderHook(() => useForm({ initialValues: { username: 'alex', bio: '' }, onSubmit: resolved })); + expect(result.current.isDirty).toBe(false); + act(() => result.current.setValue('bio', 'hi')); + expect(result.current.fields.bio.isDirty).toBe(true); + expect(result.current.fields.username.isDirty).toBe(false); + expect(result.current.isDirty).toBe(true); + act(() => result.current.setValue('bio', '')); + expect(result.current.isDirty).toBe(false); + act(() => result.current.reset({ username: 'sam', bio: 'x' })); + expect(result.current.isDirty).toBe(false); + act(() => result.current.setValue('bio', '')); + expect(result.current.fields.bio.isDirty).toBe(true); + act(() => result.current.reset()); + expect(result.current.values).toEqual({ username: 'alex', bio: '' }); + expect(result.current.isDirty).toBe(false); + }); + + it('handles a form submit event by preventing navigation and submitting', () => { + const onSubmit = vi.fn(resolved); + const { result } = renderHook(() => useForm({ initialValues: { username: 'alex' }, onSubmit })); + const preventDefault = vi.fn(); + act(() => result.current.handleSubmit({ preventDefault })); + expect(preventDefault).toHaveBeenCalledOnce(); + expect(onSubmit).toHaveBeenCalledWith({ username: 'alex' }); + }); +}); diff --git a/packages/mosaic/src/components/form/use-form.ts b/packages/mosaic/src/components/form/use-form.ts new file mode 100644 index 00000000000..f3cb0a7082c --- /dev/null +++ b/packages/mosaic/src/components/form/use-form.ts @@ -0,0 +1,188 @@ +import { useCallback, useId, useRef } from 'react'; + +import { useMessages } from '../../localization'; +import type { StateMachine } from '../../machine/types'; +import { useMachine } from '../../machine/useMachine'; +import { keysOf, mapKeys } from '../../utils/object'; +import type { FieldsConfig, FormContext, FormEvent } from './form.machine'; +import { createFormMachine, fieldFeedback, firstInvalid, initialOf, isValid } from './form.machine'; +import type { FieldFeedback } from './form-submit-error'; + +export interface UseFormOptions { + initialValues: TValues; + fields?: FieldsConfig; + onSubmit: (values: TValues) => Promise; + canSubmit?: (values: TValues) => boolean; +} + +export interface FormField { + feedback: FieldFeedback | undefined; + isValidating: boolean; + touched: boolean; + isDirty: boolean; +} + +export type TextFieldName = { + [K in keyof TValues]: string extends TValues[K] ? K : never; +}[keyof TValues] & + string; + +export interface RegisteredField { + name: K; + value: TValues[K]; + onChange: (event: { target: { value: TValues[K] } }) => void; + onBlur: () => void; + ref: (element: HTMLElement | null) => void; +} + +export interface ControlledField { + name: K; + value: TValues[K]; + onValueChange: (value: TValues[K]) => void; + onBlur: () => void; + ref: (element: HTMLElement | null) => void; +} + +export interface UseFormResult { + id: string; + values: TValues; + fields: Record; + error: string | undefined; + isSubmitting: boolean; + isDirty: boolean; + canSubmit: boolean; + register: >(name: K) => RegisteredField; + control: (name: K) => ControlledField; + setValue: (name: K, value: TValues[K]) => void; + touch: (name: keyof TValues) => void; + submit: () => void; + handleSubmit: (event: { preventDefault: () => void }) => void; + reset: (values?: TValues) => void; +} + +type ElementRef = (element: HTMLElement | null) => void; + +const always = () => true; + +export function useForm(options: UseFormOptions): UseFormResult { + const id = useId(); + const m = useMessages('form'); + const elements = useRef(new Map()); + const refs = useRef(new Map()); + + const deps = { + initialValues: options.initialValues, + fields: options.fields, + onSubmit: options.onSubmit, + canSubmit: options.canSubmit ?? always, + fallbackMessage: m.error, + }; + const machineRef = useRef, FormEvent> | null>(null); + if (machineRef.current === null) { + machineRef.current = createFormMachine(deps); + } + const [snapshot, send, actor] = useMachine(machineRef.current, { context: deps }); + const context = { ...snapshot.context, ...deps }; + const { values } = context; + + const focusFirstInvalid = useCallback(() => { + const invalid = firstInvalid(actor.getSnapshot().context); + if (invalid !== undefined) { + elements.current.get(invalid)?.focus(); + } + }, [actor]); + const setValue = useCallback( + (name: K, value: TValues[K]) => { + send({ type: 'CHANGE', name, value }); + const { async, fields, values: next } = actor.getSnapshot().context; + const validateAsync = fields?.[name]?.validateAsync; + if (validateAsync === undefined || async[name]?.pending !== true || async[name].value !== value) { + return; + } + const settle = (feedback: FieldFeedback | undefined) => { + const { submitQueued } = actor.getSnapshot().context; + send({ type: 'VALIDATED', name, value, feedback }); + if (submitQueued) { + focusFirstInvalid(); + } + }; + void new Promise(resolve => resolve(validateAsync(value, next))).then(settle, () => + settle(undefined), + ); + }, + [actor, focusFirstInvalid, send], + ); + const touch = useCallback((name: keyof TValues) => send({ type: 'TOUCH', name }), [send]); + const refFor = useCallback((name: keyof TValues): ElementRef => { + const existing = refs.current.get(name); + if (existing !== undefined) { + return existing; + } + const ref: ElementRef = element => { + if (element === null) { + elements.current.delete(name); + } else { + elements.current.set(name, element); + } + }; + refs.current.set(name, ref); + return ref; + }, []); + const submit = useCallback(() => { + send({ type: 'SUBMIT' }); + focusFirstInvalid(); + }, [focusFirstInvalid, send]); + const handleSubmit = useCallback( + (event: { preventDefault: () => void }) => { + event.preventDefault(); + submit(); + }, + [submit], + ); + const reset = useCallback((nextValues?: TValues) => send({ type: 'RESET', values: nextValues }), [send]); + + const register = >(name: K): RegisteredField => ({ + name, + value: values[name], + onChange: event => setValue(name, event.target.value), + onBlur: () => touch(name), + ref: refFor(name), + }); + const control = (name: K): ControlledField => ({ + name, + value: values[name], + onValueChange: value => setValue(name, value), + onBlur: () => touch(name), + ref: refFor(name), + }); + + const isSubmitting = snapshot.value === 'submitting' || context.submitQueued; + const initial = initialOf(context); + const fields = mapKeys(values, (name): FormField => { + const feedback = fieldFeedback(context, name); + const touched = context.touched[name] === true; + return { + feedback: feedback?.type === 'error' && !touched ? undefined : feedback, + isValidating: context.async[name]?.pending === true, + touched, + isDirty: !Object.is(values[name], initial[name]), + }; + }); + + return { + id, + values, + fields, + error: context.error?.message, + isSubmitting, + isDirty: keysOf(values).some(name => fields[name].isDirty), + canSubmit: !isSubmitting && isValid(context), + register, + control, + setValue, + touch, + submit, + handleSubmit, + reset, + }; +} diff --git a/packages/mosaic/src/localization/registry.ts b/packages/mosaic/src/localization/registry.ts index f4a40d2c76d..222e21bd691 100644 --- a/packages/mosaic/src/localization/registry.ts +++ b/packages/mosaic/src/localization/registry.ts @@ -1,3 +1,4 @@ +import { formMessages } from '../components/form/form.messages'; import { organizationProfileMessages } from '../features/organization-profile/organization-profile.messages'; import { organizationProfileApiKeysPanelMessages } from '../features/organization-profile/organization-profile-api-keys-panel.messages'; import { organizationProfileDangerSectionMessages } from '../features/organization-profile/organization-profile-danger-section/organization-profile-danger-section.messages'; @@ -24,6 +25,7 @@ import { userProfilePasswordSectionMessages } from '../features/user-profile/use import { userProfileWeb3WalletsMessages } from '../features/user-profile/user-profile-web3-wallets.messages'; export const mosaicMessages = { + form: formMessages, organizationProfile: organizationProfileMessages, organizationProfileDangerSection: organizationProfileDangerSectionMessages, organizationProfileWorkspaceSection: organizationProfileWorkspaceSectionMessages, diff --git a/packages/mosaic/src/utils/object.ts b/packages/mosaic/src/utils/object.ts new file mode 100644 index 00000000000..7bbc1f5cea3 --- /dev/null +++ b/packages/mosaic/src/utils/object.ts @@ -0,0 +1,9 @@ +export function keysOf(value: T): (keyof T)[]; +export function keysOf(value: object): string[] { + return Object.keys(value); +} + +export function mapKeys(value: T, fn: (key: keyof T) => U): Record; +export function mapKeys(value: object, fn: (key: string) => unknown): Record { + return Object.fromEntries(Object.keys(value).map(key => [key, fn(key)])); +} diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 89807ba58c5..c8cb44fcad3 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -118,6 +118,7 @@ const docModules: Record> = { hooks: { // Headless hooks — alphabetical. 'use-data-table': dynamic(() => import('../stories/use-data-table.mdx')), + 'use-form': dynamic(() => import('../stories/use-form.mdx')), }, localization: { localization: dynamic(() => import('../stories/localization.mdx')), diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 3dd91747be2..a4b099d78bf 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -277,6 +277,7 @@ import { } from '../stories/tooltip.component.stories'; import { meta as tooltipMeta } from '../stories/tooltip.stories'; import { meta as useDataTableMeta } from '../stories/use-data-table.stories'; +import { Default as UseFormDefault, meta as useFormMeta } from '../stories/use-form.stories'; import { Combined as UserButtonCombined, meta as userButtonMeta, @@ -678,6 +679,8 @@ const scrollAreaModule: StoryModule = { const useDataTableModule: StoryModule = { meta: useDataTableMeta }; +const useFormModule: StoryModule = { meta: useFormMeta, Default: UseFormDefault }; + const localizationModule: StoryModule = { meta: localizationMeta, Overrides: LocalizationOverrides, @@ -952,6 +955,7 @@ export const registry: StoryModule[] = [ scrollAreaModule, // Hooks useDataTableModule, + useFormModule, // Localization localizationModule, ]; diff --git a/packages/swingset/src/stories/use-form.mdx b/packages/swingset/src/stories/use-form.mdx new file mode 100644 index 00000000000..cb2126ce7c9 --- /dev/null +++ b/packages/swingset/src/stories/use-form.mdx @@ -0,0 +1,160 @@ +import * as UseFormStories from './use-form.stories'; + +# useForm + +Form state for Mosaic controllers. Takes the initial values, an `onSubmit`, optional per-field validators and an optional submit gate, and returns typed values, per-field feedback, and the props each control needs. Every type is inferred from `initialValues`. + +## Example + +Try `clerk` for a taken username (the error shows once you leave the field), `error` for a server rejection, and press Enter while the availability check is still running. + + + +## Usage + +```tsx +import { useForm } from '@clerk/mosaic/components/form'; + +const form = useForm({ + initialValues: { username: 'alex', displayName: 'Alex', phoneNumber: '' }, + fields: { + username: { + validate: value => (value.length < 3 ? { type: 'error', message: 'Use at least 3 characters.' } : undefined), + validateAsync: value => checkUsername(value), + }, + }, + onSubmit: values => saveProfile(values), +}); + +const { feedback } = form.fields.username; +``` + +Wire the form element with `id` and `handleSubmit`, a text control with `register`, and a control that reports its value directly with `control`: + +```tsx +
+ {form.error ? ( + + {form.error} + + ) : null} + + + Username + + + + + {feedback?.type === 'error' ? feedback.message : null} + {feedback?.type === 'success' ? feedback.message : null} + + + + + + + Save + + +``` + +`register` accepts only fields whose value is a string. `control` accepts any field and passes the value through unchanged. A checkbox reads and writes through `values` and `setValue`: + +```tsx + form.setValue('signOutOfOtherSessions', event.target.checked)} +/> +``` + +When the view also needs its own ref on a registered control, merge them: + +```tsx +const { ref, ...control } = form.register('username'); +const mergedRef = useMergeRefs([ref, initialFocusRef]); +; +``` + +A model reports a failed save by rejecting `onSubmit` with `FormSubmitError`. `message` lands on `form.error` and each `fields` entry on that field's feedback. Either part may be omitted: fields alone show only under the fields with no banner. A plain `Error` shows only its message, and anything else shows the localized generic message. + +```ts +throw new FormSubmitError({ + message: 'Your profile could not be saved.', + fields: { username: 'That username is reserved.' }, +}); +``` + +A dialog calls `reset()` when it closes, or `reset(values)` to start from values loaded after mount. Both clear touched state, feedback, and the error, and set the baseline `isDirty` compares against. + +### Behavior + +- Error feedback shows only once a field is touched, by blur or by any submit attempt, and then updates live. Success, warning and info feedback show immediately. An untouched error still blocks submit, and a submit attempt focuses the first registered control in error, in `initialValues` key order. +- A sync `validate` is a pure function of the current values, so cross-field checks need no extra wiring. Its result takes precedence over the async one for the same field. +- An async `validateAsync` runs when its own field changes. The latest result wins, stale results drop, and the field reports `isValidating` while pending. The previous result stays visible until the next one arrives, but only a settled error blocks submit. It never runs for the initial value. A validator that rejects or throws counts as no feedback. +- A submit while a validator is pending is queued: `isSubmitting` turns on straight away and `onSubmit` runs once the last check resolves clean. Changing a field drops the queued submit. A check that fails drops it and focuses that control. +- A submit error under a field clears when that field changes. The banner persists until the next submit. +- Changes and resets are ignored while `onSubmit` is running. + +## Options + +| Option | Type | Default | Description | +| --------------- | --------------------------------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `initialValues` | `TValues` | — (required) | Starting values. Every other type is inferred from this object. Every field must be present, even when its value is `undefined`. | +| `fields` | `FieldsConfig` | — | Per-field `validate` and `validateAsync`. | +| `onSubmit` | `(values: TValues) => Promise` | — (required) | Runs on a valid submit. Reject with `FormSubmitError` to report a failure. | +| `canSubmit` | `(values: TValues) => boolean` | always `true` | Extra submit gate over the values. Blocks silently, with no field feedback. | + +### `FieldConfig` + +| Option | Type | Default | Description | +| --------------- | -------------------------------------------------------- | ------- | ------------------------------------------------- | +| `validate` | `(value, values) => FieldFeedback \| undefined` | — | Sync check, evaluated on every render. | +| `validateAsync` | `(value, values) => Promise` | — | Async check, run when this field's value changes. | + +## Return + +| Value | Type | Description | +| -------------- | ---------------------------------- | ---------------------------------------------------------------------------------------- | +| `id` | `string` | Stable id for the `
` element, so a submit button can target it from outside. | +| `values` | `TValues` | Current values. | +| `fields` | `Record` | Per-field state, see below. | +| `error` | `string \| undefined` | Banner message from the last failed submit. | +| `isSubmitting` | `boolean` | `onSubmit` is running, or a submit is queued behind async validation. | +| `isDirty` | `boolean` | Any field differs from its initial value, compared with `Object.is`. | +| `canSubmit` | `boolean` | Not submitting, no field in error, and `canSubmit(values)` is `true`. | +| `register` | `(name) => RegisteredField` | `name`, `value`, `onChange`, `onBlur` and `ref` for a text control. | +| `control` | `(name) => ControlledField` | `name`, `value`, `onValueChange`, `onBlur` and `ref` for a control with `onValueChange`. | +| `setValue` | `(name, value) => void` | Set one value. | +| `touch` | `(name) => void` | Mark one field touched. | +| `submit` | `() => void` | Touch every field and submit if valid. | +| `handleSubmit` | `(event) => void` | `preventDefault` then `submit`, for ``. | +| `reset` | `(values?) => void` | Return to `initialValues`, or to `values`, clearing touched state, feedback and errors. | + +### `FormField` + +| Field | Type | Description | +| -------------- | ---------------------------- | -------------------------------------------------------------------- | +| `feedback` | `FieldFeedback \| undefined` | The message to show. Errors are withheld until the field is touched. | +| `isValidating` | `boolean` | An async check is pending. | +| `touched` | `boolean` | Blurred or submitted at least once since the last reset. | +| `isDirty` | `boolean` | Differs from its initial value. | + +## Types + +```ts +type FieldFeedbackType = 'error' | 'warning' | 'success' | 'info'; +interface FieldFeedback { + type: FieldFeedbackType; + message: string; +} + +class FormSubmitError extends Error { + constructor(init: { message?: string; fields?: Partial> }); +} + +type TextFieldName = keys of TValues whose value is a string; +``` diff --git a/packages/swingset/src/stories/use-form.stories.tsx b/packages/swingset/src/stories/use-form.stories.tsx new file mode 100644 index 00000000000..d9be33c4568 --- /dev/null +++ b/packages/swingset/src/stories/use-form.stories.tsx @@ -0,0 +1,139 @@ +import { Banner } from '@clerk/mosaic/components/banner'; +import { SubmitButton } from '@clerk/mosaic/components/button'; +import { Field } from '@clerk/mosaic/components/field'; +import type { FieldFeedback, TextFieldName, UseFormResult } from '@clerk/mosaic/components/form'; +import { FormSubmitError, useForm } from '@clerk/mosaic/components/form'; +import { InputGroup } from '@clerk/mosaic/components/input-group'; +import * as stylex from '@stylexjs/stylex'; +import React from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +export const meta: StoryMeta = { + group: 'Hooks', + status: 'stable', + title: 'useForm', + source: 'packages/mosaic/src/components/form/use-form.ts', +}; + +const styles = stylex.create({ + form: { + display: 'grid', + gap: 16, + maxWidth: 384, + width: '100%', + }, + field: { + display: 'grid', + gap: 8, + }, + actions: { + display: 'flex', + justifyContent: 'flex-end', + }, +}); + +interface ProfileValues { + username: string; + displayName: string; +} + +const TAKEN = new Set(['clerk', 'admin']); + +function wait(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +async function checkUsername(value: string): Promise { + await wait(600); + return TAKEN.has(value) + ? { type: 'error', message: `@${value} is already taken.` } + : { type: 'success', message: `@${value} is available.` }; +} + +async function saveProfile(values: ProfileValues): Promise { + await wait(800); + if (values.username === 'error') { + throw new FormSubmitError({ + message: 'Your profile could not be saved.', + fields: { username: 'That username is reserved.' }, + }); + } +} + +function TextField({ + form, + name, + label, +}: { + form: UseFormResult; + name: TextFieldName; + label: string; +}) { + const { feedback } = form.fields[name]; + return ( + + {label} + + + + + {feedback?.type === 'error' ? feedback.message : null} + {feedback?.type === 'success' ? feedback.message : null} + + + ); +} + +export function Default() { + const form = useForm({ + initialValues: { username: 'alex', displayName: 'Alex' }, + fields: { + username: { + validate: value => (value.length < 3 ? { type: 'error', message: 'Use at least 3 characters.' } : undefined), + validateAsync: checkUsername, + }, + displayName: { + validate: value => (value.trim() === '' ? { type: 'error', message: 'Enter a display name.' } : undefined), + }, + }, + onSubmit: saveProfile, + }); + + return ( + + {form.error ? ( + + {form.error} + + ) : null} + + +
+ + Save + +
+ + ); +}