diff --git a/README.md b/README.md index 4e945de..a0596c0 100644 --- a/README.md +++ b/README.md @@ -298,10 +298,13 @@ This package is intended to hold non-UI, cross-cutting logic such as date/time h ### 10. `packages/ui` -Shared UI component library (Buttons, Inputs, Cards, Layouts). +Shared UI component library (Buttons, Inputs, Cards, Layouts) with a comprehensive **Form UI Library**. * Ensures consistent design across all applications * Designed to be consumed by both web apps and Storybook +* **Form UI Library**: 22 RHF-connected Mantine form components with Zod validation and i18n error translation, built via a `withRHF()` HOC factory with `useController` micro-subscriptions and `React.memo` optimization for ERP-scale forms + +**Documentation**: [README.md](packages/ui/README.md) · [Form Components Guide](packages/ui/docs/FORM-COMPONENTS.md) --- diff --git a/Untitled.java b/Untitled.java new file mode 100644 index 0000000..7751d7c --- /dev/null +++ b/Untitled.java @@ -0,0 +1,29 @@ +**Role:** You are a Staff Level React Engineer and Architect managing a highly scalable Enterprise ERP monorepo. + +**Context:** +We have successfully established our Form UI library in `packages/ui/src/components/Form/fields` featuring 22 Mantine form components wrapped with React Hook Form (e.g., `FieldTextInput`, `FieldSelect`, `FieldColorPicker`, etc.). We also have a Zod validation layer at `packages/ui/src/validators` that uses JSON-stringified payloads for i18n translation. +We now need to build a showcase/demo page in the main web application to test and demonstrate these components in a real-world ERP form scenario. + +**Pre-Execution Analysis (READ THESE FIRST):** +Before writing any code, you MUST read and analyze: +1. The component signatures and exports in `packages/ui/src/components/Form/index.ts`. +2. The Zod validator pattern in `packages/ui/src/validators` (specifically how the JSON i18n payloads are structured). +3. The existing layout and routing patterns in `apps/web/src/apps/showcase/showcase-view.tsx` to understand how to correctly inject and mount new showcase features. + +**Task Requirements:** + +**Phase 1: Create the Form Showcase Component** +1. Create a new comprehensive demo component inside `apps/web/src/apps/showcase/example/features/`. Follow the existing file naming convention found in that directory. +2. The component should implement a realistic ERP form (e.g., Textile Production Order, Inventory Bulk Update, or User Registration) using `useForm`, `zodResolver`, and a custom Zod schema. +3. The Zod schema MUST utilize the JSON-stringified i18n message pattern for validation errors. +4. Utilize a diverse set of our generated UI components (text input, select, number input, color input/picker, etc.) to prove they function correctly in a unified form. +5. Include a visual output panel (e.g., using Mantine's `Code` or `Pre` component) that displays the validated JSON payload upon successful submission. + +**Phase 2: Connect to Showcase View** +1. Update `apps/web/src/apps/showcase/showcase-view.tsx` to import and render the newly created Form Showcase component. +2. Integrate it seamlessly into the existing UI layout of the showcase view (e.g., adding a new Tab, Accordion, or Section dedicated to the Form UI & Validation Layer). + +**Execution Rules:** +- Do not rely on hardcoded assumptions. Let your code be guided completely by the patterns, styles, and typings you discover during the Pre-Execution Analysis. +- Ensure all TypeScript typings are strict. +- Output the newly created files and the modified files cleanly. \ No newline at end of file diff --git a/apps/web/package.json b/apps/web/package.json index c959bda..90c80ec 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -13,6 +13,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@hookform/resolvers": "^5.0.1", "@repo/core-api": "workspace:*", "@repo/core-events": "workspace:*", "@repo/core-i18n": "workspace:*", @@ -26,9 +27,11 @@ "lucide-react": "^1.17.0", "react": "^19.2.3", "react-dom": "^19.2.3", + "react-hook-form": "^7.56.4", "react-i18next": "^15.4.0", "react-router-dom": "^7.11.0", - "tailwindcss": "^4.1.18" + "tailwindcss": "^4.1.18", + "zod": "^3.25.36" }, "devDependencies": { "@repo/eslint-config": "workspace:*", diff --git a/apps/web/src/apps/showcase/example/features/form-demo/components/all-fields-demo.tsx b/apps/web/src/apps/showcase/example/features/form-demo/components/all-fields-demo.tsx new file mode 100644 index 0000000..73f84c4 --- /dev/null +++ b/apps/web/src/apps/showcase/example/features/form-demo/components/all-fields-demo.tsx @@ -0,0 +1,408 @@ +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, FieldLocalSelect, FieldAsyncSelect, + FieldRichTextEditor +} from '@repo/ui/form'; +import type { LoadOptionsFn } from '@repo/ui/form'; +import { useFormDemoTranslation } from '../i18n/useFormDemoTranslation'; + +const MOCK_POKEMON = Array.from({ length: 100 }, (_, i) => ({ + id: i + 1, + name: `Pokemon ${i + 1}`, +})); + +const loadMockPokemonOptions: LoadOptionsFn = async (search, page) => { + await new Promise((resolve) => setTimeout(resolve, 500)); + 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, + }; +}; + +const loadRealPokemonOptions: LoadOptionsFn = 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 = 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() { + const t = useFormDemoTranslation(); + + const { control, handleSubmit, watch } = useForm({ + 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, + 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' } + ], + richTextEmpty: "", + richTextPrefilled: "

ERP Release Notes

This is a highly important update. Please observe the following:

  • System maintenance at midnight.
  • All users must log out.

Thank you for your cooperation.

", + realPokeSelect: null, + multiRealPokeSelect: [] + } + }); + + const onSubmit = (data: any) => console.log('All Fields Submitted:', data); + const data = watch(); + + return ( + + +
+ + {/* --- Text & Numbers --- */} +
+ {t.sections.textAndNumbers} + + + + + + + + + + + + + +
+ {t.fields.pin} + +
+
+ + {/* --- Selections --- */} +
+ {t.sections.selections} + + + + + + + + + + + + `${item.name} (${item.hex})`} + clearable + /> + + + + + + + + + + + + {t.sections.advancedObjectSelects} + + + `[${item.code}] ${item.name}`} + clearable + /> + `[${item.code}] ${item.name}`} + clearable + /> + + + `[${item.code}] ${item.name}`} + clearable + /> + `[${item.code}] ${item.name}`} + defaultOptions={[{ id: 999, code: 'ASYNC-99', name: 'Pre-loaded Async Vendor' }]} + clearable + /> + + + {t.sections.multiSelectEditMode} + + + `[${item.code}] ${item.name}`} + clearable + /> + `[${item.code}] ${item.name}`} + clearable + /> + + + {t.sections.richTextEditor} + + +
+ +
+
+ + {/* --- Toggles & Choices --- */} +
+ {t.sections.togglesAndChoices} + + + + + + + + + + + + +
+ Chip Selection + + + Option 1 + Option 2 + + +
+
+ + {/* --- Ranges & Specialized --- */} +
+ {t.sections.rangesAndSpecialized} + + + + + + + + + + +
+ {t.fields.themeColor} Picker + +
+
+ {t.fields.rating} + +
+
+
+ + +
+
+
+ + + {t.common.submittedData} + {JSON.stringify(data, null, 2)} + +
+ ); +} diff --git a/apps/web/src/apps/showcase/example/features/form-demo/components/reactive-watch-demo.tsx b/apps/web/src/apps/showcase/example/features/form-demo/components/reactive-watch-demo.tsx new file mode 100644 index 0000000..e1c3119 --- /dev/null +++ b/apps/web/src/apps/showcase/example/features/form-demo/components/reactive-watch-demo.tsx @@ -0,0 +1,368 @@ +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, TypographyStylesProvider } from '@repo/ui/components'; +import { FieldTextInput, FieldSelect, FieldSwitch, FieldLocalSelect, FieldAsyncSelect, FieldRichTextEditor } 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, useMemo } 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(); + + // 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(t.fields.email), emailValidator()); + const roleValidator = compose(z.string(), required(t.fields.role)); + + const reactiveSchema = useMemo(() => 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(), + 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(), + richTextLive: z.string().optional(), + }) + .and( + z.discriminatedUnion('userType', [ + z.object({ userType: z.literal('PERSONAL') }), + z.object({ userType: z.literal('CORPORATE'), corporateTaxId: taxIdValidator }), + ]), + ) + .and( + z.union([ + z.object({ hasSpouse: z.literal(false) }), + z.object({ hasSpouse: z.literal(true), spouseName: spouseNameValidator }), + ]), + ) + .and( + z.union([ + z.object({ newsletter: z.literal(false) }), + z.object({ newsletter: z.literal(true), newsletterEmail: newsletterEmailValidator }), + ]), + ) + .and( + z.union([ + z.object({ department: z.string().min(1), role: roleValidator }), + z.object({ department: z.union([z.literal(''), z.undefined(), z.null()]).optional() }), + ]), + ), [t, taxIdValidator, spouseNameValidator, newsletterEmailValidator, roleValidator]); + + const { control, handleSubmit, setValue, unregister, clearErrors } = useForm({ + resolver: zodResolver(reactiveSchema as any), + defaultValues: { + userType: 'PERSONAL', + corporateTaxId: '', + hasSpouse: false, + spouseName: '', + newsletter: false, + 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' } + ], + richTextLive: "

Start typing to see the live preview...

", + }, + }); + + // 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' }); + const regions = useWatch({ control, name: 'regions' }); + const watchedRichTextLive = useWatch({ control, name: 'richTextLive' }); + + // 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 = { + 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: '', + }); + + const isMounted = useRef(false); + const prevRegionIds = useRef(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 }); + + const onSubmit = (data: any) => console.log('Reactive Passed:', data); + + return ( + + +
+ + + {t.sections.reactiveWatchCascading} + + + + } title="Micro-subscription Pattern" color="blue" variant="light"> + This form demonstrates isolated re-rendering using useWatch. Instead of re-rendering the + entire form root when typing, only the specific conditional fields update their display states. + + + + + + + {userType === 'CORPORATE' && ( + + )} + + + + {hasSpouse && ( + + )} + + + + + + + + + + + + {/* * ⚠️ 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. + */} + + + + {t.sections.reactiveWatchCascading} + + + + + multiple + name="regions" + control={control as any} + label={t.fields.regions} + options={REGIONS} + valueKey="id" + labelKey="code" + clearable + /> + + + multiple + key={`warehouse-select-${regions?.map((r: any) => r.id).join(',')}`} + name="warehouses" + control={control as any} + label={t.fields.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 && ( + + {t.descriptions.selectedRegionsTax} {regions.map((r: any) => `${r.code} (${(r.taxRate * 100).toFixed(0)}%)`).join(', ')} + + )} + + + {t.sections.reactiveRichTextPreview} + + + + + + + {t.sections.liveHtmlPreview} + +
+ + + + + + + + + + + {t.common?.submittedData || 'Submitted Data'} + + {JSON.stringify(allValues, null, 2)} + + + ); +} diff --git a/apps/web/src/apps/showcase/example/features/form-demo/components/validation-bank-demo.tsx b/apps/web/src/apps/showcase/example/features/form-demo/components/validation-bank-demo.tsx new file mode 100644 index 0000000..029c089 --- /dev/null +++ b/apps/web/src/apps/showcase/example/features/form-demo/components/validation-bank-demo.tsx @@ -0,0 +1,254 @@ +import { useMemo } from 'react'; +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, + FieldLocalSelect, FieldAsyncSelect, FieldRichTextEditor +} 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 = 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 = 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, + 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 = useMemo(() => 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()), + department: z.object({ code: z.string(), name: z.string() }, { required_error: t.errors.departmentRequired }), + assignees: z.array(z.object({ id: z.number(), email: z.string() })).min(2, t.errors.min2Assignees), + prefilledVendor: z.object({ id: z.union([z.string(), z.number()]), code: z.string(), name: z.string() }, { required_error: t.errors.vendorRequired }), + emptyVendor: z.object({ id: z.union([z.string(), z.number()]), code: z.string(), name: z.string() }, { required_error: t.errors.vendorRequired }), + prefilledAsyncMulti: z.array(z.object({ id: z.union([z.string(), z.number()]), code: z.string(), name: z.string() })).min(1, t.errors.min1Vendor), + richTextNotes: z.string().min(15, t.errors.notesMin15), + }), [t]); + + type ValidationFormValues = z.infer; + + const { control, handleSubmit, watch } = useForm({ + resolver: zodResolver(validationSchema), + defaultValues: { + username: '', + simplePass: '', + complexPass: '', + age: undefined as any, + score: undefined as any, + 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, + richTextNotes: '', + } + }); + + const onSubmit = (data: ValidationFormValues) => console.log('Validation Passed:', data); + const data = watch(); + + return ( + + +
+ + {t.sections.validationBankTitle} + + + + + + + + + + + + + + + + + {t.sections.objectLevelValidations} + + + + name="department" + control={control as any} + label={t.fields.department} + options={MOCK_DEPARTMENTS} + valueKey="code" + renderLabel={(item) => `[${item.code}] ${item.name}`} + clearable + withAsterisk + /> + + + multiple + name="assignees" + control={control as any} + label={t.fields.assignees} + loadOptions={mockFetchUsers} + valueKey="id" + labelKey="email" + searchable + clearable + withAsterisk + /> + + {t.sections.validatedPrefilledObjects} + + + + `[${item.code}] ${item.name}`} + clearable + withAsterisk + /> + `[${item.code}] ${item.name}`} + defaultOptions={[{ id: 'V1', code: 'VN-01', name: 'Vendor One' }]} + clearable + withAsterisk + /> + + + `[${item.code}] ${item.name}`} + clearable + withAsterisk + /> + + {t.sections.richTextValidations} + + + + + + +
+
+ + + {t.common.submittedData} + {JSON.stringify(data, null, 2)} + +
+ ); +} diff --git a/apps/web/src/apps/showcase/example/features/form-demo/form-demo-view.tsx b/apps/web/src/apps/showcase/example/features/form-demo/form-demo-view.tsx new file mode 100644 index 0000000..9581fa1 --- /dev/null +++ b/apps/web/src/apps/showcase/example/features/form-demo/form-demo-view.tsx @@ -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 ( + + + {t.tabs.allFields} + {t.tabs.validationBank} + {t.tabs.reactiveWatch} + + + + + + + + + + + + + + + ); +} diff --git a/apps/web/src/apps/showcase/example/features/form-demo/i18n/en.json b/apps/web/src/apps/showcase/example/features/form-demo/i18n/en.json new file mode 100644 index 0000000..a32a1b4 --- /dev/null +++ b/apps/web/src/apps/showcase/example/features/form-demo/i18n/en.json @@ -0,0 +1,117 @@ +{ + "tabs": { + "allFields": "All 22 Fields Demo", + "validationBank": "Validation Bank", + "reactiveWatch": "Reactive Watch (useWatch)" + }, + "common": { + "submit": "Submit Data", + "submitReactive": "Submit Reactive Form", + "reset": "Reset Form", + "submittedData": "Submitted Data" + }, + "sections": { + "validationBankTitle": "Validation Bank (Atomic Registry)", + "objectLevelValidations": "Object Level Validations (Local & Async)", + "validatedPrefilledObjects": "Validated Prefilled Objects", + "richTextValidations": "Rich Text Editor Validations", + "textAndNumbers": "Text & Numbers", + "selections": "Selections", + "advancedObjectSelects": "Advanced Object Selects (Custom Labels & Default Values)", + "multiSelectEditMode": "Multi-Select Edit Mode (No defaultOptions fallback)", + "richTextEditor": "Rich Text Editor (TipTap)", + "togglesAndChoices": "Toggles & Choices", + "rangesAndSpecialized": "Ranges & Specialized", + "reactiveWatchCascading": "Reactive Watch (Cascading)", + "reactiveRichTextPreview": "Reactive Rich Text Preview", + "liveHtmlPreview": "Live HTML Preview Render" + }, + "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", + "department": "Department", + "assignees": "Assignees", + "emptyVendor": "Empty Vendor", + "prefilledVendor": "Prefilled Vendor", + "prefilledAsyncMulti": "Prefilled Async Multi (No Fallback)", + "importantNotes": "Important Notes", + "country": "Country", + "categories": "Categories", + "localSelect": "Local Select", + "multiLocalSelect": "Multi Local Select", + "asyncSelectMock": "Async Select (Mock API)", + "multiAsyncSelect": "Multi Async Select", + "realPokeSingle": "Real PokeAPI (Single - Tests Deduplication)", + "realPokeMulti": "Real PokeAPI (Multi - Tests Deduplication)", + "localEmpty": "Local Empty", + "localPrefilled": "Local Prefilled", + "asyncEmpty": "Async Empty", + "asyncPrefilled": "Async Prefilled (Edit Mode)", + "localMultiPrefilled": "Local Multi Prefilled", + "asyncMultiPrefilled": "Async Multi Prefilled (Ghost Items)", + "richTextEmpty": "Rich Text (Empty)", + "richTextPrefilled": "Rich Text (Prefilled / Edit Mode)", + "priceRange": "Price Range", + "role": "Role", + "regions": "Regions", + "warehouses": "Warehouses", + "liveEditor": "Live Editor" + }, + "placeholders": { + "selectComplexObject": "Select a complex object", + "selectMultipleObjects": "Select multiple objects", + "searchPokemon": "Search pokemon...", + "selectMultiplePokemon": "Select multiple pokemon...", + "scrollDeduplication": "Scroll to test deduplication..." + }, + "descriptions": { + "min6Chars": "Min 6 chars", + "min8Complex": "Min 8, 1 uppercase, 1 number, 1 special", + "mustBePositive": "Must be > 0", + "formatPhone": "Format: +62...", + "zodMinLengthString": "This uses Zod minimum length string validation", + "freshTipTap": "A fresh TipTap editor instance", + "htmlStringLoaded": "HTML string successfully loaded from default values", + "typeToSeePreview": "Type to see instantaneous reactive rendering below", + "selectedRegionsTax": "Selected regions tax rates:" + }, + "errors": { + "departmentRequired": "Department is required", + "vendorRequired": "Vendor is required", + "min2Assignees": "Select at least 2 assignees", + "min1Vendor": "Select at least 1 vendor", + "notesMin15": "Notes must be at least 15 characters long (including HTML tags)", + "selectRegionFirst": "Select a region first to load warehouses" + }, + "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" + } +} diff --git a/apps/web/src/apps/showcase/example/features/form-demo/i18n/id.json b/apps/web/src/apps/showcase/example/features/form-demo/i18n/id.json new file mode 100644 index 0000000..f0261f0 --- /dev/null +++ b/apps/web/src/apps/showcase/example/features/form-demo/i18n/id.json @@ -0,0 +1,117 @@ +{ + "tabs": { + "allFields": "Demo 22 Field", + "validationBank": "Bank Validasi", + "reactiveWatch": "Reactive Watch (useWatch)" + }, + "common": { + "submit": "Kirim Data", + "submitReactive": "Kirim Form Reaktif", + "reset": "Reset Form", + "submittedData": "Data Terkirim" + }, + "sections": { + "validationBankTitle": "Bank Validasi (Registri Atomik)", + "objectLevelValidations": "Validasi Tingkat Objek (Lokal & Async)", + "validatedPrefilledObjects": "Objek Terisi yang Divalidasi", + "richTextValidations": "Validasi Rich Text Editor", + "textAndNumbers": "Teks & Angka", + "selections": "Pilihan", + "advancedObjectSelects": "Pemilihan Objek Tingkat Lanjut (Label Kustom & Nilai Default)", + "multiSelectEditMode": "Mode Edit Multi-Select (Tanpa fallback defaultOptions)", + "richTextEditor": "Rich Text Editor (TipTap)", + "togglesAndChoices": "Tombol Sakelar & Pilihan", + "rangesAndSpecialized": "Rentang & Khusus", + "reactiveWatchCascading": "Reactive Watch (Berjenjang)", + "reactiveRichTextPreview": "Pratinjau Rich Text Reaktif", + "liveHtmlPreview": "Render Pratinjau HTML Langsung" + }, + "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", + "department": "Departemen", + "assignees": "Penerima Tugas", + "emptyVendor": "Vendor Kosong", + "prefilledVendor": "Vendor Terisi", + "prefilledAsyncMulti": "Multi Async Terisi (Tanpa Fallback)", + "importantNotes": "Catatan Penting", + "country": "Negara", + "categories": "Kategori", + "localSelect": "Pilihan Lokal", + "multiLocalSelect": "Pilihan Lokal Multi", + "asyncSelectMock": "Pilihan Async (Mock API)", + "multiAsyncSelect": "Pilihan Async Multi", + "realPokeSingle": "API Pokemon Asli (Tunggal - Uji Deduplikasi)", + "realPokeMulti": "API Pokemon Asli (Multi - Uji Deduplikasi)", + "localEmpty": "Lokal Kosong", + "localPrefilled": "Lokal Terisi", + "asyncEmpty": "Async Kosong", + "asyncPrefilled": "Async Terisi (Mode Edit)", + "localMultiPrefilled": "Multi Lokal Terisi", + "asyncMultiPrefilled": "Multi Async Terisi (Item Hantu)", + "richTextEmpty": "Rich Text (Kosong)", + "richTextPrefilled": "Rich Text (Terisi / Mode Edit)", + "priceRange": "Rentang Harga", + "role": "Peran", + "regions": "Wilayah", + "warehouses": "Gudang", + "liveEditor": "Editor Langsung" + }, + "placeholders": { + "selectComplexObject": "Pilih objek yang kompleks", + "selectMultipleObjects": "Pilih beberapa objek", + "searchPokemon": "Cari pokemon...", + "selectMultiplePokemon": "Pilih beberapa pokemon...", + "scrollDeduplication": "Gulir untuk menguji deduplikasi..." + }, + "descriptions": { + "min6Chars": "Minimal 6 karakter", + "min8Complex": "Min 8, 1 huruf besar, 1 angka, 1 karakter khusus", + "mustBePositive": "Harus > 0", + "formatPhone": "Format: +62...", + "zodMinLengthString": "Ini menggunakan validasi panjang string minimum Zod", + "freshTipTap": "Instance editor TipTap yang baru", + "htmlStringLoaded": "String HTML berhasil dimuat dari nilai default", + "typeToSeePreview": "Ketik untuk melihat render reaktif seketika di bawah", + "selectedRegionsTax": "Tarif pajak wilayah yang dipilih:" + }, + "errors": { + "departmentRequired": "Departemen wajib diisi", + "vendorRequired": "Vendor wajib diisi", + "min2Assignees": "Pilih minimal 2 penerima tugas", + "min1Vendor": "Pilih minimal 1 vendor", + "notesMin15": "Catatan minimal harus terdiri dari 15 karakter (termasuk tag HTML)", + "selectRegionFirst": "Pilih wilayah terlebih dahulu untuk memuat gudang" + }, + "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" + } +} diff --git a/apps/web/src/apps/showcase/example/features/form-demo/i18n/useFormDemoTranslation.ts b/apps/web/src/apps/showcase/example/features/form-demo/i18n/useFormDemoTranslation.ts new file mode 100644 index 0000000..0c84d67 --- /dev/null +++ b/apps/web/src/apps/showcase/example/features/form-demo/i18n/useFormDemoTranslation.ts @@ -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; +} diff --git a/apps/web/src/apps/showcase/example/features/form-demo/index.ts b/apps/web/src/apps/showcase/example/features/form-demo/index.ts new file mode 100644 index 0000000..9582b1e --- /dev/null +++ b/apps/web/src/apps/showcase/example/features/form-demo/index.ts @@ -0,0 +1 @@ +export { default } from './form-demo-view'; diff --git a/apps/web/src/apps/showcase/showcase-view.tsx b/apps/web/src/apps/showcase/showcase-view.tsx index ca8f145..b5579c7 100644 --- a/apps/web/src/apps/showcase/showcase-view.tsx +++ b/apps/web/src/apps/showcase/showcase-view.tsx @@ -22,11 +22,14 @@ import { Box, Paper, } from '@repo/ui/components'; -import { ShieldCheck, Database, Lock, Layout, Activity, Printer } from 'lucide-react'; +import { ShieldCheck, Database, Lock, Layout, Activity, Printer, FileText } from 'lucide-react'; +import { Globe } from 'lucide-react'; +import { useTranslation } from '@repo/core-i18n'; import PrinterList from './printer-list'; import ExamplePage from './example/example.page'; import EventsDemoPage from './events-demo'; import PouchSample from './pouch-sample'; +import FormDemoView from './example/features/form-demo'; interface ShowcaseViewProps { colorScheme: ColorSchemeType; @@ -37,6 +40,7 @@ interface ShowcaseViewProps { export default function ShowcaseView({ colorScheme, setColorScheme, density, setDensity }: ShowcaseViewProps) { const [activeTab, setActiveTab] = useState('ui-components'); + const { i18n } = useTranslation(); // Mock data for the table const tableData = [ @@ -55,6 +59,8 @@ export default function ShowcaseView({ colorScheme, setColorScheme, density, set return 'Authentication & Security Layers'; case 'ui-components': return 'Theme, Typography, Forms & Data Grids'; + case 'forms': + return 'Enterprise Form Engine & Zod Validation'; case 'events': return 'Global Event Bus Synchronization'; case 'hardware': @@ -100,6 +106,9 @@ export default function ShowcaseView({ colorScheme, setColorScheme, density, set }> UI Components + }> + Form Engine + }> Offline Storage @@ -138,6 +147,18 @@ export default function ShowcaseView({ colorScheme, setColorScheme, density, set {getSubtitle()} + ` | `T \| null` | `string \| null` | `T \| null` | +| `multiple={true}` | `` | `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 ( +
console.log(data.department))}> + + name="department" + control={control} + label="Department" + options={departments} + valueKey="id" + labelKey="name" + searchable + /> + + + ); +} +// 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` | ✅ | 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 = 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 ( +
console.log(data.user))}> + + name="user" + control={control} + label="Assign User" + loadOptions={loadUsers} + valueKey="id" + labelKey="fullName" + placeholder="Search users..." + /> + + + ); +} +``` + +#### Non-Paginated Example + +If your API returns all results at once, return `hasMore: false`: + +```tsx +const loadRoles: LoadOptionsFn = 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 ( + + 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 ( + + 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. + +### The "Unstable Default Value" Trap in Hooks + +When creating custom form hooks (like `useConditionalField`), you often need to provide a fallback or default value. Passing an inline array or object as a `defaultValue` can trigger infinite render loops if it is included in a `useEffect` dependency array, because React's referential equality check fails on every render. + +**Solution: The `useRef` Stabilization Pattern** + +We resolve this by storing the `defaultValue` in a `useRef`. This allows the hook's cleanup logic to access the latest value without triggering the effect again: + +```tsx +// Inside useConditionalField.ts +const defaultValueRef = useRef(defaultValue); + +// Update ref on every render without triggering dependencies +useEffect(() => { + defaultValueRef.current = defaultValue; +}, [defaultValue]); + +// The main effect no longer depends on defaultValue +useEffect(() => { + if (!condition) { + const targetValue = defaultValueRef.current !== undefined ? defaultValueRef.current : ''; + setValue(name, targetValue); + } +}, [condition, name, setValue]); +``` + +### Zod Schema Performance: Avoid superRefine for Conditionals + +For complex dynamic forms, developers often default to `.superRefine` or `.refine` to handle conditional validation (e.g., "Require Tax ID only if userType is Corporate"). + +**The Problem:** `superRefine` acts as an opaque callback. Zod cannot optimize it. In large forms, doing manual `.safeParse` inside a `superRefine` loop forces Zod to parse the entire tree continuously on every keystroke, leading to severe O(n) CPU spikes. + +**The Solution:** Use declarative schema branching via `.and()`, `z.discriminatedUnion`, and `z.union`. These are statically analyzed by Zod and evaluated at native speed. + +#### ❌ Bad: Manual Parsing (O(n) CPU Spike) + +```tsx +const badSchema = z.object({ + userType: z.enum(['PERSONAL', 'CORPORATE']), + corporateTaxId: z.string().optional() +}).superRefine((data, ctx) => { + if (data.userType === 'CORPORATE') { + // ⚠️ INCREDIBLY SLOW: Manual parsing inside refine loop + const res = taxIdValidator.safeParse(data.corporateTaxId); + if (!res.success) ctx.addIssue({ ...res.error.issues[0], path: ['corporateTaxId'] }); + } +}); +``` + +#### ✅ Good: Declarative Unions (O(1) Evaluation) + +```tsx +const goodSchema = z.object({ + userType: z.enum(['PERSONAL', 'CORPORATE']), + corporateTaxId: z.string().optional() +}).and( + z.discriminatedUnion('userType', [ + z.object({ userType: z.literal('PERSONAL') }), + z.object({ userType: z.literal('CORPORATE'), corporateTaxId: taxIdValidator }) + ]) +); +``` + +By stacking `.and(z.union([...]))` for independent conditionals (like `hasSpouse`, `newsletter`, etc.), you achieve lightning-fast, type-safe conditional validation without writing a single `superRefine` loop. + +--- + +## Usage Examples + +### Basic Form + +```tsx +import { useForm, type SubmitHandler } from 'react-hook-form'; +import { FieldTextInput, FieldPasswordInput } from '@repo/ui/form'; + +type LoginForm = { email: string; password: string }; + +function LoginForm() { + const { control, handleSubmit } = useForm({ + defaultValues: { email: '', password: '' }, + }); + + const onSubmit: SubmitHandler = (data) => { + console.log(data); + }; + + return ( +
+ + + + + ); +} +``` + +### With Zod Validation + +```tsx +import { z } from 'zod'; +import { useForm, type SubmitHandler } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { + FieldTextInput, + FieldNumberInput, + FieldSelect, + FieldCheckbox, +} from '@repo/ui/form'; + +const productSchema = z.object({ + name: z.string().min(1, { + message: JSON.stringify({ key: 'validation:required', values: { field: 'Product Name' } }) + }), + sku: z.string().regex(/^[A-Z]{3}-\d{4}$/, { + message: JSON.stringify({ key: 'validation:invalid_format', values: { format: 'AAA-0000' } }) + }), + price: z.number().min(0, { + message: JSON.stringify({ key: 'validation:min_value', values: { min: 0 } }) + }), + category: z.string().min(1, { + message: JSON.stringify({ key: 'validation:required', values: { field: 'Category' } }) + }), + isActive: z.boolean(), +}); + +type ProductForm = z.infer; + +function ProductEditor() { + const { control, handleSubmit } = useForm({ + resolver: zodResolver(productSchema), + defaultValues: { + name: '', + sku: '', + price: 0, + category: '', + isActive: true, + }, + }); + + const onSubmit: SubmitHandler = (data) => console.log(data); + + return ( +
+ + + + + + + + ); +} +``` + +### Custom Field Component + +Use `withRHF` directly to wrap any Mantine component not included in the library: + +```tsx +import { DatePickerInput, type DatePickerInputProps } from '@mantine/dates'; +import { withRHF } from '@repo/ui/form'; + +export const FieldDatePicker = withRHF( + 'FieldDatePicker', + DatePickerInput, +); +``` + +--- + +## Component Reference + +| Component | Mantine Source | Type | Notes | +|---|---|---|---| +| `FieldTextInput` | `TextInput` | Text | Standard text input | +| `FieldPasswordInput` | `PasswordInput` | Text | Password with visibility toggle | +| `FieldTextarea` | `Textarea` | Text | Multi-line text | +| `FieldNumberInput` | `NumberInput` | Text | Numeric with increment/decrement | +| `FieldJsonInput` | `JsonInput` | Text | JSON-formatted text | +| `FieldPinInput` | `PinInput` | Text | PIN/OTP code input | +| `FieldAutocomplete` | `Autocomplete` | Text | Text input with suggestions | +| `FieldSelect` | `Select` | Selection | Single-value dropdown | +| `FieldMultiSelect` | `MultiSelect` | Selection | Multi-value dropdown | +| `FieldNativeSelect` | `NativeSelect` | Selection | Native `)} + data={baseOptions} + value={currentValue} + onChange={handleSingleChange} + searchable={searchable ?? true} + onSearchChange={handleSearchChange} + scrollAreaProps={scrollAreaProps} + filter={mantineFilter as any} + rightSection={rightSection} + /> + ); +} + +export const AsyncSelect = React.memo(AsyncSelectInner) as typeof AsyncSelectInner; +(AsyncSelect as any).displayName = 'AsyncSelect'; diff --git a/packages/ui/src/components/Form/custom/selects/LocalSelect.tsx b/packages/ui/src/components/Form/custom/selects/LocalSelect.tsx new file mode 100644 index 0000000..17ebdd8 --- /dev/null +++ b/packages/ui/src/components/Form/custom/selects/LocalSelect.tsx @@ -0,0 +1,189 @@ +import React, { useMemo, useState, useCallback } from 'react'; +import { Select, MultiSelect, type SelectProps, type MultiSelectProps, type ComboboxItem } from '@mantine/core'; +import type { LocalSelectBaseProps, SelectFilterContext } from './types'; + +// --------------------------------------------------------------------------- +// LocalSelect — 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 for O(1) reverse lookups +// 3. Intercepting onChange to resolve strings back to full objects +// +// Data Mapping Contract (Single vs. Multi): +// Single: value=T|null → Mantine string|null → onChange(T|null) +// Multi: value=T[] → Mantine string[] → onChange(T[]) +// +// The RHF-connected version (FieldLocalSelect) wraps this component and +// binds it to useController, following the same pattern as withRHF → FieldXxx. +// --------------------------------------------------------------------------- + +/** 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 LocalSelectSingleProps> = + LocalSelectBaseProps & Omit & { + 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 LocalSelectMultiProps> = + LocalSelectBaseProps & Omit & { + 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 LocalSelectProps> = + | LocalSelectSingleProps + | LocalSelectMultiProps; + +// --------------------------------------------------------------------------- +// Helper: Resolve label for a data item +// --------------------------------------------------------------------------- + +function resolveLabel>( + item: T, + labelKey?: keyof T & string, + renderLabel?: (item: T) => string, +): string { + if (renderLabel) return renderLabel(item); + if (labelKey) return String(item[labelKey] ?? ''); + // Fail fast: if neither labelKey nor renderLabel is provided, fall back + // to the first property value. While not ideal, it prevents crashes. + const firstKey = Object.keys(item)[0]; + return firstKey ? String(item[firstKey as keyof T] ?? '') : ''; +} + +// --------------------------------------------------------------------------- +// Component Implementation +// --------------------------------------------------------------------------- + +function LocalSelectInner>( + props: LocalSelectProps, +) { + const { + options, + valueKey, + labelKey, + renderLabel, + multiple, + filterOption, + onSelect: onSelectCallback, + value, + onChange, + searchable, + // Extract onSearchChange BEFORE the rest spread to get a + // stable reference for the useCallback dependency array. + onSearchChange: consumerOnSearchChange, + ...mantineProps + } = props; + + // 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(); + for (const item of options) { + map.set(String(item[valueKey]), item); + } + return map; + }, [options, valueKey]); + + // Build Mantine-compatible ComboboxItem[], applying filterOption if provided + const comboboxItems = useMemo(() => { + let filtered = options; + + if (filterOption) { + const context: SelectFilterContext = { + search: searchValue, + selected: value ?? (multiple ? [] : null), + }; + filtered = options.filter((item) => filterOption(item, context)); + } + + return filtered.map((item) => ({ + value: String(item[valueKey]), + label: resolveLabel(item, labelKey, renderLabel), + })); + }, [options, valueKey, labelKey, renderLabel, filterOption, searchValue, value, multiple]); + + // Depend only on stable function references, not the + // entire mantineProps object which is a new reference every render. + const handleSearchChange = useCallback( + (val: string) => { + setSearchValue(val); + consumerOnSearchChange?.(val); + }, + [consumerOnSearchChange], + ); + + // 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[]) => { + // Resolve string[] back to T[] via the lookup map. + // .filter(Boolean) guards against missing entries (defensive). + const objects = vals.map((v) => lookupMap.get(v)!).filter(Boolean); + (onChange as ((v: T[]) => void) | undefined)?.(objects); + onSelectCallback?.(objects); + }; + + return ( + )} + data={comboboxItems} + 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); + onSelectCallback?.(obj); + }; + + return ( +