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