feat: replace ObjectSelect with LocalSelect and introduce custom FieldSelect wrappers for object-based RHF state management
This commit is contained in:
@@ -5,6 +5,7 @@ import React from 'react';
|
||||
import { useForm, FormProvider } from 'react-hook-form';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import { FieldAsyncSelect } from '../fields/async-select.field';
|
||||
import type { LoadOptionsFn } from '../custom/selects/types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock i18n & Setup
|
||||
@@ -18,20 +19,20 @@ vi.mock('@repo/core-i18n', () => ({
|
||||
|
||||
type Vendor = { id: number; code: string; name: string };
|
||||
|
||||
const MOCK_API_RESPONSE = {
|
||||
items: [
|
||||
{ id: 1, code: 'V1', name: 'Vendor 1' },
|
||||
{ id: 2, code: 'V2', name: 'Vendor 2' },
|
||||
{ id: 3, code: 'V3', name: 'Vendor 3' },
|
||||
],
|
||||
total: 3,
|
||||
const MOCK_VENDORS: Vendor[] = [
|
||||
{ id: 1, code: 'V1', name: 'Vendor 1' },
|
||||
{ id: 2, code: 'V2', name: 'Vendor 2' },
|
||||
{ id: 3, code: 'V3', name: 'Vendor 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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -43,6 +44,7 @@ describe('FieldAsyncSelect', () => {
|
||||
|
||||
it('fetches initial page on mount and renders items', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockLoadOptions = createMockLoadOptions();
|
||||
|
||||
function TestForm() {
|
||||
const { control } = useForm({ defaultValues: { vendor: null } });
|
||||
@@ -52,9 +54,7 @@ describe('FieldAsyncSelect', () => {
|
||||
<FieldAsyncSelect
|
||||
name="vendor"
|
||||
control={control}
|
||||
apiEndpoint="/api/vendors"
|
||||
fetchFn={mockFetchFn}
|
||||
transformResponse={(res) => res.items}
|
||||
loadOptions={mockLoadOptions}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
placeholder="Select async vendor"
|
||||
@@ -66,9 +66,9 @@ describe('FieldAsyncSelect', () => {
|
||||
|
||||
render(<TestForm />);
|
||||
|
||||
// Wait for initial fetch
|
||||
// Wait for initial fetch (page 1, search='')
|
||||
await waitFor(() => {
|
||||
expect(mockFetchFn).toHaveBeenCalledWith('/api/vendors?page=1&pageSize=20&search=');
|
||||
expect(mockLoadOptions).toHaveBeenCalledWith('', 1, []);
|
||||
});
|
||||
|
||||
await user.click(screen.getByPlaceholderText('Select async vendor'));
|
||||
@@ -81,6 +81,7 @@ describe('FieldAsyncSelect', () => {
|
||||
it('stores full object in RHF from async data', async () => {
|
||||
const user = userEvent.setup();
|
||||
let capturedData: any = null;
|
||||
const mockLoadOptions = createMockLoadOptions();
|
||||
|
||||
function TestForm() {
|
||||
const { control, handleSubmit } = useForm({ defaultValues: { vendor: null } });
|
||||
@@ -90,9 +91,7 @@ describe('FieldAsyncSelect', () => {
|
||||
<FieldAsyncSelect
|
||||
name="vendor"
|
||||
control={control}
|
||||
apiEndpoint="/api/vendors"
|
||||
fetchFn={mockFetchFn}
|
||||
transformResponse={(res) => res.items}
|
||||
loadOptions={mockLoadOptions}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
placeholder="Select vendor"
|
||||
@@ -106,7 +105,7 @@ describe('FieldAsyncSelect', () => {
|
||||
render(<TestForm />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchFn).toHaveBeenCalled();
|
||||
expect(mockLoadOptions).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await user.click(screen.getByPlaceholderText('Select vendor'));
|
||||
@@ -117,13 +116,11 @@ describe('FieldAsyncSelect', () => {
|
||||
});
|
||||
|
||||
it('injects pre-selected value not in fetched data', async () => {
|
||||
// We mock fetch to return only V1 and V2
|
||||
const partialFetchFn = vi.fn().mockResolvedValue({
|
||||
items: [
|
||||
{ id: 1, code: 'V1', name: 'Vendor 1' },
|
||||
{ id: 2, code: 'V2', name: 'Vendor 2' },
|
||||
]
|
||||
});
|
||||
// loadOptions returns only V1 and V2
|
||||
const partialLoadOptions = createMockLoadOptions([
|
||||
{ id: 1, code: 'V1', name: 'Vendor 1' },
|
||||
{ id: 2, code: 'V2', name: 'Vendor 2' },
|
||||
]);
|
||||
|
||||
// The form starts with V99 pre-selected (e.g. from server hydration)
|
||||
const PRESELECTED_VENDOR = { id: 99, code: 'V99', name: 'Vendor 99' };
|
||||
@@ -136,9 +133,7 @@ describe('FieldAsyncSelect', () => {
|
||||
<FieldAsyncSelect
|
||||
name="vendor"
|
||||
control={control}
|
||||
apiEndpoint="/api/vendors"
|
||||
fetchFn={partialFetchFn}
|
||||
transformResponse={(res) => res.items}
|
||||
loadOptions={partialLoadOptions}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
placeholder="Select vendor"
|
||||
@@ -151,7 +146,7 @@ describe('FieldAsyncSelect', () => {
|
||||
render(<TestForm />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(partialFetchFn).toHaveBeenCalled();
|
||||
expect(partialLoadOptions).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// 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 () => {
|
||||
const user = userEvent.setup();
|
||||
const mockLoadOptions = createMockLoadOptions();
|
||||
|
||||
function TestForm() {
|
||||
const { control } = useForm({ defaultValues: { vendor: null } });
|
||||
@@ -170,9 +166,7 @@ describe('FieldAsyncSelect', () => {
|
||||
<FieldAsyncSelect
|
||||
name="vendor"
|
||||
control={control}
|
||||
apiEndpoint="/api/vendors"
|
||||
fetchFn={mockFetchFn}
|
||||
transformResponse={(res) => res.items}
|
||||
loadOptions={mockLoadOptions}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
placeholder="Search vendor"
|
||||
@@ -187,28 +181,26 @@ describe('FieldAsyncSelect', () => {
|
||||
|
||||
// Wait for initial fetch
|
||||
await waitFor(() => {
|
||||
expect(mockFetchFn).toHaveBeenCalledTimes(1);
|
||||
expect(mockLoadOptions).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const input = screen.getByPlaceholderText('Search vendor');
|
||||
await user.type(input, 'test');
|
||||
|
||||
// Wait for debounced fetch
|
||||
// Wait for debounced fetch — should call with search='test'
|
||||
await waitFor(() => {
|
||||
expect(mockFetchFn).toHaveBeenCalledTimes(2);
|
||||
expect(mockFetchFn).toHaveBeenLastCalledWith('/api/vendors?page=1&pageSize=20&search=test');
|
||||
expect(mockLoadOptions).toHaveBeenCalledTimes(2);
|
||||
expect(mockLoadOptions).toHaveBeenLastCalledWith('test', 1, []);
|
||||
});
|
||||
});
|
||||
|
||||
it('gracefully deduplicates overlapping data across API responses', async () => {
|
||||
// API returns Vendor 1 twice
|
||||
const badFetchFn = vi.fn().mockResolvedValue({
|
||||
items: [
|
||||
{ id: 1, code: 'V1', name: 'Vendor 1' },
|
||||
{ id: 1, code: 'V1', name: 'Vendor 1 (Duplicate)' },
|
||||
{ id: 2, code: 'V2', name: 'Vendor 2' },
|
||||
]
|
||||
});
|
||||
// loadOptions returns Vendor 1 twice (duplicate id=1)
|
||||
const badLoadOptions = createMockLoadOptions([
|
||||
{ id: 1, code: 'V1', name: 'Vendor 1' },
|
||||
{ id: 1, code: 'V1', name: 'Vendor 1 (Duplicate)' },
|
||||
{ id: 2, code: 'V2', name: 'Vendor 2' },
|
||||
]);
|
||||
|
||||
function TestForm() {
|
||||
const { control } = useForm({ defaultValues: { vendor: null } });
|
||||
@@ -218,9 +210,7 @@ describe('FieldAsyncSelect', () => {
|
||||
<FieldAsyncSelect
|
||||
name="vendor"
|
||||
control={control}
|
||||
apiEndpoint="/api/bad-vendors"
|
||||
fetchFn={badFetchFn}
|
||||
transformResponse={(res) => res.items}
|
||||
loadOptions={badLoadOptions}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
placeholder="Select bad vendor"
|
||||
@@ -233,7 +223,7 @@ describe('FieldAsyncSelect', () => {
|
||||
render(<TestForm />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(badFetchFn).toHaveBeenCalledTimes(1);
|
||||
expect(badLoadOptions).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
|
||||
+16
-16
@@ -4,7 +4,7 @@ import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import { useForm, FormProvider } from 'react-hook-form';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import { FieldObjectSelect } from '../fields/object-select.field';
|
||||
import { FieldLocalSelect } from '../fields/local-select.field';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock i18n
|
||||
@@ -34,7 +34,7 @@ const VENDORS: Vendor[] = [
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('FieldObjectSelect', () => {
|
||||
describe('FieldLocalSelect', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
@@ -44,10 +44,10 @@ describe('FieldObjectSelect', () => {
|
||||
const { control } = useForm({ defaultValues: { vendor: null } });
|
||||
return (
|
||||
<MantineProvider>
|
||||
<FieldObjectSelect
|
||||
<FieldLocalSelect
|
||||
name="vendor"
|
||||
control={control}
|
||||
data={VENDORS}
|
||||
options={VENDORS}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
label="Vendor"
|
||||
@@ -71,10 +71,10 @@ describe('FieldObjectSelect', () => {
|
||||
return (
|
||||
<MantineProvider>
|
||||
<form onSubmit={handleSubmit((data) => { capturedData = data; })}>
|
||||
<FieldObjectSelect
|
||||
<FieldLocalSelect
|
||||
name="vendor"
|
||||
control={control}
|
||||
data={VENDORS}
|
||||
options={VENDORS}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
placeholder="Select vendor"
|
||||
@@ -107,10 +107,10 @@ describe('FieldObjectSelect', () => {
|
||||
const { control } = useForm({ defaultValues: { vendor: null } });
|
||||
return (
|
||||
<MantineProvider>
|
||||
<FieldObjectSelect
|
||||
<FieldLocalSelect
|
||||
name="vendor"
|
||||
control={control}
|
||||
data={VENDORS}
|
||||
options={VENDORS}
|
||||
valueKey="id"
|
||||
renderLabel={(item) => `${item.code} - ${item.name}`}
|
||||
placeholder="Select vendor"
|
||||
@@ -135,10 +135,10 @@ describe('FieldObjectSelect', () => {
|
||||
const { control } = useForm({ defaultValues: { vendor: null } });
|
||||
return (
|
||||
<MantineProvider>
|
||||
<FieldObjectSelect
|
||||
<FieldLocalSelect
|
||||
name="vendor"
|
||||
control={control}
|
||||
data={VENDORS}
|
||||
options={VENDORS}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
placeholder="Select vendor"
|
||||
@@ -167,11 +167,11 @@ describe('FieldObjectSelect', () => {
|
||||
return (
|
||||
<MantineProvider>
|
||||
<form onSubmit={handleSubmit((data) => { capturedData = data; })}>
|
||||
<FieldObjectSelect
|
||||
<FieldLocalSelect
|
||||
multiple
|
||||
name="vendors"
|
||||
control={control}
|
||||
data={VENDORS}
|
||||
options={VENDORS}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
placeholder="Select vendors"
|
||||
@@ -200,11 +200,11 @@ describe('FieldObjectSelect', () => {
|
||||
return (
|
||||
<MantineProvider>
|
||||
<form onSubmit={handleSubmit((data) => { capturedData = data; })}>
|
||||
<FieldObjectSelect
|
||||
<FieldLocalSelect
|
||||
multiple
|
||||
name="vendors"
|
||||
control={control}
|
||||
data={VENDORS}
|
||||
options={VENDORS}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
clearable
|
||||
@@ -233,10 +233,10 @@ describe('FieldObjectSelect', () => {
|
||||
const { control } = useForm({ defaultValues: { vendor: null } });
|
||||
return (
|
||||
<MantineProvider>
|
||||
<FieldObjectSelect
|
||||
<FieldLocalSelect
|
||||
name="vendor"
|
||||
control={control}
|
||||
data={VENDORS}
|
||||
options={VENDORS}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
placeholder="Select vendor"
|
||||
@@ -2,13 +2,21 @@
|
||||
// Custom Form Components — Barrel Export
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 type { ObjectSelectProps, ObjectSelectSingleProps, ObjectSelectMultiProps } from './selects/ObjectSelect';
|
||||
export { LocalSelect } from './selects/LocalSelect';
|
||||
export type { LocalSelectProps, LocalSelectSingleProps, LocalSelectMultiProps } from './selects/LocalSelect';
|
||||
|
||||
export { AsyncSelect } 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 { Select, MultiSelect, Loader, type SelectProps, type MultiSelectProps, type ComboboxItem } from '@mantine/core';
|
||||
import type { ObjectSelectBaseProps, ObjectSelectFilterContext } from './types';
|
||||
import { useAsyncSelectInfiniteScroll } from './hooks/useAsyncSelectInfiniteScroll';
|
||||
import type { AsyncSelectBaseProps, SelectFilterContext, LoadOptionsFn } from './types';
|
||||
import { useAsyncPaginate } from './hooks/useAsyncPaginate';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 */
|
||||
type ManagedSelectProps = '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> {
|
||||
apiEndpoint: string;
|
||||
transformResponse?: (res: any) => { data: T[]; hasMore?: boolean } | T[];
|
||||
pageSize?: number;
|
||||
/**
|
||||
* Async callback to load options. The component calls this when:
|
||||
* - 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;
|
||||
fetchFn?: (url: string) => Promise<any>;
|
||||
}
|
||||
|
||||
/** 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> &
|
||||
Omit<SelectProps, ManagedSelectProps> & {
|
||||
multiple?: false;
|
||||
@@ -30,7 +57,7 @@ export type AsyncSelectSingleProps<T extends Record<string, any>> = Omit<ObjectS
|
||||
};
|
||||
|
||||
/** 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> &
|
||||
Omit<MultiSelectProps, ManagedMultiSelectProps> & {
|
||||
multiple: true;
|
||||
@@ -51,7 +78,8 @@ function resolveLabel<T extends Record<string, any>>(
|
||||
): string {
|
||||
if (renderLabel) return renderLabel(item);
|
||||
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,
|
||||
multiple,
|
||||
filterOption,
|
||||
onSelect: onObjectSelect,
|
||||
onSelect: onSelectCallback,
|
||||
value,
|
||||
onChange,
|
||||
apiEndpoint,
|
||||
transformResponse,
|
||||
pageSize,
|
||||
loadOptions,
|
||||
defaultOptions,
|
||||
debounceMs,
|
||||
fetchFn,
|
||||
searchable,
|
||||
onSearchChange: consumerOnSearchChange,
|
||||
...mantineProps
|
||||
} = props;
|
||||
|
||||
// Use the infinite scroll hook for data fetching
|
||||
// Use the new paginate hook for data fetching
|
||||
const {
|
||||
data: fetchedData,
|
||||
isLoading,
|
||||
fetchNextPage,
|
||||
search,
|
||||
debouncedSearch,
|
||||
setSearch,
|
||||
} = useAsyncSelectInfiniteScroll<T>({
|
||||
apiEndpoint,
|
||||
} = useAsyncPaginate<T>({
|
||||
loadOptions,
|
||||
valueKey,
|
||||
pageSize,
|
||||
transformResponse,
|
||||
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 uniqueItems: T[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
// 1. Deduplicate fetched data from the API (hook already deduplicates internally, but this is an extra UI safeguard)
|
||||
// 1. Start with fetched data (already includes defaultOptions from the hook)
|
||||
for (const item of fetchedData) {
|
||||
const key = String(item[valueKey]);
|
||||
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)) {
|
||||
for (const v of value) {
|
||||
const key = String(v[valueKey]);
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
uniqueItems.unshift(v); // Put selected items at the top
|
||||
uniqueItems.unshift(v); // Selected items at the top
|
||||
}
|
||||
}
|
||||
} else if (!multiple && value) {
|
||||
@@ -129,7 +159,7 @@ function AsyncSelectInner<T extends Record<string, any>>(props: AsyncSelectProps
|
||||
return uniqueItems;
|
||||
}, [fetchedData, value, valueKey, multiple]);
|
||||
|
||||
// Build lookup map
|
||||
// Build lookup map — includes ALL sources for safe reverse resolution
|
||||
const lookupMap = useMemo(() => {
|
||||
const map = new Map<string, T>();
|
||||
for (const item of dataWithInjected) {
|
||||
@@ -143,8 +173,8 @@ function AsyncSelectInner<T extends Record<string, any>>(props: AsyncSelectProps
|
||||
let filtered = dataWithInjected;
|
||||
|
||||
if (filterOption) {
|
||||
const context: ObjectSelectFilterContext<T> = {
|
||||
search, // Pass the active search string to the custom filter
|
||||
const context: SelectFilterContext<T> = {
|
||||
search,
|
||||
selected: value ?? (multiple ? [] : null),
|
||||
};
|
||||
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]);
|
||||
|
||||
const isTyping = search !== debouncedSearch;
|
||||
const isFetching = isLoading || isTyping;
|
||||
const rightSection = isFetching ? <Loader size={16} /> : mantineProps.rightSection;
|
||||
const rightSection = isLoading ? <Loader size={16} /> : mantineProps.rightSection;
|
||||
|
||||
// Handle search → delegate to the hook's setSearch (debounced)
|
||||
const handleSearchChange = useCallback(
|
||||
(val: string) => {
|
||||
setSearch(val);
|
||||
consumerOnSearchChange?.(val);
|
||||
},
|
||||
[setSearch],
|
||||
[setSearch, consumerOnSearchChange],
|
||||
);
|
||||
|
||||
// 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.
|
||||
// The backend handles the search query, so we should always display what the backend returns.
|
||||
const mantineFilter = useCallback(({ options: opts }: { options: ComboboxItem[] }) => opts, []);
|
||||
// The backend handles the search query, so we always display what the backend returns.
|
||||
const mantineFilter = filterOption
|
||||
? ({ options: opts }: any) => opts
|
||||
: undefined;
|
||||
|
||||
// ----- Multi-select mode -----
|
||||
if (multiple) {
|
||||
const currentValues = Array.isArray(value) ? value.map((v) => String(v[valueKey])) : [];
|
||||
|
||||
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);
|
||||
(onChange as ((v: T[]) => void) | undefined)?.(objects);
|
||||
onObjectSelect?.(objects);
|
||||
onSelectCallback?.(objects);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -214,7 +248,7 @@ function AsyncSelectInner<T extends Record<string, any>>(props: AsyncSelectProps
|
||||
const handleSingleChange = (val: string | null) => {
|
||||
const obj = val ? (lookupMap.get(val) ?? null) : null;
|
||||
(onChange as ((v: T | null) => void) | undefined)?.(obj);
|
||||
onObjectSelect?.(obj);
|
||||
onSelectCallback?.(obj);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -226,7 +260,7 @@ function AsyncSelectInner<T extends Record<string, any>>(props: AsyncSelectProps
|
||||
searchable={searchable ?? true}
|
||||
onSearchChange={handleSearchChange}
|
||||
scrollAreaProps={scrollAreaProps}
|
||||
filter={mantineFilter}
|
||||
filter={mantineFilter as any}
|
||||
rightSection={rightSection}
|
||||
/>
|
||||
);
|
||||
|
||||
+44
-36
@@ -1,9 +1,9 @@
|
||||
import React, { useMemo, useState, useCallback } from 'react';
|
||||
import { Select, MultiSelect, type SelectProps, type MultiSelectProps, type ComboboxItem } from '@mantine/core';
|
||||
import type { ObjectSelectBaseProps, ObjectSelectFilterContext } from './types';
|
||||
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
|
||||
@@ -12,7 +12,11 @@ import type { ObjectSelectBaseProps, ObjectSelectFilterContext } from './types';
|
||||
// 2. Building a Map<string, T> for O(1) reverse lookups
|
||||
// 3. Intercepting onChange to resolve strings back to full objects
|
||||
//
|
||||
// The RHF-connected version (FieldObjectSelect) wraps this component and
|
||||
// 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.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -21,8 +25,8 @@ type ManagedSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'filt
|
||||
type ManagedMultiSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'filter' | 'name' | 'onSelect';
|
||||
|
||||
/** Props for single-select mode */
|
||||
export type ObjectSelectSingleProps<T extends Record<string, any>> =
|
||||
ObjectSelectBaseProps<T> & Omit<SelectProps, ManagedSelectProps> & {
|
||||
export type LocalSelectSingleProps<T extends Record<string, any>> =
|
||||
LocalSelectBaseProps<T> & Omit<SelectProps, ManagedSelectProps> & {
|
||||
multiple?: false;
|
||||
/** Controlled value — the full object or null */
|
||||
value?: T | null;
|
||||
@@ -31,8 +35,8 @@ export type ObjectSelectSingleProps<T extends Record<string, any>> =
|
||||
};
|
||||
|
||||
/** Props for multi-select mode */
|
||||
export type ObjectSelectMultiProps<T extends Record<string, any>> =
|
||||
ObjectSelectBaseProps<T> & Omit<MultiSelectProps, ManagedMultiSelectProps> & {
|
||||
export type LocalSelectMultiProps<T extends Record<string, any>> =
|
||||
LocalSelectBaseProps<T> & Omit<MultiSelectProps, ManagedMultiSelectProps> & {
|
||||
multiple: true;
|
||||
/** Controlled value — array of full objects */
|
||||
value?: T[];
|
||||
@@ -41,9 +45,9 @@ export type ObjectSelectMultiProps<T extends Record<string, any>> =
|
||||
};
|
||||
|
||||
/** Discriminated union — the component narrows based on `multiple` */
|
||||
export type ObjectSelectProps<T extends Record<string, any>> =
|
||||
| ObjectSelectSingleProps<T>
|
||||
| ObjectSelectMultiProps<T>;
|
||||
export type LocalSelectProps<T extends Record<string, any>> =
|
||||
| LocalSelectSingleProps<T>
|
||||
| LocalSelectMultiProps<T>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: Resolve label for a data item
|
||||
@@ -56,27 +60,33 @@ function resolveLabel<T extends Record<string, any>>(
|
||||
): string {
|
||||
if (renderLabel) return renderLabel(item);
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ObjectSelectInner<T extends Record<string, any>>(
|
||||
props: ObjectSelectProps<T>,
|
||||
function LocalSelectInner<T extends Record<string, any>>(
|
||||
props: LocalSelectProps<T>,
|
||||
) {
|
||||
const {
|
||||
data,
|
||||
options,
|
||||
valueKey,
|
||||
labelKey,
|
||||
renderLabel,
|
||||
multiple,
|
||||
filterOption,
|
||||
onSelect: onObjectSelect,
|
||||
onSelect: onSelectCallback,
|
||||
value,
|
||||
onChange,
|
||||
searchable,
|
||||
// Extract onSearchChange BEFORE the rest spread to get a
|
||||
// stable reference for the useCallback dependency array.
|
||||
onSearchChange: consumerOnSearchChange,
|
||||
...mantineProps
|
||||
} = props;
|
||||
|
||||
@@ -86,40 +96,38 @@ function ObjectSelectInner<T extends Record<string, any>>(
|
||||
// Build lookup map: string → original object (O(1) reverse lookup)
|
||||
const lookupMap = useMemo(() => {
|
||||
const map = new Map<string, T>();
|
||||
for (const item of data) {
|
||||
for (const item of options) {
|
||||
map.set(String(item[valueKey]), item);
|
||||
}
|
||||
return map;
|
||||
}, [data, valueKey]);
|
||||
}, [options, valueKey]);
|
||||
|
||||
// Build Mantine-compatible ComboboxItem[], applying filterOption if provided
|
||||
const options = useMemo<ComboboxItem[]>(() => {
|
||||
let filtered = data;
|
||||
const comboboxItems = useMemo<ComboboxItem[]>(() => {
|
||||
let filtered = options;
|
||||
|
||||
if (filterOption) {
|
||||
const context: ObjectSelectFilterContext<T> = {
|
||||
const context: SelectFilterContext<T> = {
|
||||
search: searchValue,
|
||||
selected: value ?? (multiple ? [] : null),
|
||||
};
|
||||
filtered = data.filter((item) => filterOption(item, context));
|
||||
filtered = options.filter((item) => filterOption(item, context));
|
||||
}
|
||||
|
||||
return filtered.map((item) => ({
|
||||
value: String(item[valueKey]),
|
||||
label: resolveLabel(item, labelKey, renderLabel),
|
||||
}));
|
||||
}, [data, valueKey, labelKey, renderLabel, filterOption, searchValue, value, multiple]);
|
||||
}, [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(
|
||||
(val: string) => {
|
||||
setSearchValue(val);
|
||||
// Forward to consumer's onSearchChange if provided
|
||||
if ('onSearchChange' in mantineProps && typeof mantineProps.onSearchChange === 'function') {
|
||||
mantineProps.onSearchChange(val);
|
||||
}
|
||||
consumerOnSearchChange?.(val);
|
||||
},
|
||||
[mantineProps],
|
||||
[consumerOnSearchChange],
|
||||
);
|
||||
|
||||
// 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 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);
|
||||
(onChange as ((v: T[]) => void) | undefined)?.(objects);
|
||||
onObjectSelect?.(objects);
|
||||
onSelectCallback?.(objects);
|
||||
};
|
||||
|
||||
return (
|
||||
<MultiSelect
|
||||
{...(mantineProps as Omit<MultiSelectProps, ManagedMultiSelectProps>)}
|
||||
data={options}
|
||||
data={comboboxItems}
|
||||
value={currentValues}
|
||||
onChange={handleMultiChange}
|
||||
searchable={searchable ?? false}
|
||||
onSearchChange={handleSearchChange}
|
||||
filter={mantineFilter as any}
|
||||
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -159,23 +168,22 @@ function ObjectSelectInner<T extends Record<string, any>>(
|
||||
const handleSingleChange = (val: string | null) => {
|
||||
const obj = val ? lookupMap.get(val) ?? null : null;
|
||||
(onChange as ((v: T | null) => void) | undefined)?.(obj);
|
||||
onObjectSelect?.(obj);
|
||||
onSelectCallback?.(obj);
|
||||
};
|
||||
|
||||
return (
|
||||
<Select
|
||||
{...(mantineProps as Omit<SelectProps, ManagedSelectProps>)}
|
||||
data={options}
|
||||
data={comboboxItems}
|
||||
value={currentValue}
|
||||
onChange={handleSingleChange}
|
||||
searchable={searchable ?? false}
|
||||
onSearchChange={handleSearchChange}
|
||||
filter={mantineFilter as any}
|
||||
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Apply React.memo for render optimization in large forms
|
||||
export const ObjectSelect = React.memo(ObjectSelectInner) as typeof ObjectSelectInner;
|
||||
(ObjectSelect as any).displayName = 'ObjectSelect';
|
||||
export const LocalSelect = React.memo(LocalSelectInner) as typeof LocalSelectInner;
|
||||
(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';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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)
|
||||
* so consumers can implement exclusion logic, compound search, or domain-specific filters.
|
||||
*/
|
||||
export interface ObjectSelectFilterContext<T> {
|
||||
export interface SelectFilterContext<T> {
|
||||
/** Current search input value */
|
||||
search: string;
|
||||
/** 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.
|
||||
*
|
||||
* @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 */
|
||||
data: T[];
|
||||
options: T[];
|
||||
|
||||
/** Property key to use as the unique string identifier for Mantine */
|
||||
valueKey: keyof T & string;
|
||||
@@ -42,20 +42,102 @@ export interface ObjectSelectBaseProps<T extends Record<string, any>> {
|
||||
* Custom filter function for search and exclusion logic.
|
||||
* Return `true` to keep the item in the dropdown, `false` to exclude it.
|
||||
*/
|
||||
filterOption?: (item: T, context: ObjectSelectFilterContext<T>) => boolean;
|
||||
filterOption?: (item: T, context: SelectFilterContext<T>) => boolean;
|
||||
|
||||
/** Callback fired when selection changes */
|
||||
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.
|
||||
* 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 */
|
||||
options: ComboboxItem[];
|
||||
|
||||
/** O(1) reverse lookup map: string value → original object */
|
||||
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';
|
||||
import type { SelectProps, MultiSelectProps } from '@mantine/core';
|
||||
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';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -16,19 +16,29 @@ import { useTranslatedError } from '../useTranslatedError';
|
||||
//
|
||||
// Thin RHF wrapper around the standalone AsyncSelect engine.
|
||||
// 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 */
|
||||
type ManagedSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'onBlur' | 'error' | 'filter' | 'name' | 'onSelect';
|
||||
type ManagedMultiSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'onBlur' | 'error' | 'filter' | 'name' | 'onSelect';
|
||||
|
||||
/** Async-specific props */
|
||||
/** Async-specific props (IoC pattern) */
|
||||
interface AsyncExtraProps<T> {
|
||||
apiEndpoint: string;
|
||||
transformResponse?: (res: any) => { data: T[]; hasMore?: boolean } | T[];
|
||||
pageSize?: number;
|
||||
/** Async callback to load options — (search, page, prevOptions) => Promise */
|
||||
loadOptions: LoadOptionsFn<T>;
|
||||
|
||||
/** Pre-loaded objects for edit forms (injected into dropdown regardless of fetch state) */
|
||||
defaultOptions?: T[];
|
||||
|
||||
/** Search debounce delay in ms (default: 300) */
|
||||
debounceMs?: number;
|
||||
fetchFn?: (url: string) => Promise<any>;
|
||||
}
|
||||
|
||||
/** Single-select async RHF props */
|
||||
@@ -36,7 +46,7 @@ export type FieldAsyncSelectSingleProps<
|
||||
T extends Record<string, any>,
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = Omit<ObjectSelectBaseProps<T>, 'data'> &
|
||||
> = AsyncSelectBaseProps<T> &
|
||||
AsyncExtraProps<T> &
|
||||
UseControllerProps<TFieldValues, TName> &
|
||||
Omit<SelectProps, ManagedSelectProps> & {
|
||||
@@ -48,7 +58,7 @@ export type FieldAsyncSelectMultiProps<
|
||||
T extends Record<string, any>,
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = Omit<ObjectSelectBaseProps<T>, 'data'> &
|
||||
> = AsyncSelectBaseProps<T> &
|
||||
AsyncExtraProps<T> &
|
||||
UseControllerProps<TFieldValues, TName> &
|
||||
Omit<MultiSelectProps, ManagedMultiSelectProps> & {
|
||||
@@ -86,12 +96,10 @@ function FieldAsyncSelectInner<
|
||||
renderLabel,
|
||||
multiple,
|
||||
filterOption,
|
||||
onSelect: onObjectSelect,
|
||||
apiEndpoint,
|
||||
transformResponse,
|
||||
pageSize,
|
||||
onSelect: onSelectCallback,
|
||||
loadOptions,
|
||||
defaultOptions,
|
||||
debounceMs,
|
||||
fetchFn,
|
||||
// Remaining Mantine props
|
||||
...mantineProps
|
||||
} = props;
|
||||
@@ -112,7 +120,7 @@ function FieldAsyncSelectInner<
|
||||
|
||||
const handleChange = (value: any) => {
|
||||
field.onChange(value);
|
||||
onObjectSelect?.(value);
|
||||
onSelectCallback?.(value);
|
||||
};
|
||||
|
||||
const engineProps = {
|
||||
@@ -120,11 +128,9 @@ function FieldAsyncSelectInner<
|
||||
labelKey,
|
||||
renderLabel,
|
||||
filterOption,
|
||||
apiEndpoint,
|
||||
transformResponse,
|
||||
pageSize,
|
||||
loadOptions,
|
||||
defaultOptions,
|
||||
debounceMs,
|
||||
fetchFn,
|
||||
onBlur: field.onBlur,
|
||||
error: translatedError,
|
||||
disabled: field.disabled,
|
||||
|
||||
+28
-24
@@ -6,12 +6,12 @@ import {
|
||||
type UseControllerProps,
|
||||
} from 'react-hook-form';
|
||||
import type { SelectProps, MultiSelectProps } from '@mantine/core';
|
||||
import { ObjectSelect } from '../custom/selects/ObjectSelect';
|
||||
import type { ObjectSelectBaseProps } from '../custom/selects/types';
|
||||
import { LocalSelect } from '../custom/selects/LocalSelect';
|
||||
import type { LocalSelectBaseProps } from '../custom/selects/types';
|
||||
import { useTranslatedError } from '../useTranslatedError';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FieldObjectSelect — RHF-connected Object Select
|
||||
// FieldLocalSelect — RHF-connected Local Select
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// 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),
|
||||
// we use a manual useController binding with an object interception layer.
|
||||
// The actual rendering is delegated to the standalone ObjectSelect engine
|
||||
// in `custom/ObjectSelect.tsx`.
|
||||
// The actual rendering is delegated to the standalone LocalSelect engine
|
||||
// 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 */
|
||||
@@ -28,45 +32,45 @@ type ManagedSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'onBl
|
||||
type ManagedMultiSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'onBlur' | 'error' | 'filter' | 'name' | 'onSelect';
|
||||
|
||||
/** Single-select RHF props — stores T | null */
|
||||
export type FieldObjectSelectSingleProps<
|
||||
export type FieldLocalSelectSingleProps<
|
||||
T extends Record<string, any>,
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = ObjectSelectBaseProps<T> &
|
||||
> = LocalSelectBaseProps<T> &
|
||||
UseControllerProps<TFieldValues, TName> &
|
||||
Omit<SelectProps, ManagedSelectProps> & {
|
||||
multiple?: false;
|
||||
};
|
||||
|
||||
/** Multi-select RHF props — stores T[] */
|
||||
export type FieldObjectSelectMultiProps<
|
||||
export type FieldLocalSelectMultiProps<
|
||||
T extends Record<string, any>,
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = ObjectSelectBaseProps<T> &
|
||||
> = LocalSelectBaseProps<T> &
|
||||
UseControllerProps<TFieldValues, TName> &
|
||||
Omit<MultiSelectProps, ManagedMultiSelectProps> & {
|
||||
multiple: true;
|
||||
};
|
||||
|
||||
/** Discriminated union based on `multiple` */
|
||||
export type FieldObjectSelectProps<
|
||||
export type FieldLocalSelectProps<
|
||||
T extends Record<string, any>,
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> =
|
||||
| FieldObjectSelectSingleProps<T, TFieldValues, TName>
|
||||
| FieldObjectSelectMultiProps<T, TFieldValues, TName>;
|
||||
| FieldLocalSelectSingleProps<T, TFieldValues, TName>
|
||||
| FieldLocalSelectMultiProps<T, TFieldValues, TName>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component Implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function FieldObjectSelectInner<
|
||||
function FieldLocalSelectInner<
|
||||
T extends Record<string, any>,
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>(props: FieldObjectSelectProps<T, TFieldValues, TName>) {
|
||||
>(props: FieldLocalSelectProps<T, TFieldValues, TName>) {
|
||||
const {
|
||||
// RHF controller props
|
||||
name,
|
||||
@@ -75,14 +79,14 @@ function FieldObjectSelectInner<
|
||||
shouldUnregister,
|
||||
defaultValue,
|
||||
disabled,
|
||||
// ObjectSelect engine props
|
||||
data,
|
||||
// LocalSelect engine props
|
||||
options,
|
||||
valueKey,
|
||||
labelKey,
|
||||
renderLabel,
|
||||
multiple,
|
||||
filterOption,
|
||||
onSelect: onObjectSelect,
|
||||
onSelect: onSelectCallback,
|
||||
// Remaining Mantine props
|
||||
...mantineProps
|
||||
} = props;
|
||||
@@ -105,15 +109,15 @@ function FieldObjectSelectInner<
|
||||
// Intercept onChange to pass full objects to RHF
|
||||
const handleChange = (value: any) => {
|
||||
field.onChange(value);
|
||||
onObjectSelect?.(value);
|
||||
onSelectCallback?.(value);
|
||||
};
|
||||
|
||||
// Build engine props based on single/multi mode
|
||||
if (multiple) {
|
||||
return (
|
||||
<ObjectSelect<T>
|
||||
<LocalSelect<T>
|
||||
multiple
|
||||
data={data}
|
||||
options={options}
|
||||
valueKey={valueKey}
|
||||
labelKey={labelKey}
|
||||
renderLabel={renderLabel}
|
||||
@@ -129,8 +133,8 @@ function FieldObjectSelectInner<
|
||||
}
|
||||
|
||||
return (
|
||||
<ObjectSelect<T>
|
||||
data={data}
|
||||
<LocalSelect<T>
|
||||
options={options}
|
||||
valueKey={valueKey}
|
||||
labelKey={labelKey}
|
||||
renderLabel={renderLabel}
|
||||
@@ -145,5 +149,5 @@ function FieldObjectSelectInner<
|
||||
);
|
||||
}
|
||||
|
||||
export const FieldObjectSelect = React.memo(FieldObjectSelectInner) as typeof FieldObjectSelectInner;
|
||||
(FieldObjectSelect as any).displayName = 'FieldObjectSelect';
|
||||
export const FieldLocalSelect = React.memo(FieldLocalSelectInner) as typeof FieldLocalSelectInner;
|
||||
(FieldLocalSelect as any).displayName = 'FieldLocalSelect';
|
||||
@@ -38,16 +38,23 @@ export { FieldNativeSelect } from './fields/native-select.field';
|
||||
export { FieldTagsInput } from './fields/tags-input.field';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Object & Async Selection Fields
|
||||
// Local & Async Selection Fields
|
||||
// ---------------------------------------------------------------------------
|
||||
export { FieldObjectSelect } from './fields/object-select.field';
|
||||
export type { FieldObjectSelectProps } from './fields/object-select.field';
|
||||
export { FieldLocalSelect } from './fields/local-select.field';
|
||||
export type { FieldLocalSelectProps } from './fields/local-select.field';
|
||||
export { FieldAsyncSelect } from './fields/async-select.field';
|
||||
export type { FieldAsyncSelectProps } from './fields/async-select.field';
|
||||
|
||||
// Standalone engines (no RHF dependency) for use outside form contexts
|
||||
export { ObjectSelect, AsyncSelect } from './custom';
|
||||
export type { ObjectSelectProps, AsyncSelectProps, ObjectSelectBaseProps, ObjectSelectFilterContext } from './custom';
|
||||
export { LocalSelect, AsyncSelect } from './custom';
|
||||
export type {
|
||||
LocalSelectProps,
|
||||
AsyncSelectProps,
|
||||
LocalSelectBaseProps,
|
||||
SelectFilterContext,
|
||||
LoadOptionsResponse,
|
||||
LoadOptionsFn,
|
||||
} from './custom';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Toggle / Boolean Fields
|
||||
|
||||
Reference in New Issue
Block a user