feat: add reusable AsyncSelect and ObjectSelect components with infinite scroll hook support

This commit is contained in:
Firman Ramdhani
2026-06-16 01:23:36 +07:00
parent 19066dde89
commit 195928d56a
14 changed files with 1706 additions and 80 deletions
@@ -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']}
/>
</Group>
<Group grow align="flex-start" mb="md">
<FieldObjectSelect
name="objectSelect"
control={control}
label="Object Select"
placeholder="Select a complex object"
data={[
{ id: 1, name: 'Apple', type: 'Fruit' },
{ id: 2, name: 'Carrot', type: 'Vegetable' },
{ id: 3, name: 'Banana', type: 'Fruit' }
]}
valueKey="id"
labelKey="name"
clearable
/>
<FieldObjectSelect
multiple
name="multiObjectSelect"
control={control}
label="Multi Object Select"
placeholder="Select multiple objects"
data={[
{ id: 1, name: 'Red', hex: '#f00' },
{ id: 2, name: 'Green', hex: '#0f0' },
{ id: 3, name: 'Blue', hex: '#00f' }
]}
valueKey="id"
renderLabel={(item) => `${item.name} (${item.hex})`}
clearable
/>
</Group>
<Group grow align="flex-start" mb="md">
<FieldAsyncSelect
name="asyncSelect"
control={control}
label="Async Select (Mock API)"
placeholder="Search pokemon..."
apiEndpoint="/api/mock/pokemon"
fetchFn={fetchMockPokemon}
transformResponse={(res) => res.results}
valueKey="id"
labelKey="name"
clearable
/>
<FieldAsyncSelect
multiple
name="multiAsyncSelect"
control={control}
label="Multi Async Select"
placeholder="Select multiple pokemon..."
apiEndpoint="/api/mock/pokemon"
fetchFn={fetchMockPokemon}
transformResponse={(res) => res.results}
valueKey="id"
labelKey="name"
clearable
/>
</Group>
<Group grow align="flex-start" mb="md">
<FieldAsyncSelect
name="realPokeSelect"
control={control}
label="Real PokeAPI (Single - Tests Deduplication)"
placeholder="Scroll to test deduplication..."
apiEndpoint="https://pokeapi.co/api/v2/pokemon"
transformResponse={(res) => ({
data: res.results.map((p: any, i: number) => ({ id: i + 1, ...p })),
hasMore: !!res.next
})}
valueKey="id"
labelKey="name"
clearable
/>
<FieldAsyncSelect
multiple
name="multiRealPokeSelect"
control={control}
label="Real PokeAPI (Multi - Tests Deduplication)"
placeholder="Scroll to test deduplication..."
apiEndpoint="https://pokeapi.co/api/v2/pokemon"
transformResponse={(res) => ({
data: res.results.map((p: any, i: number) => ({ id: i + 1, ...p })),
hasMore: !!res.next
})}
valueKey="id"
labelKey="name"
clearable
/>
</Group>
<FieldTagsInput name="tags" control={control} label={t.fields.tags} />
</div>
+2
View File
@@ -683,6 +683,8 @@ export const FieldDatePicker = withRHF<DatePickerInputProps>(
| `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 |
---
@@ -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 (
<MantineProvider>
<form>
<FieldAsyncSelect
name="vendor"
control={control}
apiEndpoint="/api/vendors"
fetchFn={mockFetchFn}
transformResponse={(res) => res.items}
valueKey="id"
labelKey="name"
placeholder="Select async vendor"
/>
</form>
</MantineProvider>
);
}
render(<TestForm />);
// 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 (
<MantineProvider>
<form onSubmit={handleSubmit((data) => { capturedData = data; })}>
<FieldAsyncSelect
name="vendor"
control={control}
apiEndpoint="/api/vendors"
fetchFn={mockFetchFn}
transformResponse={(res) => res.items}
valueKey="id"
labelKey="name"
placeholder="Select vendor"
/>
<button type="submit">Submit</button>
</form>
</MantineProvider>
);
}
render(<TestForm />);
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 (
<MantineProvider>
<form>
<FieldAsyncSelect
name="vendor"
control={control}
apiEndpoint="/api/vendors"
fetchFn={partialFetchFn}
transformResponse={(res) => res.items}
valueKey="id"
labelKey="name"
placeholder="Select vendor"
/>
</form>
</MantineProvider>
);
}
render(<TestForm />);
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 (
<MantineProvider>
<form>
<FieldAsyncSelect
name="vendor"
control={control}
apiEndpoint="/api/vendors"
fetchFn={mockFetchFn}
transformResponse={(res) => res.items}
valueKey="id"
labelKey="name"
placeholder="Search vendor"
debounceMs={100} // fast debounce for test
/>
</form>
</MantineProvider>
);
}
render(<TestForm />);
// 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 (
<MantineProvider>
<form>
<FieldAsyncSelect
name="vendor"
control={control}
apiEndpoint="/api/bad-vendors"
fetchFn={badFetchFn}
transformResponse={(res) => res.items}
valueKey="id"
labelKey="name"
placeholder="Select bad vendor"
/>
</form>
</MantineProvider>
);
}
render(<TestForm />);
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();
});
});
@@ -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 (
<MantineProvider>
<FieldObjectSelect
name="vendor"
control={control}
data={VENDORS}
valueKey="id"
labelKey="name"
label="Vendor"
placeholder="Select vendor"
/>
</MantineProvider>
);
}
render(<TestForm />);
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 (
<MantineProvider>
<form onSubmit={handleSubmit((data) => { capturedData = data; })}>
<FieldObjectSelect
name="vendor"
control={control}
data={VENDORS}
valueKey="id"
labelKey="name"
placeholder="Select vendor"
/>
<button type="submit">Submit</button>
</form>
</MantineProvider>
);
}
render(<TestForm />);
// 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 (
<MantineProvider>
<FieldObjectSelect
name="vendor"
control={control}
data={VENDORS}
valueKey="id"
renderLabel={(item) => `${item.code} - ${item.name}`}
placeholder="Select vendor"
/>
</MantineProvider>
);
}
render(<TestForm />);
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 (
<MantineProvider>
<FieldObjectSelect
name="vendor"
control={control}
data={VENDORS}
valueKey="id"
labelKey="name"
placeholder="Select vendor"
filterOption={(item) => item.active === true}
/>
</MantineProvider>
);
}
render(<TestForm />);
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 (
<MantineProvider>
<form onSubmit={handleSubmit((data) => { capturedData = data; })}>
<FieldObjectSelect
multiple
name="vendors"
control={control}
data={VENDORS}
valueKey="id"
labelKey="name"
placeholder="Select vendors"
/>
<button type="submit">Submit</button>
</form>
</MantineProvider>
);
}
render(<TestForm />);
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 (
<MantineProvider>
<form onSubmit={handleSubmit((data) => { capturedData = data; })}>
<FieldObjectSelect
multiple
name="vendors"
control={control}
data={VENDORS}
valueKey="id"
labelKey="name"
clearable
/>
<button type="submit">Submit</button>
</form>
</MantineProvider>
);
}
const { container } = render(<TestForm />);
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 (
<MantineProvider>
<FieldObjectSelect
name="vendor"
control={control}
data={VENDORS}
valueKey="id"
labelKey="name"
placeholder="Select vendor"
onSelect={handleSelect}
/>
</MantineProvider>
);
}
render(<TestForm />);
await user.click(screen.getByPlaceholderText('Select vendor'));
await user.click(screen.getByText('Vendor 2'));
expect(handleSelect).toHaveBeenCalledWith(VENDORS[1]);
});
});
@@ -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';
@@ -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<T> {
apiEndpoint: string;
transformResponse?: (res: any) => { data: T[]; hasMore?: boolean } | T[];
pageSize?: number;
debounceMs?: number;
fetchFn?: (url: string) => Promise<any>;
}
/** Props for single-select async mode */
export type AsyncSelectSingleProps<T extends Record<string, any>> = Omit<ObjectSelectBaseProps<T>, 'data'> &
AsyncExtraProps<T> &
Omit<SelectProps, ManagedSelectProps> & {
multiple?: false;
value?: T | null;
onChange?: (value: T | null) => void;
};
/** Props for multi-select async mode */
export type AsyncSelectMultiProps<T extends Record<string, any>> = Omit<ObjectSelectBaseProps<T>, 'data'> &
AsyncExtraProps<T> &
Omit<MultiSelectProps, ManagedMultiSelectProps> & {
multiple: true;
value?: T[];
onChange?: (value: T[]) => void;
};
export type AsyncSelectProps<T extends Record<string, any>> = AsyncSelectSingleProps<T> | AsyncSelectMultiProps<T>;
// ---------------------------------------------------------------------------
// Helper: Resolve label for a data item
// ---------------------------------------------------------------------------
function resolveLabel<T extends Record<string, any>>(
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<T extends Record<string, any>>(props: AsyncSelectProps<T>) {
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<T>({
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<string>();
// 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<string, T>();
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<ComboboxItem[]>(() => {
let filtered = dataWithInjected;
if (filterOption) {
const context: ObjectSelectFilterContext<T> = {
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 ? <Loader size={16} /> : 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 (
<MultiSelect
{...(mantineProps as Omit<MultiSelectProps, ManagedMultiSelectProps>)}
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 (
<Select
{...(mantineProps as Omit<SelectProps, ManagedSelectProps>)}
data={baseOptions}
value={currentValue}
onChange={handleSingleChange}
searchable={searchable ?? true}
onSearchChange={handleSearchChange}
scrollAreaProps={scrollAreaProps}
filter={mantineFilter}
rightSection={rightSection}
/>
);
}
export const AsyncSelect = React.memo(AsyncSelectInner) as typeof AsyncSelectInner;
(AsyncSelect as any).displayName = 'AsyncSelect';
@@ -0,0 +1,181 @@
import React, { useMemo, useState, useCallback } from 'react';
import { Select, MultiSelect, type SelectProps, type MultiSelectProps, type ComboboxItem } from '@mantine/core';
import type { ObjectSelectBaseProps, ObjectSelectFilterContext } from './types';
// ---------------------------------------------------------------------------
// ObjectSelect — Reusable Select engine for complex object data
// ---------------------------------------------------------------------------
//
// This component is the STANDALONE (non-RHF) version. It bridges Mantine's
// string-based Select/MultiSelect with object data by:
// 1. Mapping T[] → ComboboxItem[] via valueKey + labelKey/renderLabel
// 2. Building a Map<string, T> for O(1) reverse lookups
// 3. Intercepting onChange to resolve strings back to full objects
//
// The RHF-connected version (FieldObjectSelect) wraps this component and
// binds it to useController, following the same pattern as withRHF → FieldXxx.
// ---------------------------------------------------------------------------
/** Mantine props we manage ourselves — stripped from the pass-through */
type ManagedSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'filter' | 'name' | 'onSelect';
type ManagedMultiSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'filter' | 'name' | 'onSelect';
/** Props for single-select mode */
export type ObjectSelectSingleProps<T extends Record<string, any>> =
ObjectSelectBaseProps<T> & Omit<SelectProps, ManagedSelectProps> & {
multiple?: false;
/** Controlled value — the full object or null */
value?: T | null;
/** Called when the selection changes */
onChange?: (value: T | null) => void;
};
/** Props for multi-select mode */
export type ObjectSelectMultiProps<T extends Record<string, any>> =
ObjectSelectBaseProps<T> & Omit<MultiSelectProps, ManagedMultiSelectProps> & {
multiple: true;
/** Controlled value — array of full objects */
value?: T[];
/** Called when the selection changes */
onChange?: (value: T[]) => void;
};
/** Discriminated union — the component narrows based on `multiple` */
export type ObjectSelectProps<T extends Record<string, any>> =
| ObjectSelectSingleProps<T>
| ObjectSelectMultiProps<T>;
// ---------------------------------------------------------------------------
// Helper: Resolve label for a data item
// ---------------------------------------------------------------------------
function resolveLabel<T extends Record<string, any>>(
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 ObjectSelectInner<T extends Record<string, any>>(
props: ObjectSelectProps<T>,
) {
const {
data,
valueKey,
labelKey,
renderLabel,
multiple,
filterOption,
onSelect: onObjectSelect,
value,
onChange,
searchable,
...mantineProps
} = props;
// Track search input for filterOption
const [searchValue, setSearchValue] = useState('');
// Build lookup map: string → original object (O(1) reverse lookup)
const lookupMap = useMemo(() => {
const map = new Map<string, T>();
for (const item of data) {
map.set(String(item[valueKey]), item);
}
return map;
}, [data, valueKey]);
// Build Mantine-compatible ComboboxItem[], applying filterOption if provided
const options = useMemo<ComboboxItem[]>(() => {
let filtered = data;
if (filterOption) {
const context: ObjectSelectFilterContext<T> = {
search: searchValue,
selected: value ?? (multiple ? [] : null),
};
filtered = data.filter((item) => filterOption(item, context));
}
return filtered.map((item) => ({
value: String(item[valueKey]),
label: resolveLabel(item, labelKey, renderLabel),
}));
}, [data, valueKey, labelKey, renderLabel, filterOption, searchValue, value, multiple]);
// Handle search input changes
const handleSearchChange = useCallback(
(val: string) => {
setSearchValue(val);
// Forward to consumer's onSearchChange if provided
if ('onSearchChange' in mantineProps && typeof mantineProps.onSearchChange === 'function') {
mantineProps.onSearchChange(val);
}
},
[mantineProps],
);
// Passthrough filter — we handle filtering ourselves via filterOption in useMemo.
// This prevents Mantine from double-filtering.
const mantineFilter = filterOption
? ({ options: opts }: { options: ComboboxItem[] }) => opts
: undefined;
// ----- 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 (
<MultiSelect
{...(mantineProps as Omit<MultiSelectProps, ManagedMultiSelectProps>)}
data={options}
value={currentValues}
onChange={handleMultiChange}
searchable={searchable ?? false}
onSearchChange={handleSearchChange}
filter={mantineFilter as any}
/>
);
}
// ----- 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 (
<Select
{...(mantineProps as Omit<SelectProps, ManagedSelectProps>)}
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';
@@ -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<T extends Record<string, any>> {
/** 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<any>;
}
export interface UseAsyncSelectInfiniteScrollReturn<T> {
/** 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<T extends Record<string, any>>(
options: UseAsyncSelectInfiniteScrollOptions<T>,
): UseAsyncSelectInfiniteScrollReturn<T> {
const {
apiEndpoint,
valueKey,
pageSize = 20,
transformResponse = (res) => res as T[],
debounceMs = 300,
fetchFn,
} = options;
const [pages, setPages] = useState<T[][]>([]);
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,
};
}
@@ -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<T> {
/** 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<T extends Record<string, any>> {
/** 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<T>) => 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<T> {
/** Mantine-compatible ComboboxItem array for the Select/MultiSelect `data` prop */
options: ComboboxItem[];
/** O(1) reverse lookup map: string value → original object */
lookupMap: Map<string, T>;
}
@@ -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<T> {
apiEndpoint: string;
transformResponse?: (res: any) => { data: T[]; hasMore?: boolean } | T[];
pageSize?: number;
debounceMs?: number;
fetchFn?: (url: string) => Promise<any>;
}
/** Single-select async RHF props */
export type FieldAsyncSelectSingleProps<
T extends Record<string, any>,
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = Omit<ObjectSelectBaseProps<T>, 'data'> &
AsyncExtraProps<T> &
UseControllerProps<TFieldValues, TName> &
Omit<SelectProps, ManagedSelectProps> & {
multiple?: false;
};
/** Multi-select async RHF props */
export type FieldAsyncSelectMultiProps<
T extends Record<string, any>,
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = Omit<ObjectSelectBaseProps<T>, 'data'> &
AsyncExtraProps<T> &
UseControllerProps<TFieldValues, TName> &
Omit<MultiSelectProps, ManagedMultiSelectProps> & {
multiple: true;
};
export type FieldAsyncSelectProps<
T extends Record<string, any>,
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> =
| FieldAsyncSelectSingleProps<T, TFieldValues, TName>
| FieldAsyncSelectMultiProps<T, TFieldValues, TName>;
// ---------------------------------------------------------------------------
// Component Implementation
// ---------------------------------------------------------------------------
function FieldAsyncSelectInner<
T extends Record<string, any>,
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>(props: FieldAsyncSelectProps<T, TFieldValues, TName>) {
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<TFieldValues, TName>({
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 (
<AsyncSelect<T>
multiple
{...engineProps}
value={field.value ?? []}
onChange={handleChange}
{...(mantineProps as any)}
/>
);
}
return (
<AsyncSelect<T>
{...engineProps}
value={field.value ?? null}
onChange={handleChange}
{...(mantineProps as any)}
/>
);
}
export const FieldAsyncSelect = React.memo(FieldAsyncSelectInner) as typeof FieldAsyncSelectInner;
(FieldAsyncSelect as any).displayName = 'FieldAsyncSelect';
@@ -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<string, any>,
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = ObjectSelectBaseProps<T> &
UseControllerProps<TFieldValues, TName> &
Omit<SelectProps, ManagedSelectProps> & {
multiple?: false;
};
/** Multi-select RHF props — stores T[] */
export type FieldObjectSelectMultiProps<
T extends Record<string, any>,
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = ObjectSelectBaseProps<T> &
UseControllerProps<TFieldValues, TName> &
Omit<MultiSelectProps, ManagedMultiSelectProps> & {
multiple: true;
};
/** Discriminated union based on `multiple` */
export type FieldObjectSelectProps<
T extends Record<string, any>,
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> =
| FieldObjectSelectSingleProps<T, TFieldValues, TName>
| FieldObjectSelectMultiProps<T, TFieldValues, TName>;
// ---------------------------------------------------------------------------
// Component Implementation
// ---------------------------------------------------------------------------
function FieldObjectSelectInner<
T extends Record<string, any>,
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>(props: FieldObjectSelectProps<T, TFieldValues, TName>) {
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<TFieldValues, TName>({
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 (
<ObjectSelect<T>
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 (
<ObjectSelect<T>
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';
+12
View File
@@ -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
// ---------------------------------------------------------------------------
@@ -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]);
}
+3 -78
View File
@@ -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