From 00a2f218cc049375d65d89b94c013b0246d48274 Mon Sep 17 00:00:00 2001 From: Firman Ramdhani <33869609+firmanramdhani@users.noreply.github.com> Date: Fri, 29 May 2026 17:42:31 +0700 Subject: [PATCH 01/12] feat: update default density in ThemeProvider to 'compact' --- packages/ui/src/provider/theme-provider.tsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/provider/theme-provider.tsx b/packages/ui/src/provider/theme-provider.tsx index 86f6a77..3f752db 100644 --- a/packages/ui/src/provider/theme-provider.tsx +++ b/packages/ui/src/provider/theme-provider.tsx @@ -19,7 +19,7 @@ const densityMap = { standard: standardDensity, }; -export function ThemeProvider({ children, colorScheme = 'light', density = 'standard' }: ThemeProviderProps) { +export function ThemeProvider({ children, colorScheme = 'light', density = 'compact' }: ThemeProviderProps) { const baseTheme = createTheme({ colors: { brand: brandColors, @@ -39,11 +39,7 @@ export function ThemeProvider({ children, colorScheme = 'light', density = 'stan const mergedTheme = mergeThemeOverrides(baseTheme, selectedDensity); return ( - + {children} ); From a72b73bac45987a47582676b6902a4b37dabf24b Mon Sep 17 00:00:00 2001 From: Firman Ramdhani <33869609+firmanramdhani@users.noreply.github.com> Date: Mon, 15 Jun 2026 18:40:22 +0700 Subject: [PATCH 02/12] feat: implement comprehensive Form UI library with React Hook Form integration, Zod validation, and i18n support. --- README.md | 5 +- Untitled.java | 29 ++ apps/web/package.json | 5 +- .../example/features/form-showcase.tsx | 169 +++++++ apps/web/src/apps/showcase/showcase-view.tsx | 15 +- .../core-i18n/src/locales/en/validation.json | 7 + .../core-i18n/src/locales/id/validation.json | 7 + packages/core-i18n/src/setup.ts | 6 +- packages/ui/README.md | 80 +++ packages/ui/docs/FORM-COMPONENTS.md | 460 ++++++++++++++++++ packages/ui/package.json | 14 +- packages/ui/src/__tests__/setup.ts | 38 ++ .../Form/__tests__/checkbox.field.test.tsx | 154 ++++++ .../Form/__tests__/text-input.field.test.tsx | 186 +++++++ .../Form/__tests__/withRHF.test.tsx | 248 ++++++++++ .../Form/fields/autocomplete.field.tsx | 4 + .../components/Form/fields/checkbox.field.tsx | 6 + .../Form/fields/chip-group.field.tsx | 13 + .../Form/fields/color-input.field.tsx | 4 + .../Form/fields/color-picker.field.tsx | 8 + .../Form/fields/file-input.field.tsx | 4 + .../Form/fields/json-input.field.tsx | 4 + .../Form/fields/multi-select.field.tsx | 4 + .../Form/fields/native-select.field.tsx | 4 + .../Form/fields/number-input.field.tsx | 4 + .../Form/fields/password-input.field.tsx | 4 + .../Form/fields/pin-input.field.tsx | 4 + .../Form/fields/radio-group.field.tsx | 10 + .../Form/fields/range-slider.field.tsx | 4 + .../components/Form/fields/rating.field.tsx | 4 + .../Form/fields/segmented-control.field.tsx | 16 + .../components/Form/fields/select.field.tsx | 4 + .../components/Form/fields/slider.field.tsx | 4 + .../components/Form/fields/switch.field.tsx | 6 + .../Form/fields/tags-input.field.tsx | 4 + .../Form/fields/text-input.field.tsx | 4 + .../components/Form/fields/textarea.field.tsx | 4 + packages/ui/src/components/Form/index.ts | 65 +++ packages/ui/src/components/Form/types.ts | 91 ++++ packages/ui/src/components/Form/withRHF.tsx | 240 +++++++++ packages/ui/src/components/index.ts | 1 + packages/ui/src/validators/base.validator.ts | 15 + packages/ui/src/validators/index.ts | 1 + packages/ui/vitest.config.ts | 12 + pnpm-lock.yaml | 103 +++- 45 files changed, 2064 insertions(+), 10 deletions(-) create mode 100644 Untitled.java create mode 100644 apps/web/src/apps/showcase/example/features/form-showcase.tsx create mode 100644 packages/core-i18n/src/locales/en/validation.json create mode 100644 packages/core-i18n/src/locales/id/validation.json create mode 100644 packages/ui/README.md create mode 100644 packages/ui/docs/FORM-COMPONENTS.md create mode 100644 packages/ui/src/__tests__/setup.ts create mode 100644 packages/ui/src/components/Form/__tests__/checkbox.field.test.tsx create mode 100644 packages/ui/src/components/Form/__tests__/text-input.field.test.tsx create mode 100644 packages/ui/src/components/Form/__tests__/withRHF.test.tsx create mode 100644 packages/ui/src/components/Form/fields/autocomplete.field.tsx create mode 100644 packages/ui/src/components/Form/fields/checkbox.field.tsx create mode 100644 packages/ui/src/components/Form/fields/chip-group.field.tsx create mode 100644 packages/ui/src/components/Form/fields/color-input.field.tsx create mode 100644 packages/ui/src/components/Form/fields/color-picker.field.tsx create mode 100644 packages/ui/src/components/Form/fields/file-input.field.tsx create mode 100644 packages/ui/src/components/Form/fields/json-input.field.tsx create mode 100644 packages/ui/src/components/Form/fields/multi-select.field.tsx create mode 100644 packages/ui/src/components/Form/fields/native-select.field.tsx create mode 100644 packages/ui/src/components/Form/fields/number-input.field.tsx create mode 100644 packages/ui/src/components/Form/fields/password-input.field.tsx create mode 100644 packages/ui/src/components/Form/fields/pin-input.field.tsx create mode 100644 packages/ui/src/components/Form/fields/radio-group.field.tsx create mode 100644 packages/ui/src/components/Form/fields/range-slider.field.tsx create mode 100644 packages/ui/src/components/Form/fields/rating.field.tsx create mode 100644 packages/ui/src/components/Form/fields/segmented-control.field.tsx create mode 100644 packages/ui/src/components/Form/fields/select.field.tsx create mode 100644 packages/ui/src/components/Form/fields/slider.field.tsx create mode 100644 packages/ui/src/components/Form/fields/switch.field.tsx create mode 100644 packages/ui/src/components/Form/fields/tags-input.field.tsx create mode 100644 packages/ui/src/components/Form/fields/text-input.field.tsx create mode 100644 packages/ui/src/components/Form/fields/textarea.field.tsx create mode 100644 packages/ui/src/components/Form/index.ts create mode 100644 packages/ui/src/components/Form/types.ts create mode 100644 packages/ui/src/components/Form/withRHF.tsx create mode 100644 packages/ui/src/validators/base.validator.ts create mode 100644 packages/ui/src/validators/index.ts create mode 100644 packages/ui/vitest.config.ts 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-showcase.tsx b/apps/web/src/apps/showcase/example/features/form-showcase.tsx new file mode 100644 index 0000000..0bb0936 --- /dev/null +++ b/apps/web/src/apps/showcase/example/features/form-showcase.tsx @@ -0,0 +1,169 @@ +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; + +export default function FormShowcase() { + const [submittedData, setSubmittedData] = useState(null); + + // ─── 2. INITIALIZE REACT HOOK FORM WITH ZOD RESOLVER ───────────── + const methods = useForm({ + 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 = (data) => { + setSubmittedData(data); + }; + + return ( + + + 📦 RHF + Zod + i18n Enterprise Demo + + This form demonstrates the integration of our generated Mantine UI wrappers, React Hook Form micro-subscriptions, and Zod validation using JSON i18n payloads. + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + {/* Output Panel untuk pembuktian Payload akhir */} + {submittedData && ( + + ✅ Validated Payload Output (PATCH/POST Ready): + + {JSON.stringify(submittedData, null, 2)} + + + )} +
+ ); +} diff --git a/apps/web/src/apps/showcase/showcase-view.tsx b/apps/web/src/apps/showcase/showcase-view.tsx index ca8f145..75ce9a3 100644 --- a/apps/web/src/apps/showcase/showcase-view.tsx +++ b/apps/web/src/apps/showcase/showcase-view.tsx @@ -22,11 +22,12 @@ 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 PrinterList from './printer-list'; import ExamplePage from './example/example.page'; import EventsDemoPage from './events-demo'; import PouchSample from './pouch-sample'; +import FormShowcase from './example/features/form-showcase'; interface ShowcaseViewProps { colorScheme: ColorSchemeType; @@ -55,6 +56,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 +103,9 @@ export default function ShowcaseView({ colorScheme, setColorScheme, density, set }> UI Components + }> + Form Engine + }> Offline Storage @@ -277,6 +283,13 @@ export default function ShowcaseView({ colorScheme, setColorScheme, density, set )} + {/* --- FORMS TAB --- */} + {activeTab === 'forms' && ( + + + + )} + {/* --- STORAGE TAB --- */} {activeTab === 'storage' && ( diff --git a/packages/core-i18n/src/locales/en/validation.json b/packages/core-i18n/src/locales/en/validation.json new file mode 100644 index 0000000..aeeae87 --- /dev/null +++ b/packages/core-i18n/src/locales/en/validation.json @@ -0,0 +1,7 @@ +{ + "validation": { + "required": "{{field}} is required", + "min_length": "{{field}} must be at least {{min}} characters", + "invalid_email": "Invalid email format" + } +} diff --git a/packages/core-i18n/src/locales/id/validation.json b/packages/core-i18n/src/locales/id/validation.json new file mode 100644 index 0000000..4b23762 --- /dev/null +++ b/packages/core-i18n/src/locales/id/validation.json @@ -0,0 +1,7 @@ +{ + "validation": { + "required": "{{field}} wajib diisi", + "min_length": "{{field}} minimal {{min}} karakter", + "invalid_email": "Format email tidak valid" + } +} diff --git a/packages/core-i18n/src/setup.ts b/packages/core-i18n/src/setup.ts index cb77954..c7bcc77 100644 --- a/packages/core-i18n/src/setup.ts +++ b/packages/core-i18n/src/setup.ts @@ -2,6 +2,8 @@ import i18n from 'i18next'; import { initReactI18next } from 'react-i18next'; import commonEn from './locales/en/common.json'; import commonId from './locales/id/common.json'; +import validationEn from './locales/en/validation.json'; +import validationId from './locales/id/validation.json'; const DEFAULT_LANGUAGE = 'id'; const SUPPORTED_LANGUAGES = ['en', 'id'] as const; @@ -9,8 +11,8 @@ const SUPPORTED_LANGUAGES = ['en', 'id'] as const; export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number]; export const resources = { - en: { common: commonEn.common }, - id: { common: commonId.common }, + en: { common: commonEn.common, validation: validationEn.validation }, + id: { common: commonId.common, validation: validationId.validation }, } as const; export interface I18nStorageAdapter { diff --git a/packages/ui/README.md b/packages/ui/README.md new file mode 100644 index 0000000..0ac8722 --- /dev/null +++ b/packages/ui/README.md @@ -0,0 +1,80 @@ +# @repo/ui — Shared UI Component Library + +The centralized UI component library for the monorepo. Provides consistent design primitives, system pages, and **a comprehensive Form UI Library** for building enterprise-grade forms. + +## Features + +- **Mantine v8** components re-exported with unified theming +- **ThemeProvider** with dark/light mode, brand colors, and density modes (compact/standard) +- **Design tokens** — Colors, typography, radius, spacing, shadows mapped between Mantine and Tailwind +- **System pages** — Pre-built 404, 403, Maintenance, and Coming Soon pages +- **Form UI Library** — 22 RHF-connected Mantine form components with Zod validation and i18n error translation + +## Exports + +| Entry Point | Path | Description | +|---|---|---| +| `@repo/ui/components` | `./src/components/index.ts` | All components (Mantine re-exports + system pages + Form fields) | +| `@repo/ui/form` | `./src/components/Form/index.ts` | Form field components, `withRHF` factory, RHF/Zod re-exports | +| `@repo/ui/hooks` | `./src/hooks/index.ts` | Mantine hooks re-export | +| `@repo/ui/provider` | `./src/provider/index.ts` | `ThemeProvider` with color scheme and density controls | +| `@repo/ui/theme.css` | `./src/theme.css` | Base CSS with Mantine → Tailwind token mapping | + +## 📋 Form UI Library + +> **Full Documentation**: [docs/FORM-COMPONENTS.md](docs/FORM-COMPONENTS.md) + +The Form UI Library wraps **all 22 applicable Mantine form components** with React Hook Form via a single `withRHF()` HOC factory. Key features: + +- **`useController` micro-subscriptions** — O(1) render cost per keystroke, even in 1500+ field ERP forms +- **`React.memo` wrapper** — Prevents parent-driven cascade re-renders +- **Zod + i18n error translation** — JSON error payloads are auto-parsed and translated via `@repo/core-i18n` +- **Zero hardcoded styles** — All components inherit the active `ThemeProvider` configuration +- **`Field` prefix naming** — `FieldTextInput`, `FieldSelect`, etc. to avoid collisions with native Mantine exports + +### Quick Start + +```tsx +import { z } from 'zod'; +import { useForm, zodResolver, FieldTextInput, FieldSelect } from '@repo/ui/form'; + +const schema = z.object({ + name: z.string().min(1, 'Name is required'), + role: z.string().min(1, 'Please select a role'), +}); + +function UserForm() { + const { control, handleSubmit } = useForm({ + resolver: zodResolver(schema), + defaultValues: { name: '', role: '' }, + }); + + return ( +
+ + + + + ); +} +``` + +## Scripts + +| Command | Description | +|---|---| +| `pnpm test` | Run unit tests (Vitest) | +| `pnpm test:watch` | Run tests in watch mode | +| `pnpm lint` | Run ESLint | + +## Dependencies + +- `@mantine/core` v8, `@mantine/hooks` v8 +- `react-hook-form` v7, `@hookform/resolvers` v5, `zod` v3 +- `@repo/core-i18n` (workspace) +- `tailwindcss` v4, `tailwind-variants`, `tailwind-merge` diff --git a/packages/ui/docs/FORM-COMPONENTS.md b/packages/ui/docs/FORM-COMPONENTS.md new file mode 100644 index 0000000..6174152 --- /dev/null +++ b/packages/ui/docs/FORM-COMPONENTS.md @@ -0,0 +1,460 @@ +# Form UI Library — Architecture & Usage Guide + +> **Package**: `@repo/ui` · **Module Path**: `@repo/ui/form` +> **Dependencies**: React Hook Form v7, Zod v3, Mantine v8, `@repo/core-i18n` + +--- + +## Table of Contents + +- [Overview](#overview) +- [Architecture](#architecture) + - [HOC Factory Pattern](#hoc-factory-pattern) + - [Naming Conventions](#naming-conventions) + - [File Structure](#file-structure) +- [Performance & Memoization](#performance--memoization) +- [i18n Error Translation](#i18n-error-translation) +- [Theme & Style Inheritance](#theme--style-inheritance) +- [Validation Layer](#validation-layer) +- [Usage Examples](#usage-examples) + - [Basic Form](#basic-form) + - [With Zod Validation](#with-zod-validation) + - [Custom Field Component](#custom-field-component) +- [Component Reference](#component-reference) +- [Testing](#testing) + +--- + +## Overview + +The Form UI Library provides **22 pre-built form field components** that integrate [Mantine v8](https://mantine.dev/) form components with [React Hook Form (RHF)](https://react-hook-form.com/) and [Zod](https://zod.dev/) validation. Each component is generated via a central `withRHF()` HOC factory, ensuring consistent behavior across: + +- **Value binding** — Two-way data flow between RHF and Mantine +- **Error display** — Automatic rendering of validation errors +- **i18n translation** — Zod errors can be encoded as JSON payloads for translation +- **Performance** — Micro-subscriptions via `useController` + `React.memo` +- **Theme compliance** — Zero hardcoded styles; all styling flows from the existing `ThemeProvider` + +--- + +## Architecture + +### HOC Factory Pattern + +The entire library is built on a single factory function: + +``` +withRHF(displayName, MantineComponent, options?) + └─► Returns a React.memo'd component that: + ├── Uses useController() for field-level subscriptions + ├── Maps field.value/onChange/onBlur to Mantine props + ├── Intercepts fieldState.error?.message + │ ├── Attempts JSON.parse for i18n payloads + │ └── Falls back to raw string if not translatable + ├── Passes error={translated} to Mantine component + ├── Forwards ref to the underlying DOM element + └── Preserves full Mantine TypeScript generics +``` + +**Source**: [`withRHF.tsx`](../src/components/Form/withRHF.tsx) + +The factory accepts three arguments: + +| Argument | Type | Description | +|---|---|---| +| `displayName` | `string` | React DevTools name (e.g., `"FieldTextInput"`) | +| `MantineComponent` | `ComponentType` | The raw Mantine component | +| `options` | `WithRHFOptions` | Optional config for special components | + +#### Options + +| Option | Default | Description | +|---|---|---| +| `isCheckType` | `false` | Use `checked` instead of `value` (for Checkbox, Switch) | +| `requiresWrapper` | `false` | Wrap in `Input.Wrapper` for error display (for ColorPicker, SegmentedControl, Chip.Group) | + +### Naming Conventions + +All wrapped components use the **`Field` prefix** to prevent naming collisions with native Mantine exports: + +```tsx +// ✅ Our library — RHF-connected, type-safe +import { FieldTextInput } from '@repo/ui/form'; + +// ✅ Native Mantine — still accessible via the same package +import { TextInput } from '@repo/ui/components'; +``` + +This avoids ambiguity in large codebases where both raw Mantine and form-connected versions might be needed. + +### File Structure + +Each component lives in its own file following the `[kebab-case-name].field.tsx` convention within the `fields/` directory: + +``` +packages/ui/src/components/Form/ +├── withRHF.tsx # HOC factory +├── types.ts # Shared TypeScript types +├── index.ts # Barrel exports +├── __tests__/ +│ ├── withRHF.test.tsx +│ ├── text-input.field.test.tsx +│ └── checkbox.field.test.tsx +└── fields/ + ├── text-input.field.tsx # FieldTextInput + ├── password-input.field.tsx # FieldPasswordInput + ├── textarea.field.tsx # FieldTextarea + ├── number-input.field.tsx # FieldNumberInput + ├── select.field.tsx # FieldSelect + ├── multi-select.field.tsx # FieldMultiSelect + ├── native-select.field.tsx # FieldNativeSelect + ├── checkbox.field.tsx # FieldCheckbox + ├── radio-group.field.tsx # FieldRadioGroup + ├── switch.field.tsx # FieldSwitch + ├── slider.field.tsx # FieldSlider + ├── range-slider.field.tsx # FieldRangeSlider + ├── rating.field.tsx # FieldRating + ├── color-input.field.tsx # FieldColorInput + ├── color-picker.field.tsx # FieldColorPicker + ├── pin-input.field.tsx # FieldPinInput + ├── json-input.field.tsx # FieldJsonInput + ├── autocomplete.field.tsx # FieldAutocomplete + ├── tags-input.field.tsx # FieldTagsInput + ├── chip-group.field.tsx # FieldChipGroup + ├── segmented-control.field.tsx # FieldSegmentedControl + └── file-input.field.tsx # FieldFileInput +``` + +Each field file is a thin one-liner: + +```tsx +// fields/text-input.field.tsx +import { TextInput, type TextInputProps } from '@mantine/core'; +import { withRHF } from '../withRHF'; + +export const FieldTextInput = withRHF('FieldTextInput', TextInput); +``` + + +--- + +## Performance & Memoization + +### Why `React.memo` + `useController`? + +In enterprise ERP forms with **1500+ fields**, performance is critical: + +| Technique | What it prevents | Cost | +|---|---|---| +| **`useController`** | Global form state re-renders — each field subscribes only to its own slice | ~0 (hook-level isolation) | +| **`React.memo`** | Parent-driven re-renders (e.g., grid layout changes, tab switches) | O(n) shallow prop comparison (typically n < 10) | + +Together, they achieve **O(1) render cost per keystroke** regardless of form size. + +### When `React.memo` is NOT needed + +For simple forms (< 50 fields), `React.memo` adds negligible overhead but provides no measurable benefit. However, since the HOC is used across the entire organization, the default-on strategy ensures correctness at scale without requiring per-form tuning. + +--- + +## i18n Error Translation + +The HOC supports three error message formats: + +### 1. Plain String (default Zod behavior) + +```tsx +const schema = z.object({ + name: z.string().min(1, 'Name is required'), +}); +// Error displayed: "Name is required" +``` + +### 2. JSON i18n Payload (structured translation) + +Encode Zod errors as JSON with a translation key: + +```tsx +const schema = z.object({ + name: z.string().min(3, JSON.stringify({ + key: 'validation:min_length', + values: { min: 3 }, + })), +}); +// Error displayed: t('validation:min_length', { min: 3 }) +// → "Minimum 3 characters" (from validation namespace) +``` + +### 3. Translation Key String + +If the raw error string matches a key in the `validation` namespace: + +```tsx +const schema = z.object({ + email: z.string().email('validation:invalid_email'), +}); +// Error displayed: t('validation:invalid_email') +// → "Please enter a valid email address" +``` + +### Translation Resolution Chain + +``` +error.message + ├── JSON.parse → { key, values } + │ ├── t(key, { ...values, ns: 'validation' }) → translated ✓ + │ └── t(key, { ...values, ns: 'common' }) → translated ✓ + │ └── raw error.message (fallback) → displayed as-is + ├── i18n.exists(message, { ns: 'validation' }) + │ └── t(message, { ns: 'validation' }) → translated ✓ + └── raw string → displayed as-is +``` + +### Setting up the `validation` namespace + +Add validation translations to your locale files: + +```json +// packages/core-i18n/src/locales/en/validation.json +{ + "validation": { + "required": "This field is required", + "min_length": "Minimum {{min}} characters", + "max_length": "Maximum {{max}} characters", + "invalid_email": "Please enter a valid email address" + } +} +``` + +--- + +## Theme & Style Inheritance + +The Form components **do NOT hardcode any styles**. All visual appearance flows from: + +1. **`ThemeProvider`** — Wraps `MantineProvider` with brand colors, density tokens, and color scheme +2. **Density tokens** — `compactDensity` / `standardDensity` set default `size` props on all inputs (e.g., `TextInput: { defaultProps: { size: 'sm' } }`) +3. **Color scheme** — `forceColorScheme` on `MantineProvider` handles dark/light mode +4. **CSS variables** — `theme.css` maps Mantine CSS variables to Tailwind tokens + +This means: + +```tsx +// The FieldTextInput inherits compact sizing, brand colors, and dark mode +// automatically — no additional configuration needed. + +
+ + +
+``` + +--- + +## Validation Layer + +To prevent over-engineering and package fatigue, we house the validation layer directly inside the UI package at `packages/ui/src/validators` rather than creating a separate `@repo/validation` package. This layer defines centralized Zod schemas that are pre-configured to output JSON-stringified i18n payloads. + +### Writing a Centralized Validator + +```tsx +// packages/ui/src/validators/base.validator.ts +import { z } from 'zod'; + +export const baseValidator = z.object({ + email: z.string().email({ + message: JSON.stringify({ key: 'validation:invalid_email' }), + }), + name: z.string().min(3, { + message: JSON.stringify({ + key: 'validation:min_length', + values: { field: 'Nama', min: 3 }, + }), + }), +}); + +export type BaseValidatorType = z.infer; +``` + +### Applying the Validator + +When consuming these validators, use the `zodResolver` exported from `@repo/ui/form` and the validator from `@repo/ui/validators`. The Form components will automatically intercept the JSON payload, translate it using the `validation` namespace, and display the correct language to the user. + +```tsx +import { useForm, type SubmitHandler } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { FieldTextInput } from '@repo/ui/form'; +import { baseValidator, type BaseValidatorType } from '@repo/ui/validators'; + +function ExampleForm() { + const { control, handleSubmit } = useForm({ + resolver: zodResolver(baseValidator), + defaultValues: { email: '', name: '' }, + }); + + const onSubmit: SubmitHandler = (data) => console.log(data); + + return ( +
+ + + + + ); +} +``` + +--- + +## 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} + 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/ObjectSelect.tsx b/packages/ui/src/components/Form/custom/selects/ObjectSelect.tsx new file mode 100644 index 0000000..c612f6f --- /dev/null +++ b/packages/ui/src/components/Form/custom/selects/ObjectSelect.tsx @@ -0,0 +1,181 @@ +import React, { useMemo, useState, useCallback } from 'react'; +import { Select, MultiSelect, type SelectProps, type MultiSelectProps, type ComboboxItem } from '@mantine/core'; +import type { ObjectSelectBaseProps, ObjectSelectFilterContext } from './types'; + +// --------------------------------------------------------------------------- +// ObjectSelect — Reusable Select engine for complex object data +// --------------------------------------------------------------------------- +// +// This component is the STANDALONE (non-RHF) version. It bridges Mantine's +// string-based Select/MultiSelect with object data by: +// 1. Mapping T[] → ComboboxItem[] via valueKey + labelKey/renderLabel +// 2. Building a Map for O(1) reverse lookups +// 3. Intercepting onChange to resolve strings back to full objects +// +// The RHF-connected version (FieldObjectSelect) wraps this component and +// binds it to useController, following the same pattern as withRHF → FieldXxx. +// --------------------------------------------------------------------------- + +/** Mantine props we manage ourselves — stripped from the pass-through */ +type ManagedSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'filter' | 'name' | 'onSelect'; +type ManagedMultiSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'filter' | 'name' | 'onSelect'; + +/** Props for single-select mode */ +export type ObjectSelectSingleProps> = + ObjectSelectBaseProps & 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 ObjectSelectMultiProps> = + ObjectSelectBaseProps & 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 ObjectSelectProps> = + | ObjectSelectSingleProps + | ObjectSelectMultiProps; + +// --------------------------------------------------------------------------- +// 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] ?? ''); + return String(item[Object.keys(item)[0] as keyof T] ?? ''); +} + +// --------------------------------------------------------------------------- +// Component Implementation +// --------------------------------------------------------------------------- + +function ObjectSelectInner>( + props: ObjectSelectProps, +) { + const { + data, + valueKey, + labelKey, + renderLabel, + multiple, + filterOption, + onSelect: onObjectSelect, + value, + onChange, + searchable, + ...mantineProps + } = props; + + // Track search input for filterOption + const [searchValue, setSearchValue] = useState(''); + + // Build lookup map: string → original object (O(1) reverse lookup) + const lookupMap = useMemo(() => { + const map = new Map(); + for (const item of data) { + map.set(String(item[valueKey]), item); + } + return map; + }, [data, valueKey]); + + // Build Mantine-compatible ComboboxItem[], applying filterOption if provided + const options = useMemo(() => { + let filtered = data; + + if (filterOption) { + const context: ObjectSelectFilterContext = { + search: searchValue, + selected: value ?? (multiple ? [] : null), + }; + filtered = data.filter((item) => filterOption(item, context)); + } + + return filtered.map((item) => ({ + value: String(item[valueKey]), + label: resolveLabel(item, labelKey, renderLabel), + })); + }, [data, valueKey, labelKey, renderLabel, filterOption, searchValue, value, multiple]); + + // Handle search input changes + const handleSearchChange = useCallback( + (val: string) => { + setSearchValue(val); + // Forward to consumer's onSearchChange if provided + if ('onSearchChange' in mantineProps && typeof mantineProps.onSearchChange === 'function') { + mantineProps.onSearchChange(val); + } + }, + [mantineProps], + ); + + // Passthrough filter — we handle filtering ourselves via filterOption in useMemo. + // This prevents Mantine from double-filtering. + const mantineFilter = filterOption + ? ({ options: opts }: { options: ComboboxItem[] }) => opts + : undefined; + + + // ----- Multi-select mode ----- + if (multiple) { + const currentValues = Array.isArray(value) ? value.map((v) => String(v[valueKey])) : []; + + const handleMultiChange = (vals: string[]) => { + const objects = vals.map((v) => lookupMap.get(v)!).filter(Boolean); + (onChange as ((v: T[]) => void) | undefined)?.(objects); + onObjectSelect?.(objects); + }; + + return ( + )} + data={options} + value={currentValues} + onChange={handleMultiChange} + searchable={searchable ?? false} + onSearchChange={handleSearchChange} + filter={mantineFilter as any} + + /> + ); + } + + // ----- Single-select mode ----- + const currentValue = value ? String((value as T)[valueKey]) : null; + + const handleSingleChange = (val: string | null) => { + const obj = val ? lookupMap.get(val) ?? null : null; + (onChange as ((v: T | null) => void) | undefined)?.(obj); + onObjectSelect?.(obj); + }; + + return ( + ` | `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. @@ -683,8 +887,8 @@ export const FieldDatePicker = withRHF( | `FieldRating` | `Rating` | Range | Star rating | | `FieldColorInput` | `ColorInput` | Color | Color picker with text input | | `FieldColorPicker` | `ColorPicker` | Color | Color picker only (uses `Input.Wrapper`) | -| `FieldObjectSelect` | `Select / MultiSelect` | Selection | Stores full `T` or `T[]` object in RHF instead of string ID | -| `FieldAsyncSelect` | `Select / MultiSelect` | Selection | Paginated infinite scroll select mapping API responses to RHF objects | +| `FieldLocalSelect` | `Select / MultiSelect` | Selection | Stores full `T` or `T[]` object in RHF instead of string ID. Accepts static `options` array with `valueKey`/`labelKey` mapping. | +| `FieldAsyncSelect` | `Select / MultiSelect` | Selection | Async paginated object select with IoC `loadOptions` callback. Supports search-keyed caching, `defaultOptions` for edit forms, and automatic pagination detection. | | `FieldFileInput` | `FileInput` | File | File upload input | --- diff --git a/packages/ui/src/components/Form/__tests__/async-select.field.test.tsx b/packages/ui/src/components/Form/__tests__/async-select.field.test.tsx index 675ef38..56c2353 100644 --- a/packages/ui/src/components/Form/__tests__/async-select.field.test.tsx +++ b/packages/ui/src/components/Form/__tests__/async-select.field.test.tsx @@ -5,6 +5,7 @@ import React from 'react'; import { useForm, FormProvider } from 'react-hook-form'; import { MantineProvider } from '@mantine/core'; import { FieldAsyncSelect } from '../fields/async-select.field'; +import type { LoadOptionsFn } from '../custom/selects/types'; // --------------------------------------------------------------------------- // Mock i18n & Setup @@ -18,20 +19,20 @@ vi.mock('@repo/core-i18n', () => ({ type Vendor = { id: number; code: string; name: string }; -const MOCK_API_RESPONSE = { - items: [ - { id: 1, code: 'V1', name: 'Vendor 1' }, - { id: 2, code: 'V2', name: 'Vendor 2' }, - { id: 3, code: 'V3', name: 'Vendor 3' }, - ], - total: 3, +const MOCK_VENDORS: Vendor[] = [ + { id: 1, code: 'V1', name: 'Vendor 1' }, + { id: 2, code: 'V2', name: 'Vendor 2' }, + { id: 3, code: 'V3', name: 'Vendor 3' }, +]; + +// Reusable loadOptions mock — returns all vendors with hasMore=false +const createMockLoadOptions = (vendors: Vendor[] = MOCK_VENDORS) => { + return vi.fn>().mockResolvedValue({ + options: vendors, + hasMore: false, + }); }; -// Mock fetchFn -const mockFetchFn = vi.fn().mockResolvedValue(MOCK_API_RESPONSE); - -// Test wrapper removed to avoid useForm conflicts - // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -43,6 +44,7 @@ describe('FieldAsyncSelect', () => { it('fetches initial page on mount and renders items', async () => { const user = userEvent.setup(); + const mockLoadOptions = createMockLoadOptions(); function TestForm() { const { control } = useForm({ defaultValues: { vendor: null } }); @@ -52,9 +54,7 @@ describe('FieldAsyncSelect', () => { res.items} + loadOptions={mockLoadOptions} valueKey="id" labelKey="name" placeholder="Select async vendor" @@ -66,9 +66,9 @@ describe('FieldAsyncSelect', () => { render(); - // Wait for initial fetch + // Wait for initial fetch (page 1, search='') await waitFor(() => { - expect(mockFetchFn).toHaveBeenCalledWith('/api/vendors?page=1&pageSize=20&search='); + expect(mockLoadOptions).toHaveBeenCalledWith('', 1, []); }); await user.click(screen.getByPlaceholderText('Select async vendor')); @@ -81,6 +81,7 @@ describe('FieldAsyncSelect', () => { it('stores full object in RHF from async data', async () => { const user = userEvent.setup(); let capturedData: any = null; + const mockLoadOptions = createMockLoadOptions(); function TestForm() { const { control, handleSubmit } = useForm({ defaultValues: { vendor: null } }); @@ -90,9 +91,7 @@ describe('FieldAsyncSelect', () => { res.items} + loadOptions={mockLoadOptions} valueKey="id" labelKey="name" placeholder="Select vendor" @@ -106,7 +105,7 @@ describe('FieldAsyncSelect', () => { render(); await waitFor(() => { - expect(mockFetchFn).toHaveBeenCalled(); + expect(mockLoadOptions).toHaveBeenCalled(); }); await user.click(screen.getByPlaceholderText('Select vendor')); @@ -117,13 +116,11 @@ describe('FieldAsyncSelect', () => { }); it('injects pre-selected value not in fetched data', async () => { - // We mock fetch to return only V1 and V2 - const partialFetchFn = vi.fn().mockResolvedValue({ - items: [ - { id: 1, code: 'V1', name: 'Vendor 1' }, - { id: 2, code: 'V2', name: 'Vendor 2' }, - ] - }); + // loadOptions returns only V1 and V2 + const partialLoadOptions = createMockLoadOptions([ + { id: 1, code: 'V1', name: 'Vendor 1' }, + { id: 2, code: 'V2', name: 'Vendor 2' }, + ]); // The form starts with V99 pre-selected (e.g. from server hydration) const PRESELECTED_VENDOR = { id: 99, code: 'V99', name: 'Vendor 99' }; @@ -136,9 +133,7 @@ describe('FieldAsyncSelect', () => { res.items} + loadOptions={partialLoadOptions} valueKey="id" labelKey="name" placeholder="Select vendor" @@ -151,7 +146,7 @@ describe('FieldAsyncSelect', () => { render(); await waitFor(() => { - expect(partialFetchFn).toHaveBeenCalled(); + expect(partialLoadOptions).toHaveBeenCalled(); }); // The display value of the Select input should show the injected label @@ -161,6 +156,7 @@ describe('FieldAsyncSelect', () => { it('debounces search input and refetches', async () => { const user = userEvent.setup(); + const mockLoadOptions = createMockLoadOptions(); function TestForm() { const { control } = useForm({ defaultValues: { vendor: null } }); @@ -170,9 +166,7 @@ describe('FieldAsyncSelect', () => { res.items} + loadOptions={mockLoadOptions} valueKey="id" labelKey="name" placeholder="Search vendor" @@ -187,28 +181,26 @@ describe('FieldAsyncSelect', () => { // Wait for initial fetch await waitFor(() => { - expect(mockFetchFn).toHaveBeenCalledTimes(1); + expect(mockLoadOptions).toHaveBeenCalledTimes(1); }); const input = screen.getByPlaceholderText('Search vendor'); await user.type(input, 'test'); - // Wait for debounced fetch + // Wait for debounced fetch — should call with search='test' await waitFor(() => { - expect(mockFetchFn).toHaveBeenCalledTimes(2); - expect(mockFetchFn).toHaveBeenLastCalledWith('/api/vendors?page=1&pageSize=20&search=test'); + expect(mockLoadOptions).toHaveBeenCalledTimes(2); + expect(mockLoadOptions).toHaveBeenLastCalledWith('test', 1, []); }); }); it('gracefully deduplicates overlapping data across API responses', async () => { - // API returns Vendor 1 twice - const badFetchFn = vi.fn().mockResolvedValue({ - items: [ - { id: 1, code: 'V1', name: 'Vendor 1' }, - { id: 1, code: 'V1', name: 'Vendor 1 (Duplicate)' }, - { id: 2, code: 'V2', name: 'Vendor 2' }, - ] - }); + // loadOptions returns Vendor 1 twice (duplicate id=1) + const badLoadOptions = createMockLoadOptions([ + { id: 1, code: 'V1', name: 'Vendor 1' }, + { id: 1, code: 'V1', name: 'Vendor 1 (Duplicate)' }, + { id: 2, code: 'V2', name: 'Vendor 2' }, + ]); function TestForm() { const { control } = useForm({ defaultValues: { vendor: null } }); @@ -218,9 +210,7 @@ describe('FieldAsyncSelect', () => { res.items} + loadOptions={badLoadOptions} valueKey="id" labelKey="name" placeholder="Select bad vendor" @@ -233,7 +223,7 @@ describe('FieldAsyncSelect', () => { render(); await waitFor(() => { - expect(badFetchFn).toHaveBeenCalledTimes(1); + expect(badLoadOptions).toHaveBeenCalledTimes(1); }); const user = userEvent.setup(); diff --git a/packages/ui/src/components/Form/__tests__/object-select.field.test.tsx b/packages/ui/src/components/Form/__tests__/local-select.field.test.tsx similarity index 93% rename from packages/ui/src/components/Form/__tests__/object-select.field.test.tsx rename to packages/ui/src/components/Form/__tests__/local-select.field.test.tsx index 3d00073..550c922 100644 --- a/packages/ui/src/components/Form/__tests__/object-select.field.test.tsx +++ b/packages/ui/src/components/Form/__tests__/local-select.field.test.tsx @@ -4,7 +4,7 @@ import userEvent from '@testing-library/user-event'; import React from 'react'; import { useForm, FormProvider } from 'react-hook-form'; import { MantineProvider } from '@mantine/core'; -import { FieldObjectSelect } from '../fields/object-select.field'; +import { FieldLocalSelect } from '../fields/local-select.field'; // --------------------------------------------------------------------------- // Mock i18n @@ -34,7 +34,7 @@ const VENDORS: Vendor[] = [ // Tests // --------------------------------------------------------------------------- -describe('FieldObjectSelect', () => { +describe('FieldLocalSelect', () => { beforeEach(() => { vi.clearAllMocks(); }); @@ -44,10 +44,10 @@ describe('FieldObjectSelect', () => { const { control } = useForm({ defaultValues: { vendor: null } }); return ( - { return (
{ capturedData = data; })}> - { const { control } = useForm({ defaultValues: { vendor: null } }); return ( - `${item.code} - ${item.name}`} placeholder="Select vendor" @@ -135,10 +135,10 @@ describe('FieldObjectSelect', () => { const { control } = useForm({ defaultValues: { vendor: null } }); return ( - { return ( { capturedData = data; })}> - { return ( { capturedData = data; })}> - { const { control } = useForm({ defaultValues: { vendor: null } }); return ( - { - apiEndpoint: string; - transformResponse?: (res: any) => { data: T[]; hasMore?: boolean } | T[]; - pageSize?: number; + /** + * Async callback to load options. The component calls this when: + * - The dropdown opens (page 1, search='') + * - The user types a search query (page 1, search=query) + * - The user scrolls to the bottom (page N+1, search=currentQuery) + * + * The component is completely ignorant of the transport layer. + */ + loadOptions: LoadOptionsFn; + + /** + * Pre-loaded objects that are always present in the dropdown. + * Use for edit forms where the default value's object may not appear + * in page 1 of the API results. + */ + defaultOptions?: T[]; + + /** Search debounce delay in ms (default: 300) */ debounceMs?: number; - fetchFn?: (url: string) => Promise; } /** Props for single-select async mode */ -export type AsyncSelectSingleProps> = Omit, 'data'> & +export type AsyncSelectSingleProps> = AsyncSelectBaseProps & AsyncExtraProps & Omit & { multiple?: false; @@ -30,7 +57,7 @@ export type AsyncSelectSingleProps> = Omit> = Omit, 'data'> & +export type AsyncSelectMultiProps> = AsyncSelectBaseProps & AsyncExtraProps & Omit & { multiple: true; @@ -51,7 +78,8 @@ function resolveLabel>( ): string { if (renderLabel) return renderLabel(item); if (labelKey) return String(item[labelKey] ?? ''); - return String(item[Object.keys(item)[0] as keyof T] ?? ''); + const firstKey = Object.keys(item)[0]; + return firstKey ? String(item[firstKey as keyof T] ?? '') : ''; } // --------------------------------------------------------------------------- @@ -65,42 +93,42 @@ function AsyncSelectInner>(props: AsyncSelectProps renderLabel, multiple, filterOption, - onSelect: onObjectSelect, + onSelect: onSelectCallback, value, onChange, - apiEndpoint, - transformResponse, - pageSize, + loadOptions, + defaultOptions, debounceMs, - fetchFn, searchable, + onSearchChange: consumerOnSearchChange, ...mantineProps } = props; - // Use the infinite scroll hook for data fetching + // Use the new paginate hook for data fetching const { data: fetchedData, isLoading, fetchNextPage, search, - debouncedSearch, setSearch, - } = useAsyncSelectInfiniteScroll({ - apiEndpoint, + } = useAsyncPaginate({ + loadOptions, valueKey, - pageSize, - transformResponse, debounceMs, - fetchFn, + defaultOptions, }); - // Inject pre-selected values that aren't in the fetched data yet, - // and safeguard against bad APIs that return duplicate items across pages. + // ----------------------------------------------------------------------- + // Merge fetched data with currently selected values. + // This ensures the lookupMap always contains all possible values, + // preventing undefined entries during deselection. + // ----------------------------------------------------------------------- + const dataWithInjected = useMemo(() => { const uniqueItems: T[] = []; const seen = new Set(); - // 1. Deduplicate fetched data from the API (hook already deduplicates internally, but this is an extra UI safeguard) + // 1. Start with fetched data (already includes defaultOptions from the hook) for (const item of fetchedData) { const key = String(item[valueKey]); if (!seen.has(key)) { @@ -109,13 +137,15 @@ function AsyncSelectInner>(props: AsyncSelectProps } } - // 2. Inject active RHF values if they aren't in the fetched list + // 2. Inject active selected values if they aren't in the fetched list. + // This is CRITICAL for the data mapping contract: the lookupMap + // must always be able to resolve deselected items back to objects. if (multiple && Array.isArray(value)) { for (const v of value) { const key = String(v[valueKey]); if (!seen.has(key)) { seen.add(key); - uniqueItems.unshift(v); // Put selected items at the top + uniqueItems.unshift(v); // Selected items at the top } } } else if (!multiple && value) { @@ -129,7 +159,7 @@ function AsyncSelectInner>(props: AsyncSelectProps return uniqueItems; }, [fetchedData, value, valueKey, multiple]); - // Build lookup map + // Build lookup map — includes ALL sources for safe reverse resolution const lookupMap = useMemo(() => { const map = new Map(); for (const item of dataWithInjected) { @@ -143,8 +173,8 @@ function AsyncSelectInner>(props: AsyncSelectProps let filtered = dataWithInjected; if (filterOption) { - const context: ObjectSelectFilterContext = { - search, // Pass the active search string to the custom filter + const context: SelectFilterContext = { + search, selected: value ?? (multiple ? [] : null), }; filtered = dataWithInjected.filter((item) => filterOption(item, context)); @@ -156,16 +186,15 @@ function AsyncSelectInner>(props: AsyncSelectProps })); }, [dataWithInjected, valueKey, labelKey, renderLabel, filterOption, value, multiple, search]); - const isTyping = search !== debouncedSearch; - const isFetching = isLoading || isTyping; - const rightSection = isFetching ? : mantineProps.rightSection; + const rightSection = isLoading ? : mantineProps.rightSection; // Handle search → delegate to the hook's setSearch (debounced) const handleSearchChange = useCallback( (val: string) => { setSearch(val); + consumerOnSearchChange?.(val); }, - [setSearch], + [setSearch, consumerOnSearchChange], ); // ScrollArea props for infinite scroll — use onBottomReached @@ -180,17 +209,22 @@ function AsyncSelectInner>(props: AsyncSelectProps ); // Disable Mantine's internal frontend filtering. - // The backend handles the search query, so we should always display what the backend returns. - const mantineFilter = useCallback(({ options: opts }: { options: ComboboxItem[] }) => opts, []); + // The backend handles the search query, so we always display what the backend returns. + const mantineFilter = filterOption + ? ({ options: opts }: any) => opts + : undefined; // ----- Multi-select mode ----- if (multiple) { const currentValues = Array.isArray(value) ? value.map((v) => String(v[valueKey])) : []; const handleMultiChange = (vals: string[]) => { + // Resolve string[] → T[] via the lookup map. + // .filter(Boolean) is a safety net — if the map is complete (which it + // should be given the dataWithInjected merge), this is a no-op. const objects = vals.map((v) => lookupMap.get(v)!).filter(Boolean); (onChange as ((v: T[]) => void) | undefined)?.(objects); - onObjectSelect?.(objects); + onSelectCallback?.(objects); }; return ( @@ -214,7 +248,7 @@ function AsyncSelectInner>(props: AsyncSelectProps const handleSingleChange = (val: string | null) => { const obj = val ? (lookupMap.get(val) ?? null) : null; (onChange as ((v: T | null) => void) | undefined)?.(obj); - onObjectSelect?.(obj); + onSelectCallback?.(obj); }; return ( @@ -226,7 +260,7 @@ function AsyncSelectInner>(props: AsyncSelectProps searchable={searchable ?? true} onSearchChange={handleSearchChange} scrollAreaProps={scrollAreaProps} - filter={mantineFilter} + filter={mantineFilter as any} rightSection={rightSection} /> ); diff --git a/packages/ui/src/components/Form/custom/selects/ObjectSelect.tsx b/packages/ui/src/components/Form/custom/selects/LocalSelect.tsx similarity index 67% rename from packages/ui/src/components/Form/custom/selects/ObjectSelect.tsx rename to packages/ui/src/components/Form/custom/selects/LocalSelect.tsx index c612f6f..17ebdd8 100644 --- a/packages/ui/src/components/Form/custom/selects/ObjectSelect.tsx +++ b/packages/ui/src/components/Form/custom/selects/LocalSelect.tsx @@ -1,9 +1,9 @@ import React, { useMemo, useState, useCallback } from 'react'; import { Select, MultiSelect, type SelectProps, type MultiSelectProps, type ComboboxItem } from '@mantine/core'; -import type { ObjectSelectBaseProps, ObjectSelectFilterContext } from './types'; +import type { LocalSelectBaseProps, SelectFilterContext } from './types'; // --------------------------------------------------------------------------- -// ObjectSelect — Reusable Select engine for complex object data +// LocalSelect — Reusable Select engine for complex object data // --------------------------------------------------------------------------- // // This component is the STANDALONE (non-RHF) version. It bridges Mantine's @@ -12,7 +12,11 @@ import type { ObjectSelectBaseProps, ObjectSelectFilterContext } from './types'; // 2. Building a Map for O(1) reverse lookups // 3. Intercepting onChange to resolve strings back to full objects // -// The RHF-connected version (FieldObjectSelect) wraps this component and +// 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. // --------------------------------------------------------------------------- @@ -21,8 +25,8 @@ type ManagedSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'filt type ManagedMultiSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'filter' | 'name' | 'onSelect'; /** Props for single-select mode */ -export type ObjectSelectSingleProps> = - ObjectSelectBaseProps & Omit & { +export type LocalSelectSingleProps> = + LocalSelectBaseProps & Omit & { multiple?: false; /** Controlled value — the full object or null */ value?: T | null; @@ -31,8 +35,8 @@ export type ObjectSelectSingleProps> = }; /** Props for multi-select mode */ -export type ObjectSelectMultiProps> = - ObjectSelectBaseProps & Omit & { +export type LocalSelectMultiProps> = + LocalSelectBaseProps & Omit & { multiple: true; /** Controlled value — array of full objects */ value?: T[]; @@ -41,9 +45,9 @@ export type ObjectSelectMultiProps> = }; /** Discriminated union — the component narrows based on `multiple` */ -export type ObjectSelectProps> = - | ObjectSelectSingleProps - | ObjectSelectMultiProps; +export type LocalSelectProps> = + | LocalSelectSingleProps + | LocalSelectMultiProps; // --------------------------------------------------------------------------- // Helper: Resolve label for a data item @@ -56,27 +60,33 @@ function resolveLabel>( ): string { if (renderLabel) return renderLabel(item); if (labelKey) return String(item[labelKey] ?? ''); - return String(item[Object.keys(item)[0] as keyof T] ?? ''); + // 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 ObjectSelectInner>( - props: ObjectSelectProps, +function LocalSelectInner>( + props: LocalSelectProps, ) { const { - data, + options, valueKey, labelKey, renderLabel, multiple, filterOption, - onSelect: onObjectSelect, + 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; @@ -86,40 +96,38 @@ function ObjectSelectInner>( // Build lookup map: string → original object (O(1) reverse lookup) const lookupMap = useMemo(() => { const map = new Map(); - for (const item of data) { + for (const item of options) { map.set(String(item[valueKey]), item); } return map; - }, [data, valueKey]); + }, [options, valueKey]); // Build Mantine-compatible ComboboxItem[], applying filterOption if provided - const options = useMemo(() => { - let filtered = data; + const comboboxItems = useMemo(() => { + let filtered = options; if (filterOption) { - const context: ObjectSelectFilterContext = { + const context: SelectFilterContext = { search: searchValue, selected: value ?? (multiple ? [] : null), }; - filtered = data.filter((item) => filterOption(item, context)); + filtered = options.filter((item) => filterOption(item, context)); } return filtered.map((item) => ({ value: String(item[valueKey]), label: resolveLabel(item, labelKey, renderLabel), })); - }, [data, valueKey, labelKey, renderLabel, filterOption, searchValue, value, multiple]); + }, [options, valueKey, labelKey, renderLabel, filterOption, searchValue, value, multiple]); - // Handle search input changes + // 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); - // Forward to consumer's onSearchChange if provided - if ('onSearchChange' in mantineProps && typeof mantineProps.onSearchChange === 'function') { - mantineProps.onSearchChange(val); - } + consumerOnSearchChange?.(val); }, - [mantineProps], + [consumerOnSearchChange], ); // Passthrough filter — we handle filtering ourselves via filterOption in useMemo. @@ -134,21 +142,22 @@ function ObjectSelectInner>( 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); - onObjectSelect?.(objects); + onSelectCallback?.(objects); }; return ( )} - data={options} + data={comboboxItems} value={currentValues} onChange={handleMultiChange} searchable={searchable ?? false} onSearchChange={handleSearchChange} filter={mantineFilter as any} - /> ); } @@ -159,23 +168,22 @@ function ObjectSelectInner>( const handleSingleChange = (val: string | null) => { const obj = val ? lookupMap.get(val) ?? null : null; (onChange as ((v: T | null) => void) | undefined)?.(obj); - onObjectSelect?.(obj); + onSelectCallback?.(obj); }; return ( } + data={[ + { value: 'en', label: 'English' }, + { value: 'id', label: 'Bahasa Indonesia' }, + ]} + value={i18n.resolvedLanguage || i18n.language} + onChange={(val) => val && i18n.changeLanguage(val)} + /> From 4d2558cf715028aeaa76d8c0db1e4ea012cb1613 Mon Sep 17 00:00:00 2001 From: Firman Ramdhani <33869609+firmanramdhani@users.noreply.github.com> Date: Mon, 22 Jun 2026 11:34:25 +0700 Subject: [PATCH 12/12] refactor: memoize Zod schemas and internationalize form labels and messages in demo components --- .../form-demo/components/all-fields-demo.tsx | 64 ++++++++--------- .../components/reactive-watch-demo.tsx | 38 +++++----- .../components/validation-bank-demo.tsx | 47 ++++++------ .../example/features/form-demo/i18n/en.json | 72 ++++++++++++++++++- .../example/features/form-demo/i18n/id.json | 72 ++++++++++++++++++- 5 files changed, 217 insertions(+), 76 deletions(-) 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 index 738c802..73f84c4 100644 --- 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 @@ -109,7 +109,7 @@ export default function AllFieldsDemo() { {/* --- Text & Numbers --- */}
- Text & Numbers + {t.sections.textAndNumbers} @@ -131,7 +131,7 @@ export default function AllFieldsDemo() { {/* --- Selections --- */}
- Selections + {t.sections.selections} @@ -165,8 +165,8 @@ export default function AllFieldsDemo() { - Advanced Object Selects (Custom Labels & Default Values) + {t.sections.advancedObjectSelects} `[${item.code}] ${item.name}`} @@ -255,7 +255,7 @@ export default function AllFieldsDemo() { `[${item.code}] ${item.name}`} @@ -266,7 +266,7 @@ export default function AllFieldsDemo() { `[${item.code}] ${item.name}`} @@ -275,7 +275,7 @@ export default function AllFieldsDemo() { `[${item.code}] ${item.name}`} @@ -284,14 +284,14 @@ export default function AllFieldsDemo() { /> - Multi-Select Edit Mode (No defaultOptions fallback) + {t.sections.multiSelectEditMode} `[${item.code}] ${item.name}`} @@ -301,7 +301,7 @@ export default function AllFieldsDemo() { multiple name="asyncMultiPrefilled" control={control} - label="Async Multi Prefilled (Ghost Items)" + label={t.fields.asyncMultiPrefilled} loadOptions={loadMockVendorsOptions} valueKey="id" renderLabel={(item) => `[${item.code}] ${item.name}`} @@ -309,27 +309,27 @@ export default function AllFieldsDemo() { /> - Rich Text Editor (TipTap) + {t.sections.richTextEditor}
{/* --- Toggles & Choices --- */}
- Toggles & Choices + {t.sections.togglesAndChoices} @@ -372,11 +372,11 @@ export default function AllFieldsDemo() { {/* --- Ranges & Specialized --- */}
- Ranges & Specialized + {t.sections.rangesAndSpecialized} - + 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 index ad19d40..e1c3119 100644 --- 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 @@ -7,7 +7,7 @@ import { useConditionalField } from '@repo/ui/hooks'; import { compose, required, emailValidator } from '@repo/ui/validators'; import { useFormDemoTranslation } from '../i18n/useFormDemoTranslation'; import { Info } from 'lucide-react'; -import { useEffect, useCallback, useRef } from 'react'; +import { useEffect, useCallback, useRef, useMemo } from 'react'; interface Region { id: string; @@ -52,10 +52,10 @@ export default function ReactiveWatchDemo() { // 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 newsletterEmailValidator = compose(z.string(), required(t.fields.email), emailValidator()); + const roleValidator = compose(z.string(), required(t.fields.role)); - const reactiveSchema = z + const reactiveSchema = useMemo(() => z .object({ userType: z.enum(['PERSONAL', 'CORPORATE']), corporateTaxId: z.string().optional(), @@ -92,7 +92,7 @@ export default function ReactiveWatchDemo() { 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), @@ -214,7 +214,7 @@ export default function ReactiveWatchDemo() { - Dynamic Fields & Validation + {t.sections.reactiveWatchCascading} @@ -264,8 +264,8 @@ export default function ReactiveWatchDemo() { - Cascading Object Selects + {t.sections.reactiveWatchCascading} @@ -302,7 +302,7 @@ export default function ReactiveWatchDemo() { multiple name="regions" control={control as any} - label="Regions" + label={t.fields.regions} options={REGIONS} valueKey="id" labelKey="code" @@ -314,7 +314,7 @@ export default function ReactiveWatchDemo() { key={`warehouse-select-${regions?.map((r: any) => r.id).join(',')}`} name="warehouses" control={control as any} - label="Warehouses" + label={t.fields.warehouses} disabled={!regions || regions.length === 0} loadOptions={useCallback(async (search, page) => { if (!regions || regions.length === 0) return { options: [], hasMore: false }; @@ -327,31 +327,31 @@ export default function ReactiveWatchDemo() { {regions && regions.length > 0 && ( - Selected regions tax rates: {regions.map((r: any) => `${r.code} (${(r.taxRate * 100).toFixed(0)}%)`).join(', ')} + {t.descriptions.selectedRegionsTax} {regions.map((r: any) => `${r.code} (${(r.taxRate * 100).toFixed(0)}%)`).join(', ')} )} - Reactive Rich Text Preview + {t.sections.reactiveRichTextPreview} - Live HTML Preview Render + {t.sections.liveHtmlPreview}
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 index 6c14137..029c089 100644 --- 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 @@ -1,3 +1,4 @@ +import { useMemo } from 'react'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; @@ -63,20 +64,20 @@ export default function ValidationBankDemo() { const t = useFormDemoTranslation(); // Compose the Zod schema using the atomic validators - const validationSchema = z.object({ + 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: 'Department is required' }), - assignees: z.array(z.object({ id: z.number(), email: z.string() })).min(2, "Select at least 2 assignees"), - prefilledVendor: z.object({ id: z.union([z.string(), z.number()]), code: z.string(), name: z.string() }, { required_error: 'Vendor is required' }), - emptyVendor: z.object({ id: z.union([z.string(), z.number()]), code: z.string(), name: z.string() }, { required_error: 'Vendor is required' }), - prefilledAsyncMulti: z.array(z.object({ id: z.union([z.string(), z.number()]), code: z.string(), name: z.string() })).min(1, "Select at least 1 vendor"), - richTextNotes: z.string().min(15, "Notes must be at least 15 characters long (including HTML tags)"), - }); + 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; @@ -109,7 +110,7 @@ export default function ValidationBankDemo() {
- Validation Bank (Atomic Registry) + {t.sections.validationBankTitle} @@ -149,7 +150,7 @@ export default function ValidationBankDemo() { name="score" control={control} label={t.validation.score} - description="Must be > 0" + description={t.descriptions.mustBePositive} withAsterisk /> @@ -158,17 +159,17 @@ export default function ValidationBankDemo() { name="phone" control={control} label={t.validation.phone} - description="Format: +62..." + description={t.descriptions.formatPhone} withAsterisk /> - Object Level Validations (Local & Async) + {t.sections.objectLevelValidations} name="department" control={control as any} - label="Department" + label={t.fields.department} options={MOCK_DEPARTMENTS} valueKey="code" renderLabel={(item) => `[${item.code}] ${item.name}`} @@ -180,7 +181,7 @@ export default function ValidationBankDemo() { multiple name="assignees" control={control as any} - label="Assignees" + label={t.fields.assignees} loadOptions={mockFetchUsers} valueKey="id" labelKey="email" @@ -189,14 +190,14 @@ export default function ValidationBankDemo() { withAsterisk /> - Validated Prefilled Objects + {t.sections.validatedPrefilledObjects} `[${item.code}] ${item.name}`} @@ -206,7 +207,7 @@ export default function ValidationBankDemo() { `[${item.code}] ${item.name}`} @@ -220,7 +221,7 @@ export default function ValidationBankDemo() { multiple name="prefilledAsyncMulti" control={control as any} - label="Prefilled Async Multi (No Fallback)" + label={t.fields.prefilledAsyncMulti} loadOptions={mockFetchVendors} valueKey="id" renderLabel={(item) => `[${item.code}] ${item.name}`} @@ -228,14 +229,14 @@ export default function ValidationBankDemo() { withAsterisk /> - Rich Text Editor Validations + {t.sections.richTextValidations} 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 index 80bda1b..a32a1b4 100644 --- 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 @@ -6,9 +6,26 @@ }, "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", @@ -26,7 +43,60 @@ "orderType": "Order Type", "quantity": "Quantity", "fabricColor": "Fabric Color", - "pin": "Security PIN" + "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", 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 index ae86b2d..f0261f0 100644 --- 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 @@ -6,9 +6,26 @@ }, "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", @@ -26,7 +43,60 @@ "orderType": "Tipe Pesanan", "quantity": "Jumlah", "fabricColor": "Warna Kain", - "pin": "PIN Keamanan" + "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",