From 195928d56a35a0b55958dd96ba1efe60a8aadb70 Mon Sep 17 00:00:00 2001 From: Firman Ramdhani <33869609+firmanramdhani@users.noreply.github.com> Date: Tue, 16 Jun 2026 01:23:36 +0700 Subject: [PATCH] feat: add reusable AsyncSelect and ObjectSelect components with infinite scroll hook support --- .../form-demo/components/all-fields-demo.tsx | 122 ++++++++- packages/ui/docs/FORM-COMPONENTS.md | 2 + .../__tests__/async-select.field.test.tsx | 249 +++++++++++++++++ .../__tests__/object-select.field.test.tsx | 255 ++++++++++++++++++ .../ui/src/components/Form/custom/index.ts | 14 + .../Form/custom/selects/AsyncSelect.tsx | 236 ++++++++++++++++ .../Form/custom/selects/ObjectSelect.tsx | 181 +++++++++++++ .../hooks/useAsyncSelectInfiniteScroll.ts | 190 +++++++++++++ .../components/Form/custom/selects/types.ts | 61 +++++ .../Form/fields/async-select.field.tsx | 156 +++++++++++ .../Form/fields/object-select.field.tsx | 149 ++++++++++ packages/ui/src/components/Form/index.ts | 12 + .../src/components/Form/useTranslatedError.ts | 78 ++++++ packages/ui/src/components/Form/withRHF.tsx | 81 +----- 14 files changed, 1706 insertions(+), 80 deletions(-) create mode 100644 packages/ui/src/components/Form/__tests__/async-select.field.test.tsx create mode 100644 packages/ui/src/components/Form/__tests__/object-select.field.test.tsx create mode 100644 packages/ui/src/components/Form/custom/index.ts create mode 100644 packages/ui/src/components/Form/custom/selects/AsyncSelect.tsx create mode 100644 packages/ui/src/components/Form/custom/selects/ObjectSelect.tsx create mode 100644 packages/ui/src/components/Form/custom/selects/hooks/useAsyncSelectInfiniteScroll.ts create mode 100644 packages/ui/src/components/Form/custom/selects/types.ts create mode 100644 packages/ui/src/components/Form/fields/async-select.field.tsx create mode 100644 packages/ui/src/components/Form/fields/object-select.field.tsx create mode 100644 packages/ui/src/components/Form/useTranslatedError.ts diff --git a/apps/web/src/apps/showcase/example/features/form-demo/components/all-fields-demo.tsx b/apps/web/src/apps/showcase/example/features/form-demo/components/all-fields-demo.tsx index b7cd86d..77a43cb 100644 --- a/apps/web/src/apps/showcase/example/features/form-demo/components/all-fields-demo.tsx +++ b/apps/web/src/apps/showcase/example/features/form-demo/components/all-fields-demo.tsx @@ -6,10 +6,33 @@ import { FieldMultiSelect, FieldNativeSelect, FieldTagsInput, FieldCheckbox, FieldRadioGroup, FieldSwitch, FieldChipGroup, FieldSegmentedControl, FieldSlider, FieldRangeSlider, FieldRating, FieldColorInput, - FieldColorPicker, FieldFileInput + FieldColorPicker, FieldFileInput, FieldObjectSelect, FieldAsyncSelect } from '@repo/ui/form'; import { useFormDemoTranslation } from '../i18n/useFormDemoTranslation'; +const MOCK_POKEMON = Array.from({ length: 100 }, (_, i) => ({ + id: i + 1, + name: `Pokemon ${i + 1}`, +})); + +const fetchMockPokemon = async (url: string) => { + // Parse the query params sent by useInfiniteScroll + const urlObj = new URL(url, 'http://localhost'); + const page = parseInt(urlObj.searchParams.get('page') || '1', 10); + const pageSize = parseInt(urlObj.searchParams.get('pageSize') || '20', 10); + const search = urlObj.searchParams.get('search')?.toLowerCase() || ''; + + // Fake network delay + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Filter and paginate + const filtered = MOCK_POKEMON.filter((p) => p.name.toLowerCase().includes(search)); + const start = (page - 1) * pageSize; + const paginated = filtered.slice(start, start + pageSize); + + return { results: paginated }; +}; + export default function AllFieldsDemo() { const t = useFormDemoTranslation(); @@ -37,7 +60,13 @@ export default function AllFieldsDemo() { rating: 0, themeColor: '', colorPicker: '#1c7ed6', - avatar: null + avatar: null, + objectSelect: null, + asyncSelect: null, + multiObjectSelect: [], + multiAsyncSelect: [], + realPokeSelect: null, + multiRealPokeSelect: [] } }); @@ -103,6 +132,95 @@ export default function AllFieldsDemo() { data={['Electronics', 'Fashion', 'Food']} /> + + + `${item.name} (${item.hex})`} + clearable + /> + + + res.results} + valueKey="id" + labelKey="name" + clearable + /> + res.results} + valueKey="id" + labelKey="name" + clearable + /> + + + ({ + data: res.results.map((p: any, i: number) => ({ id: i + 1, ...p })), + hasMore: !!res.next + })} + valueKey="id" + labelKey="name" + clearable + /> + ({ + data: res.results.map((p: any, i: number) => ({ id: i + 1, ...p })), + hasMore: !!res.next + })} + valueKey="id" + labelKey="name" + clearable + /> + diff --git a/packages/ui/docs/FORM-COMPONENTS.md b/packages/ui/docs/FORM-COMPONENTS.md index dcbc0f1..e65c0ec 100644 --- a/packages/ui/docs/FORM-COMPONENTS.md +++ b/packages/ui/docs/FORM-COMPONENTS.md @@ -683,6 +683,8 @@ export const FieldDatePicker = withRHF( | `FieldRating` | `Rating` | Range | Star rating | | `FieldColorInput` | `ColorInput` | Color | Color picker with text input | | `FieldColorPicker` | `ColorPicker` | Color | Color picker only (uses `Input.Wrapper`) | +| `FieldObjectSelect` | `Select / MultiSelect` | Selection | Stores full `T` or `T[]` object in RHF instead of string ID | +| `FieldAsyncSelect` | `Select / MultiSelect` | Selection | Paginated infinite scroll select mapping API responses to RHF objects | | `FieldFileInput` | `FileInput` | File | File upload input | --- diff --git a/packages/ui/src/components/Form/__tests__/async-select.field.test.tsx b/packages/ui/src/components/Form/__tests__/async-select.field.test.tsx new file mode 100644 index 0000000..675ef38 --- /dev/null +++ b/packages/ui/src/components/Form/__tests__/async-select.field.test.tsx @@ -0,0 +1,249 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { useForm, FormProvider } from 'react-hook-form'; +import { MantineProvider } from '@mantine/core'; +import { FieldAsyncSelect } from '../fields/async-select.field'; + +// --------------------------------------------------------------------------- +// Mock i18n & Setup +// --------------------------------------------------------------------------- +vi.mock('@repo/core-i18n', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { exists: () => false }, + }), +})); + +type Vendor = { id: number; code: string; name: string }; + +const MOCK_API_RESPONSE = { + items: [ + { id: 1, code: 'V1', name: 'Vendor 1' }, + { id: 2, code: 'V2', name: 'Vendor 2' }, + { id: 3, code: 'V3', name: 'Vendor 3' }, + ], + total: 3, +}; + +// Mock fetchFn +const mockFetchFn = vi.fn().mockResolvedValue(MOCK_API_RESPONSE); + +// Test wrapper removed to avoid useForm conflicts + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('FieldAsyncSelect', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('fetches initial page on mount and renders items', async () => { + const user = userEvent.setup(); + + function TestForm() { + const { control } = useForm({ defaultValues: { vendor: null } }); + return ( + +
+ res.items} + valueKey="id" + labelKey="name" + placeholder="Select async vendor" + /> + +
+ ); + } + + render(); + + // Wait for initial fetch + await waitFor(() => { + expect(mockFetchFn).toHaveBeenCalledWith('/api/vendors?page=1&pageSize=20&search='); + }); + + await user.click(screen.getByPlaceholderText('Select async vendor')); + + // Items should be rendered from the mock response + expect(screen.getByText('Vendor 1')).toBeInTheDocument(); + expect(screen.getByText('Vendor 3')).toBeInTheDocument(); + }); + + it('stores full object in RHF from async data', async () => { + const user = userEvent.setup(); + let capturedData: any = null; + + function TestForm() { + const { control, handleSubmit } = useForm({ defaultValues: { vendor: null } }); + return ( + +
{ capturedData = data; })}> + res.items} + valueKey="id" + labelKey="name" + placeholder="Select vendor" + /> + + +
+ ); + } + + render(); + + await waitFor(() => { + expect(mockFetchFn).toHaveBeenCalled(); + }); + + await user.click(screen.getByPlaceholderText('Select vendor')); + await user.click(screen.getByText('Vendor 2')); + await user.click(screen.getByText('Submit')); + + expect(capturedData).toEqual({ vendor: { id: 2, code: 'V2', name: 'Vendor 2' } }); + }); + + it('injects pre-selected value not in fetched data', async () => { + // We mock fetch to return only V1 and V2 + const partialFetchFn = vi.fn().mockResolvedValue({ + items: [ + { id: 1, code: 'V1', name: 'Vendor 1' }, + { id: 2, code: 'V2', name: 'Vendor 2' }, + ] + }); + + // The form starts with V99 pre-selected (e.g. from server hydration) + const PRESELECTED_VENDOR = { id: 99, code: 'V99', name: 'Vendor 99' }; + + function TestForm() { + const { control } = useForm({ defaultValues: { vendor: PRESELECTED_VENDOR } }); + return ( + +
+ res.items} + valueKey="id" + labelKey="name" + placeholder="Select vendor" + /> + +
+ ); + } + + render(); + + await waitFor(() => { + expect(partialFetchFn).toHaveBeenCalled(); + }); + + // The display value of the Select input should show the injected label + const input = screen.getByRole('textbox') as HTMLInputElement; + expect(input.value).toBe('Vendor 99'); + }); + + it('debounces search input and refetches', async () => { + const user = userEvent.setup(); + + function TestForm() { + const { control } = useForm({ defaultValues: { vendor: null } }); + return ( + +
+ res.items} + valueKey="id" + labelKey="name" + placeholder="Search vendor" + debounceMs={100} // fast debounce for test + /> + +
+ ); + } + + render(); + + // Wait for initial fetch + await waitFor(() => { + expect(mockFetchFn).toHaveBeenCalledTimes(1); + }); + + const input = screen.getByPlaceholderText('Search vendor'); + await user.type(input, 'test'); + + // Wait for debounced fetch + await waitFor(() => { + expect(mockFetchFn).toHaveBeenCalledTimes(2); + expect(mockFetchFn).toHaveBeenLastCalledWith('/api/vendors?page=1&pageSize=20&search=test'); + }); + }); + + it('gracefully deduplicates overlapping data across API responses', async () => { + // API returns Vendor 1 twice + const badFetchFn = vi.fn().mockResolvedValue({ + items: [ + { id: 1, code: 'V1', name: 'Vendor 1' }, + { id: 1, code: 'V1', name: 'Vendor 1 (Duplicate)' }, + { id: 2, code: 'V2', name: 'Vendor 2' }, + ] + }); + + function TestForm() { + const { control } = useForm({ defaultValues: { vendor: null } }); + return ( + +
+ res.items} + valueKey="id" + labelKey="name" + placeholder="Select bad vendor" + /> + +
+ ); + } + + render(); + + await waitFor(() => { + expect(badFetchFn).toHaveBeenCalledTimes(1); + }); + + const user = userEvent.setup(); + await user.click(screen.getByPlaceholderText('Select bad vendor')); + + // Should only render "Vendor 1" once, ignoring the duplicate with id=1 + const vendor1Options = screen.getAllByText('Vendor 1'); + expect(vendor1Options.length).toBe(1); + + // The duplicate name should NOT be rendered + expect(screen.queryByText('Vendor 1 (Duplicate)')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/Form/__tests__/object-select.field.test.tsx b/packages/ui/src/components/Form/__tests__/object-select.field.test.tsx new file mode 100644 index 0000000..3d00073 --- /dev/null +++ b/packages/ui/src/components/Form/__tests__/object-select.field.test.tsx @@ -0,0 +1,255 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { useForm, FormProvider } from 'react-hook-form'; +import { MantineProvider } from '@mantine/core'; +import { FieldObjectSelect } from '../fields/object-select.field'; + +// --------------------------------------------------------------------------- +// Mock i18n +// --------------------------------------------------------------------------- +vi.mock('@repo/core-i18n', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { exists: () => false }, + }), +})); + +// --------------------------------------------------------------------------- +// Test Data & Wrapper +// --------------------------------------------------------------------------- + +type Vendor = { id: number; code: string; name: string; active?: boolean }; + +const VENDORS: Vendor[] = [ + { id: 1, code: 'V1', name: 'Vendor 1', active: true }, + { id: 2, code: 'V2', name: 'Vendor 2', active: false }, + { id: 3, code: 'V3', name: 'Vendor 3', active: true }, +]; + +// Test wrapper removed to avoid useForm conflicts + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('FieldObjectSelect', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders with labelKey and displays correct labels', () => { + function TestForm() { + const { control } = useForm({ defaultValues: { vendor: null } }); + return ( + + + + ); + } + + render(); + expect(screen.getByText('Vendor')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Select vendor')).toBeInTheDocument(); + }); + + it('stores the full original object in RHF on selection', async () => { + const user = userEvent.setup(); + let capturedData: any = null; + + function TestForm() { + const { control, handleSubmit } = useForm({ defaultValues: { vendor: null } }); + return ( + +
{ capturedData = data; })}> + + + +
+ ); + } + + render(); + + // Open dropdown + await user.click(screen.getByPlaceholderText('Select vendor')); + + // Click Vendor 2 + await user.click(screen.getByText('Vendor 2')); + + // Submit + await user.click(screen.getByText('Submit')); + + // Should contain the full object, not just '2' + expect(capturedData).toEqual({ vendor: VENDORS[1] }); + }); + + it('renders with renderLabel for compound labels', async () => { + const user = userEvent.setup(); + + function TestForm() { + const { control } = useForm({ defaultValues: { vendor: null } }); + return ( + + `${item.code} - ${item.name}`} + placeholder="Select vendor" + /> + + ); + } + + render(); + await user.click(screen.getByPlaceholderText('Select vendor')); + + // Compound label should be visible + expect(screen.getByText('V1 - Vendor 1')).toBeInTheDocument(); + }); + + + + it('filterOption excludes items from dropdown', async () => { + const user = userEvent.setup(); + + function TestForm() { + const { control } = useForm({ defaultValues: { vendor: null } }); + return ( + + item.active === true} + /> + + ); + } + + render(); + await user.click(screen.getByPlaceholderText('Select vendor')); + + // Active vendors should be present + expect(screen.getByText('Vendor 1')).toBeInTheDocument(); + expect(screen.getByText('Vendor 3')).toBeInTheDocument(); + // Inactive vendor should not be present + expect(screen.queryByText('Vendor 2')).not.toBeInTheDocument(); + }); + + it('multi-select stores T[] in RHF', async () => { + const user = userEvent.setup(); + let capturedData: any = null; + + function TestForm() { + const { control, handleSubmit } = useForm({ defaultValues: { vendors: [] } }); + return ( + +
{ capturedData = data; })}> + + + +
+ ); + } + + render(); + await user.click(screen.getByPlaceholderText('Select vendors')); + await user.click(screen.getByText('Vendor 1')); + await user.click(screen.getByText('Vendor 3')); + + await user.click(screen.getByText('Submit')); + expect(capturedData).toEqual({ vendors: [VENDORS[0], VENDORS[2]] }); + }); + + it('multi-select clearable resets to empty array', async () => { + const user = userEvent.setup(); + let capturedData: any = null; + + function TestForm() { + const { control, handleSubmit } = useForm({ defaultValues: { vendors: [VENDORS[0]] } }); + return ( + +
{ capturedData = data; })}> + + + +
+ ); + } + + const { container } = render(); + + const clearButton = container.querySelector('.mantine-CloseButton-root') || container.querySelector('button[aria-label="Clear value"]'); + expect(clearButton).not.toBeNull(); + await user.click(clearButton!); + + await user.click(screen.getByText('Submit')); + expect(capturedData).toEqual({ vendors: [] }); + }); + + it('onSelect callback fires with correct object', async () => { + const user = userEvent.setup(); + const handleSelect = vi.fn(); + + function TestForm() { + const { control } = useForm({ defaultValues: { vendor: null } }); + return ( + + + + ); + } + + render(); + await user.click(screen.getByPlaceholderText('Select vendor')); + await user.click(screen.getByText('Vendor 2')); + + expect(handleSelect).toHaveBeenCalledWith(VENDORS[1]); + }); +}); diff --git a/packages/ui/src/components/Form/custom/index.ts b/packages/ui/src/components/Form/custom/index.ts new file mode 100644 index 0000000..eb8663f --- /dev/null +++ b/packages/ui/src/components/Form/custom/index.ts @@ -0,0 +1,14 @@ +// --------------------------------------------------------------------------- +// Custom Form Components — Barrel Export +// --------------------------------------------------------------------------- +// Reusable select engines that work WITHOUT React Hook Form. +// For RHF-connected versions, use `@repo/ui/form` (FieldObjectSelect, FieldAsyncSelect). +// --------------------------------------------------------------------------- + +export { ObjectSelect } from './selects/ObjectSelect'; +export type { ObjectSelectProps, ObjectSelectSingleProps, ObjectSelectMultiProps } from './selects/ObjectSelect'; + +export { AsyncSelect } from './selects/AsyncSelect'; +export type { AsyncSelectProps, AsyncSelectSingleProps, AsyncSelectMultiProps } from './selects/AsyncSelect'; + +export type { ObjectSelectBaseProps, ObjectSelectFilterContext, ObjectSelectMappingResult } from './selects/types'; diff --git a/packages/ui/src/components/Form/custom/selects/AsyncSelect.tsx b/packages/ui/src/components/Form/custom/selects/AsyncSelect.tsx new file mode 100644 index 0000000..64de783 --- /dev/null +++ b/packages/ui/src/components/Form/custom/selects/AsyncSelect.tsx @@ -0,0 +1,236 @@ +import React, { useMemo, useCallback } from 'react'; +import { Select, MultiSelect, Loader, type SelectProps, type MultiSelectProps, type ComboboxItem } from '@mantine/core'; +import type { ObjectSelectBaseProps, ObjectSelectFilterContext } from './types'; +import { useAsyncSelectInfiniteScroll } from './hooks/useAsyncSelectInfiniteScroll'; + +// --------------------------------------------------------------------------- +// AsyncSelect — Reusable async/paginated Select engine (no RHF dependency) +// --------------------------------------------------------------------------- + +/** Mantine props we manage ourselves */ +type ManagedSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'filter' | 'name' | 'onSelect'; +type ManagedMultiSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'filter' | 'name' | 'onSelect'; + +/** Async-specific props */ +interface AsyncExtraProps { + apiEndpoint: string; + transformResponse?: (res: any) => { data: T[]; hasMore?: boolean } | T[]; + pageSize?: number; + debounceMs?: number; + fetchFn?: (url: string) => Promise; +} + +/** Props for single-select async mode */ +export type AsyncSelectSingleProps> = Omit, 'data'> & + AsyncExtraProps & + Omit & { + multiple?: false; + value?: T | null; + onChange?: (value: T | null) => void; + }; + +/** Props for multi-select async mode */ +export type AsyncSelectMultiProps> = Omit, 'data'> & + AsyncExtraProps & + Omit & { + multiple: true; + value?: T[]; + onChange?: (value: T[]) => void; + }; + +export type AsyncSelectProps> = AsyncSelectSingleProps | AsyncSelectMultiProps; + +// --------------------------------------------------------------------------- +// Helper: Resolve label for a data item +// --------------------------------------------------------------------------- + +function resolveLabel>( + item: T, + labelKey?: keyof T & string, + renderLabel?: (item: T) => string, +): string { + if (renderLabel) return renderLabel(item); + if (labelKey) return String(item[labelKey] ?? ''); + return String(item[Object.keys(item)[0] as keyof T] ?? ''); +} + +// --------------------------------------------------------------------------- +// Component Implementation +// --------------------------------------------------------------------------- + +function AsyncSelectInner>(props: AsyncSelectProps) { + const { + valueKey, + labelKey, + renderLabel, + multiple, + filterOption, + onSelect: onObjectSelect, + value, + onChange, + apiEndpoint, + transformResponse, + pageSize, + debounceMs, + fetchFn, + searchable, + ...mantineProps + } = props; + + // Use the infinite scroll hook for data fetching + const { + data: fetchedData, + isLoading, + fetchNextPage, + search, + debouncedSearch, + setSearch, + } = useAsyncSelectInfiniteScroll({ + apiEndpoint, + valueKey, + pageSize, + transformResponse, + debounceMs, + fetchFn, + }); + + // Inject pre-selected values that aren't in the fetched data yet, + // and safeguard against bad APIs that return duplicate items across pages. + const dataWithInjected = useMemo(() => { + const uniqueItems: T[] = []; + const seen = new Set(); + + // 1. Deduplicate fetched data from the API (hook already deduplicates internally, but this is an extra UI safeguard) + for (const item of fetchedData) { + const key = String(item[valueKey]); + if (!seen.has(key)) { + seen.add(key); + uniqueItems.push(item); + } + } + + // 2. Inject active RHF values if they aren't in the fetched list + if (multiple && Array.isArray(value)) { + for (const v of value) { + const key = String(v[valueKey]); + if (!seen.has(key)) { + seen.add(key); + uniqueItems.unshift(v); // Put selected items at the top + } + } + } else if (!multiple && value) { + const key = String((value as T)[valueKey]); + if (!seen.has(key)) { + seen.add(key); + uniqueItems.unshift(value as T); + } + } + + return uniqueItems; + }, [fetchedData, value, valueKey, multiple]); + + // Build lookup map + const lookupMap = useMemo(() => { + const map = new Map(); + for (const item of dataWithInjected) { + map.set(String(item[valueKey]), item); + } + return map; + }, [dataWithInjected, valueKey]); + + // Build Mantine options from the resolved data + const baseOptions = useMemo(() => { + let filtered = dataWithInjected; + + if (filterOption) { + const context: ObjectSelectFilterContext = { + search, // Pass the active search string to the custom filter + selected: value ?? (multiple ? [] : null), + }; + filtered = dataWithInjected.filter((item) => filterOption(item, context)); + } + + return filtered.map((item) => ({ + value: String(item[valueKey]), + label: resolveLabel(item, labelKey, renderLabel), + })); + }, [dataWithInjected, valueKey, labelKey, renderLabel, filterOption, value, multiple, search]); + + const isTyping = search !== debouncedSearch; + const isFetching = isLoading || isTyping; + const rightSection = isFetching ? : mantineProps.rightSection; + + // Handle search → delegate to the hook's setSearch (debounced) + const handleSearchChange = useCallback( + (val: string) => { + setSearch(val); + }, + [setSearch], + ); + + // ScrollArea props for infinite scroll — use onBottomReached + const scrollAreaProps = useMemo( + () => ({ + ...(mantineProps.scrollAreaProps || {}), + onBottomReached: () => { + fetchNextPage(); + }, + }), + [mantineProps.scrollAreaProps, fetchNextPage], + ); + + // Disable Mantine's internal frontend filtering. + // The backend handles the search query, so we should always display what the backend returns. + const mantineFilter = useCallback(({ options: opts }: { options: ComboboxItem[] }) => opts, []); + + // ----- Multi-select mode ----- + if (multiple) { + const currentValues = Array.isArray(value) ? value.map((v) => String(v[valueKey])) : []; + + const handleMultiChange = (vals: string[]) => { + const objects = vals.map((v) => lookupMap.get(v)!).filter(Boolean); + (onChange as ((v: T[]) => void) | undefined)?.(objects); + onObjectSelect?.(objects); + }; + + return ( + )} + data={baseOptions} + value={currentValues} + onChange={handleMultiChange} + searchable={searchable ?? true} + onSearchChange={handleSearchChange} + scrollAreaProps={scrollAreaProps} + filter={mantineFilter} + rightSection={rightSection} + /> + ); + } + + // ----- Single-select mode ----- + const currentValue = value ? String((value as T)[valueKey]) : null; + + const handleSingleChange = (val: string | null) => { + const obj = val ? (lookupMap.get(val) ?? null) : null; + (onChange as ((v: T | null) => void) | undefined)?.(obj); + onObjectSelect?.(obj); + }; + + return ( + )} + data={options} + value={currentValue} + onChange={handleSingleChange} + searchable={searchable ?? false} + onSearchChange={handleSearchChange} + filter={mantineFilter as any} + + /> + ); +} + +// Apply React.memo for render optimization in large forms +export const ObjectSelect = React.memo(ObjectSelectInner) as typeof ObjectSelectInner; +(ObjectSelect as any).displayName = 'ObjectSelect'; diff --git a/packages/ui/src/components/Form/custom/selects/hooks/useAsyncSelectInfiniteScroll.ts b/packages/ui/src/components/Form/custom/selects/hooks/useAsyncSelectInfiniteScroll.ts new file mode 100644 index 0000000..6eb6959 --- /dev/null +++ b/packages/ui/src/components/Form/custom/selects/hooks/useAsyncSelectInfiniteScroll.ts @@ -0,0 +1,190 @@ +import { useState, useMemo, useCallback, useEffect, useRef } from 'react'; +import { useDebouncedValue } from '@mantine/hooks'; + +// --------------------------------------------------------------------------- +// useAsyncSelectInfiniteScroll — Paginated data fetching hook +// --------------------------------------------------------------------------- +// +// Inspired by `react-select-async-paginate`. Manages: +// - Page-based accumulation (append without flickering) +// - Debounced search (resets pages on search change) +// - hasMore detection (page returns fewer items than pageSize) +// - Duplicate fetch guards +// +// Uses native `fetch` for zero-dependency portability. +// --------------------------------------------------------------------------- + +export interface UseAsyncSelectInfiniteScrollOptions> { + /** API endpoint URL. Receives query params: ?page=N&pageSize=M&search=S */ + apiEndpoint: string; + + /** Property key used to deduplicate incoming API items */ + valueKey: keyof T & string; + + /** Items per page (default: 20) */ + pageSize?: number; + + /** Transform raw API response into T[] or { data: T[], hasMore?: boolean } */ + transformResponse?: (res: any) => { data: T[]; hasMore?: boolean } | T[]; + + /** Search debounce delay in ms (default: 300) */ + debounceMs?: number; + + /** + * Custom fetch function. If provided, replaces the default `fetch` call. + * Useful for mocking in tests or using a shared HTTP client. + */ + fetchFn?: (url: string) => Promise; +} + +export interface UseAsyncSelectInfiniteScrollReturn { + /** Flattened accumulated data across all fetched pages */ + data: T[]; + + /** True during any active fetch */ + isLoading: boolean; + + /** False when the last page returned fewer items than pageSize */ + hasMore: boolean; + + /** Current search term (raw, not debounced) */ + search: string; + + /** Update the search term — triggers debounce + reset */ + setSearch: (s: string) => void; + + /** Trigger next page load */ + fetchNextPage: () => void; + + /** Clear all pages and reset to initial state */ + reset: () => void; + + /** The debounced search term */ + debouncedSearch: string; +} + +export function useAsyncSelectInfiniteScroll>( + options: UseAsyncSelectInfiniteScrollOptions, +): UseAsyncSelectInfiniteScrollReturn { + const { + apiEndpoint, + valueKey, + pageSize = 20, + transformResponse = (res) => res as T[], + debounceMs = 300, + fetchFn, + } = options; + + const [pages, setPages] = useState([]); + const [currentPage, setCurrentPage] = useState(1); + const [hasMore, setHasMore] = useState(true); + const [isLoading, setIsLoading] = useState(false); + const [search, setSearch] = useState(''); + const [debouncedSearch] = useDebouncedValue(search, debounceMs); + + // Guard against duplicate fetches + const fetchingRef = useRef(false); + + // Flatten all pages into a single stable array + const data = useMemo(() => pages.flat(), [pages]); + + // Fetch a specific page + const fetchPage = useCallback( + async (page: number, searchTerm: string) => { + if (fetchingRef.current) return; + fetchingRef.current = true; + setIsLoading(true); + + try { + const separator = apiEndpoint.includes('?') ? '&' : '?'; + const url = `${apiEndpoint}${separator}page=${page}&pageSize=${pageSize}&search=${encodeURIComponent(searchTerm)}`; + + let rawData: any; + if (fetchFn) { + rawData = await fetchFn(url); + } else { + const response = await fetch(url); + rawData = await response.json(); + } + + const transformed = transformResponse(rawData); + const isObjectForm = !Array.isArray(transformed) && 'data' in transformed; + const items = isObjectForm ? transformed.data : (transformed as T[]); + const explicitHasMore = isObjectForm ? transformed.hasMore : undefined; + + setPages((prev) => { + // Detect duplicates to break infinite loop on naive APIs + const existingKeys = new Set(prev.flat().map((i) => String(i[valueKey]))); + const newUniqueItems = items.filter((i) => !existingKeys.has(String(i[valueKey]))); + + // If the API returned items, but ALL of them were duplicates of what we already have, + // the API is likely stuck (e.g. ignoring pagination). Break the loop. + if (items.length > 0 && newUniqueItems.length === 0) { + setHasMore(false); + return prev; + } + + // Page 1 replaces everything (search reset), otherwise append unique items + if (page === 1) return [newUniqueItems]; + return [...prev, newUniqueItems]; + }); + + // Determine if we have more pages + if (explicitHasMore !== undefined) { + setHasMore(explicitHasMore); + } else { + setHasMore(items.length >= pageSize); + } + + setCurrentPage(page); + } catch (error) { + console.error('[useAsyncSelectInfiniteScroll] Fetch failed:', error); + setHasMore(false); + } finally { + setIsLoading(false); + fetchingRef.current = false; + } + }, + [apiEndpoint, pageSize, transformResponse, fetchFn, valueKey], + ); + + // Fetch next page (called by scroll handler) + const fetchNextPage = useCallback(() => { + if (!hasMore || isLoading || fetchingRef.current) return; + fetchPage(currentPage + 1, debouncedSearch); + }, [hasMore, isLoading, currentPage, debouncedSearch, fetchPage]); + + // Reset and re-fetch from page 1 + const reset = useCallback(() => { + setPages([]); + setCurrentPage(1); + setHasMore(true); + fetchPage(1, debouncedSearch); + }, [debouncedSearch, fetchPage]); + + // Initial fetch on mount + useEffect(() => { + fetchPage(1, ''); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Re-fetch on debounced search change + useEffect(() => { + setPages([]); + setCurrentPage(1); + setHasMore(true); + fetchPage(1, debouncedSearch); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [debouncedSearch]); + + return { + data, + isLoading, + hasMore, + search, + debouncedSearch, + setSearch, + fetchNextPage, + reset, + }; +} diff --git a/packages/ui/src/components/Form/custom/selects/types.ts b/packages/ui/src/components/Form/custom/selects/types.ts new file mode 100644 index 0000000..b00594b --- /dev/null +++ b/packages/ui/src/components/Form/custom/selects/types.ts @@ -0,0 +1,61 @@ +import type { ComboboxItem } from '@mantine/core'; + +// --------------------------------------------------------------------------- +// ObjectSelect — Shared types for the reusable Object Select engine +// --------------------------------------------------------------------------- + +/** + * Filter context passed to the custom `filterOption` callback. + * Provides both the current search string and the currently selected value(s) + * so consumers can implement exclusion logic, compound search, or domain-specific filters. + */ +export interface ObjectSelectFilterContext { + /** Current search input value */ + search: string; + /** Currently selected value(s) — T | null for single, T[] for multi */ + selected: T[] | T | null; +} + +/** + * Core configuration for the Object Select engine. + * This is the "headless" API — no RHF dependency. + * + * @template T - The shape of each item in the data array + */ +export interface ObjectSelectBaseProps> { + /** Array of complex objects to select from */ + data: T[]; + + /** Property key to use as the unique string identifier for Mantine */ + valueKey: keyof T & string; + + /** Property key to use as the display label (simple mode) */ + labelKey?: keyof T & string; + + /** Custom label renderer — overrides `labelKey` for compound/custom labels */ + renderLabel?: (item: T) => string; + + /** Enable multi-select mode */ + multiple?: boolean; + + /** + * Custom filter function for search and exclusion logic. + * Return `true` to keep the item in the dropdown, `false` to exclude it. + */ + filterOption?: (item: T, context: ObjectSelectFilterContext) => boolean; + + /** Callback fired when selection changes */ + onSelect?: (value: T | T[] | null) => void; +} + +/** + * Internal result of the object-to-string mapping logic. + * Used by both the standalone and RHF-connected variants. + */ +export interface ObjectSelectMappingResult { + /** Mantine-compatible ComboboxItem array for the Select/MultiSelect `data` prop */ + options: ComboboxItem[]; + + /** O(1) reverse lookup map: string value → original object */ + lookupMap: Map; +} diff --git a/packages/ui/src/components/Form/fields/async-select.field.tsx b/packages/ui/src/components/Form/fields/async-select.field.tsx new file mode 100644 index 0000000..4e81e96 --- /dev/null +++ b/packages/ui/src/components/Form/fields/async-select.field.tsx @@ -0,0 +1,156 @@ +import React from 'react'; +import { + useController, + type FieldPath, + type FieldValues, + type UseControllerProps, +} from 'react-hook-form'; +import type { SelectProps, MultiSelectProps } from '@mantine/core'; +import { AsyncSelect } from '../custom/selects/AsyncSelect'; +import type { ObjectSelectBaseProps } from '../custom/selects/types'; +import { useTranslatedError } from '../useTranslatedError'; + +// --------------------------------------------------------------------------- +// FieldAsyncSelect — RHF-connected Async Select +// --------------------------------------------------------------------------- +// +// Thin RHF wrapper around the standalone AsyncSelect engine. +// Adds useController binding + i18n error translation. +// --------------------------------------------------------------------------- + +/** Mantine props we manage ourselves */ +type ManagedSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'onBlur' | 'error' | 'filter' | 'name' | 'onSelect'; +type ManagedMultiSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'onBlur' | 'error' | 'filter' | 'name' | 'onSelect'; + +/** Async-specific props */ +interface AsyncExtraProps { + apiEndpoint: string; + transformResponse?: (res: any) => { data: T[]; hasMore?: boolean } | T[]; + pageSize?: number; + debounceMs?: number; + fetchFn?: (url: string) => Promise; +} + +/** Single-select async RHF props */ +export type FieldAsyncSelectSingleProps< + T extends Record, + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = Omit, 'data'> & + AsyncExtraProps & + UseControllerProps & + Omit & { + multiple?: false; + }; + +/** Multi-select async RHF props */ +export type FieldAsyncSelectMultiProps< + T extends Record, + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = Omit, 'data'> & + AsyncExtraProps & + UseControllerProps & + Omit & { + multiple: true; + }; + +export type FieldAsyncSelectProps< + T extends Record, + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = + | FieldAsyncSelectSingleProps + | FieldAsyncSelectMultiProps; + +// --------------------------------------------------------------------------- +// Component Implementation +// --------------------------------------------------------------------------- + +function FieldAsyncSelectInner< + T extends Record, + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>(props: FieldAsyncSelectProps) { + const { + // RHF controller props + name, + control, + rules, + shouldUnregister, + defaultValue, + disabled, + // Object/Async engine props + valueKey, + labelKey, + renderLabel, + multiple, + filterOption, + onSelect: onObjectSelect, + apiEndpoint, + transformResponse, + pageSize, + debounceMs, + fetchFn, + // Remaining Mantine props + ...mantineProps + } = props; + + const { + field, + fieldState: { error }, + } = useController({ + name, + control, + rules, + shouldUnregister, + defaultValue, + disabled, + }); + + const translatedError = useTranslatedError(error?.message); + + const handleChange = (value: any) => { + field.onChange(value); + onObjectSelect?.(value); + }; + + const engineProps = { + valueKey, + labelKey, + renderLabel, + filterOption, + apiEndpoint, + transformResponse, + pageSize, + debounceMs, + fetchFn, + onBlur: field.onBlur, + error: translatedError, + disabled: field.disabled, + }; + + if (multiple) { + return ( + + multiple + {...engineProps} + value={field.value ?? []} + onChange={handleChange} + {...(mantineProps as any)} + /> + ); + } + + return ( + + {...engineProps} + value={field.value ?? null} + onChange={handleChange} + {...(mantineProps as any)} + /> + ); +} + +export const FieldAsyncSelect = React.memo(FieldAsyncSelectInner) as typeof FieldAsyncSelectInner; +(FieldAsyncSelect as any).displayName = 'FieldAsyncSelect'; diff --git a/packages/ui/src/components/Form/fields/object-select.field.tsx b/packages/ui/src/components/Form/fields/object-select.field.tsx new file mode 100644 index 0000000..8db8106 --- /dev/null +++ b/packages/ui/src/components/Form/fields/object-select.field.tsx @@ -0,0 +1,149 @@ +import React from 'react'; +import { + useController, + type FieldPath, + type FieldValues, + type UseControllerProps, +} from 'react-hook-form'; +import type { SelectProps, MultiSelectProps } from '@mantine/core'; +import { ObjectSelect } from '../custom/selects/ObjectSelect'; +import type { ObjectSelectBaseProps } from '../custom/selects/types'; +import { useTranslatedError } from '../useTranslatedError'; + +// --------------------------------------------------------------------------- +// FieldObjectSelect — RHF-connected Object Select +// --------------------------------------------------------------------------- +// +// Follows the same architectural pattern as the existing `FieldSelect`: +// Mantine Component → withRHF HOC → FieldXxx +// +// But instead of using the generic withRHF factory (which assumes string values), +// we use a manual useController binding with an object interception layer. +// The actual rendering is delegated to the standalone ObjectSelect engine +// in `custom/ObjectSelect.tsx`. +// --------------------------------------------------------------------------- + +/** Mantine props we manage ourselves */ +type ManagedSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'onBlur' | 'error' | 'filter' | 'name' | 'onSelect'; +type ManagedMultiSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'onBlur' | 'error' | 'filter' | 'name' | 'onSelect'; + +/** Single-select RHF props — stores T | null */ +export type FieldObjectSelectSingleProps< + T extends Record, + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = ObjectSelectBaseProps & + UseControllerProps & + Omit & { + multiple?: false; + }; + +/** Multi-select RHF props — stores T[] */ +export type FieldObjectSelectMultiProps< + T extends Record, + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = ObjectSelectBaseProps & + UseControllerProps & + Omit & { + multiple: true; + }; + +/** Discriminated union based on `multiple` */ +export type FieldObjectSelectProps< + T extends Record, + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = + | FieldObjectSelectSingleProps + | FieldObjectSelectMultiProps; + +// --------------------------------------------------------------------------- +// Component Implementation +// --------------------------------------------------------------------------- + +function FieldObjectSelectInner< + T extends Record, + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>(props: FieldObjectSelectProps) { + const { + // RHF controller props + name, + control, + rules, + shouldUnregister, + defaultValue, + disabled, + // ObjectSelect engine props + data, + valueKey, + labelKey, + renderLabel, + multiple, + filterOption, + onSelect: onObjectSelect, + // Remaining Mantine props + ...mantineProps + } = props; + + const { + field, + fieldState: { error }, + } = useController({ + name, + control, + rules, + shouldUnregister, + defaultValue, + disabled, + }); + + // Translate the error message (handles JSON i18n payloads) + const translatedError = useTranslatedError(error?.message); + + // Intercept onChange to pass full objects to RHF + const handleChange = (value: any) => { + field.onChange(value); + onObjectSelect?.(value); + }; + + // Build engine props based on single/multi mode + if (multiple) { + return ( + + multiple + data={data} + valueKey={valueKey} + labelKey={labelKey} + renderLabel={renderLabel} + filterOption={filterOption} + value={field.value ?? []} + onChange={handleChange} + onBlur={field.onBlur} + error={translatedError} + disabled={field.disabled} + {...(mantineProps as any)} + /> + ); + } + + return ( + + data={data} + valueKey={valueKey} + labelKey={labelKey} + renderLabel={renderLabel} + filterOption={filterOption} + value={field.value ?? null} + onChange={handleChange} + onBlur={field.onBlur} + error={translatedError} + disabled={field.disabled} + {...(mantineProps as any)} + /> + ); +} + +export const FieldObjectSelect = React.memo(FieldObjectSelectInner) as typeof FieldObjectSelectInner; +(FieldObjectSelect as any).displayName = 'FieldObjectSelect'; diff --git a/packages/ui/src/components/Form/index.ts b/packages/ui/src/components/Form/index.ts index c668a91..57f3bcc 100644 --- a/packages/ui/src/components/Form/index.ts +++ b/packages/ui/src/components/Form/index.ts @@ -37,6 +37,18 @@ export { FieldMultiSelect } from './fields/multi-select.field'; export { FieldNativeSelect } from './fields/native-select.field'; export { FieldTagsInput } from './fields/tags-input.field'; +// --------------------------------------------------------------------------- +// Object & Async Selection Fields +// --------------------------------------------------------------------------- +export { FieldObjectSelect } from './fields/object-select.field'; +export type { FieldObjectSelectProps } from './fields/object-select.field'; +export { FieldAsyncSelect } from './fields/async-select.field'; +export type { FieldAsyncSelectProps } from './fields/async-select.field'; + +// Standalone engines (no RHF dependency) for use outside form contexts +export { ObjectSelect, AsyncSelect } from './custom'; +export type { ObjectSelectProps, AsyncSelectProps, ObjectSelectBaseProps, ObjectSelectFilterContext } from './custom'; + // --------------------------------------------------------------------------- // Toggle / Boolean Fields // --------------------------------------------------------------------------- diff --git a/packages/ui/src/components/Form/useTranslatedError.ts b/packages/ui/src/components/Form/useTranslatedError.ts new file mode 100644 index 0000000..8bd519a --- /dev/null +++ b/packages/ui/src/components/Form/useTranslatedError.ts @@ -0,0 +1,78 @@ +import { useMemo } from 'react'; +import { useTranslation } from '@repo/core-i18n'; +import type { ZodI18nPayload } from './types'; + +// --------------------------------------------------------------------------- +// Helper: Attempt to parse a Zod error message as a JSON i18n payload +// --------------------------------------------------------------------------- + +export function tryParseI18nPayload(message: string): ZodI18nPayload | null { + // Quick guard: JSON payloads always start with '{' + if (!message.startsWith('{')) return null; + + try { + const parsed: unknown = JSON.parse(message); + + if ( + typeof parsed === 'object' && + parsed !== null && + 'key' in parsed && + typeof (parsed as ZodI18nPayload).key === 'string' + ) { + return parsed as ZodI18nPayload; + } + } catch { + // Not valid JSON — this is expected for plain string error messages + } + + return null; +} + +// --------------------------------------------------------------------------- +// useTranslatedError — Hook that resolves a raw error message into a +// user-facing translated string. +// --------------------------------------------------------------------------- + +export function useTranslatedError(rawMessage: string | undefined): string | undefined { + // Always call useTranslation — React hook rules require stable call order. + // The 'validation' namespace is used for Zod error keys. + // Falls back to 'common' automatically via i18next's ns resolution. + const { t, i18n } = useTranslation(); + + return useMemo(() => { + if (!rawMessage) return undefined; + + const payload = tryParseI18nPayload(rawMessage); + + if (payload) { + // Attempt to translate. If the key exists in i18n resources, we get + // the translated string. Otherwise i18next returns the key itself, + // and we fall back to the raw Zod message. + const translated = t(payload.key, { + ...payload.values, + ns: 'validation', + defaultValue: payload.key, // fallback to the key itself + }); + + // If i18next couldn't find the key (returned the key unchanged), + // try without namespace, then fall back to the raw Zod message. + if (translated === payload.key) { + const commonAttempt = t(payload.key, { + ...payload.values, + defaultValue: rawMessage, + }); + return commonAttempt; + } + + return translated; + } + + // Not a JSON payload — check if the raw message itself is a translation key + if (i18n.exists(rawMessage, { ns: 'validation' })) { + return t(rawMessage, { ns: 'validation' }); + } + + // Plain string error message — pass through as-is + return rawMessage; + }, [rawMessage, t, i18n]); +} diff --git a/packages/ui/src/components/Form/withRHF.tsx b/packages/ui/src/components/Form/withRHF.tsx index 7f73a01..f282d0f 100644 --- a/packages/ui/src/components/Form/withRHF.tsx +++ b/packages/ui/src/components/Form/withRHF.tsx @@ -1,4 +1,4 @@ -import React, { type ComponentType, type Ref, useMemo } from 'react'; +import React, { type ComponentType, type Ref } from 'react'; import { useController, type FieldPath, @@ -6,83 +6,8 @@ import { type UseControllerProps, } from 'react-hook-form'; import { Input } from '@mantine/core'; -import { useTranslation } from '@repo/core-i18n'; -import type { ZodI18nPayload, WithRHFOptions } from './types'; - -// --------------------------------------------------------------------------- -// Helper: Attempt to parse a Zod error message as a JSON i18n payload -// --------------------------------------------------------------------------- - -function tryParseI18nPayload(message: string): ZodI18nPayload | null { - // Quick guard: JSON payloads always start with '{' - if (!message.startsWith('{')) return null; - - try { - const parsed: unknown = JSON.parse(message); - - if ( - typeof parsed === 'object' && - parsed !== null && - 'key' in parsed && - typeof (parsed as ZodI18nPayload).key === 'string' - ) { - return parsed as ZodI18nPayload; - } - } catch { - // Not valid JSON — this is expected for plain string error messages - } - - return null; -} - -// --------------------------------------------------------------------------- -// useTranslatedError — Hook that resolves a raw error message into a -// user-facing translated string. -// --------------------------------------------------------------------------- - -function useTranslatedError(rawMessage: string | undefined): string | undefined { - // Always call useTranslation — React hook rules require stable call order. - // The 'validation' namespace is used for Zod error keys. - // Falls back to 'common' automatically via i18next's ns resolution. - const { t, i18n } = useTranslation(); - - return useMemo(() => { - if (!rawMessage) return undefined; - - const payload = tryParseI18nPayload(rawMessage); - - if (payload) { - // Attempt to translate. If the key exists in i18n resources, we get - // the translated string. Otherwise i18next returns the key itself, - // and we fall back to the raw Zod message. - const translated = t(payload.key, { - ...payload.values, - ns: 'validation', - defaultValue: payload.key, // fallback to the key itself - }); - - // If i18next couldn't find the key (returned the key unchanged), - // try without namespace, then fall back to the raw Zod message. - if (translated === payload.key) { - const commonAttempt = t(payload.key, { - ...payload.values, - defaultValue: rawMessage, - }); - return commonAttempt; - } - - return translated; - } - - // Not a JSON payload — check if the raw message itself is a translation key - if (i18n.exists(rawMessage, { ns: 'validation' })) { - return t(rawMessage, { ns: 'validation' }); - } - - // Plain string error message — pass through as-is - return rawMessage; - }, [rawMessage, t, i18n]); -} +import type { WithRHFOptions } from './types'; +import { useTranslatedError } from './useTranslatedError'; // --------------------------------------------------------------------------- // withRHF — Higher-Order Component Factory