feat: add useConditionalField hook and implement reactive form showcase examples
This commit is contained in:
@@ -0,0 +1,187 @@
|
|||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { Button, Paper, Title, Group, Stack, Code, Divider, Text, Chip, Radio } from '@repo/ui/components';
|
||||||
|
import {
|
||||||
|
FieldTextInput, FieldPasswordInput, FieldTextarea, FieldNumberInput,
|
||||||
|
FieldJsonInput, FieldPinInput, FieldAutocomplete, FieldSelect,
|
||||||
|
FieldMultiSelect, FieldNativeSelect, FieldTagsInput, FieldCheckbox,
|
||||||
|
FieldRadioGroup, FieldSwitch, FieldChipGroup, FieldSegmentedControl,
|
||||||
|
FieldSlider, FieldRangeSlider, FieldRating, FieldColorInput,
|
||||||
|
FieldColorPicker, FieldFileInput
|
||||||
|
} from '@repo/ui/form';
|
||||||
|
import { useFormDemoTranslation } from '../i18n/useFormDemoTranslation';
|
||||||
|
|
||||||
|
export default function AllFieldsDemo() {
|
||||||
|
const t = useFormDemoTranslation();
|
||||||
|
|
||||||
|
const { control, handleSubmit, watch } = useForm<any>({
|
||||||
|
defaultValues: {
|
||||||
|
customerName: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
description: '',
|
||||||
|
age: undefined,
|
||||||
|
jsonConfig: '',
|
||||||
|
pin: '',
|
||||||
|
country: '',
|
||||||
|
orderType: '',
|
||||||
|
categories: [],
|
||||||
|
nativeOrderType: '',
|
||||||
|
tags: [],
|
||||||
|
terms: false,
|
||||||
|
priority: '',
|
||||||
|
receiveEmails: false,
|
||||||
|
chipSelection: '',
|
||||||
|
segmentedPriority: 'normal',
|
||||||
|
satisfaction: 5,
|
||||||
|
priceRange: [0, 100],
|
||||||
|
rating: 0,
|
||||||
|
themeColor: '',
|
||||||
|
colorPicker: '#1c7ed6',
|
||||||
|
avatar: null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = (data: any) => console.log('All Fields Submitted:', data);
|
||||||
|
const data = watch();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="xl">
|
||||||
|
<Paper p="xl" withBorder radius="md">
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<Stack gap="xl">
|
||||||
|
{/* --- Text & Numbers --- */}
|
||||||
|
<div>
|
||||||
|
<Title order={5} mb="sm" c="brand">Text & Numbers</Title>
|
||||||
|
<Divider mb="md" />
|
||||||
|
<Group grow align="flex-start" mb="md">
|
||||||
|
<FieldTextInput name="customerName" control={control} label={t.fields.customerName} />
|
||||||
|
<FieldTextInput name="email" control={control} label={t.fields.email} />
|
||||||
|
</Group>
|
||||||
|
<Group grow align="flex-start" mb="md">
|
||||||
|
<FieldPasswordInput name="password" control={control} label={t.fields.password} />
|
||||||
|
<FieldNumberInput name="age" control={control} label={t.fields.age} />
|
||||||
|
</Group>
|
||||||
|
<Group grow align="flex-start" mb="md">
|
||||||
|
<FieldTextarea name="description" control={control} label={t.fields.description} minRows={3} />
|
||||||
|
<FieldJsonInput name="jsonConfig" control={control} label={t.fields.jsonConfig} formatOnBlur />
|
||||||
|
</Group>
|
||||||
|
<div>
|
||||||
|
<Text size="sm" fw={500} mb={3}>{t.fields.pin}</Text>
|
||||||
|
<FieldPinInput name="pin" control={control} length={6} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* --- Selections --- */}
|
||||||
|
<div>
|
||||||
|
<Title order={5} mb="sm" c="brand">Selections</Title>
|
||||||
|
<Divider mb="md" />
|
||||||
|
<Group grow align="flex-start" mb="md">
|
||||||
|
<FieldSelect
|
||||||
|
name="orderType"
|
||||||
|
control={control}
|
||||||
|
label={t.fields.orderType}
|
||||||
|
data={['BULK', 'RETAIL']}
|
||||||
|
/>
|
||||||
|
<FieldNativeSelect
|
||||||
|
name="nativeOrderType"
|
||||||
|
control={control}
|
||||||
|
label={`Native ${t.fields.orderType}`}
|
||||||
|
data={['BULK', 'RETAIL']}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
<Group grow align="flex-start" mb="md">
|
||||||
|
<FieldAutocomplete
|
||||||
|
name="country"
|
||||||
|
control={control}
|
||||||
|
label="Country"
|
||||||
|
data={['Indonesia', 'Singapore', 'Malaysia']}
|
||||||
|
/>
|
||||||
|
<FieldMultiSelect
|
||||||
|
name="categories"
|
||||||
|
control={control}
|
||||||
|
label="Categories"
|
||||||
|
data={['Electronics', 'Fashion', 'Food']}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
<FieldTagsInput name="tags" control={control} label={t.fields.tags} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* --- Toggles & Choices --- */}
|
||||||
|
<div>
|
||||||
|
<Title order={5} mb="sm" c="brand">Toggles & Choices</Title>
|
||||||
|
<Divider mb="md" />
|
||||||
|
<Group mb="md">
|
||||||
|
<FieldCheckbox name="terms" control={control} label={t.fields.terms} />
|
||||||
|
<FieldSwitch name="receiveEmails" control={control} label={t.fields.receiveEmails} />
|
||||||
|
</Group>
|
||||||
|
<FieldRadioGroup
|
||||||
|
name="priority"
|
||||||
|
control={control}
|
||||||
|
label={t.fields.priority}
|
||||||
|
mb="md"
|
||||||
|
>
|
||||||
|
<Group mt="xs">
|
||||||
|
<Radio value="low" label="Low" />
|
||||||
|
<Radio value="high" label="High" />
|
||||||
|
</Group>
|
||||||
|
</FieldRadioGroup>
|
||||||
|
<FieldSegmentedControl
|
||||||
|
name="segmentedPriority"
|
||||||
|
control={control}
|
||||||
|
label={t.fields.priority}
|
||||||
|
data={[
|
||||||
|
{ label: 'Normal', value: 'normal' },
|
||||||
|
{ label: 'Urgent', value: 'urgent' }
|
||||||
|
]}
|
||||||
|
mb="md"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<Text size="sm" fw={500} mb={3}>Chip Selection</Text>
|
||||||
|
<FieldChipGroup
|
||||||
|
name="chipSelection"
|
||||||
|
control={control}
|
||||||
|
>
|
||||||
|
<Group>
|
||||||
|
<Chip value="1">Option 1</Chip>
|
||||||
|
<Chip value="2">Option 2</Chip>
|
||||||
|
</Group>
|
||||||
|
</FieldChipGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* --- Ranges & Specialized --- */}
|
||||||
|
<div>
|
||||||
|
<Title order={5} mb="sm" c="brand">Ranges & Specialized</Title>
|
||||||
|
<Divider mb="md" />
|
||||||
|
<Group grow align="flex-start" mb="md">
|
||||||
|
<FieldSlider name="satisfaction" control={control} label={t.fields.rating} />
|
||||||
|
<FieldRangeSlider name="priceRange" control={control} label="Price Range" />
|
||||||
|
</Group>
|
||||||
|
<Group grow align="flex-start" mb="md">
|
||||||
|
<FieldColorInput name="themeColor" control={control} label={t.fields.themeColor} />
|
||||||
|
<FieldFileInput name="avatar" control={control} label={t.fields.avatar} />
|
||||||
|
</Group>
|
||||||
|
<Group grow align="flex-start" mb="md">
|
||||||
|
<div>
|
||||||
|
<Text size="sm" fw={500} mb={3}>{t.fields.themeColor} Picker</Text>
|
||||||
|
<FieldColorPicker name="colorPicker" control={control} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Text size="sm" fw={500} mb={3}>{t.fields.rating}</Text>
|
||||||
|
<FieldRating name="rating" control={control} />
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" mt="md">{t.common.submit}</Button>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper p="md" withBorder radius="md" bg="var(--mantine-color-gray-0)">
|
||||||
|
<Title order={6} mb="xs">{t.common.submittedData}</Title>
|
||||||
|
<Code block>{JSON.stringify(data, null, 2)}</Code>
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
+235
@@ -0,0 +1,235 @@
|
|||||||
|
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 { useConditionalField } from '@repo/ui/hooks';
|
||||||
|
import { compose, required, emailValidator } from '@repo/ui/validators';
|
||||||
|
import { useFormDemoTranslation } from '../i18n/useFormDemoTranslation';
|
||||||
|
import { Info } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function ReactiveWatchDemo() {
|
||||||
|
const t = useFormDemoTranslation();
|
||||||
|
|
||||||
|
// Define atomic validators for conditional fields
|
||||||
|
const taxIdValidator = compose(z.string(), required(t.watch.corporateTaxId));
|
||||||
|
const spouseNameValidator = compose(z.string(), required(t.watch.spouseName));
|
||||||
|
const newsletterEmailValidator = compose(z.string(), required('Newsletter Email'), emailValidator());
|
||||||
|
const roleValidator = compose(z.string(), required('Role'));
|
||||||
|
|
||||||
|
const reactiveSchema = z
|
||||||
|
.object({
|
||||||
|
userType: z.enum(['PERSONAL', 'CORPORATE']),
|
||||||
|
corporateTaxId: z.string().optional(),
|
||||||
|
hasSpouse: z.boolean(),
|
||||||
|
spouseName: z.string().optional(),
|
||||||
|
newsletter: z.boolean(),
|
||||||
|
newsletterEmail: z.string().optional(),
|
||||||
|
department: z.string().optional(),
|
||||||
|
role: z.string().optional(),
|
||||||
|
})
|
||||||
|
.superRefine((data, ctx) => {
|
||||||
|
if (data.userType === 'CORPORATE') {
|
||||||
|
const res = taxIdValidator.safeParse(data.corporateTaxId || '');
|
||||||
|
if (!res.success) res.error.issues.forEach((i: any) => ctx.addIssue({ ...i, path: ['corporateTaxId'] }));
|
||||||
|
}
|
||||||
|
if (data.hasSpouse) {
|
||||||
|
const res = spouseNameValidator.safeParse(data.spouseName || '');
|
||||||
|
if (!res.success) res.error.issues.forEach((i: any) => ctx.addIssue({ ...i, path: ['spouseName'] }));
|
||||||
|
}
|
||||||
|
if (data.newsletter) {
|
||||||
|
const res = newsletterEmailValidator.safeParse(data.newsletterEmail || '');
|
||||||
|
if (!res.success) res.error.issues.forEach((i: any) => ctx.addIssue({ ...i, path: ['newsletterEmail'] }));
|
||||||
|
}
|
||||||
|
if (data.department) {
|
||||||
|
const res = roleValidator.safeParse(data.role || '');
|
||||||
|
if (!res.success) res.error.issues.forEach((i: any) => ctx.addIssue({ ...i, path: ['role'] }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const { control, handleSubmit, setValue, unregister, clearErrors } = useForm<any>({
|
||||||
|
resolver: zodResolver(reactiveSchema as any),
|
||||||
|
defaultValues: {
|
||||||
|
userType: 'PERSONAL',
|
||||||
|
corporateTaxId: '',
|
||||||
|
hasSpouse: false,
|
||||||
|
spouseName: '',
|
||||||
|
newsletter: false,
|
||||||
|
newsletterEmail: '',
|
||||||
|
department: '',
|
||||||
|
role: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Micro-subscriptions via useWatch
|
||||||
|
const userType = useWatch({ control, name: 'userType' });
|
||||||
|
const hasSpouse = useWatch({ control, name: 'hasSpouse' });
|
||||||
|
const newsletter = useWatch({ control, name: 'newsletter' });
|
||||||
|
const department = useWatch({ control, name: 'department' });
|
||||||
|
const role = useWatch({ control, name: 'role' });
|
||||||
|
|
||||||
|
// Use the custom hook to cleanly unregister and reset fields when hidden
|
||||||
|
useConditionalField({
|
||||||
|
condition: userType === 'CORPORATE',
|
||||||
|
name: 'corporateTaxId',
|
||||||
|
setValue,
|
||||||
|
unregister,
|
||||||
|
mode: 'unregister',
|
||||||
|
defaultValue: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
useConditionalField({
|
||||||
|
condition: hasSpouse === true,
|
||||||
|
name: 'spouseName',
|
||||||
|
setValue,
|
||||||
|
unregister,
|
||||||
|
mode: 'unregister',
|
||||||
|
defaultValue: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use the reset mode to clear values and errors without unregistering the field
|
||||||
|
useConditionalField({
|
||||||
|
condition: newsletter === true,
|
||||||
|
name: 'newsletterEmail',
|
||||||
|
setValue,
|
||||||
|
clearErrors,
|
||||||
|
mode: 'reset',
|
||||||
|
defaultValue: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cascading Dropdown Logic: Department -> Role
|
||||||
|
const roleOptions: Record<string, { value: string; label: string }[]> = {
|
||||||
|
IT: [
|
||||||
|
{ value: 'FRONTEND', label: 'Frontend Engineer' },
|
||||||
|
{ value: 'BACKEND', label: 'Backend Engineer' },
|
||||||
|
],
|
||||||
|
HR: [
|
||||||
|
{ value: 'RECRUITER', label: 'Technical Recruiter' },
|
||||||
|
{ value: 'MANAGER', label: 'HR Manager' },
|
||||||
|
],
|
||||||
|
FINANCE: [
|
||||||
|
{ value: 'ACCOUNTANT', label: 'Accountant' },
|
||||||
|
{ value: 'ANALYST', label: 'Financial Analyst' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentRoleOptions = department ? roleOptions[department] : [];
|
||||||
|
const isRoleValid = !role || (!!department && currentRoleOptions.some((opt) => opt.value === role));
|
||||||
|
|
||||||
|
// Reset Mode: Automatically clears the 'role' field value and errors if the department changes
|
||||||
|
// and the currently selected role is no longer valid for the new department.
|
||||||
|
useConditionalField({
|
||||||
|
condition: isRoleValid,
|
||||||
|
name: 'role',
|
||||||
|
setValue,
|
||||||
|
clearErrors,
|
||||||
|
mode: 'reset',
|
||||||
|
defaultValue: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use watch only to display the JSON output at the bottom
|
||||||
|
const allValues = useWatch({ control });
|
||||||
|
|
||||||
|
const onSubmit = (data: any) => console.log('Reactive Passed:', data);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="xl">
|
||||||
|
<Paper p="xl" withBorder radius="md">
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Title order={5} mb="sm" c="brand">
|
||||||
|
Dynamic Fields & Validation
|
||||||
|
</Title>
|
||||||
|
<Divider mb="sm" />
|
||||||
|
|
||||||
|
<Alert icon={<Info size={16} />} title="Micro-subscription Pattern" color="blue" variant="light">
|
||||||
|
This form demonstrates isolated re-rendering using <code>useWatch</code>. Instead of re-rendering the
|
||||||
|
entire form root when typing, only the specific conditional fields update their display states.
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Divider label="Hidden/Unmounted Pattern" labelPosition="center" my="sm" />
|
||||||
|
|
||||||
|
<FieldSelect
|
||||||
|
name="userType"
|
||||||
|
control={control}
|
||||||
|
label={t.watch.userType}
|
||||||
|
data={[
|
||||||
|
{ value: 'PERSONAL', label: t.watch.typePersonal || 'Personal' },
|
||||||
|
{ value: 'CORPORATE', label: t.watch.typeCorporate || 'Corporate' },
|
||||||
|
]}
|
||||||
|
withAsterisk
|
||||||
|
/>
|
||||||
|
|
||||||
|
{userType === 'CORPORATE' && (
|
||||||
|
<FieldTextInput name="corporateTaxId" control={control} label={t.watch.corporateTaxId} withAsterisk />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FieldSwitch name="hasSpouse" control={control} label={t.watch.hasSpouse} mt="md" />
|
||||||
|
|
||||||
|
{hasSpouse && (
|
||||||
|
<FieldTextInput name="spouseName" control={control} label={t.watch.spouseName} withAsterisk />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Divider label="Visible but Disabled Pattern" labelPosition="center" my="md" />
|
||||||
|
|
||||||
|
<FieldSwitch name="newsletter" control={control} label="Subscribe to Newsletter" />
|
||||||
|
|
||||||
|
<FieldTextInput
|
||||||
|
name="newsletterEmail"
|
||||||
|
control={control}
|
||||||
|
label="Newsletter Email"
|
||||||
|
disabled={!newsletter}
|
||||||
|
placeholder="Enter your email to subscribe"
|
||||||
|
withAsterisk={newsletter}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Divider label="Reset Mode (Cascading Dependencies)" labelPosition="center" my="md" />
|
||||||
|
|
||||||
|
<FieldSelect
|
||||||
|
name="department"
|
||||||
|
control={control}
|
||||||
|
label="Department"
|
||||||
|
placeholder="Select a department"
|
||||||
|
data={[
|
||||||
|
{ value: 'IT', label: 'Information Technology' },
|
||||||
|
{ value: 'HR', label: 'Human Resources' },
|
||||||
|
{ value: 'FINANCE', label: 'Finance' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* * ⚠️ CRITICAL UI FIX: DYNAMIC KEY
|
||||||
|
* Why bind the 'key' to the parent dependency (department)?
|
||||||
|
* * Mantine's Select component caches its internal visual state. When the parent
|
||||||
|
* 'department' changes, our useConditionalField hook successfully clears the RHF
|
||||||
|
* payload state, but Mantine might visually retain the old text on the screen.
|
||||||
|
* * By changing the 'key' whenever the department changes, we force React to
|
||||||
|
* completely unmount and remount this component. This destroys Mantine's old
|
||||||
|
* internal cache and guarantees a perfectly clean UI sync.
|
||||||
|
*/}
|
||||||
|
<FieldSelect
|
||||||
|
key={`role-select-${department}`}
|
||||||
|
name="role"
|
||||||
|
control={control}
|
||||||
|
label="Role"
|
||||||
|
placeholder="Select a role"
|
||||||
|
disabled={!department}
|
||||||
|
data={currentRoleOptions}
|
||||||
|
withAsterisk={!!department}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="submit" mt="md">
|
||||||
|
{t.common?.submit || 'Submit Reactive Form'}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper p="md" withBorder radius="md" bg="var(--mantine-color-gray-0)">
|
||||||
|
<Title order={6} mb="xs">
|
||||||
|
{t.common?.submittedData || 'Submitted Data'}
|
||||||
|
</Title>
|
||||||
|
<Code block>{JSON.stringify(allValues, null, 2)}</Code>
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
+114
@@ -0,0 +1,114 @@
|
|||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
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
|
||||||
|
} from '@repo/ui/form';
|
||||||
|
import {
|
||||||
|
compose, required, rangeLength,
|
||||||
|
positiveNumber, simplePassword,
|
||||||
|
complexPassword, phoneValidator, rangeValue
|
||||||
|
} from '@repo/ui/validators';
|
||||||
|
import { useFormDemoTranslation } from '../i18n/useFormDemoTranslation';
|
||||||
|
|
||||||
|
export default function ValidationBankDemo() {
|
||||||
|
const t = useFormDemoTranslation();
|
||||||
|
|
||||||
|
// Compose the Zod schema using the atomic validators
|
||||||
|
const validationSchema = z.object({
|
||||||
|
username: compose(z.string(), required(t.fields.customerName), rangeLength(3, 15, t.fields.customerName)),
|
||||||
|
simplePass: compose(z.string(), required(t.validation.simplePassword), simplePassword(6)),
|
||||||
|
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())
|
||||||
|
});
|
||||||
|
|
||||||
|
type ValidationFormValues = z.infer<typeof validationSchema>;
|
||||||
|
|
||||||
|
const { control, handleSubmit, watch } = useForm<ValidationFormValues>({
|
||||||
|
resolver: zodResolver(validationSchema),
|
||||||
|
defaultValues: {
|
||||||
|
username: '',
|
||||||
|
simplePass: '',
|
||||||
|
complexPass: '',
|
||||||
|
age: undefined as any,
|
||||||
|
score: undefined as any,
|
||||||
|
phone: ''
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = (data: ValidationFormValues) => console.log('Validation Passed:', data);
|
||||||
|
const data = watch();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="xl">
|
||||||
|
<Paper p="xl" withBorder radius="md">
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Title order={5} c="brand">Validation Bank (Atomic Registry)</Title>
|
||||||
|
<Divider mb="sm" />
|
||||||
|
|
||||||
|
<FieldTextInput
|
||||||
|
name="username"
|
||||||
|
control={control}
|
||||||
|
label={t.fields.customerName}
|
||||||
|
description={t.validation.usernameRange}
|
||||||
|
withAsterisk
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group grow align="flex-start">
|
||||||
|
<FieldPasswordInput
|
||||||
|
name="simplePass"
|
||||||
|
control={control}
|
||||||
|
label={t.validation.simplePassword}
|
||||||
|
description="Min 6 chars"
|
||||||
|
withAsterisk
|
||||||
|
/>
|
||||||
|
<FieldPasswordInput
|
||||||
|
name="complexPass"
|
||||||
|
control={control}
|
||||||
|
label={t.validation.complexPassword}
|
||||||
|
description="Min 8, 1 uppercase, 1 number, 1 special"
|
||||||
|
withAsterisk
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group grow align="flex-start">
|
||||||
|
<FieldNumberInput
|
||||||
|
name="age"
|
||||||
|
control={control}
|
||||||
|
label={t.fields.age}
|
||||||
|
description={t.validation.ageRange}
|
||||||
|
withAsterisk
|
||||||
|
/>
|
||||||
|
<FieldNumberInput
|
||||||
|
name="score"
|
||||||
|
control={control}
|
||||||
|
label={t.validation.score}
|
||||||
|
description="Must be > 0"
|
||||||
|
withAsterisk
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<FieldTextInput
|
||||||
|
name="phone"
|
||||||
|
control={control}
|
||||||
|
label={t.validation.phone}
|
||||||
|
description="Format: +62..."
|
||||||
|
withAsterisk
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="submit" mt="md">{t.common.submit}</Button>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper p="md" withBorder radius="md" bg="var(--mantine-color-gray-0)">
|
||||||
|
<Title order={6} mb="xs">{t.common.submittedData}</Title>
|
||||||
|
<Code block>{JSON.stringify(data, null, 2)}</Code>
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { Tabs } from '@repo/ui/components';
|
||||||
|
import AllFieldsDemo from './components/all-fields-demo';
|
||||||
|
import ValidationBankDemo from './components/validation-bank-demo';
|
||||||
|
import ReactiveWatchDemo from './components/reactive-watch-demo';
|
||||||
|
import { useFormDemoTranslation } from './i18n/useFormDemoTranslation';
|
||||||
|
|
||||||
|
export default function FormDemoView() {
|
||||||
|
const t = useFormDemoTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tabs defaultValue="all-fields" variant="outline" radius="md">
|
||||||
|
<Tabs.List mb="md">
|
||||||
|
<Tabs.Tab value="all-fields">{t.tabs.allFields}</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="validation-bank">{t.tabs.validationBank}</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="reactive-watch">{t.tabs.reactiveWatch}</Tabs.Tab>
|
||||||
|
</Tabs.List>
|
||||||
|
|
||||||
|
<Tabs.Panel value="all-fields">
|
||||||
|
<AllFieldsDemo />
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
<Tabs.Panel value="validation-bank">
|
||||||
|
<ValidationBankDemo />
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
<Tabs.Panel value="reactive-watch">
|
||||||
|
<ReactiveWatchDemo />
|
||||||
|
</Tabs.Panel>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"tabs": {
|
||||||
|
"allFields": "All 22 Fields Demo",
|
||||||
|
"validationBank": "Validation Bank",
|
||||||
|
"reactiveWatch": "Reactive Watch (useWatch)"
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"submit": "Submit Data",
|
||||||
|
"reset": "Reset Form",
|
||||||
|
"submittedData": "Submitted Data"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"customerName": "Customer Name",
|
||||||
|
"email": "Email Address",
|
||||||
|
"priority": "Production Priority",
|
||||||
|
"password": "Password",
|
||||||
|
"description": "Description",
|
||||||
|
"age": "Age",
|
||||||
|
"jsonConfig": "JSON Config",
|
||||||
|
"tags": "Tags",
|
||||||
|
"terms": "I agree to the terms and conditions",
|
||||||
|
"receiveEmails": "Receive marketing emails",
|
||||||
|
"rating": "Satisfaction Rating",
|
||||||
|
"themeColor": "Theme Color",
|
||||||
|
"avatar": "Avatar Upload",
|
||||||
|
"orderType": "Order Type",
|
||||||
|
"quantity": "Quantity",
|
||||||
|
"fabricColor": "Fabric Color",
|
||||||
|
"pin": "Security PIN"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"simplePassword": "Simple Password",
|
||||||
|
"complexPassword": "Complex Password",
|
||||||
|
"score": "Score (Positive)",
|
||||||
|
"ageRange": "Age Range (18-65)",
|
||||||
|
"usernameRange": "Username Length (3-15)",
|
||||||
|
"phone": "Phone Number (+62)"
|
||||||
|
},
|
||||||
|
"watch": {
|
||||||
|
"userType": "User Type",
|
||||||
|
"typePersonal": "Personal",
|
||||||
|
"typeCorporate": "Corporate",
|
||||||
|
"corporateTaxId": "Corporate Tax ID",
|
||||||
|
"hasSpouse": "Do you have a spouse?",
|
||||||
|
"spouseName": "Spouse Name"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"tabs": {
|
||||||
|
"allFields": "Demo 22 Field",
|
||||||
|
"validationBank": "Bank Validasi",
|
||||||
|
"reactiveWatch": "Reactive Watch (useWatch)"
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"submit": "Kirim Data",
|
||||||
|
"reset": "Reset Form",
|
||||||
|
"submittedData": "Data Terkirim"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"customerName": "Nama Pelanggan",
|
||||||
|
"email": "Alamat Email",
|
||||||
|
"priority": "Prioritas Produksi",
|
||||||
|
"password": "Kata Sandi",
|
||||||
|
"description": "Deskripsi",
|
||||||
|
"age": "Usia",
|
||||||
|
"jsonConfig": "Konfigurasi JSON",
|
||||||
|
"tags": "Label (Tags)",
|
||||||
|
"terms": "Saya setuju dengan syarat dan ketentuan",
|
||||||
|
"receiveEmails": "Terima email pemasaran",
|
||||||
|
"rating": "Peringkat Kepuasan",
|
||||||
|
"themeColor": "Warna Tema",
|
||||||
|
"avatar": "Unggah Avatar",
|
||||||
|
"orderType": "Tipe Pesanan",
|
||||||
|
"quantity": "Jumlah",
|
||||||
|
"fabricColor": "Warna Kain",
|
||||||
|
"pin": "PIN Keamanan"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"simplePassword": "Sandi Sederhana",
|
||||||
|
"complexPassword": "Sandi Kompleks",
|
||||||
|
"score": "Skor (Positif)",
|
||||||
|
"ageRange": "Rentang Usia (18-65)",
|
||||||
|
"usernameRange": "Panjang Username (3-15)",
|
||||||
|
"phone": "Nomor Telepon (+62)"
|
||||||
|
},
|
||||||
|
"watch": {
|
||||||
|
"userType": "Tipe Pengguna",
|
||||||
|
"typePersonal": "Personal",
|
||||||
|
"typeCorporate": "Perusahaan",
|
||||||
|
"corporateTaxId": "NPWP Perusahaan",
|
||||||
|
"hasSpouse": "Apakah Anda memiliki pasangan?",
|
||||||
|
"spouseName": "Nama Pasangan"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import en from './en.json';
|
||||||
|
import id from './id.json';
|
||||||
|
|
||||||
|
export type FormDemoI18n = typeof en;
|
||||||
|
|
||||||
|
export function useFormDemoTranslation(): FormDemoI18n {
|
||||||
|
const { i18n } = useTranslation();
|
||||||
|
return (i18n.language === 'id' ? id : en) as FormDemoI18n;
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './form-demo-view';
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { z } from 'zod';
|
|
||||||
import { useForm, FormProvider, type SubmitHandler } from 'react-hook-form';
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { Button, Paper, Title, Group, Stack, Code, Text } from '@repo/ui/components';
|
|
||||||
|
|
||||||
// Import our RHF-connected Field components from packages/ui
|
|
||||||
import {
|
|
||||||
FieldTextInput,
|
|
||||||
FieldNumberInput,
|
|
||||||
FieldSelect,
|
|
||||||
FieldColorInput,
|
|
||||||
FieldCheckbox,
|
|
||||||
FieldSegmentedControl
|
|
||||||
} from '@repo/ui/form';
|
|
||||||
|
|
||||||
// ─── 1. VALIDATOR SCHEMA WITH JSON I18N PAYLOAD ───────────────────
|
|
||||||
const erpOrderSchema = z.object({
|
|
||||||
customerName: z.string().min(3, {
|
|
||||||
message: JSON.stringify({ key: 'validation:min_length', values: { field: 'Nama Pelanggan', min: 3 } })
|
|
||||||
}),
|
|
||||||
email: z.string().email({
|
|
||||||
message: JSON.stringify({ key: 'validation:invalid_email' })
|
|
||||||
}),
|
|
||||||
orderType: z.enum(['BULK', 'RETAIL'], {
|
|
||||||
required_error: JSON.stringify({ key: 'validation:required', values: { field: 'Tipe Pesanan' } })
|
|
||||||
}),
|
|
||||||
quantity: z.number({
|
|
||||||
required_error: JSON.stringify({ key: 'validation:required', values: { field: 'Jumlah Roll' } })
|
|
||||||
}).min(5, {
|
|
||||||
message: JSON.stringify({ key: 'validation:min_length', values: { field: 'Jumlah Roll', min: 5 } })
|
|
||||||
}),
|
|
||||||
fabricColor: z.string().min(1, {
|
|
||||||
message: JSON.stringify({ key: 'validation:required', values: { field: 'Kode Warna Kain' } })
|
|
||||||
}),
|
|
||||||
priority: z.string(),
|
|
||||||
termsAccepted: z.literal(true, {
|
|
||||||
errorMap: () => ({ message: JSON.stringify({ key: 'validation:required', values: { field: 'Persetujuan Syarat & Ketentuan' } }) })
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
type ErpOrderPayload = z.infer<typeof erpOrderSchema>;
|
|
||||||
|
|
||||||
export default function FormShowcase() {
|
|
||||||
const [submittedData, setSubmittedData] = useState<ErpOrderPayload | null>(null);
|
|
||||||
|
|
||||||
// ─── 2. INITIALIZE REACT HOOK FORM WITH ZOD RESOLVER ─────────────
|
|
||||||
const methods = useForm<ErpOrderPayload>({
|
|
||||||
resolver: zodResolver(erpOrderSchema),
|
|
||||||
defaultValues: {
|
|
||||||
customerName: '',
|
|
||||||
email: '',
|
|
||||||
orderType: 'BULK',
|
|
||||||
quantity: 5,
|
|
||||||
fabricColor: '#228be6',
|
|
||||||
priority: 'normal',
|
|
||||||
// @ts-ignore - literal true is required but we start with false
|
|
||||||
termsAccepted: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const onSubmit: SubmitHandler<ErpOrderPayload> = (data) => {
|
|
||||||
setSubmittedData(data);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack gap="md" style={{ maxWidth: 800, margin: '0 auto', width: '100%' }}>
|
|
||||||
<Paper withBorder p="xl" radius="md" bg="var(--mantine-color-body)">
|
|
||||||
<Title order={3} mb="xs">📦 RHF + Zod + i18n Enterprise Demo</Title>
|
|
||||||
<Text c="dimmed" size="sm" mb="xl">
|
|
||||||
This form demonstrates the integration of our generated Mantine UI wrappers, React Hook Form micro-subscriptions, and Zod validation using JSON i18n payloads.
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<FormProvider {...methods}>
|
|
||||||
<form onSubmit={methods.handleSubmit(onSubmit as any)}>
|
|
||||||
<Stack gap="md">
|
|
||||||
|
|
||||||
<Group grow align="flex-start">
|
|
||||||
<FieldTextInput
|
|
||||||
name="customerName"
|
|
||||||
label="Nama Pelanggan"
|
|
||||||
placeholder="Masukkan nama perusahaan atau perorangan"
|
|
||||||
withAsterisk
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FieldTextInput
|
|
||||||
name="email"
|
|
||||||
label="Email Korespondensi"
|
|
||||||
placeholder="billing@company.com"
|
|
||||||
withAsterisk
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<FieldSegmentedControl
|
|
||||||
name="priority"
|
|
||||||
label="Prioritas Produksi"
|
|
||||||
data={[
|
|
||||||
{ label: 'Normal', value: 'normal' },
|
|
||||||
{ label: 'Urgent', value: 'urgent' },
|
|
||||||
{ label: 'Critical', value: 'critical' }
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Group grow align="flex-start">
|
|
||||||
<FieldSelect
|
|
||||||
name="orderType"
|
|
||||||
label="Tipe Pesanan"
|
|
||||||
data={[
|
|
||||||
{ value: 'BULK', label: 'Grosir (Bulk)' },
|
|
||||||
{ value: 'RETAIL', label: 'Eceran (Retail)' }
|
|
||||||
]}
|
|
||||||
withAsterisk
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FieldNumberInput
|
|
||||||
name="quantity"
|
|
||||||
label="Jumlah Produksi (Roll)"
|
|
||||||
placeholder="Minimal 5 roll"
|
|
||||||
withAsterisk
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<FieldColorInput
|
|
||||||
name="fabricColor"
|
|
||||||
label="Spesifikasi Warna Bahan"
|
|
||||||
placeholder="Pilih atau masukkan kode HEX warna"
|
|
||||||
withAsterisk
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FieldCheckbox
|
|
||||||
name="termsAccepted"
|
|
||||||
label="Saya menyetujui syarat & ketentuan produksi"
|
|
||||||
mt="md"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Group justify="flex-end" mt="xl">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="subtle"
|
|
||||||
color="gray"
|
|
||||||
onClick={() => {
|
|
||||||
methods.reset();
|
|
||||||
setSubmittedData(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Reset Form
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" color="brand">
|
|
||||||
Submit Payload
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
</Stack>
|
|
||||||
</form>
|
|
||||||
</FormProvider>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
{/* Output Panel untuk pembuktian Payload akhir */}
|
|
||||||
{submittedData && (
|
|
||||||
<Paper withBorder p="md" radius="md" bg="dark.8">
|
|
||||||
<Title order={5} c="green.4" mb="xs">✅ Validated Payload Output (PATCH/POST Ready):</Title>
|
|
||||||
<Code block color="dark" style={{ fontSize: '13px' }}>
|
|
||||||
{JSON.stringify(submittedData, null, 2)}
|
|
||||||
</Code>
|
|
||||||
</Paper>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -27,7 +27,7 @@ import PrinterList from './printer-list';
|
|||||||
import ExamplePage from './example/example.page';
|
import ExamplePage from './example/example.page';
|
||||||
import EventsDemoPage from './events-demo';
|
import EventsDemoPage from './events-demo';
|
||||||
import PouchSample from './pouch-sample';
|
import PouchSample from './pouch-sample';
|
||||||
import FormShowcase from './example/features/form-showcase';
|
import FormDemoView from './example/features/form-demo';
|
||||||
|
|
||||||
interface ShowcaseViewProps {
|
interface ShowcaseViewProps {
|
||||||
colorScheme: ColorSchemeType;
|
colorScheme: ColorSchemeType;
|
||||||
@@ -286,7 +286,7 @@ export default function ShowcaseView({ colorScheme, setColorScheme, density, set
|
|||||||
{/* --- FORMS TAB --- */}
|
{/* --- FORMS TAB --- */}
|
||||||
{activeTab === 'forms' && (
|
{activeTab === 'forms' && (
|
||||||
<Stack gap="xl">
|
<Stack gap="xl">
|
||||||
<FormShowcase />
|
<FormDemoView />
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -362,6 +362,123 @@ it('minValue() should enforce min', () => {
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Reactive Form Logic: useConditionalField
|
||||||
|
|
||||||
|
To decouple complex rendering side-effects from your component's root render function, the `@repo/ui/hooks` module provides `useConditionalField`. This hook automatically cleans up React Hook Form fields based on dynamic boolean conditions, enabling efficient micro-subscription architectures via `useWatch`.
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> The hook exclusively uses a strict `UseConditionalFieldOptions` object signature. Legacy positional parameters are no longer supported to ensure strict typing and predictability across the monorepo.
|
||||||
|
|
||||||
|
### Core Modes
|
||||||
|
|
||||||
|
The hook supports two cleanup strategies defined by the `mode` parameter:
|
||||||
|
|
||||||
|
| Mode | Behavior | Use Case |
|
||||||
|
|---|---|---|
|
||||||
|
| `unregister` | Completely unmounts the field. Value is wiped. Key is removed from submission payload. | Hidden fields (e.g. Spouse Name if "Single" is checked). |
|
||||||
|
| `reset` | Field stays active/disabled. Value is wiped. Error state is cleared. Key is sent in payload as empty/default. | Disabled or Cascading fields (e.g. Email Input if "Subscribe" is false, or resetting City when Province changes). |
|
||||||
|
|
||||||
|
### Hook Configuration
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useForm, useWatch } from 'react-hook-form';
|
||||||
|
import { useConditionalField } from '@repo/ui/hooks';
|
||||||
|
|
||||||
|
export function ExampleForm() {
|
||||||
|
const { control, setValue, unregister, clearErrors } = useForm();
|
||||||
|
|
||||||
|
const userType = useWatch({ control, name: 'userType' });
|
||||||
|
const newsletter = useWatch({ control, name: 'newsletter' });
|
||||||
|
|
||||||
|
// 1. Unregister Mode (Hidden Field)
|
||||||
|
useConditionalField({
|
||||||
|
condition: userType === 'CORPORATE',
|
||||||
|
name: 'corporateTaxId',
|
||||||
|
setValue,
|
||||||
|
unregister,
|
||||||
|
mode: 'unregister'
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Reset Mode (Visible but Disabled)
|
||||||
|
useConditionalField({
|
||||||
|
condition: newsletter === true,
|
||||||
|
name: 'newsletterEmail',
|
||||||
|
setValue,
|
||||||
|
clearErrors,
|
||||||
|
mode: 'reset'
|
||||||
|
});
|
||||||
|
|
||||||
|
return <form>...</form>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cascading Dropdowns & Reactivity
|
||||||
|
|
||||||
|
When dealing with cascading dependencies (e.g., Department -> Role), changing the parent dropdown should invalidate and reset the child dropdown.
|
||||||
|
|
||||||
|
You can accomplish this easily by supplying `mode: 'reset'` to `useConditionalField`. However, there is a **critical rendering caveat** with Mantine's `Select` (and similar complex visual inputs):
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> **The Dynamic Key Trick:** Mantine components aggressively cache their internal visual text state. Even if `useConditionalField` perfectly resets the React Hook Form payload state to `''`, Mantine may still visually display the old, stale text on the screen.
|
||||||
|
>
|
||||||
|
> To fix this UI desync, you **must bind the parent dependency to the child component's `key` prop**. This forces React's reconciliation engine to completely unmount and remount the child DOM node, flushing Mantine's internal cache and guaranteeing perfect UI synchronization.
|
||||||
|
|
||||||
|
#### Master Example: Department to Role Cascade
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useForm, useWatch } from 'react-hook-form';
|
||||||
|
import { useConditionalField } from '@repo/ui/hooks';
|
||||||
|
import { FieldSelect } from '@repo/ui/form';
|
||||||
|
|
||||||
|
export function DepartmentForm() {
|
||||||
|
const { control, setValue, clearErrors } = useForm();
|
||||||
|
|
||||||
|
const department = useWatch({ control, name: 'department' });
|
||||||
|
const role = useWatch({ control, name: 'role' });
|
||||||
|
|
||||||
|
// Derive available options based on the parent state
|
||||||
|
const currentRoleOptions = department === 'IT'
|
||||||
|
? [{ value: 'FRONTEND', label: 'Frontend' }, { value: 'BACKEND', label: 'Backend' }]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Determine if the currently selected role is still mathematically valid
|
||||||
|
const isRoleValid = !role || (!!department && currentRoleOptions.some(opt => opt.value === role));
|
||||||
|
|
||||||
|
// 3. Reset Mode: Automatically wipes the field value in the RHF Payload if it becomes invalid
|
||||||
|
useConditionalField({
|
||||||
|
condition: isRoleValid,
|
||||||
|
name: 'role',
|
||||||
|
setValue,
|
||||||
|
clearErrors,
|
||||||
|
mode: 'reset',
|
||||||
|
defaultValue: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form>
|
||||||
|
<FieldSelect
|
||||||
|
name="department"
|
||||||
|
control={control}
|
||||||
|
label="Department"
|
||||||
|
data={[{ value: 'IT', label: 'Information Technology' }]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* CRITICAL: We bind the department string to the key prop to force remounts on change */}
|
||||||
|
<FieldSelect
|
||||||
|
key={`role-select-${department}`}
|
||||||
|
name="role"
|
||||||
|
control={control}
|
||||||
|
label="Role"
|
||||||
|
disabled={!department}
|
||||||
|
data={currentRoleOptions}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Usage Examples
|
## Usage Examples
|
||||||
|
|
||||||
### Basic Form
|
### Basic Form
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export * from '@mantine/hooks';
|
export * from '@mantine/hooks';
|
||||||
|
export * from './useConditionalField';
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import type { UseFormSetValue, UseFormUnregister, UseFormClearErrors, FieldValues, Path } from 'react-hook-form';
|
||||||
|
|
||||||
|
export interface UseConditionalFieldOptions<TFieldValues extends FieldValues> {
|
||||||
|
condition: boolean;
|
||||||
|
name: Path<TFieldValues>;
|
||||||
|
setValue: UseFormSetValue<TFieldValues>;
|
||||||
|
unregister?: UseFormUnregister<TFieldValues>;
|
||||||
|
clearErrors?: UseFormClearErrors<TFieldValues>;
|
||||||
|
defaultValue?: any;
|
||||||
|
mode?: 'unregister' | 'reset';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Automatically cleans up a conditionally rendered React Hook Form field
|
||||||
|
* when its parent condition becomes false.
|
||||||
|
*
|
||||||
|
* @param options Configuration object for the conditional field behavior
|
||||||
|
*/
|
||||||
|
export function useConditionalField<TFieldValues extends FieldValues>(
|
||||||
|
options: UseConditionalFieldOptions<TFieldValues>
|
||||||
|
) {
|
||||||
|
// Destructure with default values
|
||||||
|
const {
|
||||||
|
condition,
|
||||||
|
name,
|
||||||
|
setValue,
|
||||||
|
unregister,
|
||||||
|
clearErrors,
|
||||||
|
defaultValue,
|
||||||
|
mode = 'unregister'
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const config = options;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// When the condition evaluates to false, we execute the cleanup logic
|
||||||
|
if (!condition) {
|
||||||
|
// 1. Reset the field value. We use a stable empty state (like '' instead of undefined)
|
||||||
|
// to prevent uncontrolled component fallback in the React UI. We also force RHF to sync.
|
||||||
|
const targetValue = config.defaultValue !== undefined ? config.defaultValue : ('' as any);
|
||||||
|
config.setValue(config.name, targetValue, {
|
||||||
|
shouldDirty: true,
|
||||||
|
shouldTouch: true,
|
||||||
|
shouldValidate: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Execute the appropriate side-effect strategy based on the active mode
|
||||||
|
if (mode === 'unregister' && config.unregister) {
|
||||||
|
// Unregister Mode: Completely unmounts the field from React Hook Form.
|
||||||
|
// The value is removed from the payload and validation is entirely bypassed.
|
||||||
|
config.unregister(config.name);
|
||||||
|
} else if (mode === 'reset' && config.clearErrors) {
|
||||||
|
// Reset Mode: Keeps the field active in the DOM (e.g. cascading or disabled dependencies).
|
||||||
|
// Wipes the value and clears active validation errors so the user can interact
|
||||||
|
// with a fresh state, but keeps the property in the payload.
|
||||||
|
config.clearErrors(config.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
condition,
|
||||||
|
name,
|
||||||
|
setValue,
|
||||||
|
unregister,
|
||||||
|
clearErrors,
|
||||||
|
defaultValue,
|
||||||
|
mode
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { z, type ZodString, type ZodNumber, type ZodTypeAny } from 'zod';
|
import type { ZodString, ZodNumber, ZodTypeAny } from 'zod';
|
||||||
|
|
||||||
// ─── UTILITIES ─────────────────────────────────────────────────────────────
|
// ─── UTILITIES ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user