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] 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",