feat: replace ObjectSelect with LocalSelect and introduce custom FieldSelect wrappers for object-based RHF state management

This commit is contained in:
Firman Ramdhani
2026-06-22 11:06:29 +07:00
parent 195928d56a
commit eaec6ec38f
15 changed files with 1240 additions and 438 deletions
@@ -6,8 +6,9 @@ import {
FieldMultiSelect, FieldNativeSelect, FieldTagsInput, FieldCheckbox, FieldMultiSelect, FieldNativeSelect, FieldTagsInput, FieldCheckbox,
FieldRadioGroup, FieldSwitch, FieldChipGroup, FieldSegmentedControl, FieldRadioGroup, FieldSwitch, FieldChipGroup, FieldSegmentedControl,
FieldSlider, FieldRangeSlider, FieldRating, FieldColorInput, FieldSlider, FieldRangeSlider, FieldRating, FieldColorInput,
FieldColorPicker, FieldFileInput, FieldObjectSelect, FieldAsyncSelect FieldColorPicker, FieldFileInput, FieldLocalSelect, FieldAsyncSelect
} from '@repo/ui/form'; } from '@repo/ui/form';
import type { LoadOptionsFn } from '@repo/ui/form';
import { useFormDemoTranslation } from '../i18n/useFormDemoTranslation'; import { useFormDemoTranslation } from '../i18n/useFormDemoTranslation';
const MOCK_POKEMON = Array.from({ length: 100 }, (_, i) => ({ const MOCK_POKEMON = Array.from({ length: 100 }, (_, i) => ({
@@ -15,22 +16,39 @@ const MOCK_POKEMON = Array.from({ length: 100 }, (_, i) => ({
name: `Pokemon ${i + 1}`, name: `Pokemon ${i + 1}`,
})); }));
const fetchMockPokemon = async (url: string) => { const loadMockPokemonOptions: LoadOptionsFn<any> = async (search, page) => {
// 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)); await new Promise((resolve) => setTimeout(resolve, 500));
const filtered = MOCK_POKEMON.filter((p) => p.name.toLowerCase().includes(search.toLowerCase()));
// Filter and paginate const pageSize = 20;
const filtered = MOCK_POKEMON.filter((p) => p.name.toLowerCase().includes(search));
const start = (page - 1) * pageSize; const start = (page - 1) * pageSize;
const paginated = filtered.slice(start, start + pageSize); const paginated = filtered.slice(start, start + pageSize);
return {
options: paginated,
hasMore: start + pageSize < filtered.length,
};
};
return { results: paginated }; const loadRealPokemonOptions: LoadOptionsFn<any> = async (_search, page) => {
const limit = 20;
const offset = (page - 1) * limit;
const res = await fetch(`https://pokeapi.co/api/v2/pokemon?limit=${limit}&offset=${offset}`);
const data = await res.json();
return {
options: data.results.map((p: any, i: number) => ({ id: offset + i + 1, ...p })),
hasMore: !!data.next,
};
};
const MOCK_VENDORS = [
{ id: 'V1', code: 'VN-01', name: 'Vendor One' },
{ id: 'V2', code: 'VN-02', name: 'Vendor Two' },
{ id: 'V3', code: 'VN-03', name: 'Vendor Three' },
];
const loadMockVendorsOptions: LoadOptionsFn<any> = async (search, _page) => {
await new Promise((resolve) => setTimeout(resolve, 500));
const filtered = MOCK_VENDORS.filter(v => v.name.toLowerCase().includes(search.toLowerCase()) || v.code.toLowerCase().includes(search.toLowerCase()));
return { options: filtered, hasMore: false };
}; };
export default function AllFieldsDemo() { export default function AllFieldsDemo() {
@@ -61,10 +79,18 @@ export default function AllFieldsDemo() {
themeColor: '', themeColor: '',
colorPicker: '#1c7ed6', colorPicker: '#1c7ed6',
avatar: null, avatar: null,
objectSelect: null, localSelectEmpty: null,
asyncSelect: null, localSelectPrefilled: { id: 'V2', code: 'VN-02', name: 'Vendor Two' },
multiObjectSelect: [], asyncSelectEmpty: null,
multiAsyncSelect: [], asyncSelectPrefilled: { id: 999, code: 'ASYNC-99', name: 'Pre-loaded Async Vendor' },
localMultiPrefilled: [
{ id: 'V1', code: 'VN-01', name: 'Vendor One' },
{ id: 'V2', code: 'VN-02', name: 'Vendor Two' }
],
asyncMultiPrefilled: [
{ id: 888, code: 'ASYNC-88', name: 'Ghost Async Vendor 1' },
{ id: 999, code: 'ASYNC-99', name: 'Ghost Async Vendor 2' }
],
realPokeSelect: null, realPokeSelect: null,
multiRealPokeSelect: [] multiRealPokeSelect: []
} }
@@ -133,12 +159,12 @@ export default function AllFieldsDemo() {
/> />
</Group> </Group>
<Group grow align="flex-start" mb="md"> <Group grow align="flex-start" mb="md">
<FieldObjectSelect <FieldLocalSelect
name="objectSelect" name="localSelect"
control={control} control={control}
label="Object Select" label="Local Select"
placeholder="Select a complex object" placeholder="Select a complex object"
data={[ options={[
{ id: 1, name: 'Apple', type: 'Fruit' }, { id: 1, name: 'Apple', type: 'Fruit' },
{ id: 2, name: 'Carrot', type: 'Vegetable' }, { id: 2, name: 'Carrot', type: 'Vegetable' },
{ id: 3, name: 'Banana', type: 'Fruit' } { id: 3, name: 'Banana', type: 'Fruit' }
@@ -147,13 +173,13 @@ export default function AllFieldsDemo() {
labelKey="name" labelKey="name"
clearable clearable
/> />
<FieldObjectSelect <FieldLocalSelect
multiple multiple
name="multiObjectSelect" name="multiLocalSelect"
control={control} control={control}
label="Multi Object Select" label="Multi Local Select"
placeholder="Select multiple objects" placeholder="Select multiple objects"
data={[ options={[
{ id: 1, name: 'Red', hex: '#f00' }, { id: 1, name: 'Red', hex: '#f00' },
{ id: 2, name: 'Green', hex: '#0f0' }, { id: 2, name: 'Green', hex: '#0f0' },
{ id: 3, name: 'Blue', hex: '#00f' } { id: 3, name: 'Blue', hex: '#00f' }
@@ -169,9 +195,7 @@ export default function AllFieldsDemo() {
control={control} control={control}
label="Async Select (Mock API)" label="Async Select (Mock API)"
placeholder="Search pokemon..." placeholder="Search pokemon..."
apiEndpoint="/api/mock/pokemon" loadOptions={loadMockPokemonOptions}
fetchFn={fetchMockPokemon}
transformResponse={(res) => res.results}
valueKey="id" valueKey="id"
labelKey="name" labelKey="name"
clearable clearable
@@ -182,9 +206,7 @@ export default function AllFieldsDemo() {
control={control} control={control}
label="Multi Async Select" label="Multi Async Select"
placeholder="Select multiple pokemon..." placeholder="Select multiple pokemon..."
apiEndpoint="/api/mock/pokemon" loadOptions={loadMockPokemonOptions}
fetchFn={fetchMockPokemon}
transformResponse={(res) => res.results}
valueKey="id" valueKey="id"
labelKey="name" labelKey="name"
clearable clearable
@@ -196,11 +218,7 @@ export default function AllFieldsDemo() {
control={control} control={control}
label="Real PokeAPI (Single - Tests Deduplication)" label="Real PokeAPI (Single - Tests Deduplication)"
placeholder="Scroll to test deduplication..." placeholder="Scroll to test deduplication..."
apiEndpoint="https://pokeapi.co/api/v2/pokemon" loadOptions={loadRealPokemonOptions}
transformResponse={(res) => ({
data: res.results.map((p: any, i: number) => ({ id: i + 1, ...p })),
hasMore: !!res.next
})}
valueKey="id" valueKey="id"
labelKey="name" labelKey="name"
clearable clearable
@@ -211,17 +229,82 @@ export default function AllFieldsDemo() {
control={control} control={control}
label="Real PokeAPI (Multi - Tests Deduplication)" label="Real PokeAPI (Multi - Tests Deduplication)"
placeholder="Scroll to test deduplication..." placeholder="Scroll to test deduplication..."
apiEndpoint="https://pokeapi.co/api/v2/pokemon" loadOptions={loadRealPokemonOptions}
transformResponse={(res) => ({
data: res.results.map((p: any, i: number) => ({ id: i + 1, ...p })),
hasMore: !!res.next
})}
valueKey="id" valueKey="id"
labelKey="name" labelKey="name"
clearable clearable
/> />
</Group> </Group>
<FieldTagsInput name="tags" control={control} label={t.fields.tags} /> <FieldTagsInput name="tags" control={control} label={t.fields.tags} />
<Title order={5} mb="sm" mt="lg" c="brand">Advanced Object Selects (Custom Labels & Default Values)</Title>
<Divider mb="md" />
<Group grow align="flex-start" mb="md">
<FieldLocalSelect
name="localSelectEmpty"
control={control}
label="Local Empty"
options={MOCK_VENDORS}
valueKey="id"
renderLabel={(item) => `[${item.code}] ${item.name}`}
clearable
/>
<FieldLocalSelect
name="localSelectPrefilled"
control={control}
label="Local Prefilled"
options={MOCK_VENDORS}
valueKey="id"
renderLabel={(item) => `[${item.code}] ${item.name}`}
clearable
/>
</Group>
<Group grow align="flex-start" mb="md">
<FieldAsyncSelect
name="asyncSelectEmpty"
control={control}
label="Async Empty"
loadOptions={loadMockVendorsOptions}
valueKey="id"
renderLabel={(item) => `[${item.code}] ${item.name}`}
clearable
/>
<FieldAsyncSelect
name="asyncSelectPrefilled"
control={control}
label="Async Prefilled (Edit Mode)"
loadOptions={loadMockVendorsOptions}
valueKey="id"
renderLabel={(item) => `[${item.code}] ${item.name}`}
defaultOptions={[{ id: 999, code: 'ASYNC-99', name: 'Pre-loaded Async Vendor' }]}
clearable
/>
</Group>
<Title order={5} mb="sm" mt="lg" c="brand">Multi-Select Edit Mode (No defaultOptions fallback)</Title>
<Divider mb="md" />
<Group grow align="flex-start" mb="md">
<FieldLocalSelect
multiple
name="localMultiPrefilled"
control={control}
label="Local Multi Prefilled"
options={MOCK_VENDORS}
valueKey="id"
renderLabel={(item) => `[${item.code}] ${item.name}`}
clearable
/>
<FieldAsyncSelect
multiple
name="asyncMultiPrefilled"
control={control}
label="Async Multi Prefilled (Ghost Items)"
loadOptions={loadMockVendorsOptions}
valueKey="id"
renderLabel={(item) => `[${item.code}] ${item.name}`}
clearable
/>
</Group>
</div> </div>
{/* --- Toggles & Choices --- */} {/* --- Toggles & Choices --- */}
@@ -2,11 +2,49 @@ import { useForm, useWatch } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
import { Button, Paper, Title, Divider, Stack, Code, Alert } from '@repo/ui/components'; import { Button, Paper, Title, Divider, Stack, Code, Alert } from '@repo/ui/components';
import { FieldTextInput, FieldSelect, FieldSwitch } from '@repo/ui/form'; import { FieldTextInput, FieldSelect, FieldSwitch, FieldLocalSelect, FieldAsyncSelect } from '@repo/ui/form';
import { useConditionalField } from '@repo/ui/hooks'; import { useConditionalField } from '@repo/ui/hooks';
import { compose, required, emailValidator } from '@repo/ui/validators'; import { compose, required, emailValidator } from '@repo/ui/validators';
import { useFormDemoTranslation } from '../i18n/useFormDemoTranslation'; import { useFormDemoTranslation } from '../i18n/useFormDemoTranslation';
import { Info } from 'lucide-react'; import { Info } from 'lucide-react';
import { useEffect, useCallback, useRef } from 'react';
interface Region {
id: string;
code: string;
taxRate: number;
}
interface Warehouse {
id: string;
regionId: string;
name: string;
}
const REGIONS: Region[] = [
{ id: 'R1', code: 'APAC', taxRate: 0.1 },
{ id: 'R2', code: 'EMEA', taxRate: 0.2 }
];
const mockFetchWarehouses = async (regionIds: string[], search: string, page: number) => {
await new Promise((resolve) => setTimeout(resolve, 500));
const allWarehouses: Warehouse[] = [
{ id: 'W1', regionId: 'R1', name: 'Singapore Hub' },
{ id: 'W2', regionId: 'R1', name: 'Tokyo Depot' },
{ id: 'W3', regionId: 'R2', name: 'London Central' },
{ id: 'W4', regionId: 'R2', name: 'Berlin Storage' },
];
const filtered = allWarehouses.filter(w => regionIds.includes(w.regionId) && w.name.toLowerCase().includes(search.toLowerCase()));
const pageSize = 10;
const start = (page - 1) * pageSize;
const paginated = filtered.slice(start, start + pageSize);
return {
options: paginated,
hasMore: start + pageSize < filtered.length
};
};
export default function ReactiveWatchDemo() { export default function ReactiveWatchDemo() {
const t = useFormDemoTranslation(); const t = useFormDemoTranslation();
@@ -27,6 +65,8 @@ export default function ReactiveWatchDemo() {
newsletterEmail: z.string().optional(), newsletterEmail: z.string().optional(),
department: z.string().optional(), department: z.string().optional(),
role: z.string().optional(), role: z.string().optional(),
regions: z.array(z.object({ id: z.string(), code: z.string(), taxRate: z.number() })).optional(),
warehouses: z.array(z.object({ id: z.string(), regionId: z.string(), name: z.string() })).optional(),
}) })
.and( .and(
z.discriminatedUnion('userType', [ z.discriminatedUnion('userType', [
@@ -64,6 +104,11 @@ export default function ReactiveWatchDemo() {
newsletterEmail: '', newsletterEmail: '',
department: '', department: '',
role: '', role: '',
regions: [{ id: 'R1', code: 'APAC', taxRate: 0.1 }],
warehouses: [
{ id: 'W-99', regionId: 'R1', name: 'APAC Central Hub' },
{ id: 'W-98', regionId: 'R1', name: 'APAC Backup Hub' }
],
}, },
}); });
@@ -73,6 +118,7 @@ export default function ReactiveWatchDemo() {
const newsletter = useWatch({ control, name: 'newsletter' }); const newsletter = useWatch({ control, name: 'newsletter' });
const department = useWatch({ control, name: 'department' }); const department = useWatch({ control, name: 'department' });
const role = useWatch({ control, name: 'role' }); const role = useWatch({ control, name: 'role' });
const regions = useWatch({ control, name: 'regions' });
// Use the custom hook to cleanly unregister and reset fields when hidden // Use the custom hook to cleanly unregister and reset fields when hidden
useConditionalField({ useConditionalField({
@@ -133,6 +179,27 @@ export default function ReactiveWatchDemo() {
defaultValue: '', defaultValue: '',
}); });
const isMounted = useRef(false);
const prevRegionIds = useRef<string[]>(regions?.map((r: Region) => r.id) || []);
useEffect(() => {
if (!isMounted.current) {
isMounted.current = true;
return;
}
const currentIds = regions?.map((r: Region) => r.id) || [];
const prevIds = prevRegionIds.current;
const hasChanged = currentIds.length !== prevIds.length || currentIds.some((id: string) => !prevIds.includes(id));
if (hasChanged) {
setValue('warehouses', []);
clearErrors('warehouses');
prevRegionIds.current = currentIds;
}
}, [regions, setValue, clearErrors]);
// Use watch only to display the JSON output at the bottom // Use watch only to display the JSON output at the bottom
const allValues = useWatch({ control }); const allValues = useWatch({ control });
@@ -223,6 +290,44 @@ export default function ReactiveWatchDemo() {
withAsterisk={!!department} withAsterisk={!!department}
/> />
<Title order={5} mb="sm" c="brand" mt="lg">
Cascading Object Selects
</Title>
<Divider mb="sm" />
<FieldLocalSelect<Region>
multiple
name="regions"
control={control as any}
label="Regions"
options={REGIONS}
valueKey="id"
labelKey="code"
clearable
/>
<FieldAsyncSelect<Warehouse>
multiple
key={`warehouse-select-${regions?.map((r: any) => r.id).join(',')}`}
name="warehouses"
control={control as any}
label="Warehouses"
disabled={!regions || regions.length === 0}
loadOptions={useCallback(async (search, page) => {
if (!regions || regions.length === 0) return { options: [], hasMore: false };
return mockFetchWarehouses(regions.map((r: any) => r.id), search, page);
}, [regions])}
valueKey="id"
renderLabel={(item) => `[${item.id}] ${item.name}`}
clearable
/>
{regions && regions.length > 0 && (
<Alert mt="sm" color="teal">
Selected regions tax rates: {regions.map((r: any) => `${r.code} (${(r.taxRate * 100).toFixed(0)}%)`).join(', ')}
</Alert>
)}
<Button type="submit" mt="md"> <Button type="submit" mt="md">
{t.common?.submit || 'Submit Reactive Form'} {t.common?.submit || 'Submit Reactive Form'}
</Button> </Button>
@@ -3,8 +3,55 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
import { Button, Paper, Title, Group, Stack, Code, Divider } from '@repo/ui/components'; import { Button, Paper, Title, Group, Stack, Code, Divider } from '@repo/ui/components';
import { import {
FieldTextInput, FieldPasswordInput, FieldNumberInput FieldTextInput, FieldPasswordInput, FieldNumberInput,
FieldLocalSelect, FieldAsyncSelect
} from '@repo/ui/form'; } from '@repo/ui/form';
import type { LoadOptionsFn } from '@repo/ui/form';
interface Department {
code: string;
name: string;
costCenter: string;
}
interface Assignee {
id: number;
email: string;
}
const MOCK_DEPARTMENTS: Department[] = [
{ code: 'IT', name: 'Information Technology', costCenter: 'CC-100' },
{ code: 'HR', name: 'Human Resources', costCenter: 'CC-200' },
{ code: 'FIN', name: 'Finance', costCenter: 'CC-300' },
];
const mockFetchUsers: LoadOptionsFn<Assignee> = async (search, page) => {
await new Promise((resolve) => setTimeout(resolve, 500));
const allUsers = Array.from({ length: 20 }, (_, i) => ({
id: i + 1,
email: `user${i + 1}@company.com`
}));
const filtered = allUsers.filter(u => u.email.toLowerCase().includes(search.toLowerCase()));
const pageSize = 5;
const start = (page - 1) * pageSize;
const paginated = filtered.slice(start, start + pageSize);
return {
options: paginated,
hasMore: start + pageSize < filtered.length
};
};
const MOCK_VENDORS = [
{ id: 'V1', code: 'VN-01', name: 'Vendor One' },
{ id: 'V2', code: 'VN-02', name: 'Vendor Two' },
];
const mockFetchVendors: LoadOptionsFn<any> = async (search, _page) => {
await new Promise((resolve) => setTimeout(resolve, 500));
const filtered = MOCK_VENDORS.filter(v => v.name.toLowerCase().includes(search.toLowerCase()) || v.code.toLowerCase().includes(search.toLowerCase()));
return { options: filtered, hasMore: false };
};
import { import {
compose, required, rangeLength, compose, required, rangeLength,
positiveNumber, simplePassword, positiveNumber, simplePassword,
@@ -22,7 +69,12 @@ export default function ValidationBankDemo() {
complexPass: compose(z.string(), required(t.validation.complexPassword), complexPassword(8)), complexPass: compose(z.string(), required(t.validation.complexPassword), complexPassword(8)),
age: compose(z.number(), required(t.fields.age), rangeValue(18, 65, t.fields.age)), age: compose(z.number(), required(t.fields.age), rangeValue(18, 65, t.fields.age)),
score: compose(z.number(), required(t.validation.score), positiveNumber(t.validation.score)), score: compose(z.number(), required(t.validation.score), positiveNumber(t.validation.score)),
phone: compose(z.string(), required(t.validation.phone), phoneValidator()) phone: compose(z.string(), required(t.validation.phone), phoneValidator()),
department: z.object({ code: z.string(), name: z.string() }, { required_error: 'Department is required' }),
assignees: z.array(z.object({ id: z.number(), email: z.string() })).min(2, "Select at least 2 assignees"),
prefilledVendor: z.object({ id: z.union([z.string(), z.number()]), code: z.string(), name: z.string() }, { required_error: 'Vendor is required' }),
emptyVendor: z.object({ id: z.union([z.string(), z.number()]), code: z.string(), name: z.string() }, { required_error: 'Vendor is required' }),
prefilledAsyncMulti: z.array(z.object({ id: z.union([z.string(), z.number()]), code: z.string(), name: z.string() })).min(1, "Select at least 1 vendor"),
}); });
type ValidationFormValues = z.infer<typeof validationSchema>; type ValidationFormValues = z.infer<typeof validationSchema>;
@@ -35,7 +87,15 @@ export default function ValidationBankDemo() {
complexPass: '', complexPass: '',
age: undefined as any, age: undefined as any,
score: undefined as any, score: undefined as any,
phone: '' phone: '',
department: null as any,
assignees: [],
prefilledVendor: { id: 'V1', code: 'VN-01', name: 'Vendor One' } as any,
emptyVendor: null as any,
prefilledAsyncMulti: [
{ id: 'V1', code: 'VN-01', name: 'Vendor One' },
{ id: 'V2', code: 'VN-02', name: 'Vendor Two' }
] as any,
} }
}); });
@@ -100,6 +160,72 @@ export default function ValidationBankDemo() {
withAsterisk withAsterisk
/> />
<Title order={5} c="brand" mt="md">Object Level Validations (Local & Async)</Title>
<Divider mb="sm" />
<FieldLocalSelect<Department>
name="department"
control={control as any}
label="Department"
options={MOCK_DEPARTMENTS}
valueKey="code"
renderLabel={(item) => `[${item.code}] ${item.name}`}
clearable
withAsterisk
/>
<FieldAsyncSelect<Assignee>
multiple
name="assignees"
control={control as any}
label="Assignees"
loadOptions={mockFetchUsers}
valueKey="id"
labelKey="email"
searchable
clearable
withAsterisk
/>
<Title order={5} c="brand" mt="lg">Validated Prefilled Objects</Title>
<Divider mb="sm" />
<Group grow align="flex-start">
<FieldAsyncSelect
name="emptyVendor"
control={control as any}
label="Empty Vendor"
loadOptions={mockFetchVendors}
valueKey="id"
renderLabel={(item) => `[${item.code}] ${item.name}`}
clearable
withAsterisk
/>
<FieldAsyncSelect
name="prefilledVendor"
control={control as any}
label="Prefilled Vendor"
loadOptions={mockFetchVendors}
valueKey="id"
renderLabel={(item) => `[${item.code}] ${item.name}`}
defaultOptions={[{ id: 'V1', code: 'VN-01', name: 'Vendor One' }]}
clearable
withAsterisk
/>
</Group>
<FieldAsyncSelect
multiple
name="prefilledAsyncMulti"
control={control as any}
label="Prefilled Async Multi (No Fallback)"
loadOptions={mockFetchVendors}
valueKey="id"
renderLabel={(item) => `[${item.code}] ${item.name}`}
clearable
withAsterisk
/>
<Button type="submit" mt="md">{t.common.submit}</Button> <Button type="submit" mt="md">{t.common.submit}</Button>
</Stack> </Stack>
</form> </form>
+206 -2
View File
@@ -479,6 +479,210 @@ export function DepartmentForm() {
--- ---
## Object & Async Select Components
Mantine's native `Select` and `MultiSelect` are string-based: they store `string | null` and `string[]` respectively. In enterprise applications, we often need to store **full objects** (`T | null` or `T[]`) in RHF state — for example, a user object `{ id: '1', name: 'Alice', email: 'alice@co.com' }` rather than just `'1'`.
The **LocalSelect** and **AsyncSelect** engines bridge this gap by:
1. Mapping `T[]``ComboboxItem[]` for Mantine rendering (via `valueKey` + `labelKey`/`renderLabel`)
2. Building an O(1) reverse lookup map (`Map<string, T>`) for resolving string changes back to full objects
3. Intercepting `onChange` to pass resolved objects to RHF
> [!IMPORTANT]
> These components are **separate** from the native `FieldSelect` and `FieldMultiSelect`, which continue to work as simple string-based Mantine wrappers. Use `FieldLocalSelect`/`FieldAsyncSelect` only when you need to store full objects in RHF state.
### Single vs. Multi-Select Data Mapping
| Mode | Mantine Component | RHF Value | Mantine `value` Prop | `onChange` Payload |
|---|---|---|---|---|
| `multiple={false}` (default) | `<Select />` | `T \| null` | `string \| null` | `T \| null` |
| `multiple={true}` | `<MultiSelect />` | `T[]` | `string[]` | `T[]` |
### FieldLocalSelect — Local Object Select
Accepts a static `data` array of objects. No async fetching.
#### Props
| Prop | Type | Required | Description |
|---|---|---|---|
| `options` | `T[]` | ✅ | Array of objects to select from |
| `valueKey` | `keyof T & string` | ✅ | Property used as the unique identifier |
| `labelKey` | `keyof T & string` | — | Property used as the display label |
| `renderLabel` | `(item: T) => string` | — | Custom label renderer (overrides `labelKey`) |
| `multiple` | `boolean` | — | Enable multi-select mode |
| `filterOption` | `(item: T, ctx) => boolean` | — | Custom filter/exclusion logic |
| `onSelect` | `(value: T \| T[] \| null) => void` | — | Side-effect callback on selection change |
| `name` | `FieldPath` | ✅ | RHF field path |
| `control` | `Control` | ✅ | RHF control object |
| _...all Mantine Select/MultiSelect props_ | | | Passed through to the underlying component |
#### Usage Example
```tsx
import { useForm } from 'react-hook-form';
import { FieldLocalSelect } from '@repo/ui/form';
interface Department {
id: string;
name: string;
code: string;
}
const departments: Department[] = [
{ id: '1', name: 'Engineering', code: 'ENG' },
{ id: '2', name: 'Marketing', code: 'MKT' },
{ id: '3', name: 'Finance', code: 'FIN' },
];
function DepartmentForm() {
const { control, handleSubmit } = useForm<{ department: Department | null }>({
defaultValues: { department: null },
});
return (
<form onSubmit={handleSubmit((data) => console.log(data.department))}>
<FieldLocalSelect<Department>
name="department"
control={control}
label="Department"
options={departments}
valueKey="id"
labelKey="name"
searchable
/>
<button type="submit">Submit</button>
</form>
);
}
// On submit: data.department = { id: '1', name: 'Engineering', code: 'ENG' }
```
### FieldAsyncSelect — Async Paginated Object Select
Uses **Inversion of Control**: the component does NOT handle API calls directly. Instead, you provide a `loadOptions` callback. This supports REST, GraphQL, POST-based search, or any transport.
#### Props
| Prop | Type | Required | Description |
|---|---|---|---|
| `loadOptions` | `LoadOptionsFn<T>` | ✅ | Async callback: `(search, page, prevOptions) => Promise<{ options: T[], hasMore?: boolean }>` |
| `defaultOptions` | `T[]` | — | Pre-loaded objects always present in dropdown (for edit forms) |
| `debounceMs` | `number` | — | Search debounce delay (default: 300) |
| `valueKey` | `keyof T & string` | ✅ | Property used as the unique identifier |
| `labelKey` | `keyof T & string` | — | Property used as the display label |
| `renderLabel` | `(item: T) => string` | — | Custom label renderer |
| `multiple` | `boolean` | — | Enable multi-select mode |
| `name` | `FieldPath` | ✅ | RHF field path |
| `control` | `Control` | ✅ | RHF control object |
| _...all Mantine Select/MultiSelect props_ | | | Passed through to the underlying component |
#### Paginated Example
```tsx
import { useForm } from 'react-hook-form';
import { FieldAsyncSelect, type LoadOptionsFn } from '@repo/ui/form';
import { api } from '@/lib/api';
interface User {
id: string;
fullName: string;
email: string;
}
// The loadOptions callback is completely transport-agnostic
const loadUsers: LoadOptionsFn<User> = async (search, page) => {
const res = await api.get('/users', {
params: { q: search, page, limit: 20 },
});
return {
options: res.data.items,
hasMore: res.data.hasNextPage,
};
};
function UserPickerForm() {
const { control, handleSubmit } = useForm<{ user: User | null }>({
defaultValues: { user: null },
});
return (
<form onSubmit={handleSubmit((data) => console.log(data.user))}>
<FieldAsyncSelect<User>
name="user"
control={control}
label="Assign User"
loadOptions={loadUsers}
valueKey="id"
labelKey="fullName"
placeholder="Search users..."
/>
<button type="submit">Submit</button>
</form>
);
}
```
#### Non-Paginated Example
If your API returns all results at once, return `hasMore: false`:
```tsx
const loadRoles: LoadOptionsFn<Role> = async (search) => {
const roles = await api.get('/roles', { params: { q: search } });
return { options: roles.data, hasMore: false };
};
```
#### Edit Form with `defaultOptions`
When editing an existing record, the default value's object may not appear in the first page of API results. Use `defaultOptions` to inject it:
```tsx
function EditUserForm({ existingAssignment }: { existingAssignment: User }) {
const { control } = useForm<{ user: User | null }>({
defaultValues: { user: existingAssignment },
});
return (
<FieldAsyncSelect<User>
name="user"
control={control}
label="Reassign User"
loadOptions={loadUsers}
valueKey="id"
labelKey="fullName"
defaultOptions={[existingAssignment]}
/>
);
}
```
#### Multi-Select Async Example
```tsx
function TagPickerForm() {
const { control } = useForm<{ tags: Tag[] }>({
defaultValues: { tags: [] },
});
return (
<FieldAsyncSelect<Tag>
multiple
name="tags"
control={control}
label="Tags"
loadOptions={loadTags}
valueKey="id"
renderLabel={(tag) => `${tag.name} (${tag.count})`}
/>
);
}
// On submit: data.tags = [{ id: '1', name: 'React', count: 42 }, ...]
```
---
## Enterprise Performance Guidelines: Forms & Validation ## Enterprise Performance Guidelines: Forms & Validation
When building large-scale ERP forms, seemingly trivial React or Zod patterns can catastrophically degrade performance at scale. Adhere strictly to the following optimizations. When building large-scale ERP forms, seemingly trivial React or Zod patterns can catastrophically degrade performance at scale. Adhere strictly to the following optimizations.
@@ -683,8 +887,8 @@ export const FieldDatePicker = withRHF<DatePickerInputProps>(
| `FieldRating` | `Rating` | Range | Star rating | | `FieldRating` | `Rating` | Range | Star rating |
| `FieldColorInput` | `ColorInput` | Color | Color picker with text input | | `FieldColorInput` | `ColorInput` | Color | Color picker with text input |
| `FieldColorPicker` | `ColorPicker` | Color | Color picker only (uses `Input.Wrapper`) | | `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 | | `FieldLocalSelect` | `Select / MultiSelect` | Selection | Stores full `T` or `T[]` object in RHF instead of string ID. Accepts static `options` array with `valueKey`/`labelKey` mapping. |
| `FieldAsyncSelect` | `Select / MultiSelect` | Selection | Paginated infinite scroll select mapping API responses to RHF objects | | `FieldAsyncSelect` | `Select / MultiSelect` | Selection | Async paginated object select with IoC `loadOptions` callback. Supports search-keyed caching, `defaultOptions` for edit forms, and automatic pagination detection. |
| `FieldFileInput` | `FileInput` | File | File upload input | | `FieldFileInput` | `FileInput` | File | File upload input |
--- ---
@@ -5,6 +5,7 @@ import React from 'react';
import { useForm, FormProvider } from 'react-hook-form'; import { useForm, FormProvider } from 'react-hook-form';
import { MantineProvider } from '@mantine/core'; import { MantineProvider } from '@mantine/core';
import { FieldAsyncSelect } from '../fields/async-select.field'; import { FieldAsyncSelect } from '../fields/async-select.field';
import type { LoadOptionsFn } from '../custom/selects/types';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Mock i18n & Setup // Mock i18n & Setup
@@ -18,20 +19,20 @@ vi.mock('@repo/core-i18n', () => ({
type Vendor = { id: number; code: string; name: string }; type Vendor = { id: number; code: string; name: string };
const MOCK_API_RESPONSE = { const MOCK_VENDORS: Vendor[] = [
items: [
{ id: 1, code: 'V1', name: 'Vendor 1' }, { id: 1, code: 'V1', name: 'Vendor 1' },
{ id: 2, code: 'V2', name: 'Vendor 2' }, { id: 2, code: 'V2', name: 'Vendor 2' },
{ id: 3, code: 'V3', name: 'Vendor 3' }, { id: 3, code: 'V3', name: 'Vendor 3' },
], ];
total: 3,
// Reusable loadOptions mock — returns all vendors with hasMore=false
const createMockLoadOptions = (vendors: Vendor[] = MOCK_VENDORS) => {
return vi.fn<LoadOptionsFn<Vendor>>().mockResolvedValue({
options: vendors,
hasMore: false,
});
}; };
// Mock fetchFn
const mockFetchFn = vi.fn().mockResolvedValue(MOCK_API_RESPONSE);
// Test wrapper removed to avoid useForm conflicts
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Tests // Tests
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -43,6 +44,7 @@ describe('FieldAsyncSelect', () => {
it('fetches initial page on mount and renders items', async () => { it('fetches initial page on mount and renders items', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const mockLoadOptions = createMockLoadOptions();
function TestForm() { function TestForm() {
const { control } = useForm({ defaultValues: { vendor: null } }); const { control } = useForm({ defaultValues: { vendor: null } });
@@ -52,9 +54,7 @@ describe('FieldAsyncSelect', () => {
<FieldAsyncSelect <FieldAsyncSelect
name="vendor" name="vendor"
control={control} control={control}
apiEndpoint="/api/vendors" loadOptions={mockLoadOptions}
fetchFn={mockFetchFn}
transformResponse={(res) => res.items}
valueKey="id" valueKey="id"
labelKey="name" labelKey="name"
placeholder="Select async vendor" placeholder="Select async vendor"
@@ -66,9 +66,9 @@ describe('FieldAsyncSelect', () => {
render(<TestForm />); render(<TestForm />);
// Wait for initial fetch // Wait for initial fetch (page 1, search='')
await waitFor(() => { await waitFor(() => {
expect(mockFetchFn).toHaveBeenCalledWith('/api/vendors?page=1&pageSize=20&search='); expect(mockLoadOptions).toHaveBeenCalledWith('', 1, []);
}); });
await user.click(screen.getByPlaceholderText('Select async vendor')); await user.click(screen.getByPlaceholderText('Select async vendor'));
@@ -81,6 +81,7 @@ describe('FieldAsyncSelect', () => {
it('stores full object in RHF from async data', async () => { it('stores full object in RHF from async data', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
let capturedData: any = null; let capturedData: any = null;
const mockLoadOptions = createMockLoadOptions();
function TestForm() { function TestForm() {
const { control, handleSubmit } = useForm({ defaultValues: { vendor: null } }); const { control, handleSubmit } = useForm({ defaultValues: { vendor: null } });
@@ -90,9 +91,7 @@ describe('FieldAsyncSelect', () => {
<FieldAsyncSelect <FieldAsyncSelect
name="vendor" name="vendor"
control={control} control={control}
apiEndpoint="/api/vendors" loadOptions={mockLoadOptions}
fetchFn={mockFetchFn}
transformResponse={(res) => res.items}
valueKey="id" valueKey="id"
labelKey="name" labelKey="name"
placeholder="Select vendor" placeholder="Select vendor"
@@ -106,7 +105,7 @@ describe('FieldAsyncSelect', () => {
render(<TestForm />); render(<TestForm />);
await waitFor(() => { await waitFor(() => {
expect(mockFetchFn).toHaveBeenCalled(); expect(mockLoadOptions).toHaveBeenCalled();
}); });
await user.click(screen.getByPlaceholderText('Select vendor')); await user.click(screen.getByPlaceholderText('Select vendor'));
@@ -117,13 +116,11 @@ describe('FieldAsyncSelect', () => {
}); });
it('injects pre-selected value not in fetched data', async () => { it('injects pre-selected value not in fetched data', async () => {
// We mock fetch to return only V1 and V2 // loadOptions returns only V1 and V2
const partialFetchFn = vi.fn().mockResolvedValue({ const partialLoadOptions = createMockLoadOptions([
items: [
{ id: 1, code: 'V1', name: 'Vendor 1' }, { id: 1, code: 'V1', name: 'Vendor 1' },
{ id: 2, code: 'V2', name: 'Vendor 2' }, { id: 2, code: 'V2', name: 'Vendor 2' },
] ]);
});
// The form starts with V99 pre-selected (e.g. from server hydration) // The form starts with V99 pre-selected (e.g. from server hydration)
const PRESELECTED_VENDOR = { id: 99, code: 'V99', name: 'Vendor 99' }; const PRESELECTED_VENDOR = { id: 99, code: 'V99', name: 'Vendor 99' };
@@ -136,9 +133,7 @@ describe('FieldAsyncSelect', () => {
<FieldAsyncSelect <FieldAsyncSelect
name="vendor" name="vendor"
control={control} control={control}
apiEndpoint="/api/vendors" loadOptions={partialLoadOptions}
fetchFn={partialFetchFn}
transformResponse={(res) => res.items}
valueKey="id" valueKey="id"
labelKey="name" labelKey="name"
placeholder="Select vendor" placeholder="Select vendor"
@@ -151,7 +146,7 @@ describe('FieldAsyncSelect', () => {
render(<TestForm />); render(<TestForm />);
await waitFor(() => { await waitFor(() => {
expect(partialFetchFn).toHaveBeenCalled(); expect(partialLoadOptions).toHaveBeenCalled();
}); });
// The display value of the Select input should show the injected label // The display value of the Select input should show the injected label
@@ -161,6 +156,7 @@ describe('FieldAsyncSelect', () => {
it('debounces search input and refetches', async () => { it('debounces search input and refetches', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const mockLoadOptions = createMockLoadOptions();
function TestForm() { function TestForm() {
const { control } = useForm({ defaultValues: { vendor: null } }); const { control } = useForm({ defaultValues: { vendor: null } });
@@ -170,9 +166,7 @@ describe('FieldAsyncSelect', () => {
<FieldAsyncSelect <FieldAsyncSelect
name="vendor" name="vendor"
control={control} control={control}
apiEndpoint="/api/vendors" loadOptions={mockLoadOptions}
fetchFn={mockFetchFn}
transformResponse={(res) => res.items}
valueKey="id" valueKey="id"
labelKey="name" labelKey="name"
placeholder="Search vendor" placeholder="Search vendor"
@@ -187,28 +181,26 @@ describe('FieldAsyncSelect', () => {
// Wait for initial fetch // Wait for initial fetch
await waitFor(() => { await waitFor(() => {
expect(mockFetchFn).toHaveBeenCalledTimes(1); expect(mockLoadOptions).toHaveBeenCalledTimes(1);
}); });
const input = screen.getByPlaceholderText('Search vendor'); const input = screen.getByPlaceholderText('Search vendor');
await user.type(input, 'test'); await user.type(input, 'test');
// Wait for debounced fetch // Wait for debounced fetch — should call with search='test'
await waitFor(() => { await waitFor(() => {
expect(mockFetchFn).toHaveBeenCalledTimes(2); expect(mockLoadOptions).toHaveBeenCalledTimes(2);
expect(mockFetchFn).toHaveBeenLastCalledWith('/api/vendors?page=1&pageSize=20&search=test'); expect(mockLoadOptions).toHaveBeenLastCalledWith('test', 1, []);
}); });
}); });
it('gracefully deduplicates overlapping data across API responses', async () => { it('gracefully deduplicates overlapping data across API responses', async () => {
// API returns Vendor 1 twice // loadOptions returns Vendor 1 twice (duplicate id=1)
const badFetchFn = vi.fn().mockResolvedValue({ const badLoadOptions = createMockLoadOptions([
items: [
{ id: 1, code: 'V1', name: 'Vendor 1' }, { id: 1, code: 'V1', name: 'Vendor 1' },
{ id: 1, code: 'V1', name: 'Vendor 1 (Duplicate)' }, { id: 1, code: 'V1', name: 'Vendor 1 (Duplicate)' },
{ id: 2, code: 'V2', name: 'Vendor 2' }, { id: 2, code: 'V2', name: 'Vendor 2' },
] ]);
});
function TestForm() { function TestForm() {
const { control } = useForm({ defaultValues: { vendor: null } }); const { control } = useForm({ defaultValues: { vendor: null } });
@@ -218,9 +210,7 @@ describe('FieldAsyncSelect', () => {
<FieldAsyncSelect <FieldAsyncSelect
name="vendor" name="vendor"
control={control} control={control}
apiEndpoint="/api/bad-vendors" loadOptions={badLoadOptions}
fetchFn={badFetchFn}
transformResponse={(res) => res.items}
valueKey="id" valueKey="id"
labelKey="name" labelKey="name"
placeholder="Select bad vendor" placeholder="Select bad vendor"
@@ -233,7 +223,7 @@ describe('FieldAsyncSelect', () => {
render(<TestForm />); render(<TestForm />);
await waitFor(() => { await waitFor(() => {
expect(badFetchFn).toHaveBeenCalledTimes(1); expect(badLoadOptions).toHaveBeenCalledTimes(1);
}); });
const user = userEvent.setup(); const user = userEvent.setup();
@@ -4,7 +4,7 @@ import userEvent from '@testing-library/user-event';
import React from 'react'; import React from 'react';
import { useForm, FormProvider } from 'react-hook-form'; import { useForm, FormProvider } from 'react-hook-form';
import { MantineProvider } from '@mantine/core'; import { MantineProvider } from '@mantine/core';
import { FieldObjectSelect } from '../fields/object-select.field'; import { FieldLocalSelect } from '../fields/local-select.field';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Mock i18n // Mock i18n
@@ -34,7 +34,7 @@ const VENDORS: Vendor[] = [
// Tests // Tests
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describe('FieldObjectSelect', () => { describe('FieldLocalSelect', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
@@ -44,10 +44,10 @@ describe('FieldObjectSelect', () => {
const { control } = useForm({ defaultValues: { vendor: null } }); const { control } = useForm({ defaultValues: { vendor: null } });
return ( return (
<MantineProvider> <MantineProvider>
<FieldObjectSelect <FieldLocalSelect
name="vendor" name="vendor"
control={control} control={control}
data={VENDORS} options={VENDORS}
valueKey="id" valueKey="id"
labelKey="name" labelKey="name"
label="Vendor" label="Vendor"
@@ -71,10 +71,10 @@ describe('FieldObjectSelect', () => {
return ( return (
<MantineProvider> <MantineProvider>
<form onSubmit={handleSubmit((data) => { capturedData = data; })}> <form onSubmit={handleSubmit((data) => { capturedData = data; })}>
<FieldObjectSelect <FieldLocalSelect
name="vendor" name="vendor"
control={control} control={control}
data={VENDORS} options={VENDORS}
valueKey="id" valueKey="id"
labelKey="name" labelKey="name"
placeholder="Select vendor" placeholder="Select vendor"
@@ -107,10 +107,10 @@ describe('FieldObjectSelect', () => {
const { control } = useForm({ defaultValues: { vendor: null } }); const { control } = useForm({ defaultValues: { vendor: null } });
return ( return (
<MantineProvider> <MantineProvider>
<FieldObjectSelect <FieldLocalSelect
name="vendor" name="vendor"
control={control} control={control}
data={VENDORS} options={VENDORS}
valueKey="id" valueKey="id"
renderLabel={(item) => `${item.code} - ${item.name}`} renderLabel={(item) => `${item.code} - ${item.name}`}
placeholder="Select vendor" placeholder="Select vendor"
@@ -135,10 +135,10 @@ describe('FieldObjectSelect', () => {
const { control } = useForm({ defaultValues: { vendor: null } }); const { control } = useForm({ defaultValues: { vendor: null } });
return ( return (
<MantineProvider> <MantineProvider>
<FieldObjectSelect <FieldLocalSelect
name="vendor" name="vendor"
control={control} control={control}
data={VENDORS} options={VENDORS}
valueKey="id" valueKey="id"
labelKey="name" labelKey="name"
placeholder="Select vendor" placeholder="Select vendor"
@@ -167,11 +167,11 @@ describe('FieldObjectSelect', () => {
return ( return (
<MantineProvider> <MantineProvider>
<form onSubmit={handleSubmit((data) => { capturedData = data; })}> <form onSubmit={handleSubmit((data) => { capturedData = data; })}>
<FieldObjectSelect <FieldLocalSelect
multiple multiple
name="vendors" name="vendors"
control={control} control={control}
data={VENDORS} options={VENDORS}
valueKey="id" valueKey="id"
labelKey="name" labelKey="name"
placeholder="Select vendors" placeholder="Select vendors"
@@ -200,11 +200,11 @@ describe('FieldObjectSelect', () => {
return ( return (
<MantineProvider> <MantineProvider>
<form onSubmit={handleSubmit((data) => { capturedData = data; })}> <form onSubmit={handleSubmit((data) => { capturedData = data; })}>
<FieldObjectSelect <FieldLocalSelect
multiple multiple
name="vendors" name="vendors"
control={control} control={control}
data={VENDORS} options={VENDORS}
valueKey="id" valueKey="id"
labelKey="name" labelKey="name"
clearable clearable
@@ -233,10 +233,10 @@ describe('FieldObjectSelect', () => {
const { control } = useForm({ defaultValues: { vendor: null } }); const { control } = useForm({ defaultValues: { vendor: null } });
return ( return (
<MantineProvider> <MantineProvider>
<FieldObjectSelect <FieldLocalSelect
name="vendor" name="vendor"
control={control} control={control}
data={VENDORS} options={VENDORS}
valueKey="id" valueKey="id"
labelKey="name" labelKey="name"
placeholder="Select vendor" placeholder="Select vendor"
@@ -2,13 +2,21 @@
// Custom Form Components — Barrel Export // Custom Form Components — Barrel Export
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Reusable select engines that work WITHOUT React Hook Form. // Reusable select engines that work WITHOUT React Hook Form.
// For RHF-connected versions, use `@repo/ui/form` (FieldObjectSelect, FieldAsyncSelect). // For RHF-connected versions, use `@repo/ui/form` (FieldLocalSelect, FieldAsyncSelect).
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export { ObjectSelect } from './selects/ObjectSelect'; export { LocalSelect } from './selects/LocalSelect';
export type { ObjectSelectProps, ObjectSelectSingleProps, ObjectSelectMultiProps } from './selects/ObjectSelect'; export type { LocalSelectProps, LocalSelectSingleProps, LocalSelectMultiProps } from './selects/LocalSelect';
export { AsyncSelect } from './selects/AsyncSelect'; export { AsyncSelect } from './selects/AsyncSelect';
export type { AsyncSelectProps, AsyncSelectSingleProps, AsyncSelectMultiProps } from './selects/AsyncSelect'; export type { AsyncSelectProps, AsyncSelectSingleProps, AsyncSelectMultiProps } from './selects/AsyncSelect';
export type { ObjectSelectBaseProps, ObjectSelectFilterContext, ObjectSelectMappingResult } from './selects/types'; export type {
LocalSelectBaseProps,
AsyncSelectBaseProps,
SelectFilterContext,
SelectMappingResult,
LoadOptionsResponse,
LoadOptionsFn,
OptionsCacheEntry,
} from './selects/types';
@@ -1,27 +1,54 @@
import React, { useMemo, useCallback } from 'react'; import React, { useMemo, useCallback } from 'react';
import { Select, MultiSelect, Loader, type SelectProps, type MultiSelectProps, type ComboboxItem } from '@mantine/core'; import { Select, MultiSelect, Loader, type SelectProps, type MultiSelectProps, type ComboboxItem } from '@mantine/core';
import type { ObjectSelectBaseProps, ObjectSelectFilterContext } from './types'; import type { AsyncSelectBaseProps, SelectFilterContext, LoadOptionsFn } from './types';
import { useAsyncSelectInfiniteScroll } from './hooks/useAsyncSelectInfiniteScroll'; import { useAsyncPaginate } from './hooks/useAsyncPaginate';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// AsyncSelect — Reusable async/paginated Select engine (no RHF dependency) // AsyncSelect — Reusable async/paginated Select engine (no RHF dependency)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
//
// Inversion of Control design: the component does NOT handle API calls.
// Instead, it accepts a `loadOptions` callback that the implementer provides.
// This supports REST, GraphQL, POST-based search, local filtering, or any
// transport mechanism.
//
// Data Mapping Contract (Single vs. Multi):
// Single: value=T|null → Mantine string|null → onChange(T|null)
// Multi: value=T[] → Mantine string[] → onChange(T[])
//
// The lookupMap includes ALL sources (fetched + default + selected values)
// to ensure deselection never produces undefined entries.
// ---------------------------------------------------------------------------
/** Mantine props we manage ourselves */ /** Mantine props we manage ourselves */
type ManagedSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'filter' | 'name' | 'onSelect'; type ManagedSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'filter' | 'name' | 'onSelect';
type ManagedMultiSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'filter' | 'name' | 'onSelect'; type ManagedMultiSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'filter' | 'name' | 'onSelect';
/** Async-specific props */ /** Async-specific props (IoC — no direct API coupling) */
interface AsyncExtraProps<T> { interface AsyncExtraProps<T> {
apiEndpoint: string; /**
transformResponse?: (res: any) => { data: T[]; hasMore?: boolean } | T[]; * Async callback to load options. The component calls this when:
pageSize?: number; * - The dropdown opens (page 1, search='')
* - The user types a search query (page 1, search=query)
* - The user scrolls to the bottom (page N+1, search=currentQuery)
*
* The component is completely ignorant of the transport layer.
*/
loadOptions: LoadOptionsFn<T>;
/**
* Pre-loaded objects that are always present in the dropdown.
* Use for edit forms where the default value's object may not appear
* in page 1 of the API results.
*/
defaultOptions?: T[];
/** Search debounce delay in ms (default: 300) */
debounceMs?: number; debounceMs?: number;
fetchFn?: (url: string) => Promise<any>;
} }
/** Props for single-select async mode */ /** Props for single-select async mode */
export type AsyncSelectSingleProps<T extends Record<string, any>> = Omit<ObjectSelectBaseProps<T>, 'data'> & export type AsyncSelectSingleProps<T extends Record<string, any>> = AsyncSelectBaseProps<T> &
AsyncExtraProps<T> & AsyncExtraProps<T> &
Omit<SelectProps, ManagedSelectProps> & { Omit<SelectProps, ManagedSelectProps> & {
multiple?: false; multiple?: false;
@@ -30,7 +57,7 @@ export type AsyncSelectSingleProps<T extends Record<string, any>> = Omit<ObjectS
}; };
/** Props for multi-select async mode */ /** Props for multi-select async mode */
export type AsyncSelectMultiProps<T extends Record<string, any>> = Omit<ObjectSelectBaseProps<T>, 'data'> & export type AsyncSelectMultiProps<T extends Record<string, any>> = AsyncSelectBaseProps<T> &
AsyncExtraProps<T> & AsyncExtraProps<T> &
Omit<MultiSelectProps, ManagedMultiSelectProps> & { Omit<MultiSelectProps, ManagedMultiSelectProps> & {
multiple: true; multiple: true;
@@ -51,7 +78,8 @@ function resolveLabel<T extends Record<string, any>>(
): string { ): string {
if (renderLabel) return renderLabel(item); if (renderLabel) return renderLabel(item);
if (labelKey) return String(item[labelKey] ?? ''); if (labelKey) return String(item[labelKey] ?? '');
return String(item[Object.keys(item)[0] as keyof T] ?? ''); const firstKey = Object.keys(item)[0];
return firstKey ? String(item[firstKey as keyof T] ?? '') : '';
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -65,42 +93,42 @@ function AsyncSelectInner<T extends Record<string, any>>(props: AsyncSelectProps
renderLabel, renderLabel,
multiple, multiple,
filterOption, filterOption,
onSelect: onObjectSelect, onSelect: onSelectCallback,
value, value,
onChange, onChange,
apiEndpoint, loadOptions,
transformResponse, defaultOptions,
pageSize,
debounceMs, debounceMs,
fetchFn,
searchable, searchable,
onSearchChange: consumerOnSearchChange,
...mantineProps ...mantineProps
} = props; } = props;
// Use the infinite scroll hook for data fetching // Use the new paginate hook for data fetching
const { const {
data: fetchedData, data: fetchedData,
isLoading, isLoading,
fetchNextPage, fetchNextPage,
search, search,
debouncedSearch,
setSearch, setSearch,
} = useAsyncSelectInfiniteScroll<T>({ } = useAsyncPaginate<T>({
apiEndpoint, loadOptions,
valueKey, valueKey,
pageSize,
transformResponse,
debounceMs, debounceMs,
fetchFn, defaultOptions,
}); });
// Inject pre-selected values that aren't in the fetched data yet, // -----------------------------------------------------------------------
// and safeguard against bad APIs that return duplicate items across pages. // Merge fetched data with currently selected values.
// This ensures the lookupMap always contains all possible values,
// preventing undefined entries during deselection.
// -----------------------------------------------------------------------
const dataWithInjected = useMemo(() => { const dataWithInjected = useMemo(() => {
const uniqueItems: T[] = []; const uniqueItems: T[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
// 1. Deduplicate fetched data from the API (hook already deduplicates internally, but this is an extra UI safeguard) // 1. Start with fetched data (already includes defaultOptions from the hook)
for (const item of fetchedData) { for (const item of fetchedData) {
const key = String(item[valueKey]); const key = String(item[valueKey]);
if (!seen.has(key)) { if (!seen.has(key)) {
@@ -109,13 +137,15 @@ function AsyncSelectInner<T extends Record<string, any>>(props: AsyncSelectProps
} }
} }
// 2. Inject active RHF values if they aren't in the fetched list // 2. Inject active selected values if they aren't in the fetched list.
// This is CRITICAL for the data mapping contract: the lookupMap
// must always be able to resolve deselected items back to objects.
if (multiple && Array.isArray(value)) { if (multiple && Array.isArray(value)) {
for (const v of value) { for (const v of value) {
const key = String(v[valueKey]); const key = String(v[valueKey]);
if (!seen.has(key)) { if (!seen.has(key)) {
seen.add(key); seen.add(key);
uniqueItems.unshift(v); // Put selected items at the top uniqueItems.unshift(v); // Selected items at the top
} }
} }
} else if (!multiple && value) { } else if (!multiple && value) {
@@ -129,7 +159,7 @@ function AsyncSelectInner<T extends Record<string, any>>(props: AsyncSelectProps
return uniqueItems; return uniqueItems;
}, [fetchedData, value, valueKey, multiple]); }, [fetchedData, value, valueKey, multiple]);
// Build lookup map // Build lookup map — includes ALL sources for safe reverse resolution
const lookupMap = useMemo(() => { const lookupMap = useMemo(() => {
const map = new Map<string, T>(); const map = new Map<string, T>();
for (const item of dataWithInjected) { for (const item of dataWithInjected) {
@@ -143,8 +173,8 @@ function AsyncSelectInner<T extends Record<string, any>>(props: AsyncSelectProps
let filtered = dataWithInjected; let filtered = dataWithInjected;
if (filterOption) { if (filterOption) {
const context: ObjectSelectFilterContext<T> = { const context: SelectFilterContext<T> = {
search, // Pass the active search string to the custom filter search,
selected: value ?? (multiple ? [] : null), selected: value ?? (multiple ? [] : null),
}; };
filtered = dataWithInjected.filter((item) => filterOption(item, context)); filtered = dataWithInjected.filter((item) => filterOption(item, context));
@@ -156,16 +186,15 @@ function AsyncSelectInner<T extends Record<string, any>>(props: AsyncSelectProps
})); }));
}, [dataWithInjected, valueKey, labelKey, renderLabel, filterOption, value, multiple, search]); }, [dataWithInjected, valueKey, labelKey, renderLabel, filterOption, value, multiple, search]);
const isTyping = search !== debouncedSearch; const rightSection = isLoading ? <Loader size={16} /> : mantineProps.rightSection;
const isFetching = isLoading || isTyping;
const rightSection = isFetching ? <Loader size={16} /> : mantineProps.rightSection;
// Handle search → delegate to the hook's setSearch (debounced) // Handle search → delegate to the hook's setSearch (debounced)
const handleSearchChange = useCallback( const handleSearchChange = useCallback(
(val: string) => { (val: string) => {
setSearch(val); setSearch(val);
consumerOnSearchChange?.(val);
}, },
[setSearch], [setSearch, consumerOnSearchChange],
); );
// ScrollArea props for infinite scroll — use onBottomReached // ScrollArea props for infinite scroll — use onBottomReached
@@ -180,17 +209,22 @@ function AsyncSelectInner<T extends Record<string, any>>(props: AsyncSelectProps
); );
// Disable Mantine's internal frontend filtering. // Disable Mantine's internal frontend filtering.
// The backend handles the search query, so we should always display what the backend returns. // The backend handles the search query, so we always display what the backend returns.
const mantineFilter = useCallback(({ options: opts }: { options: ComboboxItem[] }) => opts, []); const mantineFilter = filterOption
? ({ options: opts }: any) => opts
: undefined;
// ----- Multi-select mode ----- // ----- Multi-select mode -----
if (multiple) { if (multiple) {
const currentValues = Array.isArray(value) ? value.map((v) => String(v[valueKey])) : []; const currentValues = Array.isArray(value) ? value.map((v) => String(v[valueKey])) : [];
const handleMultiChange = (vals: string[]) => { const handleMultiChange = (vals: string[]) => {
// Resolve string[] → T[] via the lookup map.
// .filter(Boolean) is a safety net — if the map is complete (which it
// should be given the dataWithInjected merge), this is a no-op.
const objects = vals.map((v) => lookupMap.get(v)!).filter(Boolean); const objects = vals.map((v) => lookupMap.get(v)!).filter(Boolean);
(onChange as ((v: T[]) => void) | undefined)?.(objects); (onChange as ((v: T[]) => void) | undefined)?.(objects);
onObjectSelect?.(objects); onSelectCallback?.(objects);
}; };
return ( return (
@@ -214,7 +248,7 @@ function AsyncSelectInner<T extends Record<string, any>>(props: AsyncSelectProps
const handleSingleChange = (val: string | null) => { const handleSingleChange = (val: string | null) => {
const obj = val ? (lookupMap.get(val) ?? null) : null; const obj = val ? (lookupMap.get(val) ?? null) : null;
(onChange as ((v: T | null) => void) | undefined)?.(obj); (onChange as ((v: T | null) => void) | undefined)?.(obj);
onObjectSelect?.(obj); onSelectCallback?.(obj);
}; };
return ( return (
@@ -226,7 +260,7 @@ function AsyncSelectInner<T extends Record<string, any>>(props: AsyncSelectProps
searchable={searchable ?? true} searchable={searchable ?? true}
onSearchChange={handleSearchChange} onSearchChange={handleSearchChange}
scrollAreaProps={scrollAreaProps} scrollAreaProps={scrollAreaProps}
filter={mantineFilter} filter={mantineFilter as any}
rightSection={rightSection} rightSection={rightSection}
/> />
); );
@@ -1,9 +1,9 @@
import React, { useMemo, useState, useCallback } from 'react'; import React, { useMemo, useState, useCallback } from 'react';
import { Select, MultiSelect, type SelectProps, type MultiSelectProps, type ComboboxItem } from '@mantine/core'; import { Select, MultiSelect, type SelectProps, type MultiSelectProps, type ComboboxItem } from '@mantine/core';
import type { ObjectSelectBaseProps, ObjectSelectFilterContext } from './types'; import type { LocalSelectBaseProps, SelectFilterContext } from './types';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// ObjectSelect — Reusable Select engine for complex object data // LocalSelect — Reusable Select engine for complex object data
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// //
// This component is the STANDALONE (non-RHF) version. It bridges Mantine's // This component is the STANDALONE (non-RHF) version. It bridges Mantine's
@@ -12,7 +12,11 @@ import type { ObjectSelectBaseProps, ObjectSelectFilterContext } from './types';
// 2. Building a Map<string, T> for O(1) reverse lookups // 2. Building a Map<string, T> for O(1) reverse lookups
// 3. Intercepting onChange to resolve strings back to full objects // 3. Intercepting onChange to resolve strings back to full objects
// //
// The RHF-connected version (FieldObjectSelect) wraps this component and // Data Mapping Contract (Single vs. Multi):
// Single: value=T|null → Mantine string|null → onChange(T|null)
// Multi: value=T[] → Mantine string[] → onChange(T[])
//
// The RHF-connected version (FieldLocalSelect) wraps this component and
// binds it to useController, following the same pattern as withRHF → FieldXxx. // binds it to useController, following the same pattern as withRHF → FieldXxx.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -21,8 +25,8 @@ type ManagedSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'filt
type ManagedMultiSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'filter' | 'name' | 'onSelect'; type ManagedMultiSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'filter' | 'name' | 'onSelect';
/** Props for single-select mode */ /** Props for single-select mode */
export type ObjectSelectSingleProps<T extends Record<string, any>> = export type LocalSelectSingleProps<T extends Record<string, any>> =
ObjectSelectBaseProps<T> & Omit<SelectProps, ManagedSelectProps> & { LocalSelectBaseProps<T> & Omit<SelectProps, ManagedSelectProps> & {
multiple?: false; multiple?: false;
/** Controlled value — the full object or null */ /** Controlled value — the full object or null */
value?: T | null; value?: T | null;
@@ -31,8 +35,8 @@ export type ObjectSelectSingleProps<T extends Record<string, any>> =
}; };
/** Props for multi-select mode */ /** Props for multi-select mode */
export type ObjectSelectMultiProps<T extends Record<string, any>> = export type LocalSelectMultiProps<T extends Record<string, any>> =
ObjectSelectBaseProps<T> & Omit<MultiSelectProps, ManagedMultiSelectProps> & { LocalSelectBaseProps<T> & Omit<MultiSelectProps, ManagedMultiSelectProps> & {
multiple: true; multiple: true;
/** Controlled value — array of full objects */ /** Controlled value — array of full objects */
value?: T[]; value?: T[];
@@ -41,9 +45,9 @@ export type ObjectSelectMultiProps<T extends Record<string, any>> =
}; };
/** Discriminated union — the component narrows based on `multiple` */ /** Discriminated union — the component narrows based on `multiple` */
export type ObjectSelectProps<T extends Record<string, any>> = export type LocalSelectProps<T extends Record<string, any>> =
| ObjectSelectSingleProps<T> | LocalSelectSingleProps<T>
| ObjectSelectMultiProps<T>; | LocalSelectMultiProps<T>;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helper: Resolve label for a data item // Helper: Resolve label for a data item
@@ -56,27 +60,33 @@ function resolveLabel<T extends Record<string, any>>(
): string { ): string {
if (renderLabel) return renderLabel(item); if (renderLabel) return renderLabel(item);
if (labelKey) return String(item[labelKey] ?? ''); if (labelKey) return String(item[labelKey] ?? '');
return String(item[Object.keys(item)[0] as keyof T] ?? ''); // Fail fast: if neither labelKey nor renderLabel is provided, fall back
// to the first property value. While not ideal, it prevents crashes.
const firstKey = Object.keys(item)[0];
return firstKey ? String(item[firstKey as keyof T] ?? '') : '';
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Component Implementation // Component Implementation
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function ObjectSelectInner<T extends Record<string, any>>( function LocalSelectInner<T extends Record<string, any>>(
props: ObjectSelectProps<T>, props: LocalSelectProps<T>,
) { ) {
const { const {
data, options,
valueKey, valueKey,
labelKey, labelKey,
renderLabel, renderLabel,
multiple, multiple,
filterOption, filterOption,
onSelect: onObjectSelect, onSelect: onSelectCallback,
value, value,
onChange, onChange,
searchable, searchable,
// Extract onSearchChange BEFORE the rest spread to get a
// stable reference for the useCallback dependency array.
onSearchChange: consumerOnSearchChange,
...mantineProps ...mantineProps
} = props; } = props;
@@ -86,40 +96,38 @@ function ObjectSelectInner<T extends Record<string, any>>(
// Build lookup map: string → original object (O(1) reverse lookup) // Build lookup map: string → original object (O(1) reverse lookup)
const lookupMap = useMemo(() => { const lookupMap = useMemo(() => {
const map = new Map<string, T>(); const map = new Map<string, T>();
for (const item of data) { for (const item of options) {
map.set(String(item[valueKey]), item); map.set(String(item[valueKey]), item);
} }
return map; return map;
}, [data, valueKey]); }, [options, valueKey]);
// Build Mantine-compatible ComboboxItem[], applying filterOption if provided // Build Mantine-compatible ComboboxItem[], applying filterOption if provided
const options = useMemo<ComboboxItem[]>(() => { const comboboxItems = useMemo<ComboboxItem[]>(() => {
let filtered = data; let filtered = options;
if (filterOption) { if (filterOption) {
const context: ObjectSelectFilterContext<T> = { const context: SelectFilterContext<T> = {
search: searchValue, search: searchValue,
selected: value ?? (multiple ? [] : null), selected: value ?? (multiple ? [] : null),
}; };
filtered = data.filter((item) => filterOption(item, context)); filtered = options.filter((item) => filterOption(item, context));
} }
return filtered.map((item) => ({ return filtered.map((item) => ({
value: String(item[valueKey]), value: String(item[valueKey]),
label: resolveLabel(item, labelKey, renderLabel), label: resolveLabel(item, labelKey, renderLabel),
})); }));
}, [data, valueKey, labelKey, renderLabel, filterOption, searchValue, value, multiple]); }, [options, valueKey, labelKey, renderLabel, filterOption, searchValue, value, multiple]);
// Handle search input changes // Depend only on stable function references, not the
// entire mantineProps object which is a new reference every render.
const handleSearchChange = useCallback( const handleSearchChange = useCallback(
(val: string) => { (val: string) => {
setSearchValue(val); setSearchValue(val);
// Forward to consumer's onSearchChange if provided consumerOnSearchChange?.(val);
if ('onSearchChange' in mantineProps && typeof mantineProps.onSearchChange === 'function') {
mantineProps.onSearchChange(val);
}
}, },
[mantineProps], [consumerOnSearchChange],
); );
// Passthrough filter — we handle filtering ourselves via filterOption in useMemo. // Passthrough filter — we handle filtering ourselves via filterOption in useMemo.
@@ -134,21 +142,22 @@ function ObjectSelectInner<T extends Record<string, any>>(
const currentValues = Array.isArray(value) ? value.map((v) => String(v[valueKey])) : []; const currentValues = Array.isArray(value) ? value.map((v) => String(v[valueKey])) : [];
const handleMultiChange = (vals: string[]) => { const handleMultiChange = (vals: string[]) => {
// Resolve string[] back to T[] via the lookup map.
// .filter(Boolean) guards against missing entries (defensive).
const objects = vals.map((v) => lookupMap.get(v)!).filter(Boolean); const objects = vals.map((v) => lookupMap.get(v)!).filter(Boolean);
(onChange as ((v: T[]) => void) | undefined)?.(objects); (onChange as ((v: T[]) => void) | undefined)?.(objects);
onObjectSelect?.(objects); onSelectCallback?.(objects);
}; };
return ( return (
<MultiSelect <MultiSelect
{...(mantineProps as Omit<MultiSelectProps, ManagedMultiSelectProps>)} {...(mantineProps as Omit<MultiSelectProps, ManagedMultiSelectProps>)}
data={options} data={comboboxItems}
value={currentValues} value={currentValues}
onChange={handleMultiChange} onChange={handleMultiChange}
searchable={searchable ?? false} searchable={searchable ?? false}
onSearchChange={handleSearchChange} onSearchChange={handleSearchChange}
filter={mantineFilter as any} filter={mantineFilter as any}
/> />
); );
} }
@@ -159,23 +168,22 @@ function ObjectSelectInner<T extends Record<string, any>>(
const handleSingleChange = (val: string | null) => { const handleSingleChange = (val: string | null) => {
const obj = val ? lookupMap.get(val) ?? null : null; const obj = val ? lookupMap.get(val) ?? null : null;
(onChange as ((v: T | null) => void) | undefined)?.(obj); (onChange as ((v: T | null) => void) | undefined)?.(obj);
onObjectSelect?.(obj); onSelectCallback?.(obj);
}; };
return ( return (
<Select <Select
{...(mantineProps as Omit<SelectProps, ManagedSelectProps>)} {...(mantineProps as Omit<SelectProps, ManagedSelectProps>)}
data={options} data={comboboxItems}
value={currentValue} value={currentValue}
onChange={handleSingleChange} onChange={handleSingleChange}
searchable={searchable ?? false} searchable={searchable ?? false}
onSearchChange={handleSearchChange} onSearchChange={handleSearchChange}
filter={mantineFilter as any} filter={mantineFilter as any}
/> />
); );
} }
// Apply React.memo for render optimization in large forms // Apply React.memo for render optimization in large forms
export const ObjectSelect = React.memo(ObjectSelectInner) as typeof ObjectSelectInner; export const LocalSelect = React.memo(LocalSelectInner) as typeof LocalSelectInner;
(ObjectSelect as any).displayName = 'ObjectSelect'; (LocalSelect as any).displayName = 'LocalSelect';
@@ -0,0 +1,335 @@
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import { useDebouncedValue } from '@mantine/hooks';
import type { LoadOptionsFn, OptionsCacheEntry } from '../types';
// ---------------------------------------------------------------------------
// useAsyncPaginate — Production-grade paginated data fetching hook
// ---------------------------------------------------------------------------
//
// Architecture derived from `react-select-async-paginate` with adaptations
// for Mantine's Select/MultiSelect. Key mechanisms:
//
// 1. Search-keyed cache (Map<string, OptionsCacheEntry<T>>)
// → Switching between previously-typed searches reuses cached data
// without re-fetching from the server.
//
// 2. Request ID counter (requestIdRef)
// → Stale responses from slow or out-of-order fetches are silently
// discarded by comparing the ID captured at request start with the
// current counter value.
//
// 3. isMounted guard (mountedRef)
// → Responses arriving after the component unmounts are discarded,
// preventing React state updates on unmounted components.
//
// 4. Duplicate fetch prevention (fetchingRef boolean)
// → Guards against concurrent fetches for the same search+page combo.
//
// 5. Single consolidated effect
// → One useEffect keyed on `debouncedSearch` handles both the initial
// load (mount with search='') and subsequent search-change resets.
// This eliminates the double-initial-fetch bug from the old hook.
// ---------------------------------------------------------------------------
export interface UseAsyncPaginateOptions<T extends Record<string, any>> {
/** Async callback to load options. Receives (search, page, prevOptions). */
loadOptions: LoadOptionsFn<T>;
/** Property key used to deduplicate incoming items */
valueKey: keyof T & string;
/** Search debounce delay in ms (default: 300) */
debounceMs?: number;
/**
* Pre-loaded objects to inject into the options list.
* Used for edit-form scenarios where the RHF default value's object
* may not appear in the first page of API results.
*/
defaultOptions?: T[];
}
export interface UseAsyncPaginateReturn<T> {
/** Merged data: defaultOptions + accumulated fetched pages (deduplicated) */
data: T[];
/** True during any active fetch */
isLoading: boolean;
/** Whether the current search term has more pages available */
hasMore: boolean;
/** Current search term (raw, not debounced) */
search: string;
/** The debounced search term currently driving fetches */
debouncedSearch: string;
/** Update the search term — triggers debounce + cache lookup/fetch */
setSearch: (s: string) => void;
/** Trigger next page load for the current search term */
fetchNextPage: () => void;
/** Clear all cached pages and re-fetch from page 1 */
reset: () => void;
}
export function useAsyncPaginate<T extends Record<string, any>>(
options: UseAsyncPaginateOptions<T>,
): UseAsyncPaginateReturn<T> {
const {
loadOptions,
valueKey,
debounceMs = 300,
defaultOptions,
} = options;
// -----------------------------------------------------------------------
// State
// -----------------------------------------------------------------------
const [search, setSearch] = useState('');
const [debouncedSearch] = useDebouncedValue(search, debounceMs);
// Search-keyed cache: each search string maps to its own pagination state
const [cache, setCache] = useState<Map<string, OptionsCacheEntry<T>>>(() => new Map());
// Loading flag — drives the UI spinner
const [isLoading, setIsLoading] = useState(false);
// -----------------------------------------------------------------------
// Refs for guards
// -----------------------------------------------------------------------
/** Monotonically increasing counter to detect stale responses */
const requestIdRef = useRef(0);
/** Guards against concurrent fetches */
const fetchingRef = useRef(false);
/** Tracks if the component is still mounted */
const mountedRef = useRef(true);
/** Stable ref for loadOptions to avoid effect re-fires on closure changes */
const loadOptionsRef = useRef(loadOptions);
loadOptionsRef.current = loadOptions;
/** Stable ref for valueKey */
const valueKeyRef = useRef(valueKey);
valueKeyRef.current = valueKey;
/** Stable ref for defaultOptions to avoid dependency churn */
const defaultOptionsRef = useRef(defaultOptions);
defaultOptionsRef.current = defaultOptions;
// -----------------------------------------------------------------------
// Cleanup on unmount
// -----------------------------------------------------------------------
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
// -----------------------------------------------------------------------
// Core fetch logic
// -----------------------------------------------------------------------
const fetchPage = useCallback(
async (searchTerm: string, page: number) => {
if (fetchingRef.current) return;
fetchingRef.current = true;
// Capture request ID — if it changes before the response arrives,
// the response is stale and should be discarded.
const capturedId = ++requestIdRef.current;
setIsLoading(true);
try {
// Get accumulated options from the cache for prevOptions
const cachedEntry = cache.get(searchTerm);
const prevOptions = cachedEntry?.options ?? [];
const response = await loadOptionsRef.current(searchTerm, page, prevOptions);
// Guard: discard if unmounted or stale
if (!mountedRef.current || requestIdRef.current !== capturedId) {
return;
}
const newItems = response.options ?? [];
const hasMore = response.hasMore ?? false;
// Deduplicate: merge prev + new, keyed by valueKey
const vk = valueKeyRef.current;
const seen = new Set<string>();
const merged: T[] = [];
// Accumulate from previous pages first
for (const item of prevOptions) {
const key = String(item[vk]);
if (!seen.has(key)) {
seen.add(key);
merged.push(item);
}
}
// Append new items (only unique ones)
let newUniqueCount = 0;
for (const item of newItems) {
const key = String(item[vk]);
if (!seen.has(key)) {
seen.add(key);
merged.push(item);
newUniqueCount++;
}
}
// If the API returned items but ALL were duplicates, treat as exhausted.
// This prevents infinite scroll loops on naive APIs that ignore pagination.
const effectiveHasMore = newItems.length > 0 && newUniqueCount === 0
? false
: hasMore;
setCache((prev) => {
const next = new Map(prev);
next.set(searchTerm, {
options: merged,
hasMore: effectiveHasMore,
page,
isLoading: false,
});
return next;
});
} catch (error) {
if (!mountedRef.current || requestIdRef.current !== capturedId) {
return;
}
console.error('[useAsyncPaginate] loadOptions failed:', error);
// Mark the cache entry as exhausted to prevent retry loops
setCache((prev) => {
const next = new Map(prev);
const existing = prev.get(searchTerm);
next.set(searchTerm, {
options: existing?.options ?? [],
hasMore: false,
page: existing?.page ?? 0,
isLoading: false,
});
return next;
});
} finally {
if (mountedRef.current) {
setIsLoading(false);
}
fetchingRef.current = false;
}
},
// We intentionally exclude `cache` from deps to avoid re-creating this callback
// on every cache update. Instead, we read cache inside via the state setter's prev.
// The `cache.get(searchTerm)` read above is for prevOptions passed to loadOptions —
// this is acceptable because the callback is only called when we're NOT already fetching.
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
// -----------------------------------------------------------------------
// Effect: Fetch on debounced search change (including initial mount)
// -----------------------------------------------------------------------
//
// This single effect replaces the old two-effect pattern that caused
// double initial fetches. On mount, debouncedSearch starts as '' and
// triggers a single page-1 fetch. On search change, it looks up the
// cache and either reuses cached data or fetches page 1 for the new term.
// -----------------------------------------------------------------------
useEffect(() => {
const cached = cache.get(debouncedSearch);
// If we already have cached data for this search term, no fetch needed
if (cached && cached.options.length > 0) {
return;
}
// No cache entry — fetch page 1
fetchPage(debouncedSearch, 1);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedSearch]);
// -----------------------------------------------------------------------
// fetchNextPage — called by Mantine's scroll handler
// -----------------------------------------------------------------------
const fetchNextPage = useCallback(() => {
const cached = cache.get(debouncedSearch);
if (!cached || !cached.hasMore || isLoading || fetchingRef.current) return;
fetchPage(debouncedSearch, cached.page + 1);
}, [cache, debouncedSearch, isLoading, fetchPage]);
// -----------------------------------------------------------------------
// reset — clear cache and re-fetch from scratch
// -----------------------------------------------------------------------
const reset = useCallback(() => {
setCache(new Map());
requestIdRef.current++;
fetchPage(debouncedSearch, 1);
}, [debouncedSearch, fetchPage]);
// -----------------------------------------------------------------------
// Merge defaultOptions with fetched data (deduplicated)
// -----------------------------------------------------------------------
const currentCacheEntry = cache.get(debouncedSearch);
const data = useMemo<T[]>(() => {
const fetchedItems = currentCacheEntry?.options ?? [];
const defaults = defaultOptionsRef.current;
if (!defaults || defaults.length === 0) {
return fetchedItems;
}
// Merge: defaultOptions first, then fetched items (deduplicated)
const seen = new Set<string>();
const merged: T[] = [];
for (const item of defaults) {
const key = String(item[valueKeyRef.current]);
if (!seen.has(key)) {
seen.add(key);
merged.push(item);
}
}
for (const item of fetchedItems) {
const key = String(item[valueKeyRef.current]);
if (!seen.has(key)) {
seen.add(key);
merged.push(item);
}
}
return merged;
}, [currentCacheEntry?.options]);
const hasMore = currentCacheEntry?.hasMore ?? true;
const isTyping = search !== debouncedSearch;
return {
data,
isLoading: isLoading || isTyping,
hasMore,
search,
debouncedSearch,
setSearch,
fetchNextPage,
reset,
};
}
@@ -1,190 +0,0 @@
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,
};
}
@@ -1,7 +1,7 @@
import type { ComboboxItem } from '@mantine/core'; import type { ComboboxItem } from '@mantine/core';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// ObjectSelect — Shared types for the reusable Object Select engine // Select Engine — Shared types for LocalSelect and AsyncSelect
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** /**
@@ -9,7 +9,7 @@ import type { ComboboxItem } from '@mantine/core';
* Provides both the current search string and the currently selected value(s) * Provides both the current search string and the currently selected value(s)
* so consumers can implement exclusion logic, compound search, or domain-specific filters. * so consumers can implement exclusion logic, compound search, or domain-specific filters.
*/ */
export interface ObjectSelectFilterContext<T> { export interface SelectFilterContext<T> {
/** Current search input value */ /** Current search input value */
search: string; search: string;
/** Currently selected value(s) — T | null for single, T[] for multi */ /** Currently selected value(s) — T | null for single, T[] for multi */
@@ -17,14 +17,14 @@ export interface ObjectSelectFilterContext<T> {
} }
/** /**
* Core configuration for the Object Select engine. * Core configuration for the LocalSelect engine.
* This is the "headless" API — no RHF dependency. * This is the "headless" API — no RHF dependency.
* *
* @template T - The shape of each item in the data array * @template T - The shape of each item in the options array
*/ */
export interface ObjectSelectBaseProps<T extends Record<string, any>> { export interface LocalSelectBaseProps<T extends Record<string, any>> {
/** Array of complex objects to select from */ /** Array of complex objects to select from */
data: T[]; options: T[];
/** Property key to use as the unique string identifier for Mantine */ /** Property key to use as the unique string identifier for Mantine */
valueKey: keyof T & string; valueKey: keyof T & string;
@@ -42,20 +42,102 @@ export interface ObjectSelectBaseProps<T extends Record<string, any>> {
* Custom filter function for search and exclusion logic. * Custom filter function for search and exclusion logic.
* Return `true` to keep the item in the dropdown, `false` to exclude it. * Return `true` to keep the item in the dropdown, `false` to exclude it.
*/ */
filterOption?: (item: T, context: ObjectSelectFilterContext<T>) => boolean; filterOption?: (item: T, context: SelectFilterContext<T>) => boolean;
/** Callback fired when selection changes */ /** Callback fired when selection changes */
onSelect?: (value: T | T[] | null) => void; onSelect?: (value: T | T[] | null) => void;
} }
/**
* Core configuration for the AsyncSelect engine.
* Extends LocalSelectBaseProps but replaces `options` with `loadOptions`.
* This is the "headless" API — no RHF dependency.
*
* @template T - The shape of each item in the options array
*/
export type AsyncSelectBaseProps<T extends Record<string, any>> = Omit<LocalSelectBaseProps<T>, 'options'>;
/** /**
* Internal result of the object-to-string mapping logic. * Internal result of the object-to-string mapping logic.
* Used by both the standalone and RHF-connected variants. * Used by both the standalone and RHF-connected variants.
*/ */
export interface ObjectSelectMappingResult<T> { export interface SelectMappingResult<T> {
/** Mantine-compatible ComboboxItem array for the Select/MultiSelect `data` prop */ /** Mantine-compatible ComboboxItem array for the Select/MultiSelect `data` prop */
options: ComboboxItem[]; options: ComboboxItem[];
/** O(1) reverse lookup map: string value → original object */ /** O(1) reverse lookup map: string value → original object */
lookupMap: Map<string, T>; lookupMap: Map<string, T>;
} }
// ---------------------------------------------------------------------------
// AsyncSelect — Inversion of Control types for the async paginated engine
// ---------------------------------------------------------------------------
/**
* The response shape returned by the `loadOptions` callback.
* Supports both paginated and non-paginated APIs.
*
* @template T - The shape of each option item
*/
export interface LoadOptionsResponse<T> {
/** The array of option objects for this page/batch */
options: T[];
/**
* Whether more pages are available.
* - `true` → the engine will allow further scroll-triggered fetches.
* - `false` → no more data; subsequent scroll events are ignored.
* - `undefined` → treated as `false` (assumes non-paginated).
*/
hasMore?: boolean;
}
/**
* The callback signature for loading options asynchronously.
* This follows the Inversion of Control principle: the component is
* completely ignorant of transport (REST, GraphQL, local filter, etc.).
*
* @param search - The current search input string
* @param page - The 1-indexed page number being requested
* @param prevOptions - All options accumulated from previous pages
* @returns A promise resolving to the options for this page + pagination signal
*
* @example
* ```ts
* // REST API with cursor pagination
* const loadOptions: LoadOptionsFn<User> = async (search, page) => {
* const res = await api.get('/users', { params: { q: search, page, limit: 20 } });
* return { options: res.data.items, hasMore: res.data.hasNextPage };
* };
*
* // Non-paginated (single-shot fetch)
* const loadOptions: LoadOptionsFn<Role> = async (search) => {
* const roles = await api.get('/roles', { params: { q: search } });
* return { options: roles.data, hasMore: false };
* };
* ```
*/
export type LoadOptionsFn<T> = (
search: string,
page: number,
prevOptions: T[],
) => Promise<LoadOptionsResponse<T>>;
/**
* Internal cache entry for the search-keyed options cache.
* Each unique search string maps to one entry tracking the accumulated
* options, pagination state, and current page for that search context.
*/
export interface OptionsCacheEntry<T> {
/** Accumulated options across all fetched pages for this search term */
options: T[];
/** Whether more pages are available for this search term */
hasMore: boolean;
/** The last successfully fetched page number (1-indexed) */
page: number;
/** Whether a fetch is currently in-flight for this search term */
isLoading: boolean;
}
@@ -7,7 +7,7 @@ import {
} from 'react-hook-form'; } from 'react-hook-form';
import type { SelectProps, MultiSelectProps } from '@mantine/core'; import type { SelectProps, MultiSelectProps } from '@mantine/core';
import { AsyncSelect } from '../custom/selects/AsyncSelect'; import { AsyncSelect } from '../custom/selects/AsyncSelect';
import type { ObjectSelectBaseProps } from '../custom/selects/types'; import type { AsyncSelectBaseProps, LoadOptionsFn } from '../custom/selects/types';
import { useTranslatedError } from '../useTranslatedError'; import { useTranslatedError } from '../useTranslatedError';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -16,19 +16,29 @@ import { useTranslatedError } from '../useTranslatedError';
// //
// Thin RHF wrapper around the standalone AsyncSelect engine. // Thin RHF wrapper around the standalone AsyncSelect engine.
// Adds useController binding + i18n error translation. // Adds useController binding + i18n error translation.
//
// Data Mapping Contract:
// Single: RHF stores T | null → maps to Mantine string | null
// Multi: RHF stores T[] → maps to Mantine string[]
//
// Inversion of Control: accepts `loadOptions` callback instead of
// hardcoded API endpoint. The component is transport-agnostic.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** Mantine props we manage ourselves */ /** Mantine props we manage ourselves */
type ManagedSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'onBlur' | 'error' | 'filter' | 'name' | 'onSelect'; type ManagedSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'onBlur' | 'error' | 'filter' | 'name' | 'onSelect';
type ManagedMultiSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'onBlur' | 'error' | 'filter' | 'name' | 'onSelect'; type ManagedMultiSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'onBlur' | 'error' | 'filter' | 'name' | 'onSelect';
/** Async-specific props */ /** Async-specific props (IoC pattern) */
interface AsyncExtraProps<T> { interface AsyncExtraProps<T> {
apiEndpoint: string; /** Async callback to load options — (search, page, prevOptions) => Promise */
transformResponse?: (res: any) => { data: T[]; hasMore?: boolean } | T[]; loadOptions: LoadOptionsFn<T>;
pageSize?: number;
/** Pre-loaded objects for edit forms (injected into dropdown regardless of fetch state) */
defaultOptions?: T[];
/** Search debounce delay in ms (default: 300) */
debounceMs?: number; debounceMs?: number;
fetchFn?: (url: string) => Promise<any>;
} }
/** Single-select async RHF props */ /** Single-select async RHF props */
@@ -36,7 +46,7 @@ export type FieldAsyncSelectSingleProps<
T extends Record<string, any>, T extends Record<string, any>,
TFieldValues extends FieldValues = FieldValues, TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>, TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = Omit<ObjectSelectBaseProps<T>, 'data'> & > = AsyncSelectBaseProps<T> &
AsyncExtraProps<T> & AsyncExtraProps<T> &
UseControllerProps<TFieldValues, TName> & UseControllerProps<TFieldValues, TName> &
Omit<SelectProps, ManagedSelectProps> & { Omit<SelectProps, ManagedSelectProps> & {
@@ -48,7 +58,7 @@ export type FieldAsyncSelectMultiProps<
T extends Record<string, any>, T extends Record<string, any>,
TFieldValues extends FieldValues = FieldValues, TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>, TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = Omit<ObjectSelectBaseProps<T>, 'data'> & > = AsyncSelectBaseProps<T> &
AsyncExtraProps<T> & AsyncExtraProps<T> &
UseControllerProps<TFieldValues, TName> & UseControllerProps<TFieldValues, TName> &
Omit<MultiSelectProps, ManagedMultiSelectProps> & { Omit<MultiSelectProps, ManagedMultiSelectProps> & {
@@ -86,12 +96,10 @@ function FieldAsyncSelectInner<
renderLabel, renderLabel,
multiple, multiple,
filterOption, filterOption,
onSelect: onObjectSelect, onSelect: onSelectCallback,
apiEndpoint, loadOptions,
transformResponse, defaultOptions,
pageSize,
debounceMs, debounceMs,
fetchFn,
// Remaining Mantine props // Remaining Mantine props
...mantineProps ...mantineProps
} = props; } = props;
@@ -112,7 +120,7 @@ function FieldAsyncSelectInner<
const handleChange = (value: any) => { const handleChange = (value: any) => {
field.onChange(value); field.onChange(value);
onObjectSelect?.(value); onSelectCallback?.(value);
}; };
const engineProps = { const engineProps = {
@@ -120,11 +128,9 @@ function FieldAsyncSelectInner<
labelKey, labelKey,
renderLabel, renderLabel,
filterOption, filterOption,
apiEndpoint, loadOptions,
transformResponse, defaultOptions,
pageSize,
debounceMs, debounceMs,
fetchFn,
onBlur: field.onBlur, onBlur: field.onBlur,
error: translatedError, error: translatedError,
disabled: field.disabled, disabled: field.disabled,
@@ -6,12 +6,12 @@ import {
type UseControllerProps, type UseControllerProps,
} from 'react-hook-form'; } from 'react-hook-form';
import type { SelectProps, MultiSelectProps } from '@mantine/core'; import type { SelectProps, MultiSelectProps } from '@mantine/core';
import { ObjectSelect } from '../custom/selects/ObjectSelect'; import { LocalSelect } from '../custom/selects/LocalSelect';
import type { ObjectSelectBaseProps } from '../custom/selects/types'; import type { LocalSelectBaseProps } from '../custom/selects/types';
import { useTranslatedError } from '../useTranslatedError'; import { useTranslatedError } from '../useTranslatedError';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// FieldObjectSelect — RHF-connected Object Select // FieldLocalSelect — RHF-connected Local Select
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// //
// Follows the same architectural pattern as the existing `FieldSelect`: // Follows the same architectural pattern as the existing `FieldSelect`:
@@ -19,8 +19,12 @@ import { useTranslatedError } from '../useTranslatedError';
// //
// But instead of using the generic withRHF factory (which assumes string values), // But instead of using the generic withRHF factory (which assumes string values),
// we use a manual useController binding with an object interception layer. // we use a manual useController binding with an object interception layer.
// The actual rendering is delegated to the standalone ObjectSelect engine // The actual rendering is delegated to the standalone LocalSelect engine
// in `custom/ObjectSelect.tsx`. // in `custom/selects/LocalSelect.tsx`.
//
// Data Mapping Contract:
// Single: RHF stores T | null → maps to Mantine string | null
// Multi: RHF stores T[] → maps to Mantine string[]
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** Mantine props we manage ourselves */ /** Mantine props we manage ourselves */
@@ -28,45 +32,45 @@ type ManagedSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'onBl
type ManagedMultiSelectProps = '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 */ /** Single-select RHF props — stores T | null */
export type FieldObjectSelectSingleProps< export type FieldLocalSelectSingleProps<
T extends Record<string, any>, T extends Record<string, any>,
TFieldValues extends FieldValues = FieldValues, TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>, TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = ObjectSelectBaseProps<T> & > = LocalSelectBaseProps<T> &
UseControllerProps<TFieldValues, TName> & UseControllerProps<TFieldValues, TName> &
Omit<SelectProps, ManagedSelectProps> & { Omit<SelectProps, ManagedSelectProps> & {
multiple?: false; multiple?: false;
}; };
/** Multi-select RHF props — stores T[] */ /** Multi-select RHF props — stores T[] */
export type FieldObjectSelectMultiProps< export type FieldLocalSelectMultiProps<
T extends Record<string, any>, T extends Record<string, any>,
TFieldValues extends FieldValues = FieldValues, TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>, TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = ObjectSelectBaseProps<T> & > = LocalSelectBaseProps<T> &
UseControllerProps<TFieldValues, TName> & UseControllerProps<TFieldValues, TName> &
Omit<MultiSelectProps, ManagedMultiSelectProps> & { Omit<MultiSelectProps, ManagedMultiSelectProps> & {
multiple: true; multiple: true;
}; };
/** Discriminated union based on `multiple` */ /** Discriminated union based on `multiple` */
export type FieldObjectSelectProps< export type FieldLocalSelectProps<
T extends Record<string, any>, T extends Record<string, any>,
TFieldValues extends FieldValues = FieldValues, TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>, TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = > =
| FieldObjectSelectSingleProps<T, TFieldValues, TName> | FieldLocalSelectSingleProps<T, TFieldValues, TName>
| FieldObjectSelectMultiProps<T, TFieldValues, TName>; | FieldLocalSelectMultiProps<T, TFieldValues, TName>;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Component Implementation // Component Implementation
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function FieldObjectSelectInner< function FieldLocalSelectInner<
T extends Record<string, any>, T extends Record<string, any>,
TFieldValues extends FieldValues = FieldValues, TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>, TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>(props: FieldObjectSelectProps<T, TFieldValues, TName>) { >(props: FieldLocalSelectProps<T, TFieldValues, TName>) {
const { const {
// RHF controller props // RHF controller props
name, name,
@@ -75,14 +79,14 @@ function FieldObjectSelectInner<
shouldUnregister, shouldUnregister,
defaultValue, defaultValue,
disabled, disabled,
// ObjectSelect engine props // LocalSelect engine props
data, options,
valueKey, valueKey,
labelKey, labelKey,
renderLabel, renderLabel,
multiple, multiple,
filterOption, filterOption,
onSelect: onObjectSelect, onSelect: onSelectCallback,
// Remaining Mantine props // Remaining Mantine props
...mantineProps ...mantineProps
} = props; } = props;
@@ -105,15 +109,15 @@ function FieldObjectSelectInner<
// Intercept onChange to pass full objects to RHF // Intercept onChange to pass full objects to RHF
const handleChange = (value: any) => { const handleChange = (value: any) => {
field.onChange(value); field.onChange(value);
onObjectSelect?.(value); onSelectCallback?.(value);
}; };
// Build engine props based on single/multi mode // Build engine props based on single/multi mode
if (multiple) { if (multiple) {
return ( return (
<ObjectSelect<T> <LocalSelect<T>
multiple multiple
data={data} options={options}
valueKey={valueKey} valueKey={valueKey}
labelKey={labelKey} labelKey={labelKey}
renderLabel={renderLabel} renderLabel={renderLabel}
@@ -129,8 +133,8 @@ function FieldObjectSelectInner<
} }
return ( return (
<ObjectSelect<T> <LocalSelect<T>
data={data} options={options}
valueKey={valueKey} valueKey={valueKey}
labelKey={labelKey} labelKey={labelKey}
renderLabel={renderLabel} renderLabel={renderLabel}
@@ -145,5 +149,5 @@ function FieldObjectSelectInner<
); );
} }
export const FieldObjectSelect = React.memo(FieldObjectSelectInner) as typeof FieldObjectSelectInner; export const FieldLocalSelect = React.memo(FieldLocalSelectInner) as typeof FieldLocalSelectInner;
(FieldObjectSelect as any).displayName = 'FieldObjectSelect'; (FieldLocalSelect as any).displayName = 'FieldLocalSelect';
+12 -5
View File
@@ -38,16 +38,23 @@ export { FieldNativeSelect } from './fields/native-select.field';
export { FieldTagsInput } from './fields/tags-input.field'; export { FieldTagsInput } from './fields/tags-input.field';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Object & Async Selection Fields // Local & Async Selection Fields
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export { FieldObjectSelect } from './fields/object-select.field'; export { FieldLocalSelect } from './fields/local-select.field';
export type { FieldObjectSelectProps } from './fields/object-select.field'; export type { FieldLocalSelectProps } from './fields/local-select.field';
export { FieldAsyncSelect } from './fields/async-select.field'; export { FieldAsyncSelect } from './fields/async-select.field';
export type { FieldAsyncSelectProps } from './fields/async-select.field'; export type { FieldAsyncSelectProps } from './fields/async-select.field';
// Standalone engines (no RHF dependency) for use outside form contexts // Standalone engines (no RHF dependency) for use outside form contexts
export { ObjectSelect, AsyncSelect } from './custom'; export { LocalSelect, AsyncSelect } from './custom';
export type { ObjectSelectProps, AsyncSelectProps, ObjectSelectBaseProps, ObjectSelectFilterContext } from './custom'; export type {
LocalSelectProps,
AsyncSelectProps,
LocalSelectBaseProps,
SelectFilterContext,
LoadOptionsResponse,
LoadOptionsFn,
} from './custom';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Toggle / Boolean Fields // Toggle / Boolean Fields