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

This commit is contained in:
Firman Ramdhani
2026-06-22 11:06:29 +07:00
parent 195928d56a
commit eaec6ec38f
15 changed files with 1240 additions and 438 deletions
@@ -6,8 +6,9 @@ import {
FieldMultiSelect, FieldNativeSelect, FieldTagsInput, FieldCheckbox,
FieldRadioGroup, FieldSwitch, FieldChipGroup, FieldSegmentedControl,
FieldSlider, FieldRangeSlider, FieldRating, FieldColorInput,
FieldColorPicker, FieldFileInput, FieldObjectSelect, FieldAsyncSelect
FieldColorPicker, FieldFileInput, FieldLocalSelect, FieldAsyncSelect
} from '@repo/ui/form';
import type { LoadOptionsFn } from '@repo/ui/form';
import { useFormDemoTranslation } from '../i18n/useFormDemoTranslation';
const MOCK_POKEMON = Array.from({ length: 100 }, (_, i) => ({
@@ -15,22 +16,39 @@ const MOCK_POKEMON = Array.from({ length: 100 }, (_, i) => ({
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
const loadMockPokemonOptions: LoadOptionsFn<any> = async (search, page) => {
await new Promise((resolve) => setTimeout(resolve, 500));
// Filter and paginate
const filtered = MOCK_POKEMON.filter((p) => p.name.toLowerCase().includes(search));
const filtered = MOCK_POKEMON.filter((p) => p.name.toLowerCase().includes(search.toLowerCase()));
const pageSize = 20;
const start = (page - 1) * pageSize;
const paginated = filtered.slice(start, start + pageSize);
return {
options: paginated,
hasMore: start + pageSize < filtered.length,
};
};
return { results: paginated };
const loadRealPokemonOptions: LoadOptionsFn<any> = async (_search, page) => {
const limit = 20;
const offset = (page - 1) * limit;
const res = await fetch(`https://pokeapi.co/api/v2/pokemon?limit=${limit}&offset=${offset}`);
const data = await res.json();
return {
options: data.results.map((p: any, i: number) => ({ id: offset + i + 1, ...p })),
hasMore: !!data.next,
};
};
const MOCK_VENDORS = [
{ id: 'V1', code: 'VN-01', name: 'Vendor One' },
{ id: 'V2', code: 'VN-02', name: 'Vendor Two' },
{ id: 'V3', code: 'VN-03', name: 'Vendor Three' },
];
const loadMockVendorsOptions: LoadOptionsFn<any> = async (search, _page) => {
await new Promise((resolve) => setTimeout(resolve, 500));
const filtered = MOCK_VENDORS.filter(v => v.name.toLowerCase().includes(search.toLowerCase()) || v.code.toLowerCase().includes(search.toLowerCase()));
return { options: filtered, hasMore: false };
};
export default function AllFieldsDemo() {
@@ -61,10 +79,18 @@ export default function AllFieldsDemo() {
themeColor: '',
colorPicker: '#1c7ed6',
avatar: null,
objectSelect: null,
asyncSelect: null,
multiObjectSelect: [],
multiAsyncSelect: [],
localSelectEmpty: null,
localSelectPrefilled: { id: 'V2', code: 'VN-02', name: 'Vendor Two' },
asyncSelectEmpty: null,
asyncSelectPrefilled: { id: 999, code: 'ASYNC-99', name: 'Pre-loaded Async Vendor' },
localMultiPrefilled: [
{ id: 'V1', code: 'VN-01', name: 'Vendor One' },
{ id: 'V2', code: 'VN-02', name: 'Vendor Two' }
],
asyncMultiPrefilled: [
{ id: 888, code: 'ASYNC-88', name: 'Ghost Async Vendor 1' },
{ id: 999, code: 'ASYNC-99', name: 'Ghost Async Vendor 2' }
],
realPokeSelect: null,
multiRealPokeSelect: []
}
@@ -133,12 +159,12 @@ export default function AllFieldsDemo() {
/>
</Group>
<Group grow align="flex-start" mb="md">
<FieldObjectSelect
name="objectSelect"
<FieldLocalSelect
name="localSelect"
control={control}
label="Object Select"
label="Local Select"
placeholder="Select a complex object"
data={[
options={[
{ id: 1, name: 'Apple', type: 'Fruit' },
{ id: 2, name: 'Carrot', type: 'Vegetable' },
{ id: 3, name: 'Banana', type: 'Fruit' }
@@ -147,13 +173,13 @@ export default function AllFieldsDemo() {
labelKey="name"
clearable
/>
<FieldObjectSelect
<FieldLocalSelect
multiple
name="multiObjectSelect"
name="multiLocalSelect"
control={control}
label="Multi Object Select"
label="Multi Local Select"
placeholder="Select multiple objects"
data={[
options={[
{ id: 1, name: 'Red', hex: '#f00' },
{ id: 2, name: 'Green', hex: '#0f0' },
{ id: 3, name: 'Blue', hex: '#00f' }
@@ -169,9 +195,7 @@ export default function AllFieldsDemo() {
control={control}
label="Async Select (Mock API)"
placeholder="Search pokemon..."
apiEndpoint="/api/mock/pokemon"
fetchFn={fetchMockPokemon}
transformResponse={(res) => res.results}
loadOptions={loadMockPokemonOptions}
valueKey="id"
labelKey="name"
clearable
@@ -182,9 +206,7 @@ export default function AllFieldsDemo() {
control={control}
label="Multi Async Select"
placeholder="Select multiple pokemon..."
apiEndpoint="/api/mock/pokemon"
fetchFn={fetchMockPokemon}
transformResponse={(res) => res.results}
loadOptions={loadMockPokemonOptions}
valueKey="id"
labelKey="name"
clearable
@@ -196,11 +218,7 @@ export default function AllFieldsDemo() {
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
})}
loadOptions={loadRealPokemonOptions}
valueKey="id"
labelKey="name"
clearable
@@ -211,17 +229,82 @@ export default function AllFieldsDemo() {
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
})}
loadOptions={loadRealPokemonOptions}
valueKey="id"
labelKey="name"
clearable
/>
</Group>
<FieldTagsInput name="tags" control={control} label={t.fields.tags} />
<Title order={5} mb="sm" mt="lg" c="brand">Advanced Object Selects (Custom Labels & Default Values)</Title>
<Divider mb="md" />
<Group grow align="flex-start" mb="md">
<FieldLocalSelect
name="localSelectEmpty"
control={control}
label="Local Empty"
options={MOCK_VENDORS}
valueKey="id"
renderLabel={(item) => `[${item.code}] ${item.name}`}
clearable
/>
<FieldLocalSelect
name="localSelectPrefilled"
control={control}
label="Local Prefilled"
options={MOCK_VENDORS}
valueKey="id"
renderLabel={(item) => `[${item.code}] ${item.name}`}
clearable
/>
</Group>
<Group grow align="flex-start" mb="md">
<FieldAsyncSelect
name="asyncSelectEmpty"
control={control}
label="Async Empty"
loadOptions={loadMockVendorsOptions}
valueKey="id"
renderLabel={(item) => `[${item.code}] ${item.name}`}
clearable
/>
<FieldAsyncSelect
name="asyncSelectPrefilled"
control={control}
label="Async Prefilled (Edit Mode)"
loadOptions={loadMockVendorsOptions}
valueKey="id"
renderLabel={(item) => `[${item.code}] ${item.name}`}
defaultOptions={[{ id: 999, code: 'ASYNC-99', name: 'Pre-loaded Async Vendor' }]}
clearable
/>
</Group>
<Title order={5} mb="sm" mt="lg" c="brand">Multi-Select Edit Mode (No defaultOptions fallback)</Title>
<Divider mb="md" />
<Group grow align="flex-start" mb="md">
<FieldLocalSelect
multiple
name="localMultiPrefilled"
control={control}
label="Local Multi Prefilled"
options={MOCK_VENDORS}
valueKey="id"
renderLabel={(item) => `[${item.code}] ${item.name}`}
clearable
/>
<FieldAsyncSelect
multiple
name="asyncMultiPrefilled"
control={control}
label="Async Multi Prefilled (Ghost Items)"
loadOptions={loadMockVendorsOptions}
valueKey="id"
renderLabel={(item) => `[${item.code}] ${item.name}`}
clearable
/>
</Group>
</div>
{/* --- Toggles & Choices --- */}
@@ -2,11 +2,49 @@ import { useForm, useWatch } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button, Paper, Title, Divider, Stack, Code, Alert } from '@repo/ui/components';
import { FieldTextInput, FieldSelect, FieldSwitch } from '@repo/ui/form';
import { FieldTextInput, FieldSelect, FieldSwitch, FieldLocalSelect, FieldAsyncSelect } from '@repo/ui/form';
import { useConditionalField } from '@repo/ui/hooks';
import { compose, required, emailValidator } from '@repo/ui/validators';
import { useFormDemoTranslation } from '../i18n/useFormDemoTranslation';
import { Info } from 'lucide-react';
import { useEffect, useCallback, useRef } from 'react';
interface Region {
id: string;
code: string;
taxRate: number;
}
interface Warehouse {
id: string;
regionId: string;
name: string;
}
const REGIONS: Region[] = [
{ id: 'R1', code: 'APAC', taxRate: 0.1 },
{ id: 'R2', code: 'EMEA', taxRate: 0.2 }
];
const mockFetchWarehouses = async (regionIds: string[], search: string, page: number) => {
await new Promise((resolve) => setTimeout(resolve, 500));
const allWarehouses: Warehouse[] = [
{ id: 'W1', regionId: 'R1', name: 'Singapore Hub' },
{ id: 'W2', regionId: 'R1', name: 'Tokyo Depot' },
{ id: 'W3', regionId: 'R2', name: 'London Central' },
{ id: 'W4', regionId: 'R2', name: 'Berlin Storage' },
];
const filtered = allWarehouses.filter(w => regionIds.includes(w.regionId) && w.name.toLowerCase().includes(search.toLowerCase()));
const pageSize = 10;
const start = (page - 1) * pageSize;
const paginated = filtered.slice(start, start + pageSize);
return {
options: paginated,
hasMore: start + pageSize < filtered.length
};
};
export default function ReactiveWatchDemo() {
const t = useFormDemoTranslation();
@@ -27,6 +65,8 @@ export default function ReactiveWatchDemo() {
newsletterEmail: z.string().optional(),
department: z.string().optional(),
role: z.string().optional(),
regions: z.array(z.object({ id: z.string(), code: z.string(), taxRate: z.number() })).optional(),
warehouses: z.array(z.object({ id: z.string(), regionId: z.string(), name: z.string() })).optional(),
})
.and(
z.discriminatedUnion('userType', [
@@ -64,6 +104,11 @@ export default function ReactiveWatchDemo() {
newsletterEmail: '',
department: '',
role: '',
regions: [{ id: 'R1', code: 'APAC', taxRate: 0.1 }],
warehouses: [
{ id: 'W-99', regionId: 'R1', name: 'APAC Central Hub' },
{ id: 'W-98', regionId: 'R1', name: 'APAC Backup Hub' }
],
},
});
@@ -73,6 +118,7 @@ export default function ReactiveWatchDemo() {
const newsletter = useWatch({ control, name: 'newsletter' });
const department = useWatch({ control, name: 'department' });
const role = useWatch({ control, name: 'role' });
const regions = useWatch({ control, name: 'regions' });
// Use the custom hook to cleanly unregister and reset fields when hidden
useConditionalField({
@@ -133,6 +179,27 @@ export default function ReactiveWatchDemo() {
defaultValue: '',
});
const isMounted = useRef(false);
const prevRegionIds = useRef<string[]>(regions?.map((r: Region) => r.id) || []);
useEffect(() => {
if (!isMounted.current) {
isMounted.current = true;
return;
}
const currentIds = regions?.map((r: Region) => r.id) || [];
const prevIds = prevRegionIds.current;
const hasChanged = currentIds.length !== prevIds.length || currentIds.some((id: string) => !prevIds.includes(id));
if (hasChanged) {
setValue('warehouses', []);
clearErrors('warehouses');
prevRegionIds.current = currentIds;
}
}, [regions, setValue, clearErrors]);
// Use watch only to display the JSON output at the bottom
const allValues = useWatch({ control });
@@ -223,6 +290,44 @@ export default function ReactiveWatchDemo() {
withAsterisk={!!department}
/>
<Title order={5} mb="sm" c="brand" mt="lg">
Cascading Object Selects
</Title>
<Divider mb="sm" />
<FieldLocalSelect<Region>
multiple
name="regions"
control={control as any}
label="Regions"
options={REGIONS}
valueKey="id"
labelKey="code"
clearable
/>
<FieldAsyncSelect<Warehouse>
multiple
key={`warehouse-select-${regions?.map((r: any) => r.id).join(',')}`}
name="warehouses"
control={control as any}
label="Warehouses"
disabled={!regions || regions.length === 0}
loadOptions={useCallback(async (search, page) => {
if (!regions || regions.length === 0) return { options: [], hasMore: false };
return mockFetchWarehouses(regions.map((r: any) => r.id), search, page);
}, [regions])}
valueKey="id"
renderLabel={(item) => `[${item.id}] ${item.name}`}
clearable
/>
{regions && regions.length > 0 && (
<Alert mt="sm" color="teal">
Selected regions tax rates: {regions.map((r: any) => `${r.code} (${(r.taxRate * 100).toFixed(0)}%)`).join(', ')}
</Alert>
)}
<Button type="submit" mt="md">
{t.common?.submit || 'Submit Reactive Form'}
</Button>
@@ -3,8 +3,55 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button, Paper, Title, Group, Stack, Code, Divider } from '@repo/ui/components';
import {
FieldTextInput, FieldPasswordInput, FieldNumberInput
FieldTextInput, FieldPasswordInput, FieldNumberInput,
FieldLocalSelect, FieldAsyncSelect
} from '@repo/ui/form';
import type { LoadOptionsFn } from '@repo/ui/form';
interface Department {
code: string;
name: string;
costCenter: string;
}
interface Assignee {
id: number;
email: string;
}
const MOCK_DEPARTMENTS: Department[] = [
{ code: 'IT', name: 'Information Technology', costCenter: 'CC-100' },
{ code: 'HR', name: 'Human Resources', costCenter: 'CC-200' },
{ code: 'FIN', name: 'Finance', costCenter: 'CC-300' },
];
const mockFetchUsers: LoadOptionsFn<Assignee> = async (search, page) => {
await new Promise((resolve) => setTimeout(resolve, 500));
const allUsers = Array.from({ length: 20 }, (_, i) => ({
id: i + 1,
email: `user${i + 1}@company.com`
}));
const filtered = allUsers.filter(u => u.email.toLowerCase().includes(search.toLowerCase()));
const pageSize = 5;
const start = (page - 1) * pageSize;
const paginated = filtered.slice(start, start + pageSize);
return {
options: paginated,
hasMore: start + pageSize < filtered.length
};
};
const MOCK_VENDORS = [
{ id: 'V1', code: 'VN-01', name: 'Vendor One' },
{ id: 'V2', code: 'VN-02', name: 'Vendor Two' },
];
const mockFetchVendors: LoadOptionsFn<any> = async (search, _page) => {
await new Promise((resolve) => setTimeout(resolve, 500));
const filtered = MOCK_VENDORS.filter(v => v.name.toLowerCase().includes(search.toLowerCase()) || v.code.toLowerCase().includes(search.toLowerCase()));
return { options: filtered, hasMore: false };
};
import {
compose, required, rangeLength,
positiveNumber, simplePassword,
@@ -22,7 +69,12 @@ export default function ValidationBankDemo() {
complexPass: compose(z.string(), required(t.validation.complexPassword), complexPassword(8)),
age: compose(z.number(), required(t.fields.age), rangeValue(18, 65, t.fields.age)),
score: compose(z.number(), required(t.validation.score), positiveNumber(t.validation.score)),
phone: compose(z.string(), required(t.validation.phone), phoneValidator())
phone: compose(z.string(), required(t.validation.phone), phoneValidator()),
department: z.object({ code: z.string(), name: z.string() }, { required_error: 'Department is required' }),
assignees: z.array(z.object({ id: z.number(), email: z.string() })).min(2, "Select at least 2 assignees"),
prefilledVendor: z.object({ id: z.union([z.string(), z.number()]), code: z.string(), name: z.string() }, { required_error: 'Vendor is required' }),
emptyVendor: z.object({ id: z.union([z.string(), z.number()]), code: z.string(), name: z.string() }, { required_error: 'Vendor is required' }),
prefilledAsyncMulti: z.array(z.object({ id: z.union([z.string(), z.number()]), code: z.string(), name: z.string() })).min(1, "Select at least 1 vendor"),
});
type ValidationFormValues = z.infer<typeof validationSchema>;
@@ -35,7 +87,15 @@ export default function ValidationBankDemo() {
complexPass: '',
age: undefined as any,
score: undefined as any,
phone: ''
phone: '',
department: null as any,
assignees: [],
prefilledVendor: { id: 'V1', code: 'VN-01', name: 'Vendor One' } as any,
emptyVendor: null as any,
prefilledAsyncMulti: [
{ id: 'V1', code: 'VN-01', name: 'Vendor One' },
{ id: 'V2', code: 'VN-02', name: 'Vendor Two' }
] as any,
}
});
@@ -100,6 +160,72 @@ export default function ValidationBankDemo() {
withAsterisk
/>
<Title order={5} c="brand" mt="md">Object Level Validations (Local & Async)</Title>
<Divider mb="sm" />
<FieldLocalSelect<Department>
name="department"
control={control as any}
label="Department"
options={MOCK_DEPARTMENTS}
valueKey="code"
renderLabel={(item) => `[${item.code}] ${item.name}`}
clearable
withAsterisk
/>
<FieldAsyncSelect<Assignee>
multiple
name="assignees"
control={control as any}
label="Assignees"
loadOptions={mockFetchUsers}
valueKey="id"
labelKey="email"
searchable
clearable
withAsterisk
/>
<Title order={5} c="brand" mt="lg">Validated Prefilled Objects</Title>
<Divider mb="sm" />
<Group grow align="flex-start">
<FieldAsyncSelect
name="emptyVendor"
control={control as any}
label="Empty Vendor"
loadOptions={mockFetchVendors}
valueKey="id"
renderLabel={(item) => `[${item.code}] ${item.name}`}
clearable
withAsterisk
/>
<FieldAsyncSelect
name="prefilledVendor"
control={control as any}
label="Prefilled Vendor"
loadOptions={mockFetchVendors}
valueKey="id"
renderLabel={(item) => `[${item.code}] ${item.name}`}
defaultOptions={[{ id: 'V1', code: 'VN-01', name: 'Vendor One' }]}
clearable
withAsterisk
/>
</Group>
<FieldAsyncSelect
multiple
name="prefilledAsyncMulti"
control={control as any}
label="Prefilled Async Multi (No Fallback)"
loadOptions={mockFetchVendors}
valueKey="id"
renderLabel={(item) => `[${item.code}] ${item.name}`}
clearable
withAsterisk
/>
<Button type="submit" mt="md">{t.common.submit}</Button>
</Stack>
</form>