Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { ReactNode } from 'react'
import { FieldElementRegistryContext, type FieldElementRegistry } from './fieldElementRegistry'

interface FieldElementRegistryProviderProps {
registry: FieldElementRegistry | null | undefined
children: ReactNode
}

/**
* Publishes a `FieldElementRegistry` via context so `useField` can populate it.
* `SDKFormProvider` wires this automatically; partners who build their own form
* surface can wrap with this provider directly to opt into cross-form focus.
*/
export function FieldElementRegistryProvider({
registry,
children,
}: FieldElementRegistryProviderProps) {
return (
<FieldElementRegistryContext.Provider value={registry ?? null}>
{children}
</FieldElementRegistryContext.Provider>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { renderHook } from '@testing-library/react'
import { describe, test, expect } from 'vitest'
import { useFieldElementRegistry } from './fieldElementRegistry'

function makeElement(): HTMLElement {
return document.createElement('input')
}

describe('FieldElementRegistry', () => {
test('register stores the element under the given name', () => {
const { result } = renderHook(() => useFieldElementRegistry())
const registry = result.current
const input = makeElement()

registry.register('firstName', input)

expect(registry.get('firstName')).toBe(input)
expect(registry.names()).toEqual(['firstName'])
})

test('register with null deletes the entry (cleanup path)', () => {
const { result } = renderHook(() => useFieldElementRegistry())
const registry = result.current
const input = makeElement()

registry.register('firstName', input)
expect(registry.get('firstName')).toBe(input)

registry.register('firstName', null)

expect(registry.get('firstName')).toBeNull()
expect(registry.names()).toEqual([])
})

test('register with null is a no-op when the entry was never registered', () => {
const { result } = renderHook(() => useFieldElementRegistry())
const registry = result.current

expect(() => {
registry.register('neverRegistered', null)
}).not.toThrow()
expect(registry.get('neverRegistered')).toBeNull()
expect(registry.names()).toEqual([])
})

test('register with null only removes the targeted entry', () => {
const { result } = renderHook(() => useFieldElementRegistry())
const registry = result.current
const firstNameInput = makeElement()
const lastNameInput = makeElement()

registry.register('firstName', firstNameInput)
registry.register('lastName', lastNameInput)

registry.register('firstName', null)

expect(registry.get('firstName')).toBeNull()
expect(registry.get('lastName')).toBe(lastNameInput)
expect(registry.names()).toEqual(['lastName'])
})

test('register with a new element overwrites the previous one', () => {
const { result } = renderHook(() => useFieldElementRegistry())
const registry = result.current
const firstInput = makeElement()
const secondInput = makeElement()

registry.register('firstName', firstInput)
registry.register('firstName', secondInput)

expect(registry.get('firstName')).toBe(secondInput)
expect(registry.names()).toEqual(['firstName'])
})

test('get returns null for unknown names', () => {
const { result } = renderHook(() => useFieldElementRegistry())
const registry = result.current

expect(registry.get('unknown')).toBeNull()
})

test('useFieldElementRegistry returns a stable instance across renders', () => {
const { result, rerender } = renderHook(() => useFieldElementRegistry())
const first = result.current

rerender()

expect(result.current).toBe(first)
})
})
53 changes: 53 additions & 0 deletions src/components/Common/Fields/hooks/fieldElementRegistry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { createContext, useContext, useRef } from 'react'

/**
* Per-form map of registered field `name` → DOM element. Populated by `useField`
* via a ref callback, consumed by `composeSubmitHandler` to focus the visually
* first invalid field across multiple composed forms.
*
* Lives here (next to `useField`) rather than in `partner-hook-utils` so the
* established module-boundary direction (`partner-hook-utils` → `Common`) is
* preserved. Partner code consumes it via re-exports from `partner-hook-utils/form`.
*/
export interface FieldElementRegistry {
register: (name: string, element: HTMLElement | null) => void
get: (name: string) => HTMLElement | null
names: () => string[]
}

function createFieldElementRegistry(): FieldElementRegistry {
const elements = new Map<string, HTMLElement>()
return {
register(name, element) {
if (element) {
elements.set(name, element)
} else {
elements.delete(name)
Comment on lines +23 to +25
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Minor | test-coverage

The cleanup/unmount path (register called with null to delete an entry) has no direct test. The integration tests verify registration works but don't explicitly exercise field unregistration when a component unmounts.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added direct coverage in 7ff313b. New fieldElementRegistry.test.tsx exercises the cleanup path explicitly: register(name, null) deleting an existing entry, no-op when the entry was never registered, and isolation (deleting one entry doesn't affect siblings). Also covers overwrite-via-re-register and stable instance across renders.

}
},
get(name) {
return elements.get(name) ?? null
},
names() {
return Array.from(elements.keys())
},
}
}

/**
* Creates a stable `FieldElementRegistry` scoped to the caller's lifetime.
* Intended to be called once per form hook (via `useHookFormInternals`).
*/
export function useFieldElementRegistry(): FieldElementRegistry {
const ref = useRef<FieldElementRegistry | null>(null)
if (ref.current === null) {
ref.current = createFieldElementRegistry()
}
return ref.current
}

export const FieldElementRegistryContext = createContext<FieldElementRegistry | null>(null)

export function useFieldElementRegistryContext(): FieldElementRegistry | null {
return useContext(FieldElementRegistryContext)
}
101 changes: 100 additions & 1 deletion src/components/Common/Fields/hooks/useField.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { renderHook, act, waitFor } from '@testing-library/react'
import { renderHook, render, act, waitFor } from '@testing-library/react'
import { describe, test, expect, vi } from 'vitest'
import { type Control, FormProvider, useForm } from 'react-hook-form'
import React from 'react'
import { useField } from './useField'
import {
FieldElementRegistryContext,
useFieldElementRegistry,
type FieldElementRegistry,
} from './fieldElementRegistry'

const FormWrapper = ({ children }: { children: React.ReactNode }) => {
const methods = useForm({
Expand Down Expand Up @@ -316,4 +321,98 @@ describe('useField', () => {
expect(firstDescription).not.toBe(secondDescription)
})
})

describe('field element registry', () => {
function TestField({ name }: { name: string }) {
const { inputRef } = useField({ name })
return <input data-testid={`input-${name}`} ref={inputRef} />
}

function RegistryFormWrapper({
registry,
children,
}: {
registry: FieldElementRegistry | null
children: React.ReactNode
}) {
const methods = useForm()
return (
<FieldElementRegistryContext.Provider value={registry}>
<FormProvider {...methods}>{children}</FormProvider>
</FieldElementRegistryContext.Provider>
)
}

test('registers the DOM element under the field name when a registry is in context', () => {
const { result: registryResult } = renderHook(() => useFieldElementRegistry())
const registry = registryResult.current

const { getByTestId } = render(
<RegistryFormWrapper registry={registry}>
<TestField name="firstName" />
</RegistryFormWrapper>,
)

const input = getByTestId('input-firstName')
expect(registry.get('firstName')).toBe(input)
expect(registry.names()).toEqual(['firstName'])
})

test('unregisters the field when the component unmounts', () => {
const { result: registryResult } = renderHook(() => useFieldElementRegistry())
const registry = registryResult.current

const { unmount } = render(
<RegistryFormWrapper registry={registry}>
<TestField name="firstName" />
</RegistryFormWrapper>,
)

expect(registry.get('firstName')).not.toBeNull()

unmount()

expect(registry.get('firstName')).toBeNull()
expect(registry.names()).toEqual([])
})

test('cleans up the previous entry when the field name changes', () => {
const { result: registryResult } = renderHook(() => useFieldElementRegistry())
const registry = registryResult.current

const { rerender, getByTestId } = render(
<RegistryFormWrapper registry={registry}>
<TestField name="firstName" />
</RegistryFormWrapper>,
)

expect(registry.names()).toEqual(['firstName'])

rerender(
<RegistryFormWrapper registry={registry}>
<TestField name="lastName" />
</RegistryFormWrapper>,
)

const lastNameInput = getByTestId('input-lastName')
expect(registry.get('firstName')).toBeNull()
expect(registry.get('lastName')).toBe(lastNameInput)
expect(registry.names()).toEqual(['lastName'])
})

test('is a no-op when no registry is published in context', () => {
const FormWrapperNoRegistry = ({ children }: { children: React.ReactNode }) => {
const methods = useForm()
return <FormProvider {...methods}>{children}</FormProvider>
}

expect(() =>
render(
<FormWrapperNoRegistry>
<TestField name="firstName" />
</FormWrapperNoRegistry>,
),
).not.toThrow()
})
})
})
14 changes: 12 additions & 2 deletions src/components/Common/Fields/hooks/useField.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Control, RegisterOptions } from 'react-hook-form'
import { useController, useFormContext } from 'react-hook-form'
import React, { useMemo, type Ref } from 'react'
import React, { useCallback, useMemo, type Ref } from 'react'
import { useFieldElementRegistryContext } from './fieldElementRegistry'
import { createMarkup } from '@/helpers/formattedStrings'
import { useForkRef } from '@/hooks/useForkRef/useForkRef'

Expand Down Expand Up @@ -64,7 +65,16 @@ export function useField<TValue = string, TRef = HTMLInputElement>({

const { value } = field

const ref = useForkRef(field.ref, inputRef)
const fieldElementRegistry = useFieldElementRegistryContext()
const registryRef = useCallback(
(element: HTMLElement | null) => {
if (!fieldElementRegistry) return
fieldElementRegistry.register(name, element)
},
Comment on lines +68 to +73
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Minor | test-coverage

The new registryRef callback in useField is not tested in useField.test.tsx. While it's exercised indirectly via the composeSubmitHandler.focus.test.tsx integration test, useField's own test file has no coverage for the registry registration/unregistration lifecycle.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added direct coverage in 7ff313b. useField.test.tsx now has a field element registry describe block covering: registration on mount, unregistration on unmount, cleanup-then-register when the field name changes, and no-op behavior when no registry is published in context.

[fieldElementRegistry, name],
)

const ref = useForkRef(field.ref, inputRef, registryRef)

const handleChange = (updatedValue: TValue) => {
const value = transform ? transform(updatedValue) : updatedValue
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from './signCompanyFormSchema'
import { SignatureField, ConfirmSignatureField } from './fields'
import { useDeriveFieldsMetadata } from '@/partner-hook-utils/form/useDeriveFieldsMetadata'
import { useHookFormInternals } from '@/partner-hook-utils/form/useHookFormInternals'
import { createGetFormSubmissionValues } from '@/partner-hook-utils/form/getFormSubmissionValues'
import { composeErrorHandler } from '@/partner-hook-utils/composeErrorHandler'
import type {
Expand Down Expand Up @@ -154,6 +155,8 @@ export function useSignCompanyForm({
return submitResult
}

const hookFormInternals = useHookFormInternals(formMethods)

const isDataLoading = formQuery.isLoading || pdfQuery.isLoading

if (isDataLoading || !companyForm || !formPdf) {
Expand All @@ -180,7 +183,7 @@ export function useSignCompanyForm({
ConfirmSignature: ConfirmSignatureField,
},
fieldsMetadata,
hookFormInternals: { formMethods },
hookFormInternals,
getFormSubmissionValues: createGetFormSubmissionValues(formMethods, schema),
},
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
Day2Field,
} from './fields'
import { useDeriveFieldsMetadata } from '@/partner-hook-utils/form/useDeriveFieldsMetadata'
import { useHookFormInternals } from '@/partner-hook-utils/form/useHookFormInternals'
import { createGetFormSubmissionValues } from '@/partner-hook-utils/form/getFormSubmissionValues'
import { withOptions } from '@/partner-hook-utils/form/withOptions'
import { composeErrorHandler } from '@/partner-hook-utils/composeErrorHandler'
Expand Down Expand Up @@ -184,7 +185,7 @@
formMethods.setValue('day1', 15)
formMethods.setValue('day2', 31)
}
}, [watchedFrequency, watchedCustomTwicePerMonth, formMethods.setValue])

Check warning on line 188 in src/components/Company/PaySchedule/shared/usePayScheduleForm/usePayScheduleForm.tsx

View workflow job for this annotation

GitHub Actions / lint

React Hook useEffect has a missing dependency: 'formMethods'. Either include it or remove the dependency array

const formattedAnchorPayDate = formatWatchedDate(watchedAnchorPayDate)
const formattedAnchorEndOfPayPeriod = formatWatchedDate(watchedAnchorEndOfPayPeriod)
Expand Down Expand Up @@ -305,6 +306,8 @@
return submitResult
}

const hookFormInternals = useHookFormInternals(formMethods)

const isDataLoading =
paymentConfigsQuery.isLoading || (payScheduleId ? payScheduleQuery.isLoading : false)

Expand Down Expand Up @@ -337,7 +340,7 @@
Day2: showDay2 ? Day2Field : undefined,
},
fieldsMetadata,
hookFormInternals: { formMethods },
hookFormInternals,
getFormSubmissionValues: createGetFormSubmissionValues(formMethods, schema),
},
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
import { withOptions } from '@/partner-hook-utils/form/withOptions'
import { createGetFormSubmissionValues } from '@/partner-hook-utils/form/getFormSubmissionValues'
import { useDeriveFieldsMetadata } from '@/partner-hook-utils/form/useDeriveFieldsMetadata'
import { useHookFormInternals } from '@/partner-hook-utils/form/useHookFormInternals'
import { composeErrorHandler } from '@/partner-hook-utils/composeErrorHandler'
import type {
BaseFormHookReady,
Expand Down Expand Up @@ -244,7 +245,7 @@
} else {
formMethods.setValue('paymentUnit', resolvedDefaults.paymentUnit)
}
}, [watchedFlsaStatus, formMethods.setValue, resolvedDefaults.paymentUnit])

Check warning on line 248 in src/components/Employee/Compensation/shared/useCompensationForm/useCompensationForm.tsx

View workflow job for this annotation

GitHub Actions / lint

React Hook useEffect has a missing dependency: 'formMethods'. Either include it or remove the dependency array

const updateCompensationMutation = useJobsAndCompensationsUpdateCompensationMutation()
const createJobMutation = useJobsAndCompensationsCreateJobMutation()
Expand Down Expand Up @@ -448,6 +449,8 @@
taxQuery.isLoading
: false

const hookFormInternals = useHookFormInternals(formMethods)

if (
isDataLoading ||
(employeeId && (!employeeJobs || !employee || !companyUuid || !federalTaxDetails))
Expand Down Expand Up @@ -486,7 +489,7 @@
StateWcClassCode: isWaState && watchedStateWcCovered ? StateWcClassCodeField : undefined,
},
fieldsMetadata,
hookFormInternals: { formMethods },
hookFormInternals,
getFormSubmissionValues: createGetFormSubmissionValues(formMethods, schema),
},
}
Expand Down
Loading
Loading