feat: replace ObjectSelect with LocalSelect and introduce custom FieldSelect wrappers for object-based RHF state management
This commit is contained in:
@@ -479,6 +479,210 @@ export function DepartmentForm() {
|
||||
|
||||
---
|
||||
|
||||
## Object & Async Select Components
|
||||
|
||||
Mantine's native `Select` and `MultiSelect` are string-based: they store `string | null` and `string[]` respectively. In enterprise applications, we often need to store **full objects** (`T | null` or `T[]`) in RHF state — for example, a user object `{ id: '1', name: 'Alice', email: 'alice@co.com' }` rather than just `'1'`.
|
||||
|
||||
The **LocalSelect** and **AsyncSelect** engines bridge this gap by:
|
||||
1. Mapping `T[]` → `ComboboxItem[]` for Mantine rendering (via `valueKey` + `labelKey`/`renderLabel`)
|
||||
2. Building an O(1) reverse lookup map (`Map<string, T>`) for resolving string changes back to full objects
|
||||
3. Intercepting `onChange` to pass resolved objects to RHF
|
||||
|
||||
> [!IMPORTANT]
|
||||
> These components are **separate** from the native `FieldSelect` and `FieldMultiSelect`, which continue to work as simple string-based Mantine wrappers. Use `FieldLocalSelect`/`FieldAsyncSelect` only when you need to store full objects in RHF state.
|
||||
|
||||
### Single vs. Multi-Select Data Mapping
|
||||
|
||||
| Mode | Mantine Component | RHF Value | Mantine `value` Prop | `onChange` Payload |
|
||||
|---|---|---|---|---|
|
||||
| `multiple={false}` (default) | `<Select />` | `T \| null` | `string \| null` | `T \| null` |
|
||||
| `multiple={true}` | `<MultiSelect />` | `T[]` | `string[]` | `T[]` |
|
||||
|
||||
### FieldLocalSelect — Local Object Select
|
||||
|
||||
Accepts a static `data` array of objects. No async fetching.
|
||||
|
||||
#### Props
|
||||
|
||||
| Prop | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `options` | `T[]` | ✅ | Array of objects to select from |
|
||||
| `valueKey` | `keyof T & string` | ✅ | Property used as the unique identifier |
|
||||
| `labelKey` | `keyof T & string` | — | Property used as the display label |
|
||||
| `renderLabel` | `(item: T) => string` | — | Custom label renderer (overrides `labelKey`) |
|
||||
| `multiple` | `boolean` | — | Enable multi-select mode |
|
||||
| `filterOption` | `(item: T, ctx) => boolean` | — | Custom filter/exclusion logic |
|
||||
| `onSelect` | `(value: T \| T[] \| null) => void` | — | Side-effect callback on selection change |
|
||||
| `name` | `FieldPath` | ✅ | RHF field path |
|
||||
| `control` | `Control` | ✅ | RHF control object |
|
||||
| _...all Mantine Select/MultiSelect props_ | | | Passed through to the underlying component |
|
||||
|
||||
#### Usage Example
|
||||
|
||||
```tsx
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { FieldLocalSelect } from '@repo/ui/form';
|
||||
|
||||
interface Department {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
const departments: Department[] = [
|
||||
{ id: '1', name: 'Engineering', code: 'ENG' },
|
||||
{ id: '2', name: 'Marketing', code: 'MKT' },
|
||||
{ id: '3', name: 'Finance', code: 'FIN' },
|
||||
];
|
||||
|
||||
function DepartmentForm() {
|
||||
const { control, handleSubmit } = useForm<{ department: Department | null }>({
|
||||
defaultValues: { department: null },
|
||||
});
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit((data) => console.log(data.department))}>
|
||||
<FieldLocalSelect<Department>
|
||||
name="department"
|
||||
control={control}
|
||||
label="Department"
|
||||
options={departments}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
searchable
|
||||
/>
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
// On submit: data.department = { id: '1', name: 'Engineering', code: 'ENG' }
|
||||
```
|
||||
|
||||
### FieldAsyncSelect — Async Paginated Object Select
|
||||
|
||||
Uses **Inversion of Control**: the component does NOT handle API calls directly. Instead, you provide a `loadOptions` callback. This supports REST, GraphQL, POST-based search, or any transport.
|
||||
|
||||
#### Props
|
||||
|
||||
| Prop | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `loadOptions` | `LoadOptionsFn<T>` | ✅ | Async callback: `(search, page, prevOptions) => Promise<{ options: T[], hasMore?: boolean }>` |
|
||||
| `defaultOptions` | `T[]` | — | Pre-loaded objects always present in dropdown (for edit forms) |
|
||||
| `debounceMs` | `number` | — | Search debounce delay (default: 300) |
|
||||
| `valueKey` | `keyof T & string` | ✅ | Property used as the unique identifier |
|
||||
| `labelKey` | `keyof T & string` | — | Property used as the display label |
|
||||
| `renderLabel` | `(item: T) => string` | — | Custom label renderer |
|
||||
| `multiple` | `boolean` | — | Enable multi-select mode |
|
||||
| `name` | `FieldPath` | ✅ | RHF field path |
|
||||
| `control` | `Control` | ✅ | RHF control object |
|
||||
| _...all Mantine Select/MultiSelect props_ | | | Passed through to the underlying component |
|
||||
|
||||
#### Paginated Example
|
||||
|
||||
```tsx
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { FieldAsyncSelect, type LoadOptionsFn } from '@repo/ui/form';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
fullName: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
// The loadOptions callback is completely transport-agnostic
|
||||
const loadUsers: LoadOptionsFn<User> = async (search, page) => {
|
||||
const res = await api.get('/users', {
|
||||
params: { q: search, page, limit: 20 },
|
||||
});
|
||||
return {
|
||||
options: res.data.items,
|
||||
hasMore: res.data.hasNextPage,
|
||||
};
|
||||
};
|
||||
|
||||
function UserPickerForm() {
|
||||
const { control, handleSubmit } = useForm<{ user: User | null }>({
|
||||
defaultValues: { user: null },
|
||||
});
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit((data) => console.log(data.user))}>
|
||||
<FieldAsyncSelect<User>
|
||||
name="user"
|
||||
control={control}
|
||||
label="Assign User"
|
||||
loadOptions={loadUsers}
|
||||
valueKey="id"
|
||||
labelKey="fullName"
|
||||
placeholder="Search users..."
|
||||
/>
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### Non-Paginated Example
|
||||
|
||||
If your API returns all results at once, return `hasMore: false`:
|
||||
|
||||
```tsx
|
||||
const loadRoles: LoadOptionsFn<Role> = async (search) => {
|
||||
const roles = await api.get('/roles', { params: { q: search } });
|
||||
return { options: roles.data, hasMore: false };
|
||||
};
|
||||
```
|
||||
|
||||
#### Edit Form with `defaultOptions`
|
||||
|
||||
When editing an existing record, the default value's object may not appear in the first page of API results. Use `defaultOptions` to inject it:
|
||||
|
||||
```tsx
|
||||
function EditUserForm({ existingAssignment }: { existingAssignment: User }) {
|
||||
const { control } = useForm<{ user: User | null }>({
|
||||
defaultValues: { user: existingAssignment },
|
||||
});
|
||||
|
||||
return (
|
||||
<FieldAsyncSelect<User>
|
||||
name="user"
|
||||
control={control}
|
||||
label="Reassign User"
|
||||
loadOptions={loadUsers}
|
||||
valueKey="id"
|
||||
labelKey="fullName"
|
||||
defaultOptions={[existingAssignment]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### Multi-Select Async Example
|
||||
|
||||
```tsx
|
||||
function TagPickerForm() {
|
||||
const { control } = useForm<{ tags: Tag[] }>({
|
||||
defaultValues: { tags: [] },
|
||||
});
|
||||
|
||||
return (
|
||||
<FieldAsyncSelect<Tag>
|
||||
multiple
|
||||
name="tags"
|
||||
control={control}
|
||||
label="Tags"
|
||||
loadOptions={loadTags}
|
||||
valueKey="id"
|
||||
renderLabel={(tag) => `${tag.name} (${tag.count})`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// On submit: data.tags = [{ id: '1', name: 'React', count: 42 }, ...]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Enterprise Performance Guidelines: Forms & Validation
|
||||
|
||||
When building large-scale ERP forms, seemingly trivial React or Zod patterns can catastrophically degrade performance at scale. Adhere strictly to the following optimizations.
|
||||
@@ -683,8 +887,8 @@ export const FieldDatePicker = withRHF<DatePickerInputProps>(
|
||||
| `FieldRating` | `Rating` | Range | Star rating |
|
||||
| `FieldColorInput` | `ColorInput` | Color | Color picker with text input |
|
||||
| `FieldColorPicker` | `ColorPicker` | Color | Color picker only (uses `Input.Wrapper`) |
|
||||
| `FieldObjectSelect` | `Select / MultiSelect` | Selection | Stores full `T` or `T[]` object in RHF instead of string ID |
|
||||
| `FieldAsyncSelect` | `Select / MultiSelect` | Selection | Paginated infinite scroll select mapping API responses to RHF objects |
|
||||
| `FieldLocalSelect` | `Select / MultiSelect` | Selection | Stores full `T` or `T[]` object in RHF instead of string ID. Accepts static `options` array with `valueKey`/`labelKey` mapping. |
|
||||
| `FieldAsyncSelect` | `Select / MultiSelect` | Selection | Async paginated object select with IoC `loadOptions` callback. Supports search-keyed caching, `defaultOptions` for edit forms, and automatic pagination detection. |
|
||||
| `FieldFileInput` | `FileInput` | File | File upload input |
|
||||
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user