Skip to content
Draft
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
177 changes: 60 additions & 117 deletions .reports/embedded-react-sdk.api.md

Large diffs are not rendered by default.

3 changes: 0 additions & 3 deletions eslint.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,9 +178,6 @@ export default [
'src/components/**',
'src/contexts/**',
'src/helpers/**',
'src/partner-hook-utils/**',
'src/shared/**',
'src/types/**',
],
rules: {
'tsdoc-coverage/require-comment': 'error',
Expand Down
1 change: 1 addition & 0 deletions src/partner-hook-utils/collectErrors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ interface QueryWithError {
error: Error | null
}

/** @internal */
export function collectErrors(queries: QueryWithError[], submitError: SDKError | null): SDKError[] {
const queryErrors = queries
.filter((q): q is QueryWithError & { error: Error } => q.error != null)
Expand Down
67 changes: 61 additions & 6 deletions src/partner-hook-utils/composeErrorHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,27 @@ import type { SDKError } from '@/types/sdkError'
type QueryWithRefetch = Pick<UseQueryResult, 'error' | 'refetch'>

/**
* Submit-side error state to merge with query errors. From `useBaseSubmit`, destructure
* `{ error: submitError, setError: setSubmitError }` and pass `{ submitError, setSubmitError }`.
* Submit-side error state to merge into a composed {@link HookErrorHandling}.
*
* @remarks
* Pass to {@link composeErrorHandler} when a screen has its own submit state outside of
* any SDK form hook, so submit errors appear in the same error surface as query errors
* and can be cleared together with `clearSubmitError`.
*
* @public
*/
export type SubmitStateForErrorHandling = {
submitError: SDKError | null
setSubmitError: (error: SDKError | null) => void
}

/**
* Accepted input shape for {@link composeErrorHandler}: either a React Query result
* (anything with `error` and `refetch`) or another SDK hook result that exposes
* an `errorHandling` object.
*
* @public
*/
export type MixedErrorSource = QueryWithRefetch | { errorHandling: HookErrorHandling }

function isHookResultWithErrorHandling(
Expand All @@ -23,11 +36,53 @@ function isHookResultWithErrorHandling(
}

/**
* Composes `HookErrorHandling` from React Query results, optional submit state from `useBaseSubmit`,
* and/or nested SDK hook results that expose `errorHandling`.
* Merges multiple error sources into a single {@link HookErrorHandling}.
*
* @remarks
* Accepts any mix of `@gusto/embedded-api-v-2025-11-15` React Query results and SDK hook
* results that already expose an `errorHandling` object (including the value returned by
* {@link composeSubmitHandler}). Query errors are normalized to `SDKError`, nested hook
* errors are flattened in, and an optional submit-state argument adds a submit error to
* the same list.
*
* The returned `retryQueries` refetches every failed query and delegates into each nested
* hook so their retries fire too. `clearSubmitError` clears the optional submit state and
* delegates into each nested hook.
*
* Pairs with {@link composeSubmitHandler} by name only — this composes error state and
* recovery, not a submit callback.
*
* @param sources - Error sources to merge. Each entry is either a React Query result or
* an object with an `errorHandling` property.
* @param submitState - Optional screen-level submit state to fold into the result.
* @returns A single `HookErrorHandling` covering every source.
* @public
*
* @example
* ```tsx
* import { composeErrorHandler, useEmployeeDetailsForm } from '@gusto/embedded-react-sdk'
* import { useEmployeeFormsList } from '@gusto/embedded-api-v-2025-11-15/react-query/employeeFormsList'
*
* function EmployeeProfileView({ companyId, employeeId }: { companyId: string; employeeId: string }) {
* const employeeDetails = useEmployeeDetailsForm({ companyId, employeeId })
* const formsListQuery = useEmployeeFormsList({ employeeId })
*
* const errorHandling = composeErrorHandler([employeeDetails, formsListQuery])
*
* if (errorHandling.errors.length > 0) {
* return (
* <div role="alert">
* {errorHandling.errors.map((error, i) => (
* <p key={i}>{error.message}</p>
* ))}
* <button onClick={errorHandling.retryQueries}>Retry</button>
* </div>
* )
* }
*
* Pairs with `composeSubmitHandler` by name: this composes **error state and recovery**; it is not a
* submit callback.
* return null
* }
* ```
*/
export function composeErrorHandler(
sources: MixedErrorSource[],
Expand Down
18 changes: 18 additions & 0 deletions src/partner-hook-utils/form/FormFieldsMetadataContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,31 @@ import { createContext, useContext } from 'react'
import type { FieldsMetadata } from '../types'
import type { SDKError } from '@/types/sdkError'

/**
* Value published by {@link FormFieldsMetadataProvider} to descendant hook fields.
*
* @internal
*/
export interface FormFieldsMetadataContextValue {
metadata: FieldsMetadata
errors: SDKError[]
}

/**
* React context that carries form field metadata and current error state down to
* descendant hook fields rendered inside an SDK form provider.
*
* @internal
*/
export const FormFieldsMetadataContext = createContext<FormFieldsMetadataContextValue | null>(null)

/**
* Reads the nearest {@link FormFieldsMetadataContext} value, or `null` when no
* provider is mounted above.
*
* @returns The provider value when one is mounted, otherwise `null`.
* @internal
*/
export function useFormFieldsMetadataContext(): FormFieldsMetadataContextValue | null {
return useContext(FormFieldsMetadataContext)
}
7 changes: 7 additions & 0 deletions src/partner-hook-utils/form/FormFieldsMetadataProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ interface FormFieldsMetadataProviderProps {
children: ReactNode
}

/**
* Publishes field metadata and current form errors via {@link FormFieldsMetadataContext}
* so descendant hook fields can resolve their requiredness, options, and inline
* error messages without prop drilling.
*
* @internal
*/
export function FormFieldsMetadataProvider({
metadata,
errors,
Expand Down
20 changes: 20 additions & 0 deletions src/partner-hook-utils/form/SDKFormProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,26 @@ interface SDKFormProviderProps<
children: ReactNode
}

/**
* Wraps form fields with the React context they need to discover form state,
* field metadata, and error syncing from a single form hook result.
*
* @remarks
* Fields rendered inside `SDKFormProvider` no longer need an explicit
* `formHookResult` prop — they read metadata, control, and error state from
* context instead. Server-side field errors (e.g. 422 responses) are
* automatically synced onto the corresponding form fields so they surface
* alongside client-side validation errors.
*
* When the same field is also passed `formHookResult` as a prop, the prop wins
* and the surrounding provider is ignored. Avoid that combination.
*
* @typeParam TFormData - The shape of values managed by the underlying form hook.
* @typeParam TFieldsMetadata - The map of field names to their metadata entries.
* @param props - The wrapper props, including the `formHookResult` from a form hook.
* @returns A React element that scopes form context to its children.
* @public
*/
export function SDKFormProvider<
TFormData extends FieldValues = FieldValues,
TFieldsMetadata extends {
Expand Down
72 changes: 70 additions & 2 deletions src/partner-hook-utils/form/buildFormSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,36 @@ import type { FieldMetadata } from '../types'

// ── Types ────────────────────────────────────────────────────────────

export type FormMode = 'create' | 'update'
/**
* Form lifecycle mode used to vary requiredness and validation rules.
*
* @internal
*/
type FormMode = 'create' | 'update'

export type RequiredFieldRule<TData = Record<string, unknown>> =
/**
* Per-field requiredness rule.
*
* String values apply requiredness in a fixed mode (`'always'`, `'create'`,
* `'update'`, or `'never'`). A function form receives the current form data
* and mode so requiredness can depend on other field values at runtime.
*
* @typeParam TData - The shape of the form data passed to predicate rules.
* @internal
*/
type RequiredFieldRule<TData = Record<string, unknown>> =
| 'create'
| 'update'
| 'always'
| 'never'
| ((data: TData, mode: FormMode) => boolean)

/**
* Mapping from each field name in a schema to its {@link RequiredFieldRule}.
*
* @typeParam TSchema - The map of field names to their Zod validators.
* @internal
*/
export type RequiredFieldConfig<TSchema extends Record<string, z.ZodType>> = Partial<{
[K in keyof TSchema & string]: RequiredFieldRule<{
[F in keyof TSchema]: z.infer<TSchema[F]>
Expand All @@ -26,6 +47,16 @@ type OptionalOnUpdate<TConfig> = {
[K in keyof TConfig & string]: TConfig[K] extends 'create' | 'never' ? K : never
}[keyof TConfig & string]

/**
* Per-mode list of optional fields a caller can promote to required.
*
* Only fields whose base rule allows the override appear in the `create` /
* `update` arrays — fields that are already required in a given mode are
* excluded from that mode's list at the type level.
*
* @typeParam TConfig - The {@link RequiredFieldConfig} that constrains which fields are eligible.
* @internal
*/
export type OptionalFieldsToRequire<TConfig> = {
create?: Array<OptionalOnCreate<TConfig>>
update?: Array<OptionalOnUpdate<TConfig>>
Expand All @@ -50,6 +81,13 @@ interface BuildFormSchemaOptions<
superRefine?: (data: { [K in keyof T]: z.infer<T[K]> }, ctx: z.RefinementCtx) => void
}

/**
* Companion config returned alongside a built schema, used to derive per-field
* metadata and to identify which form values predicate-based rules depend on.
*
* @typeParam T - The map of field names to their Zod validators.
* @internal
*/
export interface FieldsMetadataConfig<T extends Record<string, z.ZodType>> {
getFieldsMetadata: (data?: Record<string, unknown>) => Record<keyof T, FieldMetadata>
/** Form field names that predicate-based requiredness rules read at runtime. */
Expand All @@ -60,11 +98,41 @@ type FormDataFromValidators<T extends Record<string, z.ZodType>> = {
[K in keyof T]: z.infer<T[K]>
}

/**
* Tuple returned by {@link buildFormSchema}: the composed Zod schema followed
* by its {@link FieldsMetadataConfig}.
*
* @typeParam T - The map of field names to their Zod validators.
* @internal
*/
export type BuildFormSchemaResult<T extends Record<string, z.ZodType>> = [
schema: z.ZodType<FormDataFromValidators<T>, FormDataFromValidators<T>>,
metadataConfig: FieldsMetadataConfig<T>,
]

/**
* Composes a Zod object schema and matching metadata config from a map of
* per-field validators, applying mode-aware requiredness rules.
*
* @remarks
* Every field is wrapped to coerce empty strings, `null`, `undefined`, and
* `NaN` to `undefined` before validation, so blank inputs surface as missing
* rather than as type-mismatch errors. Required-field checks run in a
* `superRefine` pass that emits an issue keyed by `requiredErrorCode` (defaults
* to `REQUIRED`) for each unfilled required field.
*
* Fields listed in `fieldsWithRedactedValues` remain in the schema for format
* validation and appear in metadata with `hasRedactedValue: true`, but are
* exempt from required-field validation because a server-side value already
* exists.
*
* @typeParam T - The map of field names to their Zod validators.
* @typeParam TConfig - The shape of the requiredness configuration.
* @param fieldValidators - Map from field name to the validator that runs when a value is present.
* @param options - Mode, required-field config, redaction list, and optional `superRefine`.
* @returns A {@link BuildFormSchemaResult} tuple: the schema and its metadata config.
* @internal
*/
export function buildFormSchema<
T extends Record<string, z.ZodType>,
TConfig extends RequiredFieldConfig<T>,
Expand Down
25 changes: 19 additions & 6 deletions src/partner-hook-utils/form/composeSubmitHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,14 @@ interface ComposableFormHookResult {
/**
* Accepted input for a single slot of `composeSubmitHandler`'s `forms` array.
*
* @remarks
* - SDK form hook results (anything matching `ComposableFormHookResult`) are composed directly.
* - A raw `react-hook-form` `UseFormReturn<T>` is supported for screen-local auxiliary forms
* that don't warrant a dedicated SDK hook. Raw forms contribute validation/focus behavior
* but no `errorHandling` (fields surface their own inline errors via react-hook-form).
*
* @typeParam T - The shape of the form values when a raw `UseFormReturn` is passed.
* @internal
*/
export type ComposeSubmitInput<T extends FieldValues = FieldValues> =
| ComposableFormHookResult
Expand All @@ -48,6 +52,12 @@ interface FormValidationResult {
errors: Record<string, unknown>
}

/**
* Result returned by {@link composeSubmitHandler}: a single submit handler that
* coordinates validation across the composed forms, and aggregated error state.
*
* @public
*/
export interface ComposeSubmitHandlerResult {
handleSubmit: (e: SyntheticEvent) => Promise<void>
errorHandling: HookErrorHandling
Expand Down Expand Up @@ -121,9 +131,9 @@ function focusFirstInvalidAcrossForms(results: FormValidationResult[]): void {
}

/**
* Coordinates validation and submission across multiple form hooks on the same page, and
* returns aggregated `errorHandling` for those forms so you can drive a single error surface.
* Coordinates validation and submission across multiple form hooks on the same page.
*
* @remarks
* Validates all forms simultaneously via `handleSubmit()`, then focuses the visually first
* invalid field across all forms (sorted by `getBoundingClientRect()`). Only calls
* `onAllValid` when every form passes.
Expand All @@ -140,8 +150,14 @@ function focusFirstInvalidAcrossForms(results: FormValidationResult[]): void {
* can be passed back into `composeErrorHandler` when you need to add extra
* `@gusto/embedded-api-v-2025-11-15` queries or screen-level submit state.
*
* @typeParam TForms - Tuple of form value shapes, one per slot of `forms`.
* @param forms - Form hook results and/or raw `UseFormReturn` instances to coordinate.
* @param onAllValid - Async callback invoked once every form has passed validation.
* @returns A {@link ComposeSubmitHandlerResult} with a unified `handleSubmit` and aggregated `errorHandling`.
* @public
*
* @example
* ```ts
* ```tsx
* const detailsForm = useEmployeeDetailsForm({ employeeId, shouldFocusError: false })
* const addressForm = useHomeAddressForm({ employeeId, shouldFocusError: false })
*
Expand All @@ -153,9 +169,6 @@ function focusFirstInvalidAcrossForms(results: FormValidationResult[]): void {
* },
* )
*
* // With extra queries or screen-level submit state:
* // const errorHandling = composeErrorHandler([submitResult, extraQuery], { submitError, setSubmitError })
*
* return <form onSubmit={handleSubmit}>...</form>
* ```
*/
Expand Down
14 changes: 14 additions & 0 deletions src/partner-hook-utils/form/fields/CheckboxHookField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,27 @@ import { withFieldElementRegistry } from './withFieldElementRegistry'
import { CheckboxField } from '@/components/Common'
import type { CheckboxProps } from '@/components/Common/UI/Checkbox/CheckboxTypes'

/**
* Props for {@link CheckboxHookField}.
*
* @typeParam TErrorCode - Validation error code keys mapped via `validationMessages`.
* @public
*/
export interface CheckboxHookFieldProps<TErrorCode extends string = never> extends BaseFieldProps {
name: string
formHookResult?: FormHookResult
validationMessages?: ValidationMessages<TErrorCode>
FieldComponent?: ComponentType<CheckboxProps>
}

/**
* Checkbox field connected to a partner form hook result via `useHookFieldResolution`.
*
* @typeParam TErrorCode - Validation error code keys mapped via `validationMessages`.
* @param props - Field configuration including `name`, `formHookResult`, and label content.
* @returns The rendered checkbox field wrapped in the field element registry.
* @internal
*/
export function CheckboxHookField<TErrorCode extends string>({
name,
formHookResult,
Expand Down
Loading
Loading