Merge pull request 'core/form' (#17) from core/form into main
Reviewed-on: eigen/fe-monorepo-template#17
This commit is contained in:
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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:*",
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Button, Paper, Title, Group, Stack, Code, Divider, Text, Chip, Radio } from '@repo/ui/components';
|
||||
import {
|
||||
FieldTextInput, FieldPasswordInput, FieldTextarea, FieldNumberInput,
|
||||
FieldJsonInput, FieldPinInput, FieldAutocomplete, FieldSelect,
|
||||
FieldMultiSelect, FieldNativeSelect, FieldTagsInput, FieldCheckbox,
|
||||
FieldRadioGroup, FieldSwitch, FieldChipGroup, FieldSegmentedControl,
|
||||
FieldSlider, FieldRangeSlider, FieldRating, FieldColorInput,
|
||||
FieldColorPicker, FieldFileInput, FieldLocalSelect, FieldAsyncSelect,
|
||||
FieldRichTextEditor
|
||||
} from '@repo/ui/form';
|
||||
import type { LoadOptionsFn } from '@repo/ui/form';
|
||||
import { useFormDemoTranslation } from '../i18n/useFormDemoTranslation';
|
||||
|
||||
const MOCK_POKEMON = Array.from({ length: 100 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
name: `Pokemon ${i + 1}`,
|
||||
}));
|
||||
|
||||
const loadMockPokemonOptions: LoadOptionsFn<any> = async (search, page) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
const filtered = MOCK_POKEMON.filter((p) => p.name.toLowerCase().includes(search.toLowerCase()));
|
||||
const pageSize = 20;
|
||||
const start = (page - 1) * pageSize;
|
||||
const paginated = filtered.slice(start, start + pageSize);
|
||||
return {
|
||||
options: paginated,
|
||||
hasMore: start + pageSize < filtered.length,
|
||||
};
|
||||
};
|
||||
|
||||
const loadRealPokemonOptions: LoadOptionsFn<any> = async (_search, page) => {
|
||||
const limit = 20;
|
||||
const offset = (page - 1) * limit;
|
||||
const res = await fetch(`https://pokeapi.co/api/v2/pokemon?limit=${limit}&offset=${offset}`);
|
||||
const data = await res.json();
|
||||
return {
|
||||
options: data.results.map((p: any, i: number) => ({ id: offset + i + 1, ...p })),
|
||||
hasMore: !!data.next,
|
||||
};
|
||||
};
|
||||
|
||||
const MOCK_VENDORS = [
|
||||
{ id: 'V1', code: 'VN-01', name: 'Vendor One' },
|
||||
{ id: 'V2', code: 'VN-02', name: 'Vendor Two' },
|
||||
{ id: 'V3', code: 'VN-03', name: 'Vendor Three' },
|
||||
];
|
||||
|
||||
const loadMockVendorsOptions: LoadOptionsFn<any> = async (search, _page) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
const filtered = MOCK_VENDORS.filter(v => v.name.toLowerCase().includes(search.toLowerCase()) || v.code.toLowerCase().includes(search.toLowerCase()));
|
||||
return { options: filtered, hasMore: false };
|
||||
};
|
||||
|
||||
export default function AllFieldsDemo() {
|
||||
const t = useFormDemoTranslation();
|
||||
|
||||
const { control, handleSubmit, watch } = useForm<any>({
|
||||
defaultValues: {
|
||||
customerName: '',
|
||||
email: '',
|
||||
password: '',
|
||||
description: '',
|
||||
age: undefined,
|
||||
jsonConfig: '',
|
||||
pin: '',
|
||||
country: '',
|
||||
orderType: '',
|
||||
categories: [],
|
||||
nativeOrderType: '',
|
||||
tags: [],
|
||||
terms: false,
|
||||
priority: '',
|
||||
receiveEmails: false,
|
||||
chipSelection: '',
|
||||
segmentedPriority: 'normal',
|
||||
satisfaction: 5,
|
||||
priceRange: [0, 100],
|
||||
rating: 0,
|
||||
themeColor: '',
|
||||
colorPicker: '#1c7ed6',
|
||||
avatar: null,
|
||||
localSelectEmpty: null,
|
||||
localSelectPrefilled: { id: 'V2', code: 'VN-02', name: 'Vendor Two' },
|
||||
asyncSelectEmpty: null,
|
||||
asyncSelectPrefilled: { id: 999, code: 'ASYNC-99', name: 'Pre-loaded Async Vendor' },
|
||||
localMultiPrefilled: [
|
||||
{ id: 'V1', code: 'VN-01', name: 'Vendor One' },
|
||||
{ id: 'V2', code: 'VN-02', name: 'Vendor Two' }
|
||||
],
|
||||
asyncMultiPrefilled: [
|
||||
{ id: 888, code: 'ASYNC-88', name: 'Ghost Async Vendor 1' },
|
||||
{ id: 999, code: 'ASYNC-99', name: 'Ghost Async Vendor 2' }
|
||||
],
|
||||
richTextEmpty: "",
|
||||
richTextPrefilled: "<h2 style=\"text-align: center\">ERP Release Notes</h2><p>This is a <b>highly important</b> update. Please observe the following:</p><ul><li>System maintenance at <i>midnight</i>.</li><li><u style=\"text-align: justify\">All users must log out.</u></li></ul><p style=\"text-align: justify\">Thank you for your cooperation.</p>",
|
||||
realPokeSelect: null,
|
||||
multiRealPokeSelect: []
|
||||
}
|
||||
});
|
||||
|
||||
const onSubmit = (data: any) => console.log('All Fields Submitted:', data);
|
||||
const data = watch();
|
||||
|
||||
return (
|
||||
<Stack gap="xl">
|
||||
<Paper p="xl" withBorder radius="md">
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack gap="xl">
|
||||
{/* --- Text & Numbers --- */}
|
||||
<div>
|
||||
<Title order={5} mb="sm" c="brand">{t.sections.textAndNumbers}</Title>
|
||||
<Divider mb="md" />
|
||||
<Group grow align="flex-start" mb="md">
|
||||
<FieldTextInput name="customerName" control={control} label={t.fields.customerName} />
|
||||
<FieldTextInput name="email" control={control} label={t.fields.email} />
|
||||
</Group>
|
||||
<Group grow align="flex-start" mb="md">
|
||||
<FieldPasswordInput name="password" control={control} label={t.fields.password} />
|
||||
<FieldNumberInput name="age" control={control} label={t.fields.age} />
|
||||
</Group>
|
||||
<Group grow align="flex-start" mb="md">
|
||||
<FieldTextarea name="description" control={control} label={t.fields.description} minRows={3} />
|
||||
<FieldJsonInput name="jsonConfig" control={control} label={t.fields.jsonConfig} formatOnBlur />
|
||||
</Group>
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb={3}>{t.fields.pin}</Text>
|
||||
<FieldPinInput name="pin" control={control} length={6} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* --- Selections --- */}
|
||||
<div>
|
||||
<Title order={5} mb="sm" c="brand">{t.sections.selections}</Title>
|
||||
<Divider mb="md" />
|
||||
<Group grow align="flex-start" mb="md">
|
||||
<FieldSelect
|
||||
name="orderType"
|
||||
control={control}
|
||||
label={t.fields.orderType}
|
||||
data={['BULK', 'RETAIL']}
|
||||
/>
|
||||
<FieldNativeSelect
|
||||
name="nativeOrderType"
|
||||
control={control}
|
||||
label={`Native ${t.fields.orderType}`}
|
||||
data={['BULK', 'RETAIL']}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow align="flex-start" mb="md">
|
||||
<FieldAutocomplete
|
||||
name="country"
|
||||
control={control}
|
||||
label={t.fields.country}
|
||||
data={['Indonesia', 'Singapore', 'Malaysia']}
|
||||
/>
|
||||
<FieldMultiSelect
|
||||
name="categories"
|
||||
control={control}
|
||||
label={t.fields.categories}
|
||||
data={['Electronics', 'Fashion', 'Food']}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow align="flex-start" mb="md">
|
||||
<FieldLocalSelect
|
||||
name="localSelect"
|
||||
control={control}
|
||||
label={t.fields.localSelect}
|
||||
placeholder={t.placeholders.selectComplexObject}
|
||||
options={[
|
||||
{ id: 1, name: 'Apple', type: 'Fruit' },
|
||||
{ id: 2, name: 'Carrot', type: 'Vegetable' },
|
||||
{ id: 3, name: 'Banana', type: 'Fruit' }
|
||||
]}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
clearable
|
||||
/>
|
||||
<FieldLocalSelect
|
||||
multiple
|
||||
name="multiLocalSelect"
|
||||
control={control}
|
||||
label={t.fields.multiLocalSelect}
|
||||
placeholder={t.placeholders.selectMultipleObjects}
|
||||
options={[
|
||||
{ id: 1, name: 'Red', hex: '#f00' },
|
||||
{ id: 2, name: 'Green', hex: '#0f0' },
|
||||
{ id: 3, name: 'Blue', hex: '#00f' }
|
||||
]}
|
||||
valueKey="id"
|
||||
renderLabel={(item) => `${item.name} (${item.hex})`}
|
||||
clearable
|
||||
/>
|
||||
</Group>
|
||||
<Group grow align="flex-start" mb="md">
|
||||
<FieldAsyncSelect
|
||||
name="asyncSelect"
|
||||
control={control}
|
||||
label={t.fields.asyncSelectMock}
|
||||
placeholder={t.placeholders.searchPokemon}
|
||||
loadOptions={loadMockPokemonOptions}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
clearable
|
||||
/>
|
||||
<FieldAsyncSelect
|
||||
multiple
|
||||
name="multiAsyncSelect"
|
||||
control={control}
|
||||
label={t.fields.multiAsyncSelect}
|
||||
placeholder={t.placeholders.selectMultiplePokemon}
|
||||
loadOptions={loadMockPokemonOptions}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
clearable
|
||||
/>
|
||||
</Group>
|
||||
<Group grow align="flex-start" mb="md">
|
||||
<FieldAsyncSelect
|
||||
name="realPokeSelect"
|
||||
control={control}
|
||||
label={t.fields.realPokeSingle}
|
||||
placeholder={t.placeholders.scrollDeduplication}
|
||||
loadOptions={loadRealPokemonOptions}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
clearable
|
||||
/>
|
||||
<FieldAsyncSelect
|
||||
multiple
|
||||
name="multiRealPokeSelect"
|
||||
control={control}
|
||||
label={t.fields.realPokeMulti}
|
||||
placeholder={t.placeholders.scrollDeduplication}
|
||||
loadOptions={loadRealPokemonOptions}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
clearable
|
||||
/>
|
||||
</Group>
|
||||
<FieldTagsInput name="tags" control={control} label={t.fields.tags} />
|
||||
|
||||
<Title order={5} mb="sm" mt="lg" c="brand">{t.sections.advancedObjectSelects}</Title>
|
||||
<Divider mb="md" />
|
||||
<Group grow align="flex-start" mb="md">
|
||||
<FieldLocalSelect
|
||||
name="localSelectEmpty"
|
||||
control={control}
|
||||
label={t.fields.localEmpty}
|
||||
options={MOCK_VENDORS}
|
||||
valueKey="id"
|
||||
renderLabel={(item) => `[${item.code}] ${item.name}`}
|
||||
clearable
|
||||
/>
|
||||
<FieldLocalSelect
|
||||
name="localSelectPrefilled"
|
||||
control={control}
|
||||
label={t.fields.localPrefilled}
|
||||
options={MOCK_VENDORS}
|
||||
valueKey="id"
|
||||
renderLabel={(item) => `[${item.code}] ${item.name}`}
|
||||
clearable
|
||||
/>
|
||||
</Group>
|
||||
<Group grow align="flex-start" mb="md">
|
||||
<FieldAsyncSelect
|
||||
name="asyncSelectEmpty"
|
||||
control={control}
|
||||
label={t.fields.asyncEmpty}
|
||||
loadOptions={loadMockVendorsOptions}
|
||||
valueKey="id"
|
||||
renderLabel={(item) => `[${item.code}] ${item.name}`}
|
||||
clearable
|
||||
/>
|
||||
<FieldAsyncSelect
|
||||
name="asyncSelectPrefilled"
|
||||
control={control}
|
||||
label={t.fields.asyncPrefilled}
|
||||
loadOptions={loadMockVendorsOptions}
|
||||
valueKey="id"
|
||||
renderLabel={(item) => `[${item.code}] ${item.name}`}
|
||||
defaultOptions={[{ id: 999, code: 'ASYNC-99', name: 'Pre-loaded Async Vendor' }]}
|
||||
clearable
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Title order={5} mb="sm" mt="lg" c="brand">{t.sections.multiSelectEditMode}</Title>
|
||||
<Divider mb="md" />
|
||||
<Group grow align="flex-start" mb="md">
|
||||
<FieldLocalSelect
|
||||
multiple
|
||||
name="localMultiPrefilled"
|
||||
control={control}
|
||||
label={t.fields.localMultiPrefilled}
|
||||
options={MOCK_VENDORS}
|
||||
valueKey="id"
|
||||
renderLabel={(item) => `[${item.code}] ${item.name}`}
|
||||
clearable
|
||||
/>
|
||||
<FieldAsyncSelect
|
||||
multiple
|
||||
name="asyncMultiPrefilled"
|
||||
control={control}
|
||||
label={t.fields.asyncMultiPrefilled}
|
||||
loadOptions={loadMockVendorsOptions}
|
||||
valueKey="id"
|
||||
renderLabel={(item) => `[${item.code}] ${item.name}`}
|
||||
clearable
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Title order={5} mb="sm" mt="lg" c="brand">{t.sections.richTextEditor}</Title>
|
||||
<Divider mb="md" />
|
||||
<FieldRichTextEditor
|
||||
name="richTextEmpty"
|
||||
control={control}
|
||||
label={t.fields.richTextEmpty}
|
||||
description={t.descriptions.freshTipTap}
|
||||
/>
|
||||
<div style={{ marginTop: '16px' }}>
|
||||
<FieldRichTextEditor
|
||||
name="richTextPrefilled"
|
||||
control={control}
|
||||
label={t.fields.richTextPrefilled}
|
||||
description={t.descriptions.htmlStringLoaded}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* --- Toggles & Choices --- */}
|
||||
<div>
|
||||
<Title order={5} mb="sm" c="brand">{t.sections.togglesAndChoices}</Title>
|
||||
<Divider mb="md" />
|
||||
<Group mb="md">
|
||||
<FieldCheckbox name="terms" control={control} label={t.fields.terms} />
|
||||
<FieldSwitch name="receiveEmails" control={control} label={t.fields.receiveEmails} />
|
||||
</Group>
|
||||
<FieldRadioGroup
|
||||
name="priority"
|
||||
control={control}
|
||||
label={t.fields.priority}
|
||||
mb="md"
|
||||
>
|
||||
<Group mt="xs">
|
||||
<Radio value="low" label="Low" />
|
||||
<Radio value="high" label="High" />
|
||||
</Group>
|
||||
</FieldRadioGroup>
|
||||
<FieldSegmentedControl
|
||||
name="segmentedPriority"
|
||||
control={control}
|
||||
label={t.fields.priority}
|
||||
data={[
|
||||
{ label: 'Normal', value: 'normal' },
|
||||
{ label: 'Urgent', value: 'urgent' }
|
||||
]}
|
||||
mb="md"
|
||||
/>
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb={3}>Chip Selection</Text>
|
||||
<FieldChipGroup
|
||||
name="chipSelection"
|
||||
control={control}
|
||||
>
|
||||
<Group>
|
||||
<Chip value="1">Option 1</Chip>
|
||||
<Chip value="2">Option 2</Chip>
|
||||
</Group>
|
||||
</FieldChipGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* --- Ranges & Specialized --- */}
|
||||
<div>
|
||||
<Title order={5} mb="sm" c="brand">{t.sections.rangesAndSpecialized}</Title>
|
||||
<Divider mb="md" />
|
||||
<Group grow align="flex-start" mb="md">
|
||||
<FieldSlider name="satisfaction" control={control} label={t.fields.rating} />
|
||||
<FieldRangeSlider name="priceRange" control={control} label={t.fields.priceRange} />
|
||||
</Group>
|
||||
<Group grow align="flex-start" mb="md">
|
||||
<FieldColorInput name="themeColor" control={control} label={t.fields.themeColor} />
|
||||
<FieldFileInput name="avatar" control={control} label={t.fields.avatar} />
|
||||
</Group>
|
||||
<Group grow align="flex-start" mb="md">
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb={3}>{t.fields.themeColor} Picker</Text>
|
||||
<FieldColorPicker name="colorPicker" control={control} />
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb={3}>{t.fields.rating}</Text>
|
||||
<FieldRating name="rating" control={control} />
|
||||
</div>
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<Button type="submit" mt="md">{t.common.submit}</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Paper>
|
||||
|
||||
<Paper p="md" withBorder radius="md" bg="var(--mantine-color-gray-0)">
|
||||
<Title order={6} mb="xs">{t.common.submittedData}</Title>
|
||||
<Code block>{JSON.stringify(data, null, 2)}</Code>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+368
@@ -0,0 +1,368 @@
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Button, Paper, Title, Divider, Stack, Code, Alert, TypographyStylesProvider } from '@repo/ui/components';
|
||||
import { FieldTextInput, FieldSelect, FieldSwitch, FieldLocalSelect, FieldAsyncSelect, FieldRichTextEditor } from '@repo/ui/form';
|
||||
import { useConditionalField } from '@repo/ui/hooks';
|
||||
import { compose, required, emailValidator } from '@repo/ui/validators';
|
||||
import { useFormDemoTranslation } from '../i18n/useFormDemoTranslation';
|
||||
import { Info } from 'lucide-react';
|
||||
import { useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
|
||||
interface Region {
|
||||
id: string;
|
||||
code: string;
|
||||
taxRate: number;
|
||||
}
|
||||
|
||||
interface Warehouse {
|
||||
id: string;
|
||||
regionId: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const REGIONS: Region[] = [
|
||||
{ id: 'R1', code: 'APAC', taxRate: 0.1 },
|
||||
{ id: 'R2', code: 'EMEA', taxRate: 0.2 }
|
||||
];
|
||||
|
||||
const mockFetchWarehouses = async (regionIds: string[], search: string, page: number) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
const allWarehouses: Warehouse[] = [
|
||||
{ id: 'W1', regionId: 'R1', name: 'Singapore Hub' },
|
||||
{ id: 'W2', regionId: 'R1', name: 'Tokyo Depot' },
|
||||
{ id: 'W3', regionId: 'R2', name: 'London Central' },
|
||||
{ id: 'W4', regionId: 'R2', name: 'Berlin Storage' },
|
||||
];
|
||||
|
||||
const filtered = allWarehouses.filter(w => regionIds.includes(w.regionId) && w.name.toLowerCase().includes(search.toLowerCase()));
|
||||
const pageSize = 10;
|
||||
const start = (page - 1) * pageSize;
|
||||
const paginated = filtered.slice(start, start + pageSize);
|
||||
|
||||
return {
|
||||
options: paginated,
|
||||
hasMore: start + pageSize < filtered.length
|
||||
};
|
||||
};
|
||||
|
||||
export default function ReactiveWatchDemo() {
|
||||
const t = useFormDemoTranslation();
|
||||
|
||||
// Define atomic validators for conditional fields
|
||||
const taxIdValidator = compose(z.string(), required(t.watch.corporateTaxId));
|
||||
const spouseNameValidator = compose(z.string(), required(t.watch.spouseName));
|
||||
const newsletterEmailValidator = compose(z.string(), required(t.fields.email), emailValidator());
|
||||
const roleValidator = compose(z.string(), required(t.fields.role));
|
||||
|
||||
const reactiveSchema = useMemo(() => z
|
||||
.object({
|
||||
userType: z.enum(['PERSONAL', 'CORPORATE']),
|
||||
corporateTaxId: z.string().optional(),
|
||||
hasSpouse: z.boolean(),
|
||||
spouseName: z.string().optional(),
|
||||
newsletter: z.boolean(),
|
||||
newsletterEmail: z.string().optional(),
|
||||
department: z.string().optional(),
|
||||
role: z.string().optional(),
|
||||
regions: z.array(z.object({ id: z.string(), code: z.string(), taxRate: z.number() })).optional(),
|
||||
warehouses: z.array(z.object({ id: z.string(), regionId: z.string(), name: z.string() })).optional(),
|
||||
richTextLive: z.string().optional(),
|
||||
})
|
||||
.and(
|
||||
z.discriminatedUnion('userType', [
|
||||
z.object({ userType: z.literal('PERSONAL') }),
|
||||
z.object({ userType: z.literal('CORPORATE'), corporateTaxId: taxIdValidator }),
|
||||
]),
|
||||
)
|
||||
.and(
|
||||
z.union([
|
||||
z.object({ hasSpouse: z.literal(false) }),
|
||||
z.object({ hasSpouse: z.literal(true), spouseName: spouseNameValidator }),
|
||||
]),
|
||||
)
|
||||
.and(
|
||||
z.union([
|
||||
z.object({ newsletter: z.literal(false) }),
|
||||
z.object({ newsletter: z.literal(true), newsletterEmail: newsletterEmailValidator }),
|
||||
]),
|
||||
)
|
||||
.and(
|
||||
z.union([
|
||||
z.object({ department: z.string().min(1), role: roleValidator }),
|
||||
z.object({ department: z.union([z.literal(''), z.undefined(), z.null()]).optional() }),
|
||||
]),
|
||||
), [t, taxIdValidator, spouseNameValidator, newsletterEmailValidator, roleValidator]);
|
||||
|
||||
const { control, handleSubmit, setValue, unregister, clearErrors } = useForm<any>({
|
||||
resolver: zodResolver(reactiveSchema as any),
|
||||
defaultValues: {
|
||||
userType: 'PERSONAL',
|
||||
corporateTaxId: '',
|
||||
hasSpouse: false,
|
||||
spouseName: '',
|
||||
newsletter: false,
|
||||
newsletterEmail: '',
|
||||
department: '',
|
||||
role: '',
|
||||
regions: [{ id: 'R1', code: 'APAC', taxRate: 0.1 }],
|
||||
warehouses: [
|
||||
{ id: 'W-99', regionId: 'R1', name: 'APAC Central Hub' },
|
||||
{ id: 'W-98', regionId: 'R1', name: 'APAC Backup Hub' }
|
||||
],
|
||||
richTextLive: "<p>Start typing to see the live preview...</p>",
|
||||
},
|
||||
});
|
||||
|
||||
// Micro-subscriptions via useWatch
|
||||
const userType = useWatch({ control, name: 'userType' });
|
||||
const hasSpouse = useWatch({ control, name: 'hasSpouse' });
|
||||
const newsletter = useWatch({ control, name: 'newsletter' });
|
||||
const department = useWatch({ control, name: 'department' });
|
||||
const role = useWatch({ control, name: 'role' });
|
||||
const regions = useWatch({ control, name: 'regions' });
|
||||
const watchedRichTextLive = useWatch({ control, name: 'richTextLive' });
|
||||
|
||||
// Use the custom hook to cleanly unregister and reset fields when hidden
|
||||
useConditionalField({
|
||||
condition: userType === 'CORPORATE',
|
||||
name: 'corporateTaxId',
|
||||
setValue,
|
||||
unregister,
|
||||
mode: 'unregister',
|
||||
defaultValue: '',
|
||||
});
|
||||
|
||||
useConditionalField({
|
||||
condition: hasSpouse === true,
|
||||
name: 'spouseName',
|
||||
setValue,
|
||||
unregister,
|
||||
mode: 'unregister',
|
||||
defaultValue: '',
|
||||
});
|
||||
|
||||
// Use the reset mode to clear values and errors without unregistering the field
|
||||
useConditionalField({
|
||||
condition: newsletter === true,
|
||||
name: 'newsletterEmail',
|
||||
setValue,
|
||||
clearErrors,
|
||||
mode: 'reset',
|
||||
defaultValue: '',
|
||||
});
|
||||
|
||||
// Cascading Dropdown Logic: Department -> Role
|
||||
const roleOptions: Record<string, { value: string; label: string }[]> = {
|
||||
IT: [
|
||||
{ value: 'FRONTEND', label: 'Frontend Engineer' },
|
||||
{ value: 'BACKEND', label: 'Backend Engineer' },
|
||||
],
|
||||
HR: [
|
||||
{ value: 'RECRUITER', label: 'Technical Recruiter' },
|
||||
{ value: 'MANAGER', label: 'HR Manager' },
|
||||
],
|
||||
FINANCE: [
|
||||
{ value: 'ACCOUNTANT', label: 'Accountant' },
|
||||
{ value: 'ANALYST', label: 'Financial Analyst' },
|
||||
],
|
||||
};
|
||||
|
||||
const currentRoleOptions = department ? roleOptions[department] : [];
|
||||
const isRoleValid = !role || (!!department && currentRoleOptions.some((opt) => opt.value === role));
|
||||
|
||||
// Reset Mode: Automatically clears the 'role' field value and errors if the department changes
|
||||
// and the currently selected role is no longer valid for the new department.
|
||||
useConditionalField({
|
||||
condition: isRoleValid,
|
||||
name: 'role',
|
||||
setValue,
|
||||
clearErrors,
|
||||
mode: 'reset',
|
||||
defaultValue: '',
|
||||
});
|
||||
|
||||
const isMounted = useRef(false);
|
||||
const prevRegionIds = useRef<string[]>(regions?.map((r: Region) => r.id) || []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMounted.current) {
|
||||
isMounted.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const currentIds = regions?.map((r: Region) => r.id) || [];
|
||||
const prevIds = prevRegionIds.current;
|
||||
|
||||
const hasChanged = currentIds.length !== prevIds.length || currentIds.some((id: string) => !prevIds.includes(id));
|
||||
|
||||
if (hasChanged) {
|
||||
setValue('warehouses', []);
|
||||
clearErrors('warehouses');
|
||||
prevRegionIds.current = currentIds;
|
||||
}
|
||||
}, [regions, setValue, clearErrors]);
|
||||
|
||||
// Use watch only to display the JSON output at the bottom
|
||||
const allValues = useWatch({ control });
|
||||
|
||||
const onSubmit = (data: any) => console.log('Reactive Passed:', data);
|
||||
|
||||
return (
|
||||
<Stack gap="xl">
|
||||
<Paper p="xl" withBorder radius="md">
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack gap="md">
|
||||
<Title order={5} mb="sm" c="brand">
|
||||
{t.sections.reactiveWatchCascading}
|
||||
</Title>
|
||||
<Divider mb="sm" />
|
||||
|
||||
<Alert icon={<Info size={16} />} title="Micro-subscription Pattern" color="blue" variant="light">
|
||||
This form demonstrates isolated re-rendering using <code>useWatch</code>. Instead of re-rendering the
|
||||
entire form root when typing, only the specific conditional fields update their display states.
|
||||
</Alert>
|
||||
|
||||
<Divider label="Hidden/Unmounted Pattern" labelPosition="center" my="sm" />
|
||||
|
||||
<FieldSelect
|
||||
name="userType"
|
||||
control={control}
|
||||
label={t.watch.userType}
|
||||
data={[
|
||||
{ value: 'PERSONAL', label: t.watch.typePersonal || 'Personal' },
|
||||
{ value: 'CORPORATE', label: t.watch.typeCorporate || 'Corporate' },
|
||||
]}
|
||||
withAsterisk
|
||||
/>
|
||||
|
||||
{userType === 'CORPORATE' && (
|
||||
<FieldTextInput name="corporateTaxId" control={control} label={t.watch.corporateTaxId} withAsterisk />
|
||||
)}
|
||||
|
||||
<FieldSwitch name="hasSpouse" control={control} label={t.watch.hasSpouse} mt="md" />
|
||||
|
||||
{hasSpouse && (
|
||||
<FieldTextInput name="spouseName" control={control} label={t.watch.spouseName} withAsterisk />
|
||||
)}
|
||||
|
||||
<Divider label="Visible but Disabled Pattern" labelPosition="center" my="md" />
|
||||
|
||||
<FieldSwitch name="newsletter" control={control} label="Subscribe to Newsletter" />
|
||||
|
||||
<FieldTextInput
|
||||
name="newsletterEmail"
|
||||
control={control}
|
||||
label="Newsletter Email"
|
||||
disabled={!newsletter}
|
||||
placeholder="Enter your email to subscribe"
|
||||
withAsterisk={newsletter}
|
||||
/>
|
||||
|
||||
<Divider label="Reset Mode (Cascading Dependencies)" labelPosition="center" my="md" />
|
||||
|
||||
<FieldSelect
|
||||
name="department"
|
||||
control={control}
|
||||
label={t.fields.department}
|
||||
placeholder={t.placeholders.selectComplexObject}
|
||||
data={[
|
||||
{ value: 'IT', label: 'Information Technology' },
|
||||
{ value: 'HR', label: 'Human Resources' },
|
||||
{ value: 'FINANCE', label: 'Finance' },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* * ⚠️ CRITICAL UI FIX: DYNAMIC KEY
|
||||
* Why bind the 'key' to the parent dependency (department)?
|
||||
* * Mantine's Select component caches its internal visual state. When the parent
|
||||
* 'department' changes, our useConditionalField hook successfully clears the RHF
|
||||
* payload state, but Mantine might visually retain the old text on the screen.
|
||||
* * By changing the 'key' whenever the department changes, we force React to
|
||||
* completely unmount and remount this component. This destroys Mantine's old
|
||||
* internal cache and guarantees a perfectly clean UI sync.
|
||||
*/}
|
||||
<FieldSelect
|
||||
key={`role-select-${department}`}
|
||||
name="role"
|
||||
control={control}
|
||||
label={t.fields.role}
|
||||
placeholder={t.placeholders.selectComplexObject}
|
||||
disabled={!department}
|
||||
data={currentRoleOptions}
|
||||
withAsterisk={!!department}
|
||||
/>
|
||||
|
||||
<Title order={5} mb="sm" c="brand" mt="lg">
|
||||
{t.sections.reactiveWatchCascading}
|
||||
</Title>
|
||||
<Divider mb="sm" />
|
||||
|
||||
<FieldLocalSelect<Region>
|
||||
multiple
|
||||
name="regions"
|
||||
control={control as any}
|
||||
label={t.fields.regions}
|
||||
options={REGIONS}
|
||||
valueKey="id"
|
||||
labelKey="code"
|
||||
clearable
|
||||
/>
|
||||
|
||||
<FieldAsyncSelect<Warehouse>
|
||||
multiple
|
||||
key={`warehouse-select-${regions?.map((r: any) => r.id).join(',')}`}
|
||||
name="warehouses"
|
||||
control={control as any}
|
||||
label={t.fields.warehouses}
|
||||
disabled={!regions || regions.length === 0}
|
||||
loadOptions={useCallback(async (search, page) => {
|
||||
if (!regions || regions.length === 0) return { options: [], hasMore: false };
|
||||
return mockFetchWarehouses(regions.map((r: any) => r.id), search, page);
|
||||
}, [regions])}
|
||||
valueKey="id"
|
||||
renderLabel={(item) => `[${item.id}] ${item.name}`}
|
||||
clearable
|
||||
/>
|
||||
|
||||
{regions && regions.length > 0 && (
|
||||
<Alert mt="sm" color="teal">
|
||||
{t.descriptions.selectedRegionsTax} {regions.map((r: any) => `${r.code} (${(r.taxRate * 100).toFixed(0)}%)`).join(', ')}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Title order={5} mb="sm" c="brand" mt="lg">
|
||||
{t.sections.reactiveRichTextPreview}
|
||||
</Title>
|
||||
<Divider mb="sm" />
|
||||
|
||||
<FieldRichTextEditor
|
||||
name="richTextLive"
|
||||
control={control as any}
|
||||
label={t.fields.liveEditor}
|
||||
description={t.descriptions.typeToSeePreview}
|
||||
/>
|
||||
|
||||
<Paper p="md" withBorder radius="md" mt="sm">
|
||||
<Title order={6} mb="xs">{t.sections.liveHtmlPreview}</Title>
|
||||
<TypographyStylesProvider>
|
||||
<div dangerouslySetInnerHTML={{ __html: watchedRichTextLive }} />
|
||||
</TypographyStylesProvider>
|
||||
</Paper>
|
||||
|
||||
<Button type="submit" mt="md">
|
||||
{t.common.submitReactive}
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Paper>
|
||||
|
||||
<Paper p="md" withBorder radius="md" bg="var(--mantine-color-gray-0)">
|
||||
<Title order={6} mb="xs">
|
||||
{t.common?.submittedData || 'Submitted Data'}
|
||||
</Title>
|
||||
<Code block>{JSON.stringify(allValues, null, 2)}</Code>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+254
@@ -0,0 +1,254 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Button, Paper, Title, Group, Stack, Code, Divider } from '@repo/ui/components';
|
||||
import {
|
||||
FieldTextInput, FieldPasswordInput, FieldNumberInput,
|
||||
FieldLocalSelect, FieldAsyncSelect, FieldRichTextEditor
|
||||
} from '@repo/ui/form';
|
||||
import type { LoadOptionsFn } from '@repo/ui/form';
|
||||
|
||||
interface Department {
|
||||
code: string;
|
||||
name: string;
|
||||
costCenter: string;
|
||||
}
|
||||
|
||||
interface Assignee {
|
||||
id: number;
|
||||
email: string;
|
||||
}
|
||||
|
||||
const MOCK_DEPARTMENTS: Department[] = [
|
||||
{ code: 'IT', name: 'Information Technology', costCenter: 'CC-100' },
|
||||
{ code: 'HR', name: 'Human Resources', costCenter: 'CC-200' },
|
||||
{ code: 'FIN', name: 'Finance', costCenter: 'CC-300' },
|
||||
];
|
||||
|
||||
const mockFetchUsers: LoadOptionsFn<Assignee> = async (search, page) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
const allUsers = Array.from({ length: 20 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
email: `user${i + 1}@company.com`
|
||||
}));
|
||||
const filtered = allUsers.filter(u => u.email.toLowerCase().includes(search.toLowerCase()));
|
||||
const pageSize = 5;
|
||||
const start = (page - 1) * pageSize;
|
||||
const paginated = filtered.slice(start, start + pageSize);
|
||||
|
||||
return {
|
||||
options: paginated,
|
||||
hasMore: start + pageSize < filtered.length
|
||||
};
|
||||
};
|
||||
|
||||
const MOCK_VENDORS = [
|
||||
{ id: 'V1', code: 'VN-01', name: 'Vendor One' },
|
||||
{ id: 'V2', code: 'VN-02', name: 'Vendor Two' },
|
||||
];
|
||||
|
||||
const mockFetchVendors: LoadOptionsFn<any> = async (search, _page) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
const filtered = MOCK_VENDORS.filter(v => v.name.toLowerCase().includes(search.toLowerCase()) || v.code.toLowerCase().includes(search.toLowerCase()));
|
||||
return { options: filtered, hasMore: false };
|
||||
};
|
||||
import {
|
||||
compose, required, rangeLength,
|
||||
positiveNumber, simplePassword,
|
||||
complexPassword, phoneValidator, rangeValue
|
||||
} from '@repo/ui/validators';
|
||||
import { useFormDemoTranslation } from '../i18n/useFormDemoTranslation';
|
||||
|
||||
export default function ValidationBankDemo() {
|
||||
const t = useFormDemoTranslation();
|
||||
|
||||
// Compose the Zod schema using the atomic validators
|
||||
const validationSchema = useMemo(() => z.object({
|
||||
username: compose(z.string(), required(t.fields.customerName), rangeLength(3, 15, t.fields.customerName)),
|
||||
simplePass: compose(z.string(), required(t.validation.simplePassword), simplePassword(6)),
|
||||
complexPass: compose(z.string(), required(t.validation.complexPassword), complexPassword(8)),
|
||||
age: compose(z.number(), required(t.fields.age), rangeValue(18, 65, t.fields.age)),
|
||||
score: compose(z.number(), required(t.validation.score), positiveNumber(t.validation.score)),
|
||||
phone: compose(z.string(), required(t.validation.phone), phoneValidator()),
|
||||
department: z.object({ code: z.string(), name: z.string() }, { required_error: t.errors.departmentRequired }),
|
||||
assignees: z.array(z.object({ id: z.number(), email: z.string() })).min(2, t.errors.min2Assignees),
|
||||
prefilledVendor: z.object({ id: z.union([z.string(), z.number()]), code: z.string(), name: z.string() }, { required_error: t.errors.vendorRequired }),
|
||||
emptyVendor: z.object({ id: z.union([z.string(), z.number()]), code: z.string(), name: z.string() }, { required_error: t.errors.vendorRequired }),
|
||||
prefilledAsyncMulti: z.array(z.object({ id: z.union([z.string(), z.number()]), code: z.string(), name: z.string() })).min(1, t.errors.min1Vendor),
|
||||
richTextNotes: z.string().min(15, t.errors.notesMin15),
|
||||
}), [t]);
|
||||
|
||||
type ValidationFormValues = z.infer<typeof validationSchema>;
|
||||
|
||||
const { control, handleSubmit, watch } = useForm<ValidationFormValues>({
|
||||
resolver: zodResolver(validationSchema),
|
||||
defaultValues: {
|
||||
username: '',
|
||||
simplePass: '',
|
||||
complexPass: '',
|
||||
age: undefined as any,
|
||||
score: undefined as any,
|
||||
phone: '',
|
||||
department: null as any,
|
||||
assignees: [],
|
||||
prefilledVendor: { id: 'V1', code: 'VN-01', name: 'Vendor One' } as any,
|
||||
emptyVendor: null as any,
|
||||
prefilledAsyncMulti: [
|
||||
{ id: 'V1', code: 'VN-01', name: 'Vendor One' },
|
||||
{ id: 'V2', code: 'VN-02', name: 'Vendor Two' }
|
||||
] as any,
|
||||
richTextNotes: '',
|
||||
}
|
||||
});
|
||||
|
||||
const onSubmit = (data: ValidationFormValues) => console.log('Validation Passed:', data);
|
||||
const data = watch();
|
||||
|
||||
return (
|
||||
<Stack gap="xl">
|
||||
<Paper p="xl" withBorder radius="md">
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack gap="md">
|
||||
<Title order={5} c="brand">{t.sections.validationBankTitle}</Title>
|
||||
<Divider mb="sm" />
|
||||
|
||||
<FieldTextInput
|
||||
name="username"
|
||||
control={control}
|
||||
label={t.fields.customerName}
|
||||
description={t.validation.usernameRange}
|
||||
withAsterisk
|
||||
/>
|
||||
|
||||
<Group grow align="flex-start">
|
||||
<FieldPasswordInput
|
||||
name="simplePass"
|
||||
control={control}
|
||||
label={t.validation.simplePassword}
|
||||
description={t.descriptions.min6Chars}
|
||||
withAsterisk
|
||||
/>
|
||||
<FieldPasswordInput
|
||||
name="complexPass"
|
||||
control={control}
|
||||
label={t.validation.complexPassword}
|
||||
description={t.descriptions.min8Complex}
|
||||
withAsterisk
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group grow align="flex-start">
|
||||
<FieldNumberInput
|
||||
name="age"
|
||||
control={control}
|
||||
label={t.fields.age}
|
||||
description={t.validation.ageRange}
|
||||
withAsterisk
|
||||
/>
|
||||
<FieldNumberInput
|
||||
name="score"
|
||||
control={control}
|
||||
label={t.validation.score}
|
||||
description={t.descriptions.mustBePositive}
|
||||
withAsterisk
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<FieldTextInput
|
||||
name="phone"
|
||||
control={control}
|
||||
label={t.validation.phone}
|
||||
description={t.descriptions.formatPhone}
|
||||
withAsterisk
|
||||
/>
|
||||
|
||||
<Title order={5} c="brand" mt="md">{t.sections.objectLevelValidations}</Title>
|
||||
<Divider mb="sm" />
|
||||
|
||||
<FieldLocalSelect<Department>
|
||||
name="department"
|
||||
control={control as any}
|
||||
label={t.fields.department}
|
||||
options={MOCK_DEPARTMENTS}
|
||||
valueKey="code"
|
||||
renderLabel={(item) => `[${item.code}] ${item.name}`}
|
||||
clearable
|
||||
withAsterisk
|
||||
/>
|
||||
|
||||
<FieldAsyncSelect<Assignee>
|
||||
multiple
|
||||
name="assignees"
|
||||
control={control as any}
|
||||
label={t.fields.assignees}
|
||||
loadOptions={mockFetchUsers}
|
||||
valueKey="id"
|
||||
labelKey="email"
|
||||
searchable
|
||||
clearable
|
||||
withAsterisk
|
||||
/>
|
||||
|
||||
<Title order={5} c="brand" mt="lg">{t.sections.validatedPrefilledObjects}</Title>
|
||||
<Divider mb="sm" />
|
||||
|
||||
<Group grow align="flex-start">
|
||||
<FieldAsyncSelect
|
||||
name="emptyVendor"
|
||||
control={control as any}
|
||||
label={t.fields.emptyVendor}
|
||||
loadOptions={mockFetchVendors}
|
||||
valueKey="id"
|
||||
renderLabel={(item) => `[${item.code}] ${item.name}`}
|
||||
clearable
|
||||
withAsterisk
|
||||
/>
|
||||
<FieldAsyncSelect
|
||||
name="prefilledVendor"
|
||||
control={control as any}
|
||||
label={t.fields.prefilledVendor}
|
||||
loadOptions={mockFetchVendors}
|
||||
valueKey="id"
|
||||
renderLabel={(item) => `[${item.code}] ${item.name}`}
|
||||
defaultOptions={[{ id: 'V1', code: 'VN-01', name: 'Vendor One' }]}
|
||||
clearable
|
||||
withAsterisk
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<FieldAsyncSelect
|
||||
multiple
|
||||
name="prefilledAsyncMulti"
|
||||
control={control as any}
|
||||
label={t.fields.prefilledAsyncMulti}
|
||||
loadOptions={mockFetchVendors}
|
||||
valueKey="id"
|
||||
renderLabel={(item) => `[${item.code}] ${item.name}`}
|
||||
clearable
|
||||
withAsterisk
|
||||
/>
|
||||
|
||||
<Title order={5} c="brand" mt="lg">{t.sections.richTextValidations}</Title>
|
||||
<Divider mb="sm" />
|
||||
|
||||
<FieldRichTextEditor
|
||||
name="richTextNotes"
|
||||
control={control as any}
|
||||
label={t.fields.importantNotes}
|
||||
description={t.descriptions.zodMinLengthString}
|
||||
withAsterisk
|
||||
/>
|
||||
|
||||
<Button type="submit" mt="md">{t.common.submit}</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Paper>
|
||||
|
||||
<Paper p="md" withBorder radius="md" bg="var(--mantine-color-gray-0)">
|
||||
<Title order={6} mb="xs">{t.common.submittedData}</Title>
|
||||
<Code block>{JSON.stringify(data, null, 2)}</Code>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Tabs } from '@repo/ui/components';
|
||||
import AllFieldsDemo from './components/all-fields-demo';
|
||||
import ValidationBankDemo from './components/validation-bank-demo';
|
||||
import ReactiveWatchDemo from './components/reactive-watch-demo';
|
||||
import { useFormDemoTranslation } from './i18n/useFormDemoTranslation';
|
||||
|
||||
export default function FormDemoView() {
|
||||
const t = useFormDemoTranslation();
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="all-fields" variant="outline" radius="md">
|
||||
<Tabs.List mb="md">
|
||||
<Tabs.Tab value="all-fields">{t.tabs.allFields}</Tabs.Tab>
|
||||
<Tabs.Tab value="validation-bank">{t.tabs.validationBank}</Tabs.Tab>
|
||||
<Tabs.Tab value="reactive-watch">{t.tabs.reactiveWatch}</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="all-fields">
|
||||
<AllFieldsDemo />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="validation-bank">
|
||||
<ValidationBankDemo />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="reactive-watch">
|
||||
<ReactiveWatchDemo />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
{
|
||||
"tabs": {
|
||||
"allFields": "All 22 Fields Demo",
|
||||
"validationBank": "Validation Bank",
|
||||
"reactiveWatch": "Reactive Watch (useWatch)"
|
||||
},
|
||||
"common": {
|
||||
"submit": "Submit Data",
|
||||
"submitReactive": "Submit Reactive Form",
|
||||
"reset": "Reset Form",
|
||||
"submittedData": "Submitted Data"
|
||||
},
|
||||
"sections": {
|
||||
"validationBankTitle": "Validation Bank (Atomic Registry)",
|
||||
"objectLevelValidations": "Object Level Validations (Local & Async)",
|
||||
"validatedPrefilledObjects": "Validated Prefilled Objects",
|
||||
"richTextValidations": "Rich Text Editor Validations",
|
||||
"textAndNumbers": "Text & Numbers",
|
||||
"selections": "Selections",
|
||||
"advancedObjectSelects": "Advanced Object Selects (Custom Labels & Default Values)",
|
||||
"multiSelectEditMode": "Multi-Select Edit Mode (No defaultOptions fallback)",
|
||||
"richTextEditor": "Rich Text Editor (TipTap)",
|
||||
"togglesAndChoices": "Toggles & Choices",
|
||||
"rangesAndSpecialized": "Ranges & Specialized",
|
||||
"reactiveWatchCascading": "Reactive Watch (Cascading)",
|
||||
"reactiveRichTextPreview": "Reactive Rich Text Preview",
|
||||
"liveHtmlPreview": "Live HTML Preview Render"
|
||||
},
|
||||
"fields": {
|
||||
"customerName": "Customer Name",
|
||||
"email": "Email Address",
|
||||
"priority": "Production Priority",
|
||||
"password": "Password",
|
||||
"description": "Description",
|
||||
"age": "Age",
|
||||
"jsonConfig": "JSON Config",
|
||||
"tags": "Tags",
|
||||
"terms": "I agree to the terms and conditions",
|
||||
"receiveEmails": "Receive marketing emails",
|
||||
"rating": "Satisfaction Rating",
|
||||
"themeColor": "Theme Color",
|
||||
"avatar": "Avatar Upload",
|
||||
"orderType": "Order Type",
|
||||
"quantity": "Quantity",
|
||||
"fabricColor": "Fabric Color",
|
||||
"pin": "Security PIN",
|
||||
"department": "Department",
|
||||
"assignees": "Assignees",
|
||||
"emptyVendor": "Empty Vendor",
|
||||
"prefilledVendor": "Prefilled Vendor",
|
||||
"prefilledAsyncMulti": "Prefilled Async Multi (No Fallback)",
|
||||
"importantNotes": "Important Notes",
|
||||
"country": "Country",
|
||||
"categories": "Categories",
|
||||
"localSelect": "Local Select",
|
||||
"multiLocalSelect": "Multi Local Select",
|
||||
"asyncSelectMock": "Async Select (Mock API)",
|
||||
"multiAsyncSelect": "Multi Async Select",
|
||||
"realPokeSingle": "Real PokeAPI (Single - Tests Deduplication)",
|
||||
"realPokeMulti": "Real PokeAPI (Multi - Tests Deduplication)",
|
||||
"localEmpty": "Local Empty",
|
||||
"localPrefilled": "Local Prefilled",
|
||||
"asyncEmpty": "Async Empty",
|
||||
"asyncPrefilled": "Async Prefilled (Edit Mode)",
|
||||
"localMultiPrefilled": "Local Multi Prefilled",
|
||||
"asyncMultiPrefilled": "Async Multi Prefilled (Ghost Items)",
|
||||
"richTextEmpty": "Rich Text (Empty)",
|
||||
"richTextPrefilled": "Rich Text (Prefilled / Edit Mode)",
|
||||
"priceRange": "Price Range",
|
||||
"role": "Role",
|
||||
"regions": "Regions",
|
||||
"warehouses": "Warehouses",
|
||||
"liveEditor": "Live Editor"
|
||||
},
|
||||
"placeholders": {
|
||||
"selectComplexObject": "Select a complex object",
|
||||
"selectMultipleObjects": "Select multiple objects",
|
||||
"searchPokemon": "Search pokemon...",
|
||||
"selectMultiplePokemon": "Select multiple pokemon...",
|
||||
"scrollDeduplication": "Scroll to test deduplication..."
|
||||
},
|
||||
"descriptions": {
|
||||
"min6Chars": "Min 6 chars",
|
||||
"min8Complex": "Min 8, 1 uppercase, 1 number, 1 special",
|
||||
"mustBePositive": "Must be > 0",
|
||||
"formatPhone": "Format: +62...",
|
||||
"zodMinLengthString": "This uses Zod minimum length string validation",
|
||||
"freshTipTap": "A fresh TipTap editor instance",
|
||||
"htmlStringLoaded": "HTML string successfully loaded from default values",
|
||||
"typeToSeePreview": "Type to see instantaneous reactive rendering below",
|
||||
"selectedRegionsTax": "Selected regions tax rates:"
|
||||
},
|
||||
"errors": {
|
||||
"departmentRequired": "Department is required",
|
||||
"vendorRequired": "Vendor is required",
|
||||
"min2Assignees": "Select at least 2 assignees",
|
||||
"min1Vendor": "Select at least 1 vendor",
|
||||
"notesMin15": "Notes must be at least 15 characters long (including HTML tags)",
|
||||
"selectRegionFirst": "Select a region first to load warehouses"
|
||||
},
|
||||
"validation": {
|
||||
"simplePassword": "Simple Password",
|
||||
"complexPassword": "Complex Password",
|
||||
"score": "Score (Positive)",
|
||||
"ageRange": "Age Range (18-65)",
|
||||
"usernameRange": "Username Length (3-15)",
|
||||
"phone": "Phone Number (+62)"
|
||||
},
|
||||
"watch": {
|
||||
"userType": "User Type",
|
||||
"typePersonal": "Personal",
|
||||
"typeCorporate": "Corporate",
|
||||
"corporateTaxId": "Corporate Tax ID",
|
||||
"hasSpouse": "Do you have a spouse?",
|
||||
"spouseName": "Spouse Name"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
{
|
||||
"tabs": {
|
||||
"allFields": "Demo 22 Field",
|
||||
"validationBank": "Bank Validasi",
|
||||
"reactiveWatch": "Reactive Watch (useWatch)"
|
||||
},
|
||||
"common": {
|
||||
"submit": "Kirim Data",
|
||||
"submitReactive": "Kirim Form Reaktif",
|
||||
"reset": "Reset Form",
|
||||
"submittedData": "Data Terkirim"
|
||||
},
|
||||
"sections": {
|
||||
"validationBankTitle": "Bank Validasi (Registri Atomik)",
|
||||
"objectLevelValidations": "Validasi Tingkat Objek (Lokal & Async)",
|
||||
"validatedPrefilledObjects": "Objek Terisi yang Divalidasi",
|
||||
"richTextValidations": "Validasi Rich Text Editor",
|
||||
"textAndNumbers": "Teks & Angka",
|
||||
"selections": "Pilihan",
|
||||
"advancedObjectSelects": "Pemilihan Objek Tingkat Lanjut (Label Kustom & Nilai Default)",
|
||||
"multiSelectEditMode": "Mode Edit Multi-Select (Tanpa fallback defaultOptions)",
|
||||
"richTextEditor": "Rich Text Editor (TipTap)",
|
||||
"togglesAndChoices": "Tombol Sakelar & Pilihan",
|
||||
"rangesAndSpecialized": "Rentang & Khusus",
|
||||
"reactiveWatchCascading": "Reactive Watch (Berjenjang)",
|
||||
"reactiveRichTextPreview": "Pratinjau Rich Text Reaktif",
|
||||
"liveHtmlPreview": "Render Pratinjau HTML Langsung"
|
||||
},
|
||||
"fields": {
|
||||
"customerName": "Nama Pelanggan",
|
||||
"email": "Alamat Email",
|
||||
"priority": "Prioritas Produksi",
|
||||
"password": "Kata Sandi",
|
||||
"description": "Deskripsi",
|
||||
"age": "Usia",
|
||||
"jsonConfig": "Konfigurasi JSON",
|
||||
"tags": "Label (Tags)",
|
||||
"terms": "Saya setuju dengan syarat dan ketentuan",
|
||||
"receiveEmails": "Terima email pemasaran",
|
||||
"rating": "Peringkat Kepuasan",
|
||||
"themeColor": "Warna Tema",
|
||||
"avatar": "Unggah Avatar",
|
||||
"orderType": "Tipe Pesanan",
|
||||
"quantity": "Jumlah",
|
||||
"fabricColor": "Warna Kain",
|
||||
"pin": "PIN Keamanan",
|
||||
"department": "Departemen",
|
||||
"assignees": "Penerima Tugas",
|
||||
"emptyVendor": "Vendor Kosong",
|
||||
"prefilledVendor": "Vendor Terisi",
|
||||
"prefilledAsyncMulti": "Multi Async Terisi (Tanpa Fallback)",
|
||||
"importantNotes": "Catatan Penting",
|
||||
"country": "Negara",
|
||||
"categories": "Kategori",
|
||||
"localSelect": "Pilihan Lokal",
|
||||
"multiLocalSelect": "Pilihan Lokal Multi",
|
||||
"asyncSelectMock": "Pilihan Async (Mock API)",
|
||||
"multiAsyncSelect": "Pilihan Async Multi",
|
||||
"realPokeSingle": "API Pokemon Asli (Tunggal - Uji Deduplikasi)",
|
||||
"realPokeMulti": "API Pokemon Asli (Multi - Uji Deduplikasi)",
|
||||
"localEmpty": "Lokal Kosong",
|
||||
"localPrefilled": "Lokal Terisi",
|
||||
"asyncEmpty": "Async Kosong",
|
||||
"asyncPrefilled": "Async Terisi (Mode Edit)",
|
||||
"localMultiPrefilled": "Multi Lokal Terisi",
|
||||
"asyncMultiPrefilled": "Multi Async Terisi (Item Hantu)",
|
||||
"richTextEmpty": "Rich Text (Kosong)",
|
||||
"richTextPrefilled": "Rich Text (Terisi / Mode Edit)",
|
||||
"priceRange": "Rentang Harga",
|
||||
"role": "Peran",
|
||||
"regions": "Wilayah",
|
||||
"warehouses": "Gudang",
|
||||
"liveEditor": "Editor Langsung"
|
||||
},
|
||||
"placeholders": {
|
||||
"selectComplexObject": "Pilih objek yang kompleks",
|
||||
"selectMultipleObjects": "Pilih beberapa objek",
|
||||
"searchPokemon": "Cari pokemon...",
|
||||
"selectMultiplePokemon": "Pilih beberapa pokemon...",
|
||||
"scrollDeduplication": "Gulir untuk menguji deduplikasi..."
|
||||
},
|
||||
"descriptions": {
|
||||
"min6Chars": "Minimal 6 karakter",
|
||||
"min8Complex": "Min 8, 1 huruf besar, 1 angka, 1 karakter khusus",
|
||||
"mustBePositive": "Harus > 0",
|
||||
"formatPhone": "Format: +62...",
|
||||
"zodMinLengthString": "Ini menggunakan validasi panjang string minimum Zod",
|
||||
"freshTipTap": "Instance editor TipTap yang baru",
|
||||
"htmlStringLoaded": "String HTML berhasil dimuat dari nilai default",
|
||||
"typeToSeePreview": "Ketik untuk melihat render reaktif seketika di bawah",
|
||||
"selectedRegionsTax": "Tarif pajak wilayah yang dipilih:"
|
||||
},
|
||||
"errors": {
|
||||
"departmentRequired": "Departemen wajib diisi",
|
||||
"vendorRequired": "Vendor wajib diisi",
|
||||
"min2Assignees": "Pilih minimal 2 penerima tugas",
|
||||
"min1Vendor": "Pilih minimal 1 vendor",
|
||||
"notesMin15": "Catatan minimal harus terdiri dari 15 karakter (termasuk tag HTML)",
|
||||
"selectRegionFirst": "Pilih wilayah terlebih dahulu untuk memuat gudang"
|
||||
},
|
||||
"validation": {
|
||||
"simplePassword": "Sandi Sederhana",
|
||||
"complexPassword": "Sandi Kompleks",
|
||||
"score": "Skor (Positif)",
|
||||
"ageRange": "Rentang Usia (18-65)",
|
||||
"usernameRange": "Panjang Username (3-15)",
|
||||
"phone": "Nomor Telepon (+62)"
|
||||
},
|
||||
"watch": {
|
||||
"userType": "Tipe Pengguna",
|
||||
"typePersonal": "Personal",
|
||||
"typeCorporate": "Perusahaan",
|
||||
"corporateTaxId": "NPWP Perusahaan",
|
||||
"hasSpouse": "Apakah Anda memiliki pasangan?",
|
||||
"spouseName": "Nama Pasangan"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import en from './en.json';
|
||||
import id from './id.json';
|
||||
|
||||
export type FormDemoI18n = typeof en;
|
||||
|
||||
export function useFormDemoTranslation(): FormDemoI18n {
|
||||
const { i18n } = useTranslation();
|
||||
return (i18n.language === 'id' ? id : en) as FormDemoI18n;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './form-demo-view';
|
||||
@@ -22,11 +22,14 @@ import {
|
||||
Box,
|
||||
Paper,
|
||||
} from '@repo/ui/components';
|
||||
import { ShieldCheck, Database, Lock, Layout, Activity, Printer } from 'lucide-react';
|
||||
import { ShieldCheck, Database, Lock, Layout, Activity, Printer, FileText } from 'lucide-react';
|
||||
import { Globe } from 'lucide-react';
|
||||
import { useTranslation } from '@repo/core-i18n';
|
||||
import PrinterList from './printer-list';
|
||||
import ExamplePage from './example/example.page';
|
||||
import EventsDemoPage from './events-demo';
|
||||
import PouchSample from './pouch-sample';
|
||||
import FormDemoView from './example/features/form-demo';
|
||||
|
||||
interface ShowcaseViewProps {
|
||||
colorScheme: ColorSchemeType;
|
||||
@@ -37,6 +40,7 @@ interface ShowcaseViewProps {
|
||||
|
||||
export default function ShowcaseView({ colorScheme, setColorScheme, density, setDensity }: ShowcaseViewProps) {
|
||||
const [activeTab, setActiveTab] = useState<string | null>('ui-components');
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
// Mock data for the table
|
||||
const tableData = [
|
||||
@@ -55,6 +59,8 @@ export default function ShowcaseView({ colorScheme, setColorScheme, density, set
|
||||
return 'Authentication & Security Layers';
|
||||
case 'ui-components':
|
||||
return 'Theme, Typography, Forms & Data Grids';
|
||||
case 'forms':
|
||||
return 'Enterprise Form Engine & Zod Validation';
|
||||
case 'events':
|
||||
return 'Global Event Bus Synchronization';
|
||||
case 'hardware':
|
||||
@@ -100,6 +106,9 @@ export default function ShowcaseView({ colorScheme, setColorScheme, density, set
|
||||
<Tabs.Tab value="ui-components" leftSection={<Layout size={18} />}>
|
||||
UI Components
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="forms" leftSection={<FileText size={18} />}>
|
||||
Form Engine
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="storage" leftSection={<Database size={18} />}>
|
||||
Offline Storage
|
||||
</Tabs.Tab>
|
||||
@@ -138,6 +147,18 @@ export default function ShowcaseView({ colorScheme, setColorScheme, density, set
|
||||
{getSubtitle()}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Select
|
||||
w={180}
|
||||
size="sm"
|
||||
variant="filled"
|
||||
leftSection={<Globe size={16} />}
|
||||
data={[
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'id', label: 'Bahasa Indonesia' },
|
||||
]}
|
||||
value={i18n.resolvedLanguage || i18n.language}
|
||||
onChange={(val) => val && i18n.changeLanguage(val)}
|
||||
/>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
@@ -277,6 +298,13 @@ export default function ShowcaseView({ colorScheme, setColorScheme, density, set
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* --- FORMS TAB --- */}
|
||||
{activeTab === 'forms' && (
|
||||
<Stack gap="xl">
|
||||
<FormDemoView />
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* --- STORAGE TAB --- */}
|
||||
{activeTab === 'storage' && (
|
||||
<Stack gap="xl">
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"validation": {
|
||||
"required": "{{field}} is required",
|
||||
"min_length": "{{field}} must be at least {{min}} characters",
|
||||
"min_len": "{{field}} must be at least {{min}} characters",
|
||||
"max_len": "{{field}} must be at most {{max}} characters",
|
||||
"range_len": "{{field}} must be between {{min}} and {{max}} characters",
|
||||
"min_val": "{{field}} must be greater than or equal to {{min}}",
|
||||
"max_val": "{{field}} must be less than or equal to {{max}}",
|
||||
"range_val": "{{field}} must be between {{min}} and {{max}}",
|
||||
"must_be_positive": "{{field}} must be a positive number",
|
||||
"invalid_email": "Invalid email format",
|
||||
"invalid_phone": "Invalid phone number format",
|
||||
"invalid_password_simple": "Password must be at least {{min}} characters",
|
||||
"invalid_password_complex": "Password must contain at least 1 uppercase, 1 lowercase, 1 number, and 1 special character"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"validation": {
|
||||
"required": "{{field}} wajib diisi",
|
||||
"min_length": "{{field}} minimal {{min}} karakter",
|
||||
"min_len": "{{field}} minimal {{min}} karakter",
|
||||
"max_len": "{{field}} maksimal {{max}} karakter",
|
||||
"range_len": "{{field}} harus antara {{min}} dan {{max}} karakter",
|
||||
"min_val": "{{field}} minimal bernilai {{min}}",
|
||||
"max_val": "{{field}} maksimal bernilai {{max}}",
|
||||
"range_val": "{{field}} harus bernilai antara {{min}} dan {{max}}",
|
||||
"must_be_positive": "{{field}} harus bernilai positif",
|
||||
"invalid_email": "Format email tidak valid",
|
||||
"invalid_phone": "Format nomor telepon tidak valid",
|
||||
"invalid_password_simple": "Kata sandi minimal {{min}} karakter",
|
||||
"invalid_password_complex": "Kata sandi harus mengandung minimal 1 huruf besar, 1 huruf kecil, 1 angka, dan 1 karakter spesial"
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 (
|
||||
<form onSubmit={handleSubmit(console.log)}>
|
||||
<FieldTextInput name="name" control={control} label="Name" />
|
||||
<FieldSelect
|
||||
name="role"
|
||||
control={control}
|
||||
label="Role"
|
||||
data={['Admin', 'Editor', 'Viewer']}
|
||||
/>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 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`
|
||||
@@ -0,0 +1,915 @@
|
||||
# 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<MantineComponentProps>(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
|
||||
└── rich-text.field.tsx # FieldRichTextEditor
|
||||
```
|
||||
|
||||
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<TextInputProps>('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.
|
||||
<ThemeProvider colorScheme="dark" density="compact">
|
||||
<form>
|
||||
<FieldTextInput name="email" control={control} label="Email" />
|
||||
</form>
|
||||
</ThemeProvider>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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/sample.validator.ts
|
||||
import { z } from 'zod';
|
||||
import { compose, emailValidator, minLength } from './registry.validator';
|
||||
|
||||
export const sampleValidator = z.object({
|
||||
email: compose(z.string(), emailValidator()),
|
||||
name: compose(z.string(), minLength(3, 'Nama')),
|
||||
});
|
||||
|
||||
export type SampleValidatorType = z.infer<typeof sampleValidator>;
|
||||
```
|
||||
|
||||
### 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 { sampleValidator, type SampleValidatorType } from '@repo/ui/validators';
|
||||
|
||||
function ExampleForm() {
|
||||
const { control, handleSubmit } = useForm<SampleValidatorType>({
|
||||
resolver: zodResolver(sampleValidator),
|
||||
defaultValues: { email: '', name: '' },
|
||||
});
|
||||
|
||||
const onSubmit: SubmitHandler<SampleValidatorType> = (data) => console.log(data);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<FieldTextInput name="email" control={control} label="Email" />
|
||||
<FieldTextInput name="name" control={control} label="Name" />
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Validator Bank Reference
|
||||
|
||||
The `registry.validator.ts` provides a set of pre-configured atomic validators returning modified Zod schemas that automatically emit translated JSON payloads.
|
||||
|
||||
### Available Atomic Validators
|
||||
|
||||
| Category | Validator | Target Type | Description |
|
||||
|---|---|---|---|
|
||||
| **Numeric** | `minValue(min, field?)` | `ZodNumber` | Minimum numeric value |
|
||||
| **Numeric** | `maxValue(max, field?)` | `ZodNumber` | Maximum numeric value |
|
||||
| **Numeric** | `rangeValue(min, max, field?)` | `ZodNumber` | Restricts value between `min` and `max` limits |
|
||||
| **Numeric** | `positiveNumber(field?)` | `ZodNumber` | Restricts to positive numbers |
|
||||
| **String** | `minLength(len, field?)` | `ZodString` | Minimum string character length |
|
||||
| **String** | `maxLength(len, field?)` | `ZodString` | Maximum string character length |
|
||||
| **String** | `rangeLength(min, max, field?)` | `ZodString` | Restricts string length between `min` and `max` bounds |
|
||||
| **Security** | `simplePassword(min)` | `ZodString` | Checks password string length bounds only |
|
||||
| **Security** | `complexPassword(min)` | `ZodString` | Enforces length, 1 uppercase, 1 lowercase, 1 number, and 1 special char |
|
||||
| **Technical** | `emailValidator()` | `ZodString` | Standard email format |
|
||||
| **Technical** | `phoneValidator()` | `ZodString` | Enforces Indonesian (+62) phone number format |
|
||||
|
||||
> [!WARNING]
|
||||
> Always distinguish between `rangeValue` (which bounds the actual numeric integer/float) and `rangeLength` (which bounds the amount of characters in a string).
|
||||
|
||||
### Composition Guide
|
||||
|
||||
Instead of manually chaining long `.min().max().regex()` methods, use the `compose()` helper utility to elegantly stack atomic validators onto a base primitive.
|
||||
|
||||
**Example: User Registration Password Field**
|
||||
|
||||
```tsx
|
||||
import { z } from 'zod';
|
||||
import { compose, required, minLength, complexPassword } from '@repo/ui/validators';
|
||||
|
||||
export const userRegistrationSchema = z.object({
|
||||
password: compose(
|
||||
z.string(),
|
||||
required('Password'),
|
||||
complexPassword(8)
|
||||
)
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Validators
|
||||
|
||||
We enforce strict test coverage for our Validation Bank. If you add a new atomic validator to `registry.validator.ts`, you MUST add corresponding tests to `__tests__/registry.validator.test.ts`.
|
||||
|
||||
Tests must explicitly verify the JSON stringified i18n payload:
|
||||
|
||||
```typescript
|
||||
it('minValue() should enforce min', () => {
|
||||
const schema = compose(z.number(), minValue(10, 'Age'));
|
||||
const res = schema.safeParse(5);
|
||||
|
||||
expect(res.success).toBe(false);
|
||||
expect(res.error?.issues[0].message).toBe(
|
||||
JSON.stringify({ key: 'validation:min_val', values: { min: 10, field: 'Age' } })
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Reactive Form Logic: useConditionalField
|
||||
|
||||
To decouple complex rendering side-effects from your component's root render function, the `@repo/ui/hooks` module provides `useConditionalField`. This hook automatically cleans up React Hook Form fields based on dynamic boolean conditions, enabling efficient micro-subscription architectures via `useWatch`.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> The hook exclusively uses a strict `UseConditionalFieldOptions` object signature. Legacy positional parameters are no longer supported to ensure strict typing and predictability across the monorepo.
|
||||
|
||||
### Core Modes
|
||||
|
||||
The hook supports two cleanup strategies defined by the `mode` parameter:
|
||||
|
||||
| Mode | Behavior | Use Case |
|
||||
|---|---|---|
|
||||
| `unregister` | Completely unmounts the field. Value is wiped. Key is removed from submission payload. | Hidden fields (e.g. Spouse Name if "Single" is checked). |
|
||||
| `reset` | Field stays active/disabled. Value is wiped. Error state is cleared. Key is sent in payload as empty/default. | Disabled or Cascading fields (e.g. Email Input if "Subscribe" is false, or resetting City when Province changes). |
|
||||
|
||||
### Hook Configuration
|
||||
|
||||
```tsx
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import { useConditionalField } from '@repo/ui/hooks';
|
||||
|
||||
export function ExampleForm() {
|
||||
const { control, setValue, unregister, clearErrors } = useForm();
|
||||
|
||||
const userType = useWatch({ control, name: 'userType' });
|
||||
const newsletter = useWatch({ control, name: 'newsletter' });
|
||||
|
||||
// 1. Unregister Mode (Hidden Field)
|
||||
useConditionalField({
|
||||
condition: userType === 'CORPORATE',
|
||||
name: 'corporateTaxId',
|
||||
setValue,
|
||||
unregister,
|
||||
mode: 'unregister'
|
||||
});
|
||||
|
||||
// 2. Reset Mode (Visible but Disabled)
|
||||
useConditionalField({
|
||||
condition: newsletter === true,
|
||||
name: 'newsletterEmail',
|
||||
setValue,
|
||||
clearErrors,
|
||||
mode: 'reset'
|
||||
});
|
||||
|
||||
return <form>...</form>;
|
||||
}
|
||||
```
|
||||
|
||||
### Cascading Dropdowns & Reactivity
|
||||
|
||||
When dealing with cascading dependencies (e.g., Department -> Role), changing the parent dropdown should invalidate and reset the child dropdown.
|
||||
|
||||
You can accomplish this easily by supplying `mode: 'reset'` to `useConditionalField`. However, there is a **critical rendering caveat** with Mantine's `Select` (and similar complex visual inputs):
|
||||
|
||||
> [!WARNING]
|
||||
> **The Dynamic Key Trick:** Mantine components aggressively cache their internal visual text state. Even if `useConditionalField` perfectly resets the React Hook Form payload state to `''`, Mantine may still visually display the old, stale text on the screen.
|
||||
>
|
||||
> To fix this UI desync, you **must bind the parent dependency to the child component's `key` prop**. This forces React's reconciliation engine to completely unmount and remount the child DOM node, flushing Mantine's internal cache and guaranteeing perfect UI synchronization.
|
||||
|
||||
#### Master Example: Department to Role Cascade
|
||||
|
||||
```tsx
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import { useConditionalField } from '@repo/ui/hooks';
|
||||
import { FieldSelect } from '@repo/ui/form';
|
||||
|
||||
export function DepartmentForm() {
|
||||
const { control, setValue, clearErrors } = useForm();
|
||||
|
||||
const department = useWatch({ control, name: 'department' });
|
||||
const role = useWatch({ control, name: 'role' });
|
||||
|
||||
// Derive available options based on the parent state
|
||||
const currentRoleOptions = department === 'IT'
|
||||
? [{ value: 'FRONTEND', label: 'Frontend' }, { value: 'BACKEND', label: 'Backend' }]
|
||||
: [];
|
||||
|
||||
// Determine if the currently selected role is still mathematically valid
|
||||
const isRoleValid = !role || (!!department && currentRoleOptions.some(opt => opt.value === role));
|
||||
|
||||
// 3. Reset Mode: Automatically wipes the field value in the RHF Payload if it becomes invalid
|
||||
useConditionalField({
|
||||
condition: isRoleValid,
|
||||
name: 'role',
|
||||
setValue,
|
||||
clearErrors,
|
||||
mode: 'reset',
|
||||
defaultValue: ''
|
||||
});
|
||||
|
||||
return (
|
||||
<form>
|
||||
<FieldSelect
|
||||
name="department"
|
||||
control={control}
|
||||
label="Department"
|
||||
data={[{ value: 'IT', label: 'Information Technology' }]}
|
||||
/>
|
||||
|
||||
{/* CRITICAL: We bind the department string to the key prop to force remounts on change */}
|
||||
<FieldSelect
|
||||
key={`role-select-${department}`}
|
||||
name="role"
|
||||
control={control}
|
||||
label="Role"
|
||||
disabled={!department}
|
||||
data={currentRoleOptions}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Object & Async Select Components
|
||||
|
||||
Mantine's native `Select` and `MultiSelect` are string-based: they store `string | null` and `string[]` respectively. In enterprise applications, we often need to store **full objects** (`T | null` or `T[]`) in RHF state — for example, a user object `{ id: '1', name: 'Alice', email: 'alice@co.com' }` rather than just `'1'`.
|
||||
|
||||
The **LocalSelect** and **AsyncSelect** engines bridge this gap by:
|
||||
1. Mapping `T[]` → `ComboboxItem[]` for Mantine rendering (via `valueKey` + `labelKey`/`renderLabel`)
|
||||
2. Building an O(1) reverse lookup map (`Map<string, T>`) for resolving string changes back to full objects
|
||||
3. Intercepting `onChange` to pass resolved objects to RHF
|
||||
|
||||
> [!IMPORTANT]
|
||||
> These components are **separate** from the native `FieldSelect` and `FieldMultiSelect`, which continue to work as simple string-based Mantine wrappers. Use `FieldLocalSelect`/`FieldAsyncSelect` only when you need to store full objects in RHF state.
|
||||
|
||||
### Single vs. Multi-Select Data Mapping
|
||||
|
||||
| Mode | Mantine Component | RHF Value | Mantine `value` Prop | `onChange` Payload |
|
||||
|---|---|---|---|---|
|
||||
| `multiple={false}` (default) | `<Select />` | `T \| null` | `string \| null` | `T \| null` |
|
||||
| `multiple={true}` | `<MultiSelect />` | `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 (
|
||||
<form onSubmit={handleSubmit((data) => console.log(data.department))}>
|
||||
<FieldLocalSelect<Department>
|
||||
name="department"
|
||||
control={control}
|
||||
label="Department"
|
||||
options={departments}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
searchable
|
||||
/>
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
// 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<T>` | ✅ | 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<User> = 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 (
|
||||
<form onSubmit={handleSubmit((data) => console.log(data.user))}>
|
||||
<FieldAsyncSelect<User>
|
||||
name="user"
|
||||
control={control}
|
||||
label="Assign User"
|
||||
loadOptions={loadUsers}
|
||||
valueKey="id"
|
||||
labelKey="fullName"
|
||||
placeholder="Search users..."
|
||||
/>
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### Non-Paginated Example
|
||||
|
||||
If your API returns all results at once, return `hasMore: false`:
|
||||
|
||||
```tsx
|
||||
const loadRoles: LoadOptionsFn<Role> = 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 (
|
||||
<FieldAsyncSelect<User>
|
||||
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 (
|
||||
<FieldAsyncSelect<Tag>
|
||||
multiple
|
||||
name="tags"
|
||||
control={control}
|
||||
label="Tags"
|
||||
loadOptions={loadTags}
|
||||
valueKey="id"
|
||||
renderLabel={(tag) => `${tag.name} (${tag.count})`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// On submit: data.tags = [{ id: '1', name: 'React', count: 42 }, ...]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Enterprise Performance Guidelines: Forms & Validation
|
||||
|
||||
When building large-scale ERP forms, seemingly trivial React or Zod patterns can catastrophically degrade performance at scale. Adhere strictly to the following optimizations.
|
||||
|
||||
### The "Unstable Default Value" Trap in Hooks
|
||||
|
||||
When creating custom form hooks (like `useConditionalField`), you often need to provide a fallback or default value. Passing an inline array or object as a `defaultValue` can trigger infinite render loops if it is included in a `useEffect` dependency array, because React's referential equality check fails on every render.
|
||||
|
||||
**Solution: The `useRef` Stabilization Pattern**
|
||||
|
||||
We resolve this by storing the `defaultValue` in a `useRef`. This allows the hook's cleanup logic to access the latest value without triggering the effect again:
|
||||
|
||||
```tsx
|
||||
// Inside useConditionalField.ts
|
||||
const defaultValueRef = useRef(defaultValue);
|
||||
|
||||
// Update ref on every render without triggering dependencies
|
||||
useEffect(() => {
|
||||
defaultValueRef.current = defaultValue;
|
||||
}, [defaultValue]);
|
||||
|
||||
// The main effect no longer depends on defaultValue
|
||||
useEffect(() => {
|
||||
if (!condition) {
|
||||
const targetValue = defaultValueRef.current !== undefined ? defaultValueRef.current : '';
|
||||
setValue(name, targetValue);
|
||||
}
|
||||
}, [condition, name, setValue]);
|
||||
```
|
||||
|
||||
### Zod Schema Performance: Avoid superRefine for Conditionals
|
||||
|
||||
For complex dynamic forms, developers often default to `.superRefine` or `.refine` to handle conditional validation (e.g., "Require Tax ID only if userType is Corporate").
|
||||
|
||||
**The Problem:** `superRefine` acts as an opaque callback. Zod cannot optimize it. In large forms, doing manual `.safeParse` inside a `superRefine` loop forces Zod to parse the entire tree continuously on every keystroke, leading to severe O(n) CPU spikes.
|
||||
|
||||
**The Solution:** Use declarative schema branching via `.and()`, `z.discriminatedUnion`, and `z.union`. These are statically analyzed by Zod and evaluated at native speed.
|
||||
|
||||
#### ❌ Bad: Manual Parsing (O(n) CPU Spike)
|
||||
|
||||
```tsx
|
||||
const badSchema = z.object({
|
||||
userType: z.enum(['PERSONAL', 'CORPORATE']),
|
||||
corporateTaxId: z.string().optional()
|
||||
}).superRefine((data, ctx) => {
|
||||
if (data.userType === 'CORPORATE') {
|
||||
// ⚠️ INCREDIBLY SLOW: Manual parsing inside refine loop
|
||||
const res = taxIdValidator.safeParse(data.corporateTaxId);
|
||||
if (!res.success) ctx.addIssue({ ...res.error.issues[0], path: ['corporateTaxId'] });
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
#### ✅ Good: Declarative Unions (O(1) Evaluation)
|
||||
|
||||
```tsx
|
||||
const goodSchema = z.object({
|
||||
userType: z.enum(['PERSONAL', 'CORPORATE']),
|
||||
corporateTaxId: z.string().optional()
|
||||
}).and(
|
||||
z.discriminatedUnion('userType', [
|
||||
z.object({ userType: z.literal('PERSONAL') }),
|
||||
z.object({ userType: z.literal('CORPORATE'), corporateTaxId: taxIdValidator })
|
||||
])
|
||||
);
|
||||
```
|
||||
|
||||
By stacking `.and(z.union([...]))` for independent conditionals (like `hasSpouse`, `newsletter`, etc.), you achieve lightning-fast, type-safe conditional validation without writing a single `superRefine` loop.
|
||||
|
||||
---
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Form
|
||||
|
||||
```tsx
|
||||
import { useForm, type SubmitHandler } from 'react-hook-form';
|
||||
import { FieldTextInput, FieldPasswordInput } from '@repo/ui/form';
|
||||
|
||||
type LoginForm = { email: string; password: string };
|
||||
|
||||
function LoginForm() {
|
||||
const { control, handleSubmit } = useForm<LoginForm>({
|
||||
defaultValues: { email: '', password: '' },
|
||||
});
|
||||
|
||||
const onSubmit: SubmitHandler<LoginForm> = (data) => {
|
||||
console.log(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<FieldTextInput name="email" control={control} label="Email" />
|
||||
<FieldPasswordInput name="password" control={control} label="Password" />
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 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<typeof productSchema>;
|
||||
|
||||
function ProductEditor() {
|
||||
const { control, handleSubmit } = useForm<ProductForm>({
|
||||
resolver: zodResolver(productSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
sku: '',
|
||||
price: 0,
|
||||
category: '',
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit: SubmitHandler<ProductForm> = (data) => console.log(data);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<FieldTextInput name="name" control={control} label="Product Name" />
|
||||
<FieldTextInput name="sku" control={control} label="SKU" placeholder="ABC-1234" />
|
||||
<FieldNumberInput name="price" control={control} label="Price" min={0} prefix="$" />
|
||||
<FieldSelect
|
||||
name="category"
|
||||
control={control}
|
||||
label="Category"
|
||||
data={['Electronics', 'Clothing', 'Food']}
|
||||
/>
|
||||
<FieldCheckbox name="isActive" control={control} label="Active" />
|
||||
<button type="submit">Save Product</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 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<DatePickerInputProps>(
|
||||
'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 `<select>` element |
|
||||
| `FieldTagsInput` | `TagsInput` | Selection | Free-form tag entry |
|
||||
| `FieldCheckbox` | `Checkbox` | Toggle | Boolean checkbox (uses `checked`) |
|
||||
| `FieldRadioGroup` | `Radio.Group` | Toggle | Radio button group |
|
||||
| `FieldSwitch` | `Switch` | Toggle | Boolean switch (uses `checked`) |
|
||||
| `FieldChipGroup` | `Chip.Group` | Toggle | Chip selection group (uses `Input.Wrapper`) |
|
||||
| `FieldSegmentedControl` | `SegmentedControl` | Toggle | Segmented control (uses `Input.Wrapper`) |
|
||||
| `FieldSlider` | `Slider` | Range | Single-value slider |
|
||||
| `FieldRangeSlider` | `RangeSlider` | Range | Dual-handle range slider |
|
||||
| `FieldRating` | `Rating` | Range | Star rating |
|
||||
| `FieldColorInput` | `ColorInput` | Color | Color picker with text input |
|
||||
| `FieldColorPicker` | `ColorPicker` | Color | Color picker only (uses `Input.Wrapper`) |
|
||||
| `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[] | null` |
|
||||
| `FieldRichTextEditor` | `@mantine/tiptap` | `string` (HTML) |
|
||||
|
||||
### Rich Text Editor (TipTap)
|
||||
The `FieldRichTextEditor` component integrates `@mantine/tiptap` directly with React Hook Form. It safely stores the Editor's HTML output directly into the RHF state as a `string`. Because TipTap is an uncontrolled editor natively, this field uses a specialized `useController` wrapper that automatically syncs bidirectional updates (e.g., calling `editor.commands.setContent(field.value)` when the form is reset or async default values arrive).
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
Tests are located in `src/components/Form/__tests__/` and can be run via:
|
||||
|
||||
```bash
|
||||
cd packages/ui && pnpm test
|
||||
```
|
||||
|
||||
The test suite covers:
|
||||
|
||||
- **`withRHF.test.tsx`** (8 tests) — Core HOC behavior: rendering, value binding, input mutation, error display, i18n translation, fallback behavior, displayName, prop forwarding
|
||||
- **`text-input.field.test.tsx`** (4 tests) — FieldTextInput integration with Zod validation, error display/clearing, and full submission flow
|
||||
- **`checkbox.field.test.tsx`** (4 tests) — FieldCheckbox boolean toggle, checked state, RHF submission, and Zod required validation
|
||||
|
||||
All tests use `@testing-library/react` with mocked `@repo/core-i18n` and a `window.matchMedia` polyfill for jsdom compatibility with Mantine v8.
|
||||
@@ -4,8 +4,10 @@
|
||||
"exports": {
|
||||
"./theme.css": "./src/theme.css",
|
||||
"./components": "./src/components/index.ts",
|
||||
"./form": "./src/components/Form/index.ts",
|
||||
"./hooks": "./src/hooks/index.ts",
|
||||
"./provider": "./src/provider/index.ts"
|
||||
"./provider": "./src/provider/index.ts",
|
||||
"./validators": "./src/validators/index.ts"
|
||||
},
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
@@ -18,22 +20,37 @@
|
||||
"react-dom": "^19.2.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.0.1",
|
||||
"@mantine/core": "^8.3.15",
|
||||
"@mantine/hooks": "^8.3.15",
|
||||
"@mantine/tiptap": "^9.3.2",
|
||||
"@repo/core-i18n": "workspace:*",
|
||||
"@repo/utils": "workspace:*",
|
||||
"@tiptap/extension-link": "^3.27.1",
|
||||
"@tiptap/extension-text-align": "^3.27.1",
|
||||
"@tiptap/extension-underline": "^3.27.1",
|
||||
"@tiptap/pm": "^3.27.1",
|
||||
"@tiptap/react": "^3.27.1",
|
||||
"@tiptap/starter-kit": "^3.27.1",
|
||||
"dayjs": "^1.11.19",
|
||||
"react-hook-form": "^7.56.4",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwind-variants": "^3.2.2",
|
||||
"tailwindcss": "^4.1.18"
|
||||
"tailwindcss": "^4.1.18",
|
||||
"zod": "^3.25.36"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/eslint-config": "workspace:*",
|
||||
"@repo/typescript-config": "workspace:*",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"eslint": "^8.57.1",
|
||||
"jsdom": "^26.1.0",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"typescript": "5.5.4",
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Polyfill: window.matchMedia
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mantine v8's MantineProvider calls window.matchMedia internally for
|
||||
// color scheme detection. jsdom does not implement matchMedia, so we
|
||||
// provide a minimal stub to prevent TypeError during test rendering.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: (query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
}),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Polyfill: ResizeObserver
|
||||
// ---------------------------------------------------------------------------
|
||||
// Some Mantine components (Popover, Select dropdown) use ResizeObserver
|
||||
// which is also not available in jsdom.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class ResizeObserverStub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
window.ResizeObserver = ResizeObserverStub as unknown as typeof ResizeObserver;
|
||||
@@ -0,0 +1,239 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
vi.mock('@repo/core-i18n', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { exists: () => false },
|
||||
}),
|
||||
}));
|
||||
|
||||
type Vendor = { id: number; code: string; name: string };
|
||||
|
||||
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<LoadOptionsFn<Vendor>>().mockResolvedValue({
|
||||
options: vendors,
|
||||
hasMore: false,
|
||||
});
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('FieldAsyncSelect', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
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 } });
|
||||
return (
|
||||
<MantineProvider>
|
||||
<form>
|
||||
<FieldAsyncSelect
|
||||
name="vendor"
|
||||
control={control}
|
||||
loadOptions={mockLoadOptions}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
placeholder="Select async vendor"
|
||||
/>
|
||||
</form>
|
||||
</MantineProvider>
|
||||
);
|
||||
}
|
||||
|
||||
render(<TestForm />);
|
||||
|
||||
// Wait for initial fetch (page 1, search='')
|
||||
await waitFor(() => {
|
||||
expect(mockLoadOptions).toHaveBeenCalledWith('', 1, []);
|
||||
});
|
||||
|
||||
await user.click(screen.getByPlaceholderText('Select async vendor'));
|
||||
|
||||
// Items should be rendered from the mock response
|
||||
expect(screen.getByText('Vendor 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Vendor 3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
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 } });
|
||||
return (
|
||||
<MantineProvider>
|
||||
<form onSubmit={handleSubmit((data) => { capturedData = data; })}>
|
||||
<FieldAsyncSelect
|
||||
name="vendor"
|
||||
control={control}
|
||||
loadOptions={mockLoadOptions}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
placeholder="Select vendor"
|
||||
/>
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
</MantineProvider>
|
||||
);
|
||||
}
|
||||
|
||||
render(<TestForm />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockLoadOptions).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await user.click(screen.getByPlaceholderText('Select vendor'));
|
||||
await user.click(screen.getByText('Vendor 2'));
|
||||
await user.click(screen.getByText('Submit'));
|
||||
|
||||
expect(capturedData).toEqual({ vendor: { id: 2, code: 'V2', name: 'Vendor 2' } });
|
||||
});
|
||||
|
||||
it('injects pre-selected value not in fetched data', async () => {
|
||||
// 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' };
|
||||
|
||||
function TestForm() {
|
||||
const { control } = useForm({ defaultValues: { vendor: PRESELECTED_VENDOR } });
|
||||
return (
|
||||
<MantineProvider>
|
||||
<form>
|
||||
<FieldAsyncSelect
|
||||
name="vendor"
|
||||
control={control}
|
||||
loadOptions={partialLoadOptions}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
placeholder="Select vendor"
|
||||
/>
|
||||
</form>
|
||||
</MantineProvider>
|
||||
);
|
||||
}
|
||||
|
||||
render(<TestForm />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(partialLoadOptions).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// The display value of the Select input should show the injected label
|
||||
const input = screen.getByRole('textbox') as HTMLInputElement;
|
||||
expect(input.value).toBe('Vendor 99');
|
||||
});
|
||||
|
||||
it('debounces search input and refetches', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockLoadOptions = createMockLoadOptions();
|
||||
|
||||
function TestForm() {
|
||||
const { control } = useForm({ defaultValues: { vendor: null } });
|
||||
return (
|
||||
<MantineProvider>
|
||||
<form>
|
||||
<FieldAsyncSelect
|
||||
name="vendor"
|
||||
control={control}
|
||||
loadOptions={mockLoadOptions}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
placeholder="Search vendor"
|
||||
debounceMs={100} // fast debounce for test
|
||||
/>
|
||||
</form>
|
||||
</MantineProvider>
|
||||
);
|
||||
}
|
||||
|
||||
render(<TestForm />);
|
||||
|
||||
// Wait for initial fetch
|
||||
await waitFor(() => {
|
||||
expect(mockLoadOptions).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const input = screen.getByPlaceholderText('Search vendor');
|
||||
await user.type(input, 'test');
|
||||
|
||||
// Wait for debounced fetch — should call with search='test'
|
||||
await waitFor(() => {
|
||||
expect(mockLoadOptions).toHaveBeenCalledTimes(2);
|
||||
expect(mockLoadOptions).toHaveBeenLastCalledWith('test', 1, []);
|
||||
});
|
||||
});
|
||||
|
||||
it('gracefully deduplicates overlapping data across API responses', async () => {
|
||||
// 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 } });
|
||||
return (
|
||||
<MantineProvider>
|
||||
<form>
|
||||
<FieldAsyncSelect
|
||||
name="vendor"
|
||||
control={control}
|
||||
loadOptions={badLoadOptions}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
placeholder="Select bad vendor"
|
||||
/>
|
||||
</form>
|
||||
</MantineProvider>
|
||||
);
|
||||
}
|
||||
|
||||
render(<TestForm />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(badLoadOptions).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByPlaceholderText('Select bad vendor'));
|
||||
|
||||
// Should only render "Vendor 1" once, ignoring the duplicate with id=1
|
||||
const vendor1Options = screen.getAllByText('Vendor 1');
|
||||
expect(vendor1Options.length).toBe(1);
|
||||
|
||||
// The duplicate name should NOT be rendered
|
||||
expect(screen.queryByText('Vendor 1 (Duplicate)')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import { FieldCheckbox } from '../fields/checkbox.field';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock @repo/core-i18n
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
vi.mock('@repo/core-i18n', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: Record<string, unknown>) => {
|
||||
return (options?.defaultValue as string) ?? key;
|
||||
},
|
||||
i18n: {
|
||||
exists: () => false,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test schema
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const termsSchema = z.object({
|
||||
acceptTerms: z.literal(true, {
|
||||
errorMap: () => ({ message: 'You must accept the terms' }),
|
||||
}),
|
||||
});
|
||||
|
||||
type TermsFormValues = z.infer<typeof termsSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('FieldCheckbox', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders with a label', () => {
|
||||
function TestForm() {
|
||||
const { control } = useForm({ defaultValues: { acceptTerms: false } });
|
||||
return (
|
||||
<MantineProvider>
|
||||
<FieldCheckbox
|
||||
name="acceptTerms"
|
||||
control={control}
|
||||
label="I accept the terms and conditions"
|
||||
/>
|
||||
</MantineProvider>
|
||||
);
|
||||
}
|
||||
|
||||
render(<TestForm />);
|
||||
expect(screen.getByLabelText('I accept the terms and conditions')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggles checked state on click', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
function TestForm() {
|
||||
const { control } = useForm({ defaultValues: { acceptTerms: false } });
|
||||
return (
|
||||
<MantineProvider>
|
||||
<FieldCheckbox
|
||||
name="acceptTerms"
|
||||
control={control}
|
||||
label="Accept Terms"
|
||||
/>
|
||||
</MantineProvider>
|
||||
);
|
||||
}
|
||||
|
||||
render(<TestForm />);
|
||||
|
||||
const checkbox = screen.getByLabelText('Accept Terms');
|
||||
expect(checkbox).not.toBeChecked();
|
||||
|
||||
await user.click(checkbox);
|
||||
expect(checkbox).toBeChecked();
|
||||
|
||||
await user.click(checkbox);
|
||||
expect(checkbox).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('submits the boolean value via RHF', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = vi.fn();
|
||||
|
||||
function TestForm() {
|
||||
const { control, handleSubmit } = useForm({
|
||||
defaultValues: { acceptTerms: false },
|
||||
});
|
||||
return (
|
||||
<MantineProvider>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<FieldCheckbox name="acceptTerms" control={control} label="Accept" />
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
</MantineProvider>
|
||||
);
|
||||
}
|
||||
|
||||
render(<TestForm />);
|
||||
|
||||
await user.click(screen.getByLabelText('Accept'));
|
||||
await user.click(screen.getByText('Submit'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
{ acceptTerms: true },
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('displays Zod validation error when not checked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = vi.fn();
|
||||
|
||||
function TestForm() {
|
||||
const { control, handleSubmit } = useForm<TermsFormValues>({
|
||||
resolver: zodResolver(termsSchema),
|
||||
defaultValues: { acceptTerms: false as unknown as true },
|
||||
});
|
||||
return (
|
||||
<MantineProvider>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<FieldCheckbox name="acceptTerms" control={control} label="Accept" />
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
</MantineProvider>
|
||||
);
|
||||
}
|
||||
|
||||
render(<TestForm />);
|
||||
|
||||
// Submit without checking
|
||||
await user.click(screen.getByText('Submit'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('You must accept the terms')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import { useForm, FormProvider } from 'react-hook-form';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import { FieldLocalSelect } from '../fields/local-select.field';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock i18n
|
||||
// ---------------------------------------------------------------------------
|
||||
vi.mock('@repo/core-i18n', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { exists: () => false },
|
||||
}),
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test Data & Wrapper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Vendor = { id: number; code: string; name: string; active?: boolean };
|
||||
|
||||
const VENDORS: Vendor[] = [
|
||||
{ id: 1, code: 'V1', name: 'Vendor 1', active: true },
|
||||
{ id: 2, code: 'V2', name: 'Vendor 2', active: false },
|
||||
{ id: 3, code: 'V3', name: 'Vendor 3', active: true },
|
||||
];
|
||||
|
||||
// Test wrapper removed to avoid useForm conflicts
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('FieldLocalSelect', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders with labelKey and displays correct labels', () => {
|
||||
function TestForm() {
|
||||
const { control } = useForm({ defaultValues: { vendor: null } });
|
||||
return (
|
||||
<MantineProvider>
|
||||
<FieldLocalSelect
|
||||
name="vendor"
|
||||
control={control}
|
||||
options={VENDORS}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
label="Vendor"
|
||||
placeholder="Select vendor"
|
||||
/>
|
||||
</MantineProvider>
|
||||
);
|
||||
}
|
||||
|
||||
render(<TestForm />);
|
||||
expect(screen.getByText('Vendor')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('Select vendor')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('stores the full original object in RHF on selection', async () => {
|
||||
const user = userEvent.setup();
|
||||
let capturedData: any = null;
|
||||
|
||||
function TestForm() {
|
||||
const { control, handleSubmit } = useForm({ defaultValues: { vendor: null } });
|
||||
return (
|
||||
<MantineProvider>
|
||||
<form onSubmit={handleSubmit((data) => { capturedData = data; })}>
|
||||
<FieldLocalSelect
|
||||
name="vendor"
|
||||
control={control}
|
||||
options={VENDORS}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
placeholder="Select vendor"
|
||||
/>
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
</MantineProvider>
|
||||
);
|
||||
}
|
||||
|
||||
render(<TestForm />);
|
||||
|
||||
// Open dropdown
|
||||
await user.click(screen.getByPlaceholderText('Select vendor'));
|
||||
|
||||
// Click Vendor 2
|
||||
await user.click(screen.getByText('Vendor 2'));
|
||||
|
||||
// Submit
|
||||
await user.click(screen.getByText('Submit'));
|
||||
|
||||
// Should contain the full object, not just '2'
|
||||
expect(capturedData).toEqual({ vendor: VENDORS[1] });
|
||||
});
|
||||
|
||||
it('renders with renderLabel for compound labels', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
function TestForm() {
|
||||
const { control } = useForm({ defaultValues: { vendor: null } });
|
||||
return (
|
||||
<MantineProvider>
|
||||
<FieldLocalSelect
|
||||
name="vendor"
|
||||
control={control}
|
||||
options={VENDORS}
|
||||
valueKey="id"
|
||||
renderLabel={(item) => `${item.code} - ${item.name}`}
|
||||
placeholder="Select vendor"
|
||||
/>
|
||||
</MantineProvider>
|
||||
);
|
||||
}
|
||||
|
||||
render(<TestForm />);
|
||||
await user.click(screen.getByPlaceholderText('Select vendor'));
|
||||
|
||||
// Compound label should be visible
|
||||
expect(screen.getByText('V1 - Vendor 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
|
||||
it('filterOption excludes items from dropdown', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
function TestForm() {
|
||||
const { control } = useForm({ defaultValues: { vendor: null } });
|
||||
return (
|
||||
<MantineProvider>
|
||||
<FieldLocalSelect
|
||||
name="vendor"
|
||||
control={control}
|
||||
options={VENDORS}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
placeholder="Select vendor"
|
||||
filterOption={(item) => item.active === true}
|
||||
/>
|
||||
</MantineProvider>
|
||||
);
|
||||
}
|
||||
|
||||
render(<TestForm />);
|
||||
await user.click(screen.getByPlaceholderText('Select vendor'));
|
||||
|
||||
// Active vendors should be present
|
||||
expect(screen.getByText('Vendor 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Vendor 3')).toBeInTheDocument();
|
||||
// Inactive vendor should not be present
|
||||
expect(screen.queryByText('Vendor 2')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('multi-select stores T[] in RHF', async () => {
|
||||
const user = userEvent.setup();
|
||||
let capturedData: any = null;
|
||||
|
||||
function TestForm() {
|
||||
const { control, handleSubmit } = useForm({ defaultValues: { vendors: [] } });
|
||||
return (
|
||||
<MantineProvider>
|
||||
<form onSubmit={handleSubmit((data) => { capturedData = data; })}>
|
||||
<FieldLocalSelect
|
||||
multiple
|
||||
name="vendors"
|
||||
control={control}
|
||||
options={VENDORS}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
placeholder="Select vendors"
|
||||
/>
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
</MantineProvider>
|
||||
);
|
||||
}
|
||||
|
||||
render(<TestForm />);
|
||||
await user.click(screen.getByPlaceholderText('Select vendors'));
|
||||
await user.click(screen.getByText('Vendor 1'));
|
||||
await user.click(screen.getByText('Vendor 3'));
|
||||
|
||||
await user.click(screen.getByText('Submit'));
|
||||
expect(capturedData).toEqual({ vendors: [VENDORS[0], VENDORS[2]] });
|
||||
});
|
||||
|
||||
it('multi-select clearable resets to empty array', async () => {
|
||||
const user = userEvent.setup();
|
||||
let capturedData: any = null;
|
||||
|
||||
function TestForm() {
|
||||
const { control, handleSubmit } = useForm({ defaultValues: { vendors: [VENDORS[0]] } });
|
||||
return (
|
||||
<MantineProvider>
|
||||
<form onSubmit={handleSubmit((data) => { capturedData = data; })}>
|
||||
<FieldLocalSelect
|
||||
multiple
|
||||
name="vendors"
|
||||
control={control}
|
||||
options={VENDORS}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
clearable
|
||||
/>
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
</MantineProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const { container } = render(<TestForm />);
|
||||
|
||||
const clearButton = container.querySelector('.mantine-CloseButton-root') || container.querySelector('button[aria-label="Clear value"]');
|
||||
expect(clearButton).not.toBeNull();
|
||||
await user.click(clearButton!);
|
||||
|
||||
await user.click(screen.getByText('Submit'));
|
||||
expect(capturedData).toEqual({ vendors: [] });
|
||||
});
|
||||
|
||||
it('onSelect callback fires with correct object', async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleSelect = vi.fn();
|
||||
|
||||
function TestForm() {
|
||||
const { control } = useForm({ defaultValues: { vendor: null } });
|
||||
return (
|
||||
<MantineProvider>
|
||||
<FieldLocalSelect
|
||||
name="vendor"
|
||||
control={control}
|
||||
options={VENDORS}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
placeholder="Select vendor"
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
</MantineProvider>
|
||||
);
|
||||
}
|
||||
|
||||
render(<TestForm />);
|
||||
await user.click(screen.getByPlaceholderText('Select vendor'));
|
||||
await user.click(screen.getByText('Vendor 2'));
|
||||
|
||||
expect(handleSelect).toHaveBeenCalledWith(VENDORS[1]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import { FieldTextInput } from '../fields/text-input.field';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock @repo/core-i18n
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
vi.mock('@repo/core-i18n', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: Record<string, unknown>) => {
|
||||
const translations: Record<string, string> = {
|
||||
'validation.required': 'This field is required',
|
||||
'validation.too_small': `Minimum ${options?.min ?? ''} characters required`,
|
||||
};
|
||||
return translations[key] ?? (options?.defaultValue as string) ?? key;
|
||||
},
|
||||
i18n: {
|
||||
exists: (key: string) => ['validation.required', 'validation.too_small'].includes(key),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test schema
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const loginSchema = z.object({
|
||||
username: z
|
||||
.string()
|
||||
.min(1, 'Username cannot be empty')
|
||||
.min(3, 'Username must be at least 3 characters'),
|
||||
email: z.string().email('Please enter a valid email address'),
|
||||
});
|
||||
|
||||
type LoginFormValues = z.infer<typeof loginSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('FieldTextInput', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders with label and placeholder', () => {
|
||||
function TestForm() {
|
||||
const { control } = useForm<LoginFormValues>({
|
||||
defaultValues: { username: '', email: '' },
|
||||
});
|
||||
return (
|
||||
<MantineProvider>
|
||||
<FieldTextInput
|
||||
name="username"
|
||||
control={control}
|
||||
label="Username"
|
||||
placeholder="Enter username"
|
||||
/>
|
||||
</MantineProvider>
|
||||
);
|
||||
}
|
||||
|
||||
render(<TestForm />);
|
||||
expect(screen.getByLabelText('Username')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('Enter username')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('integrates with Zod validation and displays errors on submit', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = vi.fn();
|
||||
|
||||
function TestForm() {
|
||||
const { control, handleSubmit } = useForm<LoginFormValues>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
defaultValues: { username: '', email: '' },
|
||||
});
|
||||
return (
|
||||
<MantineProvider>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<FieldTextInput name="username" control={control} label="Username" />
|
||||
<FieldTextInput name="email" control={control} label="Email" />
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
</MantineProvider>
|
||||
);
|
||||
}
|
||||
|
||||
render(<TestForm />);
|
||||
|
||||
// Submit with empty fields
|
||||
await user.click(screen.getByText('Login'));
|
||||
|
||||
// Zod should generate validation errors
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Username cannot be empty')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// onSubmit should NOT have been called
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears errors when valid input is provided', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = vi.fn();
|
||||
|
||||
function TestForm() {
|
||||
const { control, handleSubmit } = useForm<LoginFormValues>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
defaultValues: { username: '', email: '' },
|
||||
mode: 'onSubmit',
|
||||
});
|
||||
return (
|
||||
<MantineProvider>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<FieldTextInput name="username" control={control} label="Username" />
|
||||
<FieldTextInput name="email" control={control} label="Email" />
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
</MantineProvider>
|
||||
);
|
||||
}
|
||||
|
||||
render(<TestForm />);
|
||||
|
||||
// Trigger validation errors
|
||||
await user.click(screen.getByText('Login'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Username cannot be empty')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Fill in valid data
|
||||
await user.type(screen.getByLabelText('Username'), 'john');
|
||||
await user.type(screen.getByLabelText('Email'), 'john@example.com');
|
||||
|
||||
// Re-submit with valid data
|
||||
await user.click(screen.getByText('Login'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
{ username: 'john', email: 'john@example.com' },
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('submits successfully with valid data on first try', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = vi.fn();
|
||||
|
||||
function TestForm() {
|
||||
const { control, handleSubmit } = useForm<LoginFormValues>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
defaultValues: { username: '', email: '' },
|
||||
});
|
||||
return (
|
||||
<MantineProvider>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<FieldTextInput name="username" control={control} label="Username" />
|
||||
<FieldTextInput name="email" control={control} label="Email" />
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
</MantineProvider>
|
||||
);
|
||||
}
|
||||
|
||||
render(<TestForm />);
|
||||
|
||||
await user.type(screen.getByLabelText('Username'), 'johndoe');
|
||||
await user.type(screen.getByLabelText('Email'), 'john@example.com');
|
||||
await user.click(screen.getByText('Login'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
{ username: 'johndoe', email: 'john@example.com' },
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import { useForm, FormProvider } from 'react-hook-form';
|
||||
import { MantineProvider, TextInput } from '@mantine/core';
|
||||
import { withRHF } from '../withRHF';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock @repo/core-i18n — provides a controllable useTranslation hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const mockT = vi.fn((key: string, options?: Record<string, unknown>) => {
|
||||
// Simulate i18next: return translated value if key matches, else return
|
||||
// the defaultValue or the key itself.
|
||||
const translations: Record<string, string> = {
|
||||
'validation.required': 'This field is required',
|
||||
'validation.min_length': `Minimum ${options?.min ?? ''} characters`,
|
||||
};
|
||||
return translations[key] ?? (options?.defaultValue as string) ?? key;
|
||||
});
|
||||
|
||||
const mockI18n = {
|
||||
exists: vi.fn((key: string) => {
|
||||
const knownKeys = ['validation.required', 'validation.min_length'];
|
||||
return knownKeys.includes(key);
|
||||
}),
|
||||
};
|
||||
|
||||
vi.mock('@repo/core-i18n', () => ({
|
||||
useTranslation: () => ({ t: mockT, i18n: mockI18n }),
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test wrapper component that provides MantineProvider + FormProvider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface FormTestWrapperProps {
|
||||
children: React.ReactNode;
|
||||
defaultValues?: Record<string, unknown>;
|
||||
onSubmit?: (data: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
function FormTestWrapper({
|
||||
children,
|
||||
defaultValues = {},
|
||||
onSubmit = () => {},
|
||||
}: FormTestWrapperProps) {
|
||||
const methods = useForm({ defaultValues });
|
||||
|
||||
return (
|
||||
<MantineProvider>
|
||||
<FormProvider {...methods}>
|
||||
<form onSubmit={methods.handleSubmit(onSubmit)}>
|
||||
{children}
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</MantineProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Create a test field component using the HOC
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TestFieldTextInput = withRHF<React.ComponentProps<typeof TextInput>>(
|
||||
'TestFieldTextInput',
|
||||
TextInput,
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('withRHF HOC', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders the wrapped Mantine component without crashing', () => {
|
||||
function TestForm() {
|
||||
const { control } = useForm({ defaultValues: { name: '' } });
|
||||
return (
|
||||
<MantineProvider>
|
||||
<TestFieldTextInput name="name" control={control} label="Name" />
|
||||
</MantineProvider>
|
||||
);
|
||||
}
|
||||
|
||||
render(<TestForm />);
|
||||
expect(screen.getByLabelText('Name')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays the initial value from RHF form state', () => {
|
||||
function TestForm() {
|
||||
const { control } = useForm({ defaultValues: { name: 'John Doe' } });
|
||||
return (
|
||||
<MantineProvider>
|
||||
<TestFieldTextInput name="name" control={control} label="Name" />
|
||||
</MantineProvider>
|
||||
);
|
||||
}
|
||||
|
||||
render(<TestForm />);
|
||||
expect(screen.getByLabelText('Name')).toHaveValue('John Doe');
|
||||
});
|
||||
|
||||
it('mutates RHF state on user input', async () => {
|
||||
const user = userEvent.setup();
|
||||
let capturedData: Record<string, unknown> | null = null;
|
||||
|
||||
function TestForm() {
|
||||
const { control, handleSubmit } = useForm({ defaultValues: { email: '' } });
|
||||
return (
|
||||
<MantineProvider>
|
||||
<form onSubmit={handleSubmit((data) => { capturedData = data; })}>
|
||||
<TestFieldTextInput name="email" control={control} label="Email" />
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
</MantineProvider>
|
||||
);
|
||||
}
|
||||
|
||||
render(<TestForm />);
|
||||
|
||||
const input = screen.getByLabelText('Email');
|
||||
await user.type(input, 'test@example.com');
|
||||
expect(input).toHaveValue('test@example.com');
|
||||
|
||||
await user.click(screen.getByText('Submit'));
|
||||
expect(capturedData).toEqual({ email: 'test@example.com' });
|
||||
});
|
||||
|
||||
it('renders raw string error messages from RHF validation', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
function TestForm() {
|
||||
const { control, handleSubmit } = useForm({ defaultValues: { username: '' } });
|
||||
return (
|
||||
<MantineProvider>
|
||||
<form onSubmit={handleSubmit(() => {})}>
|
||||
<TestFieldTextInput
|
||||
name="username"
|
||||
control={control}
|
||||
rules={{ required: 'Username is required' }}
|
||||
label="Username"
|
||||
/>
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
</MantineProvider>
|
||||
);
|
||||
}
|
||||
|
||||
render(<TestForm />);
|
||||
await user.click(screen.getByText('Submit'));
|
||||
|
||||
// The raw string error should appear in the DOM
|
||||
expect(screen.getByText('Username is required')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('intercepts JSON i18n error payloads and translates them', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
function TestForm() {
|
||||
const { control, handleSubmit } = useForm({ defaultValues: { title: '' } });
|
||||
return (
|
||||
<MantineProvider>
|
||||
<form onSubmit={handleSubmit(() => {})}>
|
||||
<TestFieldTextInput
|
||||
name="title"
|
||||
control={control}
|
||||
rules={{
|
||||
required: JSON.stringify({ key: 'validation.required' }),
|
||||
}}
|
||||
label="Title"
|
||||
/>
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
</MantineProvider>
|
||||
);
|
||||
}
|
||||
|
||||
render(<TestForm />);
|
||||
await user.click(screen.getByText('Submit'));
|
||||
|
||||
// The mock translation should resolve "validation.required" → "This field is required"
|
||||
expect(screen.getByText('This field is required')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to raw message when i18n key is not found', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
function TestForm() {
|
||||
const { control, handleSubmit } = useForm({ defaultValues: { code: '' } });
|
||||
return (
|
||||
<MantineProvider>
|
||||
<form onSubmit={handleSubmit(() => {})}>
|
||||
<TestFieldTextInput
|
||||
name="code"
|
||||
control={control}
|
||||
rules={{
|
||||
required: JSON.stringify({ key: 'validation.unknown_key' }),
|
||||
}}
|
||||
label="Code"
|
||||
/>
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
</MantineProvider>
|
||||
);
|
||||
}
|
||||
|
||||
render(<TestForm />);
|
||||
await user.click(screen.getByText('Submit'));
|
||||
|
||||
// The fallback should be the raw JSON string since neither namespace has the key.
|
||||
// Our mock t() returns defaultValue when key is unknown, which is the raw JSON.
|
||||
const errorElements = screen.getAllByText((content) =>
|
||||
content.includes('validation.unknown_key'),
|
||||
);
|
||||
expect(errorElements.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('has the correct displayName for React DevTools', () => {
|
||||
expect(
|
||||
(TestFieldTextInput as unknown as { displayName: string }).displayName,
|
||||
).toBe('TestFieldTextInput');
|
||||
});
|
||||
|
||||
it('forwards additional Mantine props (placeholder, etc.)', () => {
|
||||
function TestForm() {
|
||||
const { control } = useForm({ defaultValues: { search: '' } });
|
||||
return (
|
||||
<MantineProvider>
|
||||
<TestFieldTextInput
|
||||
name="search"
|
||||
control={control}
|
||||
label="Search"
|
||||
placeholder="Type to search..."
|
||||
/>
|
||||
</MantineProvider>
|
||||
);
|
||||
}
|
||||
|
||||
render(<TestForm />);
|
||||
expect(screen.getByPlaceholderText('Type to search...')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Custom Form Components — Barrel Export
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reusable select engines that work WITHOUT React Hook Form.
|
||||
// For RHF-connected versions, use `@repo/ui/form` (FieldLocalSelect, FieldAsyncSelect).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export { LocalSelect } from './selects/LocalSelect';
|
||||
export type { LocalSelectProps, LocalSelectSingleProps, LocalSelectMultiProps } from './selects/LocalSelect';
|
||||
|
||||
export { AsyncSelect } from './selects/AsyncSelect';
|
||||
export type { AsyncSelectProps, AsyncSelectSingleProps, AsyncSelectMultiProps } from './selects/AsyncSelect';
|
||||
|
||||
export type {
|
||||
LocalSelectBaseProps,
|
||||
AsyncSelectBaseProps,
|
||||
SelectFilterContext,
|
||||
SelectMappingResult,
|
||||
LoadOptionsResponse,
|
||||
LoadOptionsFn,
|
||||
OptionsCacheEntry,
|
||||
} from './selects/types';
|
||||
@@ -0,0 +1,270 @@
|
||||
import React, { useMemo, useCallback } from 'react';
|
||||
import { Select, MultiSelect, Loader, type SelectProps, type MultiSelectProps, type ComboboxItem } from '@mantine/core';
|
||||
import type { AsyncSelectBaseProps, SelectFilterContext, LoadOptionsFn } from './types';
|
||||
import { useAsyncPaginate } from './hooks/useAsyncPaginate';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AsyncSelect — Reusable async/paginated Select engine (no RHF dependency)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Inversion of Control design: the component does NOT handle API calls.
|
||||
// Instead, it accepts a `loadOptions` callback that the implementer provides.
|
||||
// This supports REST, GraphQL, POST-based search, local filtering, or any
|
||||
// transport mechanism.
|
||||
//
|
||||
// Data Mapping Contract (Single vs. Multi):
|
||||
// Single: value=T|null → Mantine string|null → onChange(T|null)
|
||||
// Multi: value=T[] → Mantine string[] → onChange(T[])
|
||||
//
|
||||
// The lookupMap includes ALL sources (fetched + default + selected values)
|
||||
// to ensure deselection never produces undefined entries.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Mantine props we manage ourselves */
|
||||
type ManagedSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'filter' | 'name' | 'onSelect';
|
||||
type ManagedMultiSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'filter' | 'name' | 'onSelect';
|
||||
|
||||
/** Async-specific props (IoC — no direct API coupling) */
|
||||
interface AsyncExtraProps<T> {
|
||||
/**
|
||||
* 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<T>;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/** Props for single-select async mode */
|
||||
export type AsyncSelectSingleProps<T extends Record<string, any>> = AsyncSelectBaseProps<T> &
|
||||
AsyncExtraProps<T> &
|
||||
Omit<SelectProps, ManagedSelectProps> & {
|
||||
multiple?: false;
|
||||
value?: T | null;
|
||||
onChange?: (value: T | null) => void;
|
||||
};
|
||||
|
||||
/** Props for multi-select async mode */
|
||||
export type AsyncSelectMultiProps<T extends Record<string, any>> = AsyncSelectBaseProps<T> &
|
||||
AsyncExtraProps<T> &
|
||||
Omit<MultiSelectProps, ManagedMultiSelectProps> & {
|
||||
multiple: true;
|
||||
value?: T[];
|
||||
onChange?: (value: T[]) => void;
|
||||
};
|
||||
|
||||
export type AsyncSelectProps<T extends Record<string, any>> = AsyncSelectSingleProps<T> | AsyncSelectMultiProps<T>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: Resolve label for a data item
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function resolveLabel<T extends Record<string, any>>(
|
||||
item: T,
|
||||
labelKey?: keyof T & string,
|
||||
renderLabel?: (item: T) => string,
|
||||
): string {
|
||||
if (renderLabel) return renderLabel(item);
|
||||
if (labelKey) return String(item[labelKey] ?? '');
|
||||
const firstKey = Object.keys(item)[0];
|
||||
return firstKey ? String(item[firstKey as keyof T] ?? '') : '';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component Implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function AsyncSelectInner<T extends Record<string, any>>(props: AsyncSelectProps<T>) {
|
||||
const {
|
||||
valueKey,
|
||||
labelKey,
|
||||
renderLabel,
|
||||
multiple,
|
||||
filterOption,
|
||||
onSelect: onSelectCallback,
|
||||
value,
|
||||
onChange,
|
||||
loadOptions,
|
||||
defaultOptions,
|
||||
debounceMs,
|
||||
searchable,
|
||||
onSearchChange: consumerOnSearchChange,
|
||||
...mantineProps
|
||||
} = props;
|
||||
|
||||
// Use the new paginate hook for data fetching
|
||||
const {
|
||||
data: fetchedData,
|
||||
isLoading,
|
||||
fetchNextPage,
|
||||
search,
|
||||
setSearch,
|
||||
} = useAsyncPaginate<T>({
|
||||
loadOptions,
|
||||
valueKey,
|
||||
debounceMs,
|
||||
defaultOptions,
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 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<string>();
|
||||
|
||||
// 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)) {
|
||||
seen.add(key);
|
||||
uniqueItems.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
// 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); // Selected items at the top
|
||||
}
|
||||
}
|
||||
} else if (!multiple && value) {
|
||||
const key = String((value as T)[valueKey]);
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
uniqueItems.unshift(value as T);
|
||||
}
|
||||
}
|
||||
|
||||
return uniqueItems;
|
||||
}, [fetchedData, value, valueKey, multiple]);
|
||||
|
||||
// Build lookup map — includes ALL sources for safe reverse resolution
|
||||
const lookupMap = useMemo(() => {
|
||||
const map = new Map<string, T>();
|
||||
for (const item of dataWithInjected) {
|
||||
map.set(String(item[valueKey]), item);
|
||||
}
|
||||
return map;
|
||||
}, [dataWithInjected, valueKey]);
|
||||
|
||||
// Build Mantine options from the resolved data
|
||||
const baseOptions = useMemo<ComboboxItem[]>(() => {
|
||||
let filtered = dataWithInjected;
|
||||
|
||||
if (filterOption) {
|
||||
const context: SelectFilterContext<T> = {
|
||||
search,
|
||||
selected: value ?? (multiple ? [] : null),
|
||||
};
|
||||
filtered = dataWithInjected.filter((item) => filterOption(item, context));
|
||||
}
|
||||
|
||||
return filtered.map((item) => ({
|
||||
value: String(item[valueKey]),
|
||||
label: resolveLabel(item, labelKey, renderLabel),
|
||||
}));
|
||||
}, [dataWithInjected, valueKey, labelKey, renderLabel, filterOption, value, multiple, search]);
|
||||
|
||||
const rightSection = isLoading ? <Loader size={16} /> : mantineProps.rightSection;
|
||||
|
||||
// Handle search → delegate to the hook's setSearch (debounced)
|
||||
const handleSearchChange = useCallback(
|
||||
(val: string) => {
|
||||
setSearch(val);
|
||||
consumerOnSearchChange?.(val);
|
||||
},
|
||||
[setSearch, consumerOnSearchChange],
|
||||
);
|
||||
|
||||
// ScrollArea props for infinite scroll — use onBottomReached
|
||||
const scrollAreaProps = useMemo(
|
||||
() => ({
|
||||
...(mantineProps.scrollAreaProps || {}),
|
||||
onBottomReached: () => {
|
||||
fetchNextPage();
|
||||
},
|
||||
}),
|
||||
[mantineProps.scrollAreaProps, fetchNextPage],
|
||||
);
|
||||
|
||||
// Disable Mantine's internal frontend filtering.
|
||||
// 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);
|
||||
onSelectCallback?.(objects);
|
||||
};
|
||||
|
||||
return (
|
||||
<MultiSelect
|
||||
{...(mantineProps as Omit<MultiSelectProps, ManagedMultiSelectProps>)}
|
||||
data={baseOptions}
|
||||
value={currentValues}
|
||||
onChange={handleMultiChange}
|
||||
searchable={searchable ?? true}
|
||||
onSearchChange={handleSearchChange}
|
||||
scrollAreaProps={scrollAreaProps}
|
||||
filter={mantineFilter}
|
||||
rightSection={rightSection}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ----- Single-select mode -----
|
||||
const currentValue = value ? String((value as T)[valueKey]) : null;
|
||||
|
||||
const handleSingleChange = (val: string | null) => {
|
||||
const obj = val ? (lookupMap.get(val) ?? null) : null;
|
||||
(onChange as ((v: T | null) => void) | undefined)?.(obj);
|
||||
onSelectCallback?.(obj);
|
||||
};
|
||||
|
||||
return (
|
||||
<Select
|
||||
{...(mantineProps as Omit<SelectProps, ManagedSelectProps>)}
|
||||
data={baseOptions}
|
||||
value={currentValue}
|
||||
onChange={handleSingleChange}
|
||||
searchable={searchable ?? true}
|
||||
onSearchChange={handleSearchChange}
|
||||
scrollAreaProps={scrollAreaProps}
|
||||
filter={mantineFilter as any}
|
||||
rightSection={rightSection}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const AsyncSelect = React.memo(AsyncSelectInner) as typeof AsyncSelectInner;
|
||||
(AsyncSelect as any).displayName = 'AsyncSelect';
|
||||
@@ -0,0 +1,189 @@
|
||||
import React, { useMemo, useState, useCallback } from 'react';
|
||||
import { Select, MultiSelect, type SelectProps, type MultiSelectProps, type ComboboxItem } from '@mantine/core';
|
||||
import type { LocalSelectBaseProps, SelectFilterContext } from './types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LocalSelect — Reusable Select engine for complex object data
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// This component is the STANDALONE (non-RHF) version. It bridges Mantine's
|
||||
// string-based Select/MultiSelect with object data by:
|
||||
// 1. Mapping T[] → ComboboxItem[] via valueKey + labelKey/renderLabel
|
||||
// 2. Building a Map<string, T> for O(1) reverse lookups
|
||||
// 3. Intercepting onChange to resolve strings back to full objects
|
||||
//
|
||||
// Data Mapping Contract (Single vs. Multi):
|
||||
// Single: value=T|null → Mantine string|null → onChange(T|null)
|
||||
// Multi: value=T[] → Mantine string[] → onChange(T[])
|
||||
//
|
||||
// The RHF-connected version (FieldLocalSelect) wraps this component and
|
||||
// binds it to useController, following the same pattern as withRHF → FieldXxx.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Mantine props we manage ourselves — stripped from the pass-through */
|
||||
type ManagedSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'filter' | 'name' | 'onSelect';
|
||||
type ManagedMultiSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'filter' | 'name' | 'onSelect';
|
||||
|
||||
/** Props for single-select mode */
|
||||
export type LocalSelectSingleProps<T extends Record<string, any>> =
|
||||
LocalSelectBaseProps<T> & Omit<SelectProps, ManagedSelectProps> & {
|
||||
multiple?: false;
|
||||
/** Controlled value — the full object or null */
|
||||
value?: T | null;
|
||||
/** Called when the selection changes */
|
||||
onChange?: (value: T | null) => void;
|
||||
};
|
||||
|
||||
/** Props for multi-select mode */
|
||||
export type LocalSelectMultiProps<T extends Record<string, any>> =
|
||||
LocalSelectBaseProps<T> & Omit<MultiSelectProps, ManagedMultiSelectProps> & {
|
||||
multiple: true;
|
||||
/** Controlled value — array of full objects */
|
||||
value?: T[];
|
||||
/** Called when the selection changes */
|
||||
onChange?: (value: T[]) => void;
|
||||
};
|
||||
|
||||
/** Discriminated union — the component narrows based on `multiple` */
|
||||
export type LocalSelectProps<T extends Record<string, any>> =
|
||||
| LocalSelectSingleProps<T>
|
||||
| LocalSelectMultiProps<T>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: Resolve label for a data item
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function resolveLabel<T extends Record<string, any>>(
|
||||
item: T,
|
||||
labelKey?: keyof T & string,
|
||||
renderLabel?: (item: T) => string,
|
||||
): string {
|
||||
if (renderLabel) return renderLabel(item);
|
||||
if (labelKey) return String(item[labelKey] ?? '');
|
||||
// Fail fast: if neither labelKey nor renderLabel is provided, fall back
|
||||
// to the first property value. While not ideal, it prevents crashes.
|
||||
const firstKey = Object.keys(item)[0];
|
||||
return firstKey ? String(item[firstKey as keyof T] ?? '') : '';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component Implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function LocalSelectInner<T extends Record<string, any>>(
|
||||
props: LocalSelectProps<T>,
|
||||
) {
|
||||
const {
|
||||
options,
|
||||
valueKey,
|
||||
labelKey,
|
||||
renderLabel,
|
||||
multiple,
|
||||
filterOption,
|
||||
onSelect: onSelectCallback,
|
||||
value,
|
||||
onChange,
|
||||
searchable,
|
||||
// Extract onSearchChange BEFORE the rest spread to get a
|
||||
// stable reference for the useCallback dependency array.
|
||||
onSearchChange: consumerOnSearchChange,
|
||||
...mantineProps
|
||||
} = props;
|
||||
|
||||
// Track search input for filterOption
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
|
||||
// Build lookup map: string → original object (O(1) reverse lookup)
|
||||
const lookupMap = useMemo(() => {
|
||||
const map = new Map<string, T>();
|
||||
for (const item of options) {
|
||||
map.set(String(item[valueKey]), item);
|
||||
}
|
||||
return map;
|
||||
}, [options, valueKey]);
|
||||
|
||||
// Build Mantine-compatible ComboboxItem[], applying filterOption if provided
|
||||
const comboboxItems = useMemo<ComboboxItem[]>(() => {
|
||||
let filtered = options;
|
||||
|
||||
if (filterOption) {
|
||||
const context: SelectFilterContext<T> = {
|
||||
search: searchValue,
|
||||
selected: value ?? (multiple ? [] : null),
|
||||
};
|
||||
filtered = options.filter((item) => filterOption(item, context));
|
||||
}
|
||||
|
||||
return filtered.map((item) => ({
|
||||
value: String(item[valueKey]),
|
||||
label: resolveLabel(item, labelKey, renderLabel),
|
||||
}));
|
||||
}, [options, valueKey, labelKey, renderLabel, filterOption, searchValue, value, multiple]);
|
||||
|
||||
// Depend only on stable function references, not the
|
||||
// entire mantineProps object which is a new reference every render.
|
||||
const handleSearchChange = useCallback(
|
||||
(val: string) => {
|
||||
setSearchValue(val);
|
||||
consumerOnSearchChange?.(val);
|
||||
},
|
||||
[consumerOnSearchChange],
|
||||
);
|
||||
|
||||
// Passthrough filter — we handle filtering ourselves via filterOption in useMemo.
|
||||
// This prevents Mantine from double-filtering.
|
||||
const mantineFilter = filterOption
|
||||
? ({ options: opts }: { options: ComboboxItem[] }) => opts
|
||||
: undefined;
|
||||
|
||||
|
||||
// ----- Multi-select mode -----
|
||||
if (multiple) {
|
||||
const currentValues = Array.isArray(value) ? value.map((v) => String(v[valueKey])) : [];
|
||||
|
||||
const handleMultiChange = (vals: string[]) => {
|
||||
// Resolve string[] back to T[] via the lookup map.
|
||||
// .filter(Boolean) guards against missing entries (defensive).
|
||||
const objects = vals.map((v) => lookupMap.get(v)!).filter(Boolean);
|
||||
(onChange as ((v: T[]) => void) | undefined)?.(objects);
|
||||
onSelectCallback?.(objects);
|
||||
};
|
||||
|
||||
return (
|
||||
<MultiSelect
|
||||
{...(mantineProps as Omit<MultiSelectProps, ManagedMultiSelectProps>)}
|
||||
data={comboboxItems}
|
||||
value={currentValues}
|
||||
onChange={handleMultiChange}
|
||||
searchable={searchable ?? false}
|
||||
onSearchChange={handleSearchChange}
|
||||
filter={mantineFilter as any}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ----- Single-select mode -----
|
||||
const currentValue = value ? String((value as T)[valueKey]) : null;
|
||||
|
||||
const handleSingleChange = (val: string | null) => {
|
||||
const obj = val ? lookupMap.get(val) ?? null : null;
|
||||
(onChange as ((v: T | null) => void) | undefined)?.(obj);
|
||||
onSelectCallback?.(obj);
|
||||
};
|
||||
|
||||
return (
|
||||
<Select
|
||||
{...(mantineProps as Omit<SelectProps, ManagedSelectProps>)}
|
||||
data={comboboxItems}
|
||||
value={currentValue}
|
||||
onChange={handleSingleChange}
|
||||
searchable={searchable ?? false}
|
||||
onSearchChange={handleSearchChange}
|
||||
filter={mantineFilter as any}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Apply React.memo for render optimization in large forms
|
||||
export const LocalSelect = React.memo(LocalSelectInner) as typeof LocalSelectInner;
|
||||
(LocalSelect as any).displayName = 'LocalSelect';
|
||||
@@ -0,0 +1,335 @@
|
||||
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import type { LoadOptionsFn, OptionsCacheEntry } from '../types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// useAsyncPaginate — Production-grade paginated data fetching hook
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Architecture derived from `react-select-async-paginate` with adaptations
|
||||
// for Mantine's Select/MultiSelect. Key mechanisms:
|
||||
//
|
||||
// 1. Search-keyed cache (Map<string, OptionsCacheEntry<T>>)
|
||||
// → Switching between previously-typed searches reuses cached data
|
||||
// without re-fetching from the server.
|
||||
//
|
||||
// 2. Request ID counter (requestIdRef)
|
||||
// → Stale responses from slow or out-of-order fetches are silently
|
||||
// discarded by comparing the ID captured at request start with the
|
||||
// current counter value.
|
||||
//
|
||||
// 3. isMounted guard (mountedRef)
|
||||
// → Responses arriving after the component unmounts are discarded,
|
||||
// preventing React state updates on unmounted components.
|
||||
//
|
||||
// 4. Duplicate fetch prevention (fetchingRef boolean)
|
||||
// → Guards against concurrent fetches for the same search+page combo.
|
||||
//
|
||||
// 5. Single consolidated effect
|
||||
// → One useEffect keyed on `debouncedSearch` handles both the initial
|
||||
// load (mount with search='') and subsequent search-change resets.
|
||||
// This eliminates the double-initial-fetch bug from the old hook.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface UseAsyncPaginateOptions<T extends Record<string, any>> {
|
||||
/** Async callback to load options. Receives (search, page, prevOptions). */
|
||||
loadOptions: LoadOptionsFn<T>;
|
||||
|
||||
/** Property key used to deduplicate incoming items */
|
||||
valueKey: keyof T & string;
|
||||
|
||||
/** Search debounce delay in ms (default: 300) */
|
||||
debounceMs?: number;
|
||||
|
||||
/**
|
||||
* Pre-loaded objects to inject into the options list.
|
||||
* Used for edit-form scenarios where the RHF default value's object
|
||||
* may not appear in the first page of API results.
|
||||
*/
|
||||
defaultOptions?: T[];
|
||||
}
|
||||
|
||||
export interface UseAsyncPaginateReturn<T> {
|
||||
/** Merged data: defaultOptions + accumulated fetched pages (deduplicated) */
|
||||
data: T[];
|
||||
|
||||
/** True during any active fetch */
|
||||
isLoading: boolean;
|
||||
|
||||
/** Whether the current search term has more pages available */
|
||||
hasMore: boolean;
|
||||
|
||||
/** Current search term (raw, not debounced) */
|
||||
search: string;
|
||||
|
||||
/** The debounced search term currently driving fetches */
|
||||
debouncedSearch: string;
|
||||
|
||||
/** Update the search term — triggers debounce + cache lookup/fetch */
|
||||
setSearch: (s: string) => void;
|
||||
|
||||
/** Trigger next page load for the current search term */
|
||||
fetchNextPage: () => void;
|
||||
|
||||
/** Clear all cached pages and re-fetch from page 1 */
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export function useAsyncPaginate<T extends Record<string, any>>(
|
||||
options: UseAsyncPaginateOptions<T>,
|
||||
): UseAsyncPaginateReturn<T> {
|
||||
const {
|
||||
loadOptions,
|
||||
valueKey,
|
||||
debounceMs = 300,
|
||||
defaultOptions,
|
||||
} = options;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// State
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch] = useDebouncedValue(search, debounceMs);
|
||||
|
||||
// Search-keyed cache: each search string maps to its own pagination state
|
||||
const [cache, setCache] = useState<Map<string, OptionsCacheEntry<T>>>(() => new Map());
|
||||
|
||||
// Loading flag — drives the UI spinner
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Refs for guards
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** Monotonically increasing counter to detect stale responses */
|
||||
const requestIdRef = useRef(0);
|
||||
|
||||
/** Guards against concurrent fetches */
|
||||
const fetchingRef = useRef(false);
|
||||
|
||||
/** Tracks if the component is still mounted */
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
/** Stable ref for loadOptions to avoid effect re-fires on closure changes */
|
||||
const loadOptionsRef = useRef(loadOptions);
|
||||
loadOptionsRef.current = loadOptions;
|
||||
|
||||
/** Stable ref for valueKey */
|
||||
const valueKeyRef = useRef(valueKey);
|
||||
valueKeyRef.current = valueKey;
|
||||
|
||||
/** Stable ref for defaultOptions to avoid dependency churn */
|
||||
const defaultOptionsRef = useRef(defaultOptions);
|
||||
defaultOptionsRef.current = defaultOptions;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Cleanup on unmount
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Core fetch logic
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
const fetchPage = useCallback(
|
||||
async (searchTerm: string, page: number) => {
|
||||
if (fetchingRef.current) return;
|
||||
fetchingRef.current = true;
|
||||
|
||||
// Capture request ID — if it changes before the response arrives,
|
||||
// the response is stale and should be discarded.
|
||||
const capturedId = ++requestIdRef.current;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Get accumulated options from the cache for prevOptions
|
||||
const cachedEntry = cache.get(searchTerm);
|
||||
const prevOptions = cachedEntry?.options ?? [];
|
||||
|
||||
const response = await loadOptionsRef.current(searchTerm, page, prevOptions);
|
||||
|
||||
// Guard: discard if unmounted or stale
|
||||
if (!mountedRef.current || requestIdRef.current !== capturedId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newItems = response.options ?? [];
|
||||
const hasMore = response.hasMore ?? false;
|
||||
|
||||
// Deduplicate: merge prev + new, keyed by valueKey
|
||||
const vk = valueKeyRef.current;
|
||||
const seen = new Set<string>();
|
||||
const merged: T[] = [];
|
||||
|
||||
// Accumulate from previous pages first
|
||||
for (const item of prevOptions) {
|
||||
const key = String(item[vk]);
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
merged.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
// Append new items (only unique ones)
|
||||
let newUniqueCount = 0;
|
||||
for (const item of newItems) {
|
||||
const key = String(item[vk]);
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
merged.push(item);
|
||||
newUniqueCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// If the API returned items but ALL were duplicates, treat as exhausted.
|
||||
// This prevents infinite scroll loops on naive APIs that ignore pagination.
|
||||
const effectiveHasMore = newItems.length > 0 && newUniqueCount === 0
|
||||
? false
|
||||
: hasMore;
|
||||
|
||||
setCache((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(searchTerm, {
|
||||
options: merged,
|
||||
hasMore: effectiveHasMore,
|
||||
page,
|
||||
isLoading: false,
|
||||
});
|
||||
return next;
|
||||
});
|
||||
} catch (error) {
|
||||
if (!mountedRef.current || requestIdRef.current !== capturedId) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('[useAsyncPaginate] loadOptions failed:', error);
|
||||
|
||||
// Mark the cache entry as exhausted to prevent retry loops
|
||||
setCache((prev) => {
|
||||
const next = new Map(prev);
|
||||
const existing = prev.get(searchTerm);
|
||||
next.set(searchTerm, {
|
||||
options: existing?.options ?? [],
|
||||
hasMore: false,
|
||||
page: existing?.page ?? 0,
|
||||
isLoading: false,
|
||||
});
|
||||
return next;
|
||||
});
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
fetchingRef.current = false;
|
||||
}
|
||||
},
|
||||
// We intentionally exclude `cache` from deps to avoid re-creating this callback
|
||||
// on every cache update. Instead, we read cache inside via the state setter's prev.
|
||||
// The `cache.get(searchTerm)` read above is for prevOptions passed to loadOptions —
|
||||
// this is acceptable because the callback is only called when we're NOT already fetching.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[],
|
||||
);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Effect: Fetch on debounced search change (including initial mount)
|
||||
// -----------------------------------------------------------------------
|
||||
//
|
||||
// This single effect replaces the old two-effect pattern that caused
|
||||
// double initial fetches. On mount, debouncedSearch starts as '' and
|
||||
// triggers a single page-1 fetch. On search change, it looks up the
|
||||
// cache and either reuses cached data or fetches page 1 for the new term.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
useEffect(() => {
|
||||
const cached = cache.get(debouncedSearch);
|
||||
|
||||
// If we already have cached data for this search term, no fetch needed
|
||||
if (cached && cached.options.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// No cache entry — fetch page 1
|
||||
fetchPage(debouncedSearch, 1);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [debouncedSearch]);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// fetchNextPage — called by Mantine's scroll handler
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
const fetchNextPage = useCallback(() => {
|
||||
const cached = cache.get(debouncedSearch);
|
||||
if (!cached || !cached.hasMore || isLoading || fetchingRef.current) return;
|
||||
fetchPage(debouncedSearch, cached.page + 1);
|
||||
}, [cache, debouncedSearch, isLoading, fetchPage]);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// reset — clear cache and re-fetch from scratch
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setCache(new Map());
|
||||
requestIdRef.current++;
|
||||
fetchPage(debouncedSearch, 1);
|
||||
}, [debouncedSearch, fetchPage]);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Merge defaultOptions with fetched data (deduplicated)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
const currentCacheEntry = cache.get(debouncedSearch);
|
||||
|
||||
const data = useMemo<T[]>(() => {
|
||||
const fetchedItems = currentCacheEntry?.options ?? [];
|
||||
const defaults = defaultOptionsRef.current;
|
||||
|
||||
if (!defaults || defaults.length === 0) {
|
||||
return fetchedItems;
|
||||
}
|
||||
|
||||
// Merge: defaultOptions first, then fetched items (deduplicated)
|
||||
const seen = new Set<string>();
|
||||
const merged: T[] = [];
|
||||
|
||||
for (const item of defaults) {
|
||||
const key = String(item[valueKeyRef.current]);
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
merged.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of fetchedItems) {
|
||||
const key = String(item[valueKeyRef.current]);
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
merged.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}, [currentCacheEntry?.options]);
|
||||
|
||||
const hasMore = currentCacheEntry?.hasMore ?? true;
|
||||
const isTyping = search !== debouncedSearch;
|
||||
|
||||
return {
|
||||
data,
|
||||
isLoading: isLoading || isTyping,
|
||||
hasMore,
|
||||
search,
|
||||
debouncedSearch,
|
||||
setSearch,
|
||||
fetchNextPage,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { ComboboxItem } from '@mantine/core';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Select Engine — Shared types for LocalSelect and AsyncSelect
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Filter context passed to the custom `filterOption` callback.
|
||||
* Provides both the current search string and the currently selected value(s)
|
||||
* so consumers can implement exclusion logic, compound search, or domain-specific filters.
|
||||
*/
|
||||
export interface SelectFilterContext<T> {
|
||||
/** Current search input value */
|
||||
search: string;
|
||||
/** Currently selected value(s) — T | null for single, T[] for multi */
|
||||
selected: T[] | T | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core configuration for the LocalSelect engine.
|
||||
* This is the "headless" API — no RHF dependency.
|
||||
*
|
||||
* @template T - The shape of each item in the options array
|
||||
*/
|
||||
export interface LocalSelectBaseProps<T extends Record<string, any>> {
|
||||
/** Array of complex objects to select from */
|
||||
options: T[];
|
||||
|
||||
/** Property key to use as the unique string identifier for Mantine */
|
||||
valueKey: keyof T & string;
|
||||
|
||||
/** Property key to use as the display label (simple mode) */
|
||||
labelKey?: keyof T & string;
|
||||
|
||||
/** Custom label renderer — overrides `labelKey` for compound/custom labels */
|
||||
renderLabel?: (item: T) => string;
|
||||
|
||||
/** Enable multi-select mode */
|
||||
multiple?: boolean;
|
||||
|
||||
/**
|
||||
* Custom filter function for search and exclusion logic.
|
||||
* Return `true` to keep the item in the dropdown, `false` to exclude it.
|
||||
*/
|
||||
filterOption?: (item: T, context: SelectFilterContext<T>) => boolean;
|
||||
|
||||
/** Callback fired when selection changes */
|
||||
onSelect?: (value: T | T[] | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core configuration for the AsyncSelect engine.
|
||||
* Extends LocalSelectBaseProps but replaces `options` with `loadOptions`.
|
||||
* This is the "headless" API — no RHF dependency.
|
||||
*
|
||||
* @template T - The shape of each item in the options array
|
||||
*/
|
||||
export type AsyncSelectBaseProps<T extends Record<string, any>> = Omit<LocalSelectBaseProps<T>, 'options'>;
|
||||
|
||||
/**
|
||||
* Internal result of the object-to-string mapping logic.
|
||||
* Used by both the standalone and RHF-connected variants.
|
||||
*/
|
||||
export interface SelectMappingResult<T> {
|
||||
/** Mantine-compatible ComboboxItem array for the Select/MultiSelect `data` prop */
|
||||
options: ComboboxItem[];
|
||||
|
||||
/** O(1) reverse lookup map: string value → original object */
|
||||
lookupMap: Map<string, T>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AsyncSelect — Inversion of Control types for the async paginated engine
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The response shape returned by the `loadOptions` callback.
|
||||
* Supports both paginated and non-paginated APIs.
|
||||
*
|
||||
* @template T - The shape of each option item
|
||||
*/
|
||||
export interface LoadOptionsResponse<T> {
|
||||
/** The array of option objects for this page/batch */
|
||||
options: T[];
|
||||
|
||||
/**
|
||||
* Whether more pages are available.
|
||||
* - `true` → the engine will allow further scroll-triggered fetches.
|
||||
* - `false` → no more data; subsequent scroll events are ignored.
|
||||
* - `undefined` → treated as `false` (assumes non-paginated).
|
||||
*/
|
||||
hasMore?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The callback signature for loading options asynchronously.
|
||||
* This follows the Inversion of Control principle: the component is
|
||||
* completely ignorant of transport (REST, GraphQL, local filter, etc.).
|
||||
*
|
||||
* @param search - The current search input string
|
||||
* @param page - The 1-indexed page number being requested
|
||||
* @param prevOptions - All options accumulated from previous pages
|
||||
* @returns A promise resolving to the options for this page + pagination signal
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // REST API with cursor pagination
|
||||
* const loadOptions: LoadOptionsFn<User> = async (search, page) => {
|
||||
* const res = await api.get('/users', { params: { q: search, page, limit: 20 } });
|
||||
* return { options: res.data.items, hasMore: res.data.hasNextPage };
|
||||
* };
|
||||
*
|
||||
* // Non-paginated (single-shot fetch)
|
||||
* const loadOptions: LoadOptionsFn<Role> = async (search) => {
|
||||
* const roles = await api.get('/roles', { params: { q: search } });
|
||||
* return { options: roles.data, hasMore: false };
|
||||
* };
|
||||
* ```
|
||||
*/
|
||||
export type LoadOptionsFn<T> = (
|
||||
search: string,
|
||||
page: number,
|
||||
prevOptions: T[],
|
||||
) => Promise<LoadOptionsResponse<T>>;
|
||||
|
||||
/**
|
||||
* Internal cache entry for the search-keyed options cache.
|
||||
* Each unique search string maps to one entry tracking the accumulated
|
||||
* options, pagination state, and current page for that search context.
|
||||
*/
|
||||
export interface OptionsCacheEntry<T> {
|
||||
/** Accumulated options across all fetched pages for this search term */
|
||||
options: T[];
|
||||
|
||||
/** Whether more pages are available for this search term */
|
||||
hasMore: boolean;
|
||||
|
||||
/** The last successfully fetched page number (1-indexed) */
|
||||
page: number;
|
||||
|
||||
/** Whether a fetch is currently in-flight for this search term */
|
||||
isLoading: boolean;
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
useController,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
type UseControllerProps,
|
||||
} from 'react-hook-form';
|
||||
import type { SelectProps, MultiSelectProps } from '@mantine/core';
|
||||
import { AsyncSelect } from '../custom/selects/AsyncSelect';
|
||||
import type { AsyncSelectBaseProps, LoadOptionsFn } from '../custom/selects/types';
|
||||
import { useTranslatedError } from '../useTranslatedError';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FieldAsyncSelect — RHF-connected Async Select
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Thin RHF wrapper around the standalone AsyncSelect engine.
|
||||
// Adds useController binding + i18n error translation.
|
||||
//
|
||||
// Data Mapping Contract:
|
||||
// Single: RHF stores T | null → maps to Mantine string | null
|
||||
// Multi: RHF stores T[] → maps to Mantine string[]
|
||||
//
|
||||
// Inversion of Control: accepts `loadOptions` callback instead of
|
||||
// hardcoded API endpoint. The component is transport-agnostic.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Mantine props we manage ourselves */
|
||||
type ManagedSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'onBlur' | 'error' | 'filter' | 'name' | 'onSelect';
|
||||
type ManagedMultiSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'onBlur' | 'error' | 'filter' | 'name' | 'onSelect';
|
||||
|
||||
/** Async-specific props (IoC pattern) */
|
||||
interface AsyncExtraProps<T> {
|
||||
/** Async callback to load options — (search, page, prevOptions) => Promise */
|
||||
loadOptions: LoadOptionsFn<T>;
|
||||
|
||||
/** Pre-loaded objects for edit forms (injected into dropdown regardless of fetch state) */
|
||||
defaultOptions?: T[];
|
||||
|
||||
/** Search debounce delay in ms (default: 300) */
|
||||
debounceMs?: number;
|
||||
}
|
||||
|
||||
/** Single-select async RHF props */
|
||||
export type FieldAsyncSelectSingleProps<
|
||||
T extends Record<string, any>,
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = AsyncSelectBaseProps<T> &
|
||||
AsyncExtraProps<T> &
|
||||
UseControllerProps<TFieldValues, TName> &
|
||||
Omit<SelectProps, ManagedSelectProps> & {
|
||||
multiple?: false;
|
||||
};
|
||||
|
||||
/** Multi-select async RHF props */
|
||||
export type FieldAsyncSelectMultiProps<
|
||||
T extends Record<string, any>,
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = AsyncSelectBaseProps<T> &
|
||||
AsyncExtraProps<T> &
|
||||
UseControllerProps<TFieldValues, TName> &
|
||||
Omit<MultiSelectProps, ManagedMultiSelectProps> & {
|
||||
multiple: true;
|
||||
};
|
||||
|
||||
export type FieldAsyncSelectProps<
|
||||
T extends Record<string, any>,
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> =
|
||||
| FieldAsyncSelectSingleProps<T, TFieldValues, TName>
|
||||
| FieldAsyncSelectMultiProps<T, TFieldValues, TName>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component Implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function FieldAsyncSelectInner<
|
||||
T extends Record<string, any>,
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>(props: FieldAsyncSelectProps<T, TFieldValues, TName>) {
|
||||
const {
|
||||
// RHF controller props
|
||||
name,
|
||||
control,
|
||||
rules,
|
||||
shouldUnregister,
|
||||
defaultValue,
|
||||
disabled,
|
||||
// Object/Async engine props
|
||||
valueKey,
|
||||
labelKey,
|
||||
renderLabel,
|
||||
multiple,
|
||||
filterOption,
|
||||
onSelect: onSelectCallback,
|
||||
loadOptions,
|
||||
defaultOptions,
|
||||
debounceMs,
|
||||
// Remaining Mantine props
|
||||
...mantineProps
|
||||
} = props;
|
||||
|
||||
const {
|
||||
field,
|
||||
fieldState: { error },
|
||||
} = useController<TFieldValues, TName>({
|
||||
name,
|
||||
control,
|
||||
rules,
|
||||
shouldUnregister,
|
||||
defaultValue,
|
||||
disabled,
|
||||
});
|
||||
|
||||
const translatedError = useTranslatedError(error?.message);
|
||||
|
||||
const handleChange = (value: any) => {
|
||||
field.onChange(value);
|
||||
onSelectCallback?.(value);
|
||||
};
|
||||
|
||||
const engineProps = {
|
||||
valueKey,
|
||||
labelKey,
|
||||
renderLabel,
|
||||
filterOption,
|
||||
loadOptions,
|
||||
defaultOptions,
|
||||
debounceMs,
|
||||
onBlur: field.onBlur,
|
||||
error: translatedError,
|
||||
disabled: field.disabled,
|
||||
};
|
||||
|
||||
if (multiple) {
|
||||
return (
|
||||
<AsyncSelect<T>
|
||||
multiple
|
||||
{...engineProps}
|
||||
value={field.value ?? []}
|
||||
onChange={handleChange}
|
||||
{...(mantineProps as any)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AsyncSelect<T>
|
||||
{...engineProps}
|
||||
value={field.value ?? null}
|
||||
onChange={handleChange}
|
||||
{...(mantineProps as any)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const FieldAsyncSelect = React.memo(FieldAsyncSelectInner) as typeof FieldAsyncSelectInner;
|
||||
(FieldAsyncSelect as any).displayName = 'FieldAsyncSelect';
|
||||
@@ -0,0 +1,4 @@
|
||||
import { Autocomplete, type AutocompleteProps } from '@mantine/core';
|
||||
import { withRHF } from '../withRHF';
|
||||
|
||||
export const FieldAutocomplete = withRHF<AutocompleteProps>('FieldAutocomplete', Autocomplete);
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Checkbox, type CheckboxProps } from '@mantine/core';
|
||||
import { withRHF } from '../withRHF';
|
||||
|
||||
export const FieldCheckbox = withRHF<CheckboxProps>('FieldCheckbox', Checkbox, {
|
||||
isCheckType: true,
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Chip, type ChipGroupProps } from '@mantine/core';
|
||||
import { withRHF } from '../withRHF';
|
||||
|
||||
// Wraps Chip.Group — individual Chip items are passed as children.
|
||||
// Usage:
|
||||
// <FieldChipGroup name="size" control={control}>
|
||||
// <Chip value="sm">Small</Chip>
|
||||
// <Chip value="md">Medium</Chip>
|
||||
// <Chip value="lg">Large</Chip>
|
||||
// </FieldChipGroup>
|
||||
export const FieldChipGroup = withRHF<ChipGroupProps>('FieldChipGroup', Chip.Group, {
|
||||
requiresWrapper: true,
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
import { ColorInput, type ColorInputProps } from '@mantine/core';
|
||||
import { withRHF } from '../withRHF';
|
||||
|
||||
export const FieldColorInput = withRHF<ColorInputProps>('FieldColorInput', ColorInput);
|
||||
@@ -0,0 +1,8 @@
|
||||
import { ColorPicker, type ColorPickerProps } from '@mantine/core';
|
||||
import { withRHF } from '../withRHF';
|
||||
|
||||
// ColorPicker does NOT have a native `error` prop.
|
||||
// The HOC wraps it in Input.Wrapper to display validation errors.
|
||||
export const FieldColorPicker = withRHF<ColorPickerProps>('FieldColorPicker', ColorPicker, {
|
||||
requiresWrapper: true,
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
import { FileInput, type FileInputProps } from '@mantine/core';
|
||||
import { withRHF } from '../withRHF';
|
||||
|
||||
export const FieldFileInput = withRHF<FileInputProps>('FieldFileInput', FileInput);
|
||||
@@ -0,0 +1,4 @@
|
||||
import { JsonInput, type JsonInputProps } from '@mantine/core';
|
||||
import { withRHF } from '../withRHF';
|
||||
|
||||
export const FieldJsonInput = withRHF<JsonInputProps>('FieldJsonInput', JsonInput);
|
||||
@@ -0,0 +1,153 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
useController,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
type UseControllerProps,
|
||||
} from 'react-hook-form';
|
||||
import type { SelectProps, MultiSelectProps } from '@mantine/core';
|
||||
import { LocalSelect } from '../custom/selects/LocalSelect';
|
||||
import type { LocalSelectBaseProps } from '../custom/selects/types';
|
||||
import { useTranslatedError } from '../useTranslatedError';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FieldLocalSelect — RHF-connected Local Select
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Follows the same architectural pattern as the existing `FieldSelect`:
|
||||
// Mantine Component → withRHF HOC → FieldXxx
|
||||
//
|
||||
// But instead of using the generic withRHF factory (which assumes string values),
|
||||
// we use a manual useController binding with an object interception layer.
|
||||
// The actual rendering is delegated to the standalone LocalSelect engine
|
||||
// in `custom/selects/LocalSelect.tsx`.
|
||||
//
|
||||
// Data Mapping Contract:
|
||||
// Single: RHF stores T | null → maps to Mantine string | null
|
||||
// Multi: RHF stores T[] → maps to Mantine string[]
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Mantine props we manage ourselves */
|
||||
type ManagedSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'onBlur' | 'error' | 'filter' | 'name' | 'onSelect';
|
||||
type ManagedMultiSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'onBlur' | 'error' | 'filter' | 'name' | 'onSelect';
|
||||
|
||||
/** Single-select RHF props — stores T | null */
|
||||
export type FieldLocalSelectSingleProps<
|
||||
T extends Record<string, any>,
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = LocalSelectBaseProps<T> &
|
||||
UseControllerProps<TFieldValues, TName> &
|
||||
Omit<SelectProps, ManagedSelectProps> & {
|
||||
multiple?: false;
|
||||
};
|
||||
|
||||
/** Multi-select RHF props — stores T[] */
|
||||
export type FieldLocalSelectMultiProps<
|
||||
T extends Record<string, any>,
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = LocalSelectBaseProps<T> &
|
||||
UseControllerProps<TFieldValues, TName> &
|
||||
Omit<MultiSelectProps, ManagedMultiSelectProps> & {
|
||||
multiple: true;
|
||||
};
|
||||
|
||||
/** Discriminated union based on `multiple` */
|
||||
export type FieldLocalSelectProps<
|
||||
T extends Record<string, any>,
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> =
|
||||
| FieldLocalSelectSingleProps<T, TFieldValues, TName>
|
||||
| FieldLocalSelectMultiProps<T, TFieldValues, TName>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component Implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function FieldLocalSelectInner<
|
||||
T extends Record<string, any>,
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>(props: FieldLocalSelectProps<T, TFieldValues, TName>) {
|
||||
const {
|
||||
// RHF controller props
|
||||
name,
|
||||
control,
|
||||
rules,
|
||||
shouldUnregister,
|
||||
defaultValue,
|
||||
disabled,
|
||||
// LocalSelect engine props
|
||||
options,
|
||||
valueKey,
|
||||
labelKey,
|
||||
renderLabel,
|
||||
multiple,
|
||||
filterOption,
|
||||
onSelect: onSelectCallback,
|
||||
// Remaining Mantine props
|
||||
...mantineProps
|
||||
} = props;
|
||||
|
||||
const {
|
||||
field,
|
||||
fieldState: { error },
|
||||
} = useController<TFieldValues, TName>({
|
||||
name,
|
||||
control,
|
||||
rules,
|
||||
shouldUnregister,
|
||||
defaultValue,
|
||||
disabled,
|
||||
});
|
||||
|
||||
// Translate the error message (handles JSON i18n payloads)
|
||||
const translatedError = useTranslatedError(error?.message);
|
||||
|
||||
// Intercept onChange to pass full objects to RHF
|
||||
const handleChange = (value: any) => {
|
||||
field.onChange(value);
|
||||
onSelectCallback?.(value);
|
||||
};
|
||||
|
||||
// Build engine props based on single/multi mode
|
||||
if (multiple) {
|
||||
return (
|
||||
<LocalSelect<T>
|
||||
multiple
|
||||
options={options}
|
||||
valueKey={valueKey}
|
||||
labelKey={labelKey}
|
||||
renderLabel={renderLabel}
|
||||
filterOption={filterOption}
|
||||
value={field.value ?? []}
|
||||
onChange={handleChange}
|
||||
onBlur={field.onBlur}
|
||||
error={translatedError}
|
||||
disabled={field.disabled}
|
||||
{...(mantineProps as any)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LocalSelect<T>
|
||||
options={options}
|
||||
valueKey={valueKey}
|
||||
labelKey={labelKey}
|
||||
renderLabel={renderLabel}
|
||||
filterOption={filterOption}
|
||||
value={field.value ?? null}
|
||||
onChange={handleChange}
|
||||
onBlur={field.onBlur}
|
||||
error={translatedError}
|
||||
disabled={field.disabled}
|
||||
{...(mantineProps as any)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const FieldLocalSelect = React.memo(FieldLocalSelectInner) as typeof FieldLocalSelectInner;
|
||||
(FieldLocalSelect as any).displayName = 'FieldLocalSelect';
|
||||
@@ -0,0 +1,4 @@
|
||||
import { MultiSelect, type MultiSelectProps } from '@mantine/core';
|
||||
import { withRHF } from '../withRHF';
|
||||
|
||||
export const FieldMultiSelect = withRHF<MultiSelectProps>('FieldMultiSelect', MultiSelect);
|
||||
@@ -0,0 +1,4 @@
|
||||
import { NativeSelect, type NativeSelectProps } from '@mantine/core';
|
||||
import { withRHF } from '../withRHF';
|
||||
|
||||
export const FieldNativeSelect = withRHF<NativeSelectProps>('FieldNativeSelect', NativeSelect);
|
||||
@@ -0,0 +1,4 @@
|
||||
import { NumberInput, type NumberInputProps } from '@mantine/core';
|
||||
import { withRHF } from '../withRHF';
|
||||
|
||||
export const FieldNumberInput = withRHF<NumberInputProps>('FieldNumberInput', NumberInput);
|
||||
@@ -0,0 +1,4 @@
|
||||
import { PasswordInput, type PasswordInputProps } from '@mantine/core';
|
||||
import { withRHF } from '../withRHF';
|
||||
|
||||
export const FieldPasswordInput = withRHF<PasswordInputProps>('FieldPasswordInput', PasswordInput);
|
||||
@@ -0,0 +1,4 @@
|
||||
import { PinInput, type PinInputProps } from '@mantine/core';
|
||||
import { withRHF } from '../withRHF';
|
||||
|
||||
export const FieldPinInput = withRHF<PinInputProps>('FieldPinInput', PinInput);
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Radio, type RadioGroupProps } from '@mantine/core';
|
||||
import { withRHF } from '../withRHF';
|
||||
|
||||
// Wraps Radio.Group — individual Radio items are passed as children.
|
||||
// Usage:
|
||||
// <FieldRadioGroup name="gender" control={control}>
|
||||
// <Radio value="male" label="Male" />
|
||||
// <Radio value="female" label="Female" />
|
||||
// </FieldRadioGroup>
|
||||
export const FieldRadioGroup = withRHF<RadioGroupProps>('FieldRadioGroup', Radio.Group);
|
||||
@@ -0,0 +1,4 @@
|
||||
import { RangeSlider, type RangeSliderProps } from '@mantine/core';
|
||||
import { withRHF } from '../withRHF';
|
||||
|
||||
export const FieldRangeSlider = withRHF<RangeSliderProps>('FieldRangeSlider', RangeSlider);
|
||||
@@ -0,0 +1,4 @@
|
||||
import { Rating, type RatingProps } from '@mantine/core';
|
||||
import { withRHF } from '../withRHF';
|
||||
|
||||
export const FieldRating = withRHF<RatingProps>('FieldRating', Rating);
|
||||
@@ -0,0 +1,121 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useController, type FieldPath, type FieldValues, type UseControllerProps } from 'react-hook-form';
|
||||
import { useEditor } from '@tiptap/react';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import Underline from '@tiptap/extension-underline';
|
||||
import Link from '@tiptap/extension-link';
|
||||
import TextAlign from '@tiptap/extension-text-align';
|
||||
import { RichTextEditor } from '@mantine/tiptap';
|
||||
import { Input } from '@mantine/core';
|
||||
import { useTranslatedError } from '../useTranslatedError';
|
||||
|
||||
export type FieldRichTextEditorProps<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = UseControllerProps<TFieldValues, TName> & {
|
||||
label?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
withAsterisk?: boolean;
|
||||
};
|
||||
|
||||
function FieldRichTextEditorComponent<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>(props: FieldRichTextEditorProps<TFieldValues, TName>) {
|
||||
const {
|
||||
name,
|
||||
control,
|
||||
rules,
|
||||
shouldUnregister,
|
||||
defaultValue,
|
||||
disabled,
|
||||
label,
|
||||
description,
|
||||
withAsterisk,
|
||||
} = props;
|
||||
|
||||
const {
|
||||
field,
|
||||
fieldState: { error },
|
||||
} = useController({
|
||||
name,
|
||||
control,
|
||||
rules,
|
||||
shouldUnregister,
|
||||
defaultValue,
|
||||
disabled,
|
||||
});
|
||||
|
||||
const translatedError = useTranslatedError(error?.message);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Underline,
|
||||
Link,
|
||||
TextAlign.configure({ types: ['heading', 'paragraph'], alignments: ['left', 'center', 'right', 'justify'] }),
|
||||
],
|
||||
content: field.value || '',
|
||||
onUpdate({ editor }) {
|
||||
field.onChange(editor.getHTML());
|
||||
},
|
||||
onBlur() {
|
||||
field.onBlur();
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (editor && field.value !== editor.getHTML()) {
|
||||
editor.commands.setContent(field.value || '');
|
||||
}
|
||||
}, [field.value, editor]);
|
||||
|
||||
return (
|
||||
<Input.Wrapper
|
||||
label={label}
|
||||
description={description}
|
||||
withAsterisk={withAsterisk}
|
||||
error={translatedError}
|
||||
>
|
||||
<RichTextEditor editor={editor}>
|
||||
<RichTextEditor.Toolbar sticky stickyOffset={60}>
|
||||
<RichTextEditor.ControlsGroup>
|
||||
<RichTextEditor.Bold />
|
||||
<RichTextEditor.Italic />
|
||||
<RichTextEditor.Underline />
|
||||
<RichTextEditor.Strikethrough />
|
||||
<RichTextEditor.ClearFormatting />
|
||||
</RichTextEditor.ControlsGroup>
|
||||
|
||||
<RichTextEditor.ControlsGroup>
|
||||
<RichTextEditor.H1 />
|
||||
<RichTextEditor.H2 />
|
||||
<RichTextEditor.H3 />
|
||||
</RichTextEditor.ControlsGroup>
|
||||
|
||||
<RichTextEditor.ControlsGroup>
|
||||
<RichTextEditor.BulletList />
|
||||
<RichTextEditor.OrderedList />
|
||||
</RichTextEditor.ControlsGroup>
|
||||
|
||||
<RichTextEditor.ControlsGroup>
|
||||
<RichTextEditor.Link />
|
||||
<RichTextEditor.Unlink />
|
||||
</RichTextEditor.ControlsGroup>
|
||||
|
||||
<RichTextEditor.ControlsGroup>
|
||||
<RichTextEditor.AlignLeft />
|
||||
<RichTextEditor.AlignCenter />
|
||||
<RichTextEditor.AlignRight />
|
||||
<RichTextEditor.AlignJustify />
|
||||
</RichTextEditor.ControlsGroup>
|
||||
</RichTextEditor.Toolbar>
|
||||
|
||||
<RichTextEditor.Content />
|
||||
</RichTextEditor>
|
||||
</Input.Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// Wrap with React.memo for identical performance characteristics as withRHF components
|
||||
export const FieldRichTextEditor = React.memo(FieldRichTextEditorComponent) as typeof FieldRichTextEditorComponent;
|
||||
@@ -0,0 +1,16 @@
|
||||
import { SegmentedControl, type SegmentedControlProps } from '@mantine/core';
|
||||
import { withRHF } from '../withRHF';
|
||||
|
||||
export interface FieldSegmentedControlProps extends SegmentedControlProps {
|
||||
label?: string;
|
||||
description?: string;
|
||||
withAsterisk?: boolean;
|
||||
}
|
||||
|
||||
// SegmentedControl does NOT have a native `error` prop.
|
||||
// The HOC wraps it in Input.Wrapper to display validation errors.
|
||||
export const FieldSegmentedControl = withRHF<FieldSegmentedControlProps>(
|
||||
'FieldSegmentedControl',
|
||||
SegmentedControl,
|
||||
{ requiresWrapper: true },
|
||||
);
|
||||
@@ -0,0 +1,4 @@
|
||||
import { Select, type SelectProps } from '@mantine/core';
|
||||
import { withRHF } from '../withRHF';
|
||||
|
||||
export const FieldSelect = withRHF<SelectProps>('FieldSelect', Select);
|
||||
@@ -0,0 +1,4 @@
|
||||
import { Slider, type SliderProps } from '@mantine/core';
|
||||
import { withRHF } from '../withRHF';
|
||||
|
||||
export const FieldSlider = withRHF<SliderProps>('FieldSlider', Slider);
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Switch, type SwitchProps } from '@mantine/core';
|
||||
import { withRHF } from '../withRHF';
|
||||
|
||||
export const FieldSwitch = withRHF<SwitchProps>('FieldSwitch', Switch, {
|
||||
isCheckType: true,
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
import { TagsInput, type TagsInputProps } from '@mantine/core';
|
||||
import { withRHF } from '../withRHF';
|
||||
|
||||
export const FieldTagsInput = withRHF<TagsInputProps>('FieldTagsInput', TagsInput);
|
||||
@@ -0,0 +1,4 @@
|
||||
import { TextInput, type TextInputProps } from '@mantine/core';
|
||||
import { withRHF } from '../withRHF';
|
||||
|
||||
export const FieldTextInput = withRHF<TextInputProps>('FieldTextInput', TextInput);
|
||||
@@ -0,0 +1,4 @@
|
||||
import { Textarea, type TextareaProps } from '@mantine/core';
|
||||
import { withRHF } from '../withRHF';
|
||||
|
||||
export const FieldTextarea = withRHF<TextareaProps>('FieldTextarea', Textarea);
|
||||
@@ -0,0 +1,85 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Form Field Components — Barrel Export
|
||||
// ---------------------------------------------------------------------------
|
||||
// All components are generated via the withRHF() HOC factory.
|
||||
// They use the `Field` prefix to prevent naming collisions with native
|
||||
// Mantine components (e.g., FieldTextInput vs TextInput).
|
||||
//
|
||||
// Import patterns:
|
||||
// import { FieldTextInput, FieldSelect } from '@repo/ui/form';
|
||||
// import { FieldTextInput, FieldSelect } from '@repo/ui/components';
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Core HOC & Types (for advanced usage / custom field creation)
|
||||
export { withRHF } from './withRHF';
|
||||
export type { WithRHFProps, WithRHFOptions, ZodI18nPayload, ValueTransform } from './types';
|
||||
|
||||
// Re-export RHF essentials so consuming apps don't need separate imports
|
||||
export { useForm, useFormContext, useWatch, useFieldArray, FormProvider } from 'react-hook-form';
|
||||
export { zodResolver } from '@hookform/resolvers/zod';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Text Input Fields
|
||||
// ---------------------------------------------------------------------------
|
||||
export { FieldTextInput } from './fields/text-input.field';
|
||||
export { FieldPasswordInput } from './fields/password-input.field';
|
||||
export { FieldTextarea } from './fields/textarea.field';
|
||||
export { FieldNumberInput } from './fields/number-input.field';
|
||||
export { FieldJsonInput } from './fields/json-input.field';
|
||||
export { FieldPinInput } from './fields/pin-input.field';
|
||||
export { FieldAutocomplete } from './fields/autocomplete.field';
|
||||
export { FieldRichTextEditor } from './fields/rich-text.field';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Selection Fields
|
||||
// ---------------------------------------------------------------------------
|
||||
export { FieldSelect } from './fields/select.field';
|
||||
export { FieldMultiSelect } from './fields/multi-select.field';
|
||||
export { FieldNativeSelect } from './fields/native-select.field';
|
||||
export { FieldTagsInput } from './fields/tags-input.field';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Local & Async Selection Fields
|
||||
// ---------------------------------------------------------------------------
|
||||
export { FieldLocalSelect } from './fields/local-select.field';
|
||||
export type { FieldLocalSelectProps } from './fields/local-select.field';
|
||||
export { FieldAsyncSelect } from './fields/async-select.field';
|
||||
export type { FieldAsyncSelectProps } from './fields/async-select.field';
|
||||
|
||||
// Standalone engines (no RHF dependency) for use outside form contexts
|
||||
export { LocalSelect, AsyncSelect } from './custom';
|
||||
export type {
|
||||
LocalSelectProps,
|
||||
AsyncSelectProps,
|
||||
LocalSelectBaseProps,
|
||||
SelectFilterContext,
|
||||
LoadOptionsResponse,
|
||||
LoadOptionsFn,
|
||||
} from './custom';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Toggle / Boolean Fields
|
||||
// ---------------------------------------------------------------------------
|
||||
export { FieldCheckbox } from './fields/checkbox.field';
|
||||
export { FieldRadioGroup } from './fields/radio-group.field';
|
||||
export { FieldSwitch } from './fields/switch.field';
|
||||
export { FieldChipGroup } from './fields/chip-group.field';
|
||||
export { FieldSegmentedControl } from './fields/segmented-control.field';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Range / Numeric Fields
|
||||
// ---------------------------------------------------------------------------
|
||||
export { FieldSlider } from './fields/slider.field';
|
||||
export { FieldRangeSlider } from './fields/range-slider.field';
|
||||
export { FieldRating } from './fields/rating.field';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Color Fields
|
||||
// ---------------------------------------------------------------------------
|
||||
export { FieldColorInput } from './fields/color-input.field';
|
||||
export { FieldColorPicker } from './fields/color-picker.field';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File Fields
|
||||
// ---------------------------------------------------------------------------
|
||||
export { FieldFileInput } from './fields/file-input.field';
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import type {
|
||||
FieldPath,
|
||||
FieldValues,
|
||||
UseControllerProps,
|
||||
} from 'react-hook-form';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Zod i18n JSON payload shape
|
||||
// ---------------------------------------------------------------------------
|
||||
// When Zod errors are encoded for i18n, they follow this shape:
|
||||
// { "key": "validation.required", "values": { "min": 3 } }
|
||||
// The HOC will attempt JSON.parse on the error message string. If parsing
|
||||
// succeeds and the shape matches, it will call t(key, values) for translation.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ZodI18nPayload {
|
||||
/** The i18n translation key, e.g. "validation.required" */
|
||||
key: string;
|
||||
/** Optional interpolation values, e.g. { min: 3, max: 255 } */
|
||||
values?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WithRHFProps — Props injected by the withRHF HOC
|
||||
// ---------------------------------------------------------------------------
|
||||
// This type removes Mantine's own value/onChange/onBlur/error props (which
|
||||
// are controlled by RHF) and injects the RHF controller props instead.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Props that RHF will manage — stripped from the Mantine component's API */
|
||||
type ManagedProps = 'value' | 'defaultValue' | 'onChange' | 'onBlur' | 'error';
|
||||
|
||||
/**
|
||||
* Final props type for a wrapped Field component.
|
||||
*
|
||||
* @template TComponentProps - The original Mantine component props
|
||||
* @template TFieldValues - The form values shape (default: FieldValues)
|
||||
* @template TName - The field path (auto-inferred from TFieldValues)
|
||||
*/
|
||||
export type WithRHFProps<
|
||||
TComponentProps,
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = Omit<TComponentProps, ManagedProps> &
|
||||
UseControllerProps<TFieldValues, TName>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Value transform — for components with non-standard value semantics
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Defines how a Mantine component's native event value maps to/from the
|
||||
* RHF field value. Used for components like Checkbox (boolean ↔ checked)
|
||||
* or NumberInput (number | string → number).
|
||||
*/
|
||||
export interface ValueTransform<TFieldValue = unknown, TNativeValue = unknown> {
|
||||
/** Convert RHF field value → Mantine component prop */
|
||||
toComponentValue: (fieldValue: TFieldValue) => TNativeValue;
|
||||
/** Convert Mantine onChange argument → RHF field value */
|
||||
toFieldValue: (nativeValue: TNativeValue) => TFieldValue;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HOC configuration options
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface WithRHFOptions {
|
||||
/**
|
||||
* When true, the component uses `checked` instead of `value` for its
|
||||
* controlled state (e.g., Checkbox, Switch).
|
||||
*/
|
||||
isCheckType?: boolean;
|
||||
|
||||
/**
|
||||
* When true, the wrapped Mantine component does NOT have a native `error`
|
||||
* prop. The HOC will render the component inside `Input.Wrapper` to
|
||||
* display validation errors.
|
||||
*/
|
||||
requiresWrapper?: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utility: Extract the component's ref type for forwardRef
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ExtractRef<T> = T extends ComponentType<infer P>
|
||||
? P extends { ref?: infer R }
|
||||
? R
|
||||
: never
|
||||
: never;
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from '@repo/core-i18n';
|
||||
import type { ZodI18nPayload } from './types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: Attempt to parse a Zod error message as a JSON i18n payload
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function tryParseI18nPayload(message: string): ZodI18nPayload | null {
|
||||
// Quick guard: JSON payloads always start with '{'
|
||||
if (!message.startsWith('{')) return null;
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(message);
|
||||
|
||||
if (
|
||||
typeof parsed === 'object' &&
|
||||
parsed !== null &&
|
||||
'key' in parsed &&
|
||||
typeof (parsed as ZodI18nPayload).key === 'string'
|
||||
) {
|
||||
return parsed as ZodI18nPayload;
|
||||
}
|
||||
} catch {
|
||||
// Not valid JSON — this is expected for plain string error messages
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// useTranslatedError — Hook that resolves a raw error message into a
|
||||
// user-facing translated string.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useTranslatedError(rawMessage: string | undefined): string | undefined {
|
||||
// Always call useTranslation — React hook rules require stable call order.
|
||||
// The 'validation' namespace is used for Zod error keys.
|
||||
// Falls back to 'common' automatically via i18next's ns resolution.
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
return useMemo(() => {
|
||||
if (!rawMessage) return undefined;
|
||||
|
||||
const payload = tryParseI18nPayload(rawMessage);
|
||||
|
||||
if (payload) {
|
||||
// Attempt to translate. If the key exists in i18n resources, we get
|
||||
// the translated string. Otherwise i18next returns the key itself,
|
||||
// and we fall back to the raw Zod message.
|
||||
const translated = t(payload.key, {
|
||||
...payload.values,
|
||||
ns: 'validation',
|
||||
defaultValue: payload.key, // fallback to the key itself
|
||||
});
|
||||
|
||||
// If i18next couldn't find the key (returned the key unchanged),
|
||||
// try without namespace, then fall back to the raw Zod message.
|
||||
if (translated === payload.key) {
|
||||
const commonAttempt = t(payload.key, {
|
||||
...payload.values,
|
||||
defaultValue: rawMessage,
|
||||
});
|
||||
return commonAttempt;
|
||||
}
|
||||
|
||||
return translated;
|
||||
}
|
||||
|
||||
// Not a JSON payload — check if the raw message itself is a translation key
|
||||
if (i18n.exists(rawMessage, { ns: 'validation' })) {
|
||||
return t(rawMessage, { ns: 'validation' });
|
||||
}
|
||||
|
||||
// Plain string error message — pass through as-is
|
||||
return rawMessage;
|
||||
}, [rawMessage, t, i18n]);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import React, { type ComponentType, type Ref } from 'react';
|
||||
import {
|
||||
useController,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
type UseControllerProps,
|
||||
} from 'react-hook-form';
|
||||
import { Input } from '@mantine/core';
|
||||
import type { WithRHFOptions } from './types';
|
||||
import { useTranslatedError } from './useTranslatedError';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// withRHF — Higher-Order Component Factory
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// PERFORMANCE NOTES (ERP 1500+ field forms):
|
||||
// -------------------------------------------
|
||||
// 1. `useController` creates a MICRO-SUBSCRIPTION for this field only.
|
||||
// The component will NOT re-render when unrelated fields change.
|
||||
//
|
||||
// 2. `React.memo` is applied on the OUTER wrapper component. This provides
|
||||
// a second defense layer: even if a parent component re-renders (e.g.,
|
||||
// a layout grid reshuffles), this field will bail out of rendering if
|
||||
// its own props haven't changed.
|
||||
//
|
||||
// 3. Together, useController + React.memo gives us O(1) render cost per
|
||||
// keystroke regardless of total form size — critical for ERP-scale forms.
|
||||
//
|
||||
// WHY React.memo IS WARRANTED HERE:
|
||||
// In smaller forms (<50 fields), React.memo's shallow comparison cost is
|
||||
// negligible but unnecessary. However, in ERP forms with 1500+ fields
|
||||
// rendered in virtualized grids, each wasted render cascade can add
|
||||
// ~16ms of jank. The memo wrapper prevents this with near-zero overhead
|
||||
// (shallow prop comparison is O(n) on prop count, typically <10 props).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Creates a React Hook Form-connected wrapper around any Mantine form component.
|
||||
*
|
||||
* @param displayName - The display name for the wrapped component (e.g., "FieldTextInput")
|
||||
* @param MantineComponent - The Mantine component to wrap
|
||||
* @param options - Configuration for special component types (checkbox, wrapper-needed, etc.)
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { TextInput } from '@mantine/core';
|
||||
* import { withRHF } from './withRHF';
|
||||
*
|
||||
* export const FieldTextInput = withRHF('FieldTextInput', TextInput);
|
||||
* ```
|
||||
*/
|
||||
export function withRHF<TComponentProps extends Record<string, any>>(
|
||||
displayName: string,
|
||||
MantineComponent: ComponentType<TComponentProps>,
|
||||
options: WithRHFOptions = {},
|
||||
) {
|
||||
const { isCheckType = false, requiresWrapper = false } = options;
|
||||
|
||||
type Props<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = Omit<TComponentProps, 'value' | 'defaultValue' | 'onChange' | 'onBlur' | 'error' | 'checked'> &
|
||||
UseControllerProps<TFieldValues, TName> & {
|
||||
/** Optional ref forwarded to the underlying Mantine component */
|
||||
ref?: Ref<unknown>;
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// The inner component — separated so React.memo can wrap it cleanly.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
function FieldComponent<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>(props: Props<TFieldValues, TName>) {
|
||||
const {
|
||||
name,
|
||||
control,
|
||||
rules,
|
||||
shouldUnregister,
|
||||
defaultValue,
|
||||
disabled,
|
||||
ref,
|
||||
...mantineProps
|
||||
} = props;
|
||||
|
||||
const {
|
||||
field,
|
||||
fieldState: { error },
|
||||
} = useController<TFieldValues, TName>({
|
||||
name,
|
||||
control,
|
||||
rules,
|
||||
shouldUnregister,
|
||||
defaultValue,
|
||||
disabled,
|
||||
});
|
||||
|
||||
// Translate the error message (handles JSON i18n payloads)
|
||||
const translatedError = useTranslatedError(error?.message);
|
||||
|
||||
// Build the props to spread onto the Mantine component
|
||||
const componentProps: Record<string, unknown> = {
|
||||
...mantineProps,
|
||||
ref: ref ?? field.ref,
|
||||
onBlur: field.onBlur,
|
||||
disabled: field.disabled,
|
||||
};
|
||||
|
||||
if (isCheckType) {
|
||||
// Checkbox / Switch: use `checked` and boolean onChange
|
||||
componentProps['checked'] = !!field.value;
|
||||
componentProps['onChange'] = (event: React.ChangeEvent<HTMLInputElement> | boolean) => {
|
||||
if (typeof event === 'boolean') {
|
||||
field.onChange(event);
|
||||
} else {
|
||||
field.onChange(event.currentTarget.checked);
|
||||
}
|
||||
};
|
||||
} else {
|
||||
// Standard components: use `value` and direct onChange
|
||||
componentProps['value'] = field.value ?? '';
|
||||
componentProps['onChange'] = field.onChange;
|
||||
}
|
||||
|
||||
// Components that lack a native `error` prop need Input.Wrapper
|
||||
if (requiresWrapper) {
|
||||
const { label, description, withAsterisk, ...innerProps } = componentProps as Record<string, unknown>;
|
||||
|
||||
return (
|
||||
<Input.Wrapper
|
||||
label={label as string}
|
||||
description={description as string}
|
||||
withAsterisk={withAsterisk as boolean}
|
||||
error={translatedError}
|
||||
>
|
||||
<MantineComponent {...(innerProps as TComponentProps)} />
|
||||
</Input.Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// Standard path: pass error directly to the Mantine component
|
||||
componentProps['error'] = translatedError;
|
||||
|
||||
return <MantineComponent {...(componentProps as TComponentProps)} />;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Apply React.memo for render bailout in large forms.
|
||||
//
|
||||
// We use the default shallow comparison. For ERP forms, this means a
|
||||
// field component like <FieldTextInput name="address.city" /> will NOT
|
||||
// re-render when <FieldTextInput name="address.zip" /> changes, because:
|
||||
// 1. useController isolates the subscription (different field path)
|
||||
// 2. React.memo catches any parent-driven re-renders where our own
|
||||
// props haven't changed (e.g., a Grid layout re-render)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
const Memoized = React.memo(FieldComponent) as typeof FieldComponent;
|
||||
|
||||
// Preserve the display name for React DevTools
|
||||
(Memoized as unknown as { displayName: string }).displayName = displayName;
|
||||
|
||||
return Memoized;
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
export * from '@mantine/core';
|
||||
export {
|
||||
List,
|
||||
TypographyStylesProvider,
|
||||
} from '@mantine/core';
|
||||
|
||||
export * from './Form';
|
||||
export * from './system-pages/coming-soon';
|
||||
export * from './system-pages/forbidden';
|
||||
export * from './system-pages/maintenance';
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from '@mantine/hooks';
|
||||
export * from './useConditionalField';
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { UseFormSetValue, UseFormUnregister, UseFormClearErrors, FieldValues, Path } from 'react-hook-form';
|
||||
|
||||
export interface UseConditionalFieldOptions<TFieldValues extends FieldValues> {
|
||||
condition: boolean;
|
||||
name: Path<TFieldValues>;
|
||||
setValue: UseFormSetValue<TFieldValues>;
|
||||
unregister?: UseFormUnregister<TFieldValues>;
|
||||
clearErrors?: UseFormClearErrors<TFieldValues>;
|
||||
defaultValue?: any;
|
||||
mode?: 'unregister' | 'reset';
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatically cleans up a conditionally rendered React Hook Form field
|
||||
* when its parent condition becomes false.
|
||||
*
|
||||
* @param options Configuration object for the conditional field behavior
|
||||
*/
|
||||
export function useConditionalField<TFieldValues extends FieldValues>(
|
||||
options: UseConditionalFieldOptions<TFieldValues>
|
||||
) {
|
||||
// Destructure with default values
|
||||
const {
|
||||
condition,
|
||||
name,
|
||||
setValue,
|
||||
unregister,
|
||||
clearErrors,
|
||||
defaultValue,
|
||||
mode = 'unregister'
|
||||
} = options;
|
||||
|
||||
const config = options;
|
||||
|
||||
// Stabilize defaultValue using useRef to prevent infinite render loops
|
||||
// if developers pass inline arrays/objects (e.g. defaultValue: [])
|
||||
const defaultValueRef = useRef(defaultValue);
|
||||
useEffect(() => {
|
||||
defaultValueRef.current = defaultValue;
|
||||
}, [defaultValue]);
|
||||
|
||||
useEffect(() => {
|
||||
// When the condition evaluates to false, we execute the cleanup logic
|
||||
if (!condition) {
|
||||
// 1. Reset the field value. We use a stable empty state (like '' instead of undefined)
|
||||
// to prevent uncontrolled component fallback in the React UI. We also force RHF to sync.
|
||||
const targetValue = defaultValueRef.current !== undefined ? defaultValueRef.current : ('' as any);
|
||||
config.setValue(config.name, targetValue, {
|
||||
shouldDirty: true,
|
||||
shouldTouch: true,
|
||||
shouldValidate: true
|
||||
});
|
||||
|
||||
// 2. Execute the appropriate side-effect strategy based on the active mode
|
||||
if (mode === 'unregister' && config.unregister) {
|
||||
// Unregister Mode: Completely unmounts the field from React Hook Form.
|
||||
// The value is removed from the payload and validation is entirely bypassed.
|
||||
config.unregister(config.name);
|
||||
} else if (mode === 'reset' && config.clearErrors) {
|
||||
// Reset Mode: Keeps the field active in the DOM (e.g. cascading or disabled dependencies).
|
||||
// Wipes the value and clears active validation errors so the user can interact
|
||||
// with a fresh state, but keeps the property in the payload.
|
||||
config.clearErrors(config.name);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
condition,
|
||||
name,
|
||||
setValue,
|
||||
unregister,
|
||||
clearErrors,
|
||||
mode
|
||||
]);
|
||||
}
|
||||
@@ -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 (
|
||||
<MantineProvider
|
||||
theme={mergedTheme}
|
||||
forceColorScheme={colorScheme}
|
||||
defaultColorScheme={colorScheme}
|
||||
>
|
||||
<MantineProvider theme={mergedTheme} forceColorScheme={colorScheme} defaultColorScheme={colorScheme}>
|
||||
{children}
|
||||
</MantineProvider>
|
||||
);
|
||||
|
||||
+59
-27
@@ -1,19 +1,35 @@
|
||||
/* =========================================
|
||||
1. CORE IMPORTS & TAILWIND CONFIG
|
||||
========================================= */
|
||||
/* Import Mantine core and TipTap extensions */
|
||||
@import '@mantine/core/styles.css';
|
||||
@import '@mantine/tiptap/styles.css';
|
||||
|
||||
/* Initialize Tailwind CSS v4 engine */
|
||||
@import 'tailwindcss';
|
||||
|
||||
/* Instruct Tailwind to scan the src directory for utility class usage */
|
||||
@source "../src";
|
||||
|
||||
@theme {
|
||||
/* =========================================
|
||||
FONT FAMILY MAPPING (Mantine -> Tailwind)
|
||||
2. FONT FAMILY MAPPING
|
||||
Synchronizes Tailwind's typography utilities
|
||||
with Mantine's global font configurations.
|
||||
========================================= */
|
||||
--base-font-size: 13px;
|
||||
--font-sans: var(--mantine-font-family);
|
||||
--font-mono: var(--mantine-font-family-monospace);
|
||||
|
||||
/* =========================================
|
||||
1. COLORS (Strictly mapped to Mantine 0-9)
|
||||
3. COLOR SYSTEM (Mantine to Tailwind Sync)
|
||||
Maps Tailwind's 50-900 scale directly to
|
||||
Mantine's 0-9 scale for seamless theming.
|
||||
Usage: `bg-brand-500`, `text-error-700`
|
||||
========================================= */
|
||||
--color-brand-50: var(--mantine-color-brand-0);
|
||||
|
||||
/* Brand Colors */
|
||||
--color-brand-50: var(--mantine-color-brand-0);
|
||||
--color-brand-100: var(--mantine-color-brand-1);
|
||||
--color-brand-200: var(--mantine-color-brand-2);
|
||||
--color-brand-300: var(--mantine-color-brand-3);
|
||||
@@ -24,7 +40,8 @@
|
||||
--color-brand-800: var(--mantine-color-brand-8);
|
||||
--color-brand-900: var(--mantine-color-brand-9);
|
||||
|
||||
--color-error-50: var(--mantine-color-error-0);
|
||||
/* Error Colors (Red/Danger) */
|
||||
--color-error-50: var(--mantine-color-error-0);
|
||||
--color-error-100: var(--mantine-color-error-1);
|
||||
--color-error-200: var(--mantine-color-error-2);
|
||||
--color-error-300: var(--mantine-color-error-3);
|
||||
@@ -35,7 +52,8 @@
|
||||
--color-error-800: var(--mantine-color-error-8);
|
||||
--color-error-900: var(--mantine-color-error-9);
|
||||
|
||||
--color-warning-50: var(--mantine-color-warning-0);
|
||||
/* Warning Colors (Yellow/Orange) */
|
||||
--color-warning-50: var(--mantine-color-warning-0);
|
||||
--color-warning-100: var(--mantine-color-warning-1);
|
||||
--color-warning-200: var(--mantine-color-warning-2);
|
||||
--color-warning-300: var(--mantine-color-warning-3);
|
||||
@@ -46,7 +64,8 @@
|
||||
--color-warning-800: var(--mantine-color-warning-8);
|
||||
--color-warning-900: var(--mantine-color-warning-9);
|
||||
|
||||
--color-success-50: var(--mantine-color-success-0);
|
||||
/* Success Colors (Green) */
|
||||
--color-success-50: var(--mantine-color-success-0);
|
||||
--color-success-100: var(--mantine-color-success-1);
|
||||
--color-success-200: var(--mantine-color-success-2);
|
||||
--color-success-300: var(--mantine-color-success-3);
|
||||
@@ -57,7 +76,8 @@
|
||||
--color-success-800: var(--mantine-color-success-8);
|
||||
--color-success-900: var(--mantine-color-success-9);
|
||||
|
||||
--color-info-50: var(--mantine-color-info-0);
|
||||
/* Info Colors (Blue/Cyan) */
|
||||
--color-info-50: var(--mantine-color-info-0);
|
||||
--color-info-100: var(--mantine-color-info-1);
|
||||
--color-info-200: var(--mantine-color-info-2);
|
||||
--color-info-300: var(--mantine-color-info-3);
|
||||
@@ -69,22 +89,26 @@
|
||||
--color-info-900: var(--mantine-color-info-9);
|
||||
|
||||
/* =========================================
|
||||
2. SPACING & CONTAINERS
|
||||
4. SPACING, BREAKPOINTS & CONTAINERS
|
||||
Aligns Tailwind's padding/margin scale
|
||||
with Mantine's layout engine.
|
||||
========================================= */
|
||||
--spacing: 0.25rem;
|
||||
--spacing: 0.25rem; /* Base Tailwind unit (1 = 0.25rem) */
|
||||
|
||||
/* Core mapped to Mantine */
|
||||
/* Map core layout spacing to Mantine */
|
||||
--spacing-xs: var(--mantine-spacing-xs);
|
||||
--spacing-sm: var(--mantine-spacing-sm);
|
||||
--spacing-md: var(--mantine-spacing-md);
|
||||
--spacing-lg: var(--mantine-spacing-lg);
|
||||
--spacing-xl: var(--mantine-spacing-xl);
|
||||
|
||||
/* Standard Tailwind responsive breakpoints and container sizes */
|
||||
--breakpoint-sm: 40rem;
|
||||
--breakpoint-md: 48rem;
|
||||
--breakpoint-lg: 64rem;
|
||||
--breakpoint-xl: 80rem;
|
||||
--breakpoint-2xl: 96rem;
|
||||
|
||||
--container-3xs: 16rem;
|
||||
--container-2xs: 18rem;
|
||||
--container-xs: 20rem;
|
||||
@@ -100,17 +124,22 @@
|
||||
--container-7xl: 80rem;
|
||||
|
||||
/* =========================================
|
||||
3. TYPOGRAPHY
|
||||
5. TYPOGRAPHY SCALES
|
||||
Base sizes (xs to xl) inherit from Mantine.
|
||||
Extended sizes (2xl to 9xl) use static rems.
|
||||
========================================= */
|
||||
/* Core text sizes mapped to Mantine, extended kept static */
|
||||
--text-xs: var(--mantine-font-size-xs);
|
||||
--text-xs--line-height: calc(1 / 0.75);
|
||||
|
||||
--text-sm: var(--mantine-font-size-sm);
|
||||
--text-sm--line-height: calc(1.25 / 0.875);
|
||||
|
||||
--text-base: var(--mantine-font-size-md);
|
||||
--text-base--line-height: calc(1.5 / 1);
|
||||
|
||||
--text-lg: var(--mantine-font-size-lg);
|
||||
--text-lg--line-height: calc(1.75 / 1.125);
|
||||
|
||||
--text-xl: var(--mantine-font-size-xl);
|
||||
--text-xl--line-height: calc(1.75 / 1.25);
|
||||
|
||||
@@ -131,6 +160,7 @@
|
||||
--text-9xl: 8rem;
|
||||
--text-9xl--line-height: 1;
|
||||
|
||||
/* Font Weights */
|
||||
--font-weight-thin: 100;
|
||||
--font-weight-extralight: 200;
|
||||
--font-weight-light: 300;
|
||||
@@ -141,6 +171,7 @@
|
||||
--font-weight-extrabold: 800;
|
||||
--font-weight-black: 900;
|
||||
|
||||
/* Letter Spacing (Tracking) */
|
||||
--tracking-tighter: -0.05em;
|
||||
--tracking-tight: -0.025em;
|
||||
--tracking-normal: 0em;
|
||||
@@ -148,6 +179,7 @@
|
||||
--tracking-wider: 0.05em;
|
||||
--tracking-widest: 0.1em;
|
||||
|
||||
/* Line Height (Leading) */
|
||||
--leading-tight: 1.25;
|
||||
--leading-snug: 1.375;
|
||||
--leading-normal: 1.5;
|
||||
@@ -155,19 +187,23 @@
|
||||
--leading-loose: 2;
|
||||
|
||||
/* =========================================
|
||||
4. RADIUS
|
||||
6. BORDER RADIUS
|
||||
Inherits exact corner rounding from Mantine.
|
||||
========================================= */
|
||||
--radius-xs: var(--mantine-radius-xs);
|
||||
--radius-sm: var(--mantine-radius-sm);
|
||||
--radius-md: var(--mantine-radius-md);
|
||||
--radius-lg: var(--mantine-radius-lg);
|
||||
--radius-xl: var(--mantine-radius-xl);
|
||||
|
||||
--radius-2xl: 1rem;
|
||||
--radius-3xl: 1.5rem;
|
||||
--radius-4xl: 2rem;
|
||||
|
||||
/* =========================================
|
||||
5. SHADOWS & BLURS
|
||||
7. SHADOWS & BLURS
|
||||
Ensures popovers, modals, and dropdowns
|
||||
share identical elevation depths.
|
||||
========================================= */
|
||||
--shadow-2xs: 0 1px rgb(0 0 0 / 0.05);
|
||||
--shadow-xs: var(--mantine-shadow-xs);
|
||||
@@ -203,7 +239,7 @@
|
||||
--blur-3xl: 64px;
|
||||
|
||||
/* =========================================
|
||||
6. MISCELLANEOUS (Aspect, Anim, Perspective)
|
||||
8. MISCELLANEOUS & ANIMATIONS
|
||||
========================================= */
|
||||
--perspective-dramatic: 100px;
|
||||
--perspective-near: 300px;
|
||||
@@ -217,31 +253,26 @@
|
||||
--ease-out: cubic-bezier(0, 0, 0.2, 1);
|
||||
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
/* Standard Tailwind Animations */
|
||||
--animate-spin: spin 1s linear infinite;
|
||||
--animate-ping: ping 1s cubic-bezier(0, 0, 0.2, 1) infinite;
|
||||
--animate-pulse: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
--animate-bounce: bounce 1s infinite;
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
@keyframes ping {
|
||||
75%,
|
||||
100% {
|
||||
75%, 100% {
|
||||
transform: scale(2);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@keyframes pulse {
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
@keyframes bounce {
|
||||
0%,
|
||||
100% {
|
||||
0%, 100% {
|
||||
transform: translateY(-25%);
|
||||
animation-timing-function: cubic-bezier(0.8, 0, 1, 1);
|
||||
}
|
||||
@@ -253,9 +284,10 @@
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
BASE RESETS
|
||||
9. BASE RESETS
|
||||
Applies global typography smoothing and
|
||||
sets the root font size.
|
||||
========================================= */
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
font-size: var(--base-font-size);
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
compose,
|
||||
required,
|
||||
emailValidator,
|
||||
minValue,
|
||||
maxValue,
|
||||
rangeValue,
|
||||
positiveNumber,
|
||||
minLength,
|
||||
maxLength,
|
||||
rangeLength,
|
||||
simplePassword,
|
||||
complexPassword,
|
||||
phoneValidator,
|
||||
} from '../registry.validator';
|
||||
|
||||
describe('Validator Registry', () => {
|
||||
describe('compose()', () => {
|
||||
it('should compose multiple string modifiers', () => {
|
||||
const schema = compose(z.string(), required('Password'), complexPassword(8));
|
||||
const res = schema.safeParse('Weak');
|
||||
expect(res.success).toBe(false);
|
||||
|
||||
const res2 = schema.safeParse('StrongPass1!');
|
||||
expect(res2.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('General Validators', () => {
|
||||
it('required() should enforce min 1 length', () => {
|
||||
const schema = compose(z.string(), required('TestField'));
|
||||
const res = schema.safeParse('');
|
||||
expect(res.success).toBe(false);
|
||||
expect(res.error?.issues[0].message).toBe(
|
||||
JSON.stringify({ key: 'validation:required', values: { field: 'TestField' } })
|
||||
);
|
||||
});
|
||||
|
||||
it('emailValidator() should enforce email format', () => {
|
||||
const schema = compose(z.string(), emailValidator());
|
||||
const res = schema.safeParse('invalid-email');
|
||||
expect(res.success).toBe(false);
|
||||
expect(res.error?.issues[0].message).toBe(
|
||||
JSON.stringify({ key: 'validation:invalid_email' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Numeric Validators', () => {
|
||||
it('minValue() should enforce min', () => {
|
||||
const schema = compose(z.number(), minValue(10, 'Age'));
|
||||
const res = schema.safeParse(5);
|
||||
expect(res.success).toBe(false);
|
||||
expect(res.error?.issues[0].message).toBe(
|
||||
JSON.stringify({ key: 'validation:min_val', values: { min: 10, field: 'Age' } })
|
||||
);
|
||||
});
|
||||
|
||||
it('maxValue() should enforce max', () => {
|
||||
const schema = compose(z.number(), maxValue(100, 'Percentage'));
|
||||
const res = schema.safeParse(105);
|
||||
expect(res.success).toBe(false);
|
||||
expect(res.error?.issues[0].message).toBe(
|
||||
JSON.stringify({ key: 'validation:max_val', values: { max: 100, field: 'Percentage' } })
|
||||
);
|
||||
});
|
||||
|
||||
it('rangeValue() should enforce range', () => {
|
||||
const schema = compose(z.number(), rangeValue(10, 20, 'Range'));
|
||||
const res = schema.safeParse(5);
|
||||
expect(res.success).toBe(false);
|
||||
expect(res.error?.issues[0].message).toBe(
|
||||
JSON.stringify({ key: 'validation:range_val', values: { min: 10, max: 20, field: 'Range' } })
|
||||
);
|
||||
});
|
||||
|
||||
it('positiveNumber() should enforce positive', () => {
|
||||
const schema = compose(z.number(), positiveNumber('Amount'));
|
||||
const res = schema.safeParse(-5);
|
||||
expect(res.success).toBe(false);
|
||||
expect(res.error?.issues[0].message).toBe(
|
||||
JSON.stringify({ key: 'validation:must_be_positive', values: { field: 'Amount' } })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('String Length Validators', () => {
|
||||
it('minLength() should enforce min length', () => {
|
||||
const schema = compose(z.string(), minLength(5, 'Username'));
|
||||
const res = schema.safeParse('abc');
|
||||
expect(res.success).toBe(false);
|
||||
expect(res.error?.issues[0].message).toBe(
|
||||
JSON.stringify({ key: 'validation:min_len', values: { min: 5, field: 'Username' } })
|
||||
);
|
||||
});
|
||||
|
||||
it('maxLength() should enforce max length', () => {
|
||||
const schema = compose(z.string(), maxLength(10, 'Username'));
|
||||
const res = schema.safeParse('thisisaverylongusername');
|
||||
expect(res.success).toBe(false);
|
||||
expect(res.error?.issues[0].message).toBe(
|
||||
JSON.stringify({ key: 'validation:max_len', values: { max: 10, field: 'Username' } })
|
||||
);
|
||||
});
|
||||
|
||||
it('rangeLength() should enforce range', () => {
|
||||
const schema = compose(z.string(), rangeLength(3, 5, 'Code'));
|
||||
const res = schema.safeParse('ab');
|
||||
expect(res.success).toBe(false);
|
||||
expect(res.error?.issues[0].message).toBe(
|
||||
JSON.stringify({ key: 'validation:range_len', values: { min: 3, max: 5, field: 'Code' } })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Security Validators', () => {
|
||||
it('simplePassword() should enforce length only', () => {
|
||||
const schema = compose(z.string(), simplePassword(6));
|
||||
const res = schema.safeParse('short');
|
||||
expect(res.success).toBe(false);
|
||||
expect(res.error?.issues[0].message).toBe(
|
||||
JSON.stringify({ key: 'validation:invalid_password_simple', values: { min: 6 } })
|
||||
);
|
||||
expect(schema.safeParse('longenough').success).toBe(true);
|
||||
});
|
||||
|
||||
it('complexPassword() should enforce complex rules', () => {
|
||||
const schema = compose(z.string(), complexPassword(8));
|
||||
expect(schema.safeParse('weakpassword').success).toBe(false);
|
||||
expect(schema.safeParse('NoSpecial1').success).toBe(false);
|
||||
expect(schema.safeParse('ValidPass1!').success).toBe(true);
|
||||
|
||||
const res = schema.safeParse('short');
|
||||
expect(res.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Technical Validators', () => {
|
||||
it('phoneValidator() should enforce indonesian phone pattern', () => {
|
||||
const schema = compose(z.string(), phoneValidator());
|
||||
expect(schema.safeParse('08123456789').success).toBe(false);
|
||||
expect(schema.safeParse('+628123456789').success).toBe(true);
|
||||
|
||||
const res = schema.safeParse('invalid');
|
||||
expect(res.success).toBe(false);
|
||||
expect(res.error?.issues[0].message).toBe(
|
||||
JSON.stringify({ key: 'validation:invalid_phone' })
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './sample.validator';
|
||||
export * from './registry.validator';
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { ZodString, ZodNumber, ZodTypeAny } from 'zod';
|
||||
|
||||
// ─── UTILITIES ─────────────────────────────────────────────────────────────
|
||||
|
||||
export const compose = <T extends ZodTypeAny>(
|
||||
base: T,
|
||||
...modifiers: ((schema: any) => any)[]
|
||||
): any => {
|
||||
return modifiers.reduce((acc, curr) => curr(acc), base);
|
||||
};
|
||||
|
||||
// ─── GENERAL ───────────────────────────────────────────────────────────────
|
||||
|
||||
export const required = (field?: string) => (schema: ZodString) => {
|
||||
return schema.min(1, {
|
||||
message: JSON.stringify({ key: 'validation:required', values: { field: field || 'Field' } }),
|
||||
});
|
||||
};
|
||||
|
||||
export const emailValidator = () => (schema: ZodString) => {
|
||||
return schema.email({
|
||||
message: JSON.stringify({ key: 'validation:invalid_email' }),
|
||||
});
|
||||
};
|
||||
|
||||
// ─── NUMERIC ───────────────────────────────────────────────────────────────
|
||||
|
||||
export const minValue = (min: number, field?: string) => (schema: ZodNumber) => {
|
||||
return schema.min(min, {
|
||||
message: JSON.stringify({ key: 'validation:min_val', values: { min, field } }),
|
||||
});
|
||||
};
|
||||
|
||||
export const maxValue = (max: number, field?: string) => (schema: ZodNumber) => {
|
||||
return schema.max(max, {
|
||||
message: JSON.stringify({ key: 'validation:max_val', values: { max, field } }),
|
||||
});
|
||||
};
|
||||
|
||||
export const rangeValue = (min: number, max: number, field?: string) => (schema: ZodNumber) => {
|
||||
return schema
|
||||
.min(min, { message: JSON.stringify({ key: 'validation:range_val', values: { min, max, field } }) })
|
||||
.max(max, { message: JSON.stringify({ key: 'validation:range_val', values: { min, max, field } }) });
|
||||
};
|
||||
|
||||
export const positiveNumber = (field?: string) => (schema: ZodNumber) => {
|
||||
return schema.positive({
|
||||
message: JSON.stringify({ key: 'validation:must_be_positive', values: { field } }),
|
||||
});
|
||||
};
|
||||
|
||||
// ─── STRING LENGTH ─────────────────────────────────────────────────────────
|
||||
|
||||
export const minLength = (min: number, field?: string) => (schema: ZodString) => {
|
||||
return schema.min(min, {
|
||||
message: JSON.stringify({ key: 'validation:min_len', values: { min, field } }),
|
||||
});
|
||||
};
|
||||
|
||||
export const maxLength = (max: number, field?: string) => (schema: ZodString) => {
|
||||
return schema.max(max, {
|
||||
message: JSON.stringify({ key: 'validation:max_len', values: { max, field } }),
|
||||
});
|
||||
};
|
||||
|
||||
export const rangeLength = (min: number, max: number, field?: string) => (schema: ZodString) => {
|
||||
return schema
|
||||
.min(min, { message: JSON.stringify({ key: 'validation:range_len', values: { min, max, field } }) })
|
||||
.max(max, { message: JSON.stringify({ key: 'validation:range_len', values: { min, max, field } }) });
|
||||
};
|
||||
|
||||
// ─── SECURITY ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const simplePassword = (min: number = 8) => (schema: ZodString) => {
|
||||
return schema.min(min, {
|
||||
message: JSON.stringify({ key: 'validation:invalid_password_simple', values: { min } }),
|
||||
});
|
||||
};
|
||||
|
||||
export const complexPassword = (min: number = 8) => (schema: ZodString) => {
|
||||
return schema
|
||||
.min(min, { message: JSON.stringify({ key: 'validation:invalid_password_complex' }) })
|
||||
.regex(/[A-Z]/, { message: JSON.stringify({ key: 'validation:invalid_password_complex' }) })
|
||||
.regex(/[a-z]/, { message: JSON.stringify({ key: 'validation:invalid_password_complex' }) })
|
||||
.regex(/[0-9]/, { message: JSON.stringify({ key: 'validation:invalid_password_complex' }) })
|
||||
.regex(/[^A-Za-z0-9]/, { message: JSON.stringify({ key: 'validation:invalid_password_complex' }) });
|
||||
};
|
||||
|
||||
// ─── TECHNICAL ─────────────────────────────────────────────────────────────
|
||||
|
||||
export const phoneValidator = () => (schema: ZodString) => {
|
||||
// Regex for Indonesian phone number (+62...)
|
||||
return schema.regex(/^\+62\d{8,13}$/, {
|
||||
message: JSON.stringify({ key: 'validation:invalid_phone' }),
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { z } from 'zod';
|
||||
import { compose, emailValidator, minLength } from './registry.validator';
|
||||
|
||||
export const sampleValidator = z.object({
|
||||
email: compose(z.string(), emailValidator()),
|
||||
name: compose(z.string(), minLength(3, 'Nama')),
|
||||
});
|
||||
|
||||
export type SampleValidatorType = z.infer<typeof sampleValidator>;
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: ['./src/__tests__/setup.ts'],
|
||||
css: false,
|
||||
},
|
||||
});
|
||||
Generated
+578
-7
@@ -161,6 +161,9 @@ importers:
|
||||
|
||||
apps/web:
|
||||
dependencies:
|
||||
'@hookform/resolvers':
|
||||
specifier: ^5.0.1
|
||||
version: 5.4.0(react-hook-form@7.79.0)
|
||||
'@repo/core-api':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/core-api
|
||||
@@ -200,6 +203,9 @@ importers:
|
||||
react-dom:
|
||||
specifier: ^19.2.3
|
||||
version: 19.2.3(react@19.2.3)
|
||||
react-hook-form:
|
||||
specifier: ^7.56.4
|
||||
version: 7.79.0(react@19.2.3)
|
||||
react-i18next:
|
||||
specifier: ^15.4.0
|
||||
version: 15.7.4(i18next@24.2.3)(react-dom@19.2.3)(react@19.2.3)(typescript@5.5.4)
|
||||
@@ -209,6 +215,9 @@ importers:
|
||||
tailwindcss:
|
||||
specifier: ^4.1.18
|
||||
version: 4.1.18
|
||||
zod:
|
||||
specifier: ^3.25.36
|
||||
version: 3.25.76
|
||||
devDependencies:
|
||||
'@repo/eslint-config':
|
||||
specifier: workspace:*
|
||||
@@ -236,7 +245,7 @@ importers:
|
||||
version: 5.4.17(@types/node@22.19.3)
|
||||
vitest:
|
||||
specifier: ^4.0.17
|
||||
version: 4.0.17(jsdom@26.1.0)
|
||||
version: 4.0.17(@opentelemetry/api@1.9.1)
|
||||
|
||||
packages/configs/eslint:
|
||||
dependencies:
|
||||
@@ -433,22 +442,52 @@ importers:
|
||||
version: 5.5.4
|
||||
vitest:
|
||||
specifier: ^4.0.17
|
||||
version: 4.0.17(jsdom@26.1.0)
|
||||
version: 4.0.17(@opentelemetry/api@1.9.1)
|
||||
|
||||
packages/ui:
|
||||
dependencies:
|
||||
'@hookform/resolvers':
|
||||
specifier: ^5.0.1
|
||||
version: 5.4.0(react-hook-form@7.79.0)
|
||||
'@mantine/core':
|
||||
specifier: ^8.3.15
|
||||
version: 8.3.15(@mantine/hooks@8.3.15)(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3)
|
||||
'@mantine/hooks':
|
||||
specifier: ^8.3.15
|
||||
version: 8.3.15(react@19.2.3)
|
||||
'@mantine/tiptap':
|
||||
specifier: ^9.3.2
|
||||
version: 9.3.2(@mantine/core@8.3.15)(@mantine/hooks@8.3.15)(@tiptap/extension-link@3.27.1)(@tiptap/react@3.27.1)(react-dom@19.2.3)(react@19.2.3)
|
||||
'@repo/core-i18n':
|
||||
specifier: workspace:*
|
||||
version: link:../core-i18n
|
||||
'@repo/utils':
|
||||
specifier: workspace:*
|
||||
version: link:../utils
|
||||
'@tiptap/extension-link':
|
||||
specifier: ^3.27.1
|
||||
version: 3.27.1(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1)
|
||||
'@tiptap/extension-text-align':
|
||||
specifier: ^3.27.1
|
||||
version: 3.27.1(@tiptap/core@3.27.1)
|
||||
'@tiptap/extension-underline':
|
||||
specifier: ^3.27.1
|
||||
version: 3.27.1(@tiptap/core@3.27.1)
|
||||
'@tiptap/pm':
|
||||
specifier: ^3.27.1
|
||||
version: 3.27.1
|
||||
'@tiptap/react':
|
||||
specifier: ^3.27.1
|
||||
version: 3.27.1(@floating-ui/dom@1.7.5)(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1)(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3)
|
||||
'@tiptap/starter-kit':
|
||||
specifier: ^3.27.1
|
||||
version: 3.27.1
|
||||
dayjs:
|
||||
specifier: ^1.11.19
|
||||
version: 1.11.19
|
||||
react-hook-form:
|
||||
specifier: ^7.56.4
|
||||
version: 7.79.0(react@19.2.3)
|
||||
tailwind-merge:
|
||||
specifier: ^3.4.0
|
||||
version: 3.4.0
|
||||
@@ -458,6 +497,9 @@ importers:
|
||||
tailwindcss:
|
||||
specifier: ^4.1.18
|
||||
version: 4.1.18
|
||||
zod:
|
||||
specifier: ^3.25.36
|
||||
version: 3.25.76
|
||||
devDependencies:
|
||||
'@repo/eslint-config':
|
||||
specifier: workspace:*
|
||||
@@ -468,6 +510,15 @@ importers:
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.1.18
|
||||
version: 4.1.18(vite@5.4.17)
|
||||
'@testing-library/jest-dom':
|
||||
specifier: ^6.6.3
|
||||
version: 6.9.1
|
||||
'@testing-library/react':
|
||||
specifier: ^16.3.0
|
||||
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3)
|
||||
'@testing-library/user-event':
|
||||
specifier: ^14.6.1
|
||||
version: 14.6.1(@testing-library/dom@10.4.1)
|
||||
'@types/react':
|
||||
specifier: ^19.2.7
|
||||
version: 19.2.7
|
||||
@@ -480,6 +531,9 @@ importers:
|
||||
eslint:
|
||||
specifier: ^8.57.1
|
||||
version: 8.57.1
|
||||
jsdom:
|
||||
specifier: ^26.1.0
|
||||
version: 26.1.0
|
||||
react:
|
||||
specifier: ^19.2.3
|
||||
version: 19.2.3
|
||||
@@ -522,7 +576,7 @@ importers:
|
||||
version: 5.5.4
|
||||
vitest:
|
||||
specifier: ^4.0.17
|
||||
version: 4.0.17(jsdom@26.1.0)
|
||||
version: 4.0.17(@opentelemetry/api@1.9.1)
|
||||
|
||||
packages:
|
||||
|
||||
@@ -530,6 +584,10 @@ packages:
|
||||
resolution: {integrity: sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==}
|
||||
dev: true
|
||||
|
||||
/@adobe/css-tools@4.5.0:
|
||||
resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==}
|
||||
dev: true
|
||||
|
||||
/@asamuzakjp/css-color@3.2.0:
|
||||
resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==}
|
||||
dependencies:
|
||||
@@ -1464,6 +1522,15 @@ packages:
|
||||
- supports-color
|
||||
dev: false
|
||||
|
||||
/@hookform/resolvers@5.4.0(react-hook-form@7.79.0):
|
||||
resolution: {integrity: sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==}
|
||||
peerDependencies:
|
||||
react-hook-form: ^7.55.0
|
||||
dependencies:
|
||||
'@standard-schema/utils': 0.3.0
|
||||
react-hook-form: 7.79.0(react@19.2.3)
|
||||
dev: false
|
||||
|
||||
/@humanwhocodes/config-array@0.13.0:
|
||||
resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==}
|
||||
engines: {node: '>=10.10.0'}
|
||||
@@ -1601,6 +1668,24 @@ packages:
|
||||
react: 19.2.3
|
||||
dev: false
|
||||
|
||||
/@mantine/tiptap@9.3.2(@mantine/core@8.3.15)(@mantine/hooks@8.3.15)(@tiptap/extension-link@3.27.1)(@tiptap/react@3.27.1)(react-dom@19.2.3)(react@19.2.3):
|
||||
resolution: {integrity: sha512-X344wqt3eusMLPANWuNSnKoFjTDlCEOUpYq6hPWU6uBvxEHJGHuJFXjtm/jg/D6aNEvWr+P+m1+ElE7eLT/G0A==}
|
||||
peerDependencies:
|
||||
'@mantine/core': 9.3.2
|
||||
'@mantine/hooks': 9.3.2
|
||||
'@tiptap/extension-link': '>=3.3.0'
|
||||
'@tiptap/react': '>=3.3.0'
|
||||
react: ^19.2.0
|
||||
react-dom: ^19.2.0
|
||||
dependencies:
|
||||
'@mantine/core': 8.3.15(@mantine/hooks@8.3.15)(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3)
|
||||
'@mantine/hooks': 8.3.15(react@19.2.3)
|
||||
'@tiptap/extension-link': 3.27.1(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1)
|
||||
'@tiptap/react': 3.27.1(@floating-ui/dom@1.7.5)(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1)(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3)
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
dev: false
|
||||
|
||||
/@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.3):
|
||||
resolution: {integrity: sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==}
|
||||
peerDependencies:
|
||||
@@ -2452,6 +2537,10 @@ packages:
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
dev: true
|
||||
|
||||
/@standard-schema/utils@0.3.0:
|
||||
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
|
||||
dev: false
|
||||
|
||||
/@storybook/addon-actions@8.6.14(storybook@8.6.15):
|
||||
resolution: {integrity: sha512-mDQxylxGGCQSK7tJPkD144J8jWh9IU9ziJMHfB84PKpI/V5ZgqMDnpr2bssTrUaGDqU5e1/z8KcRF+Melhs9pQ==}
|
||||
peerDependencies:
|
||||
@@ -2972,6 +3061,18 @@ packages:
|
||||
pretty-format: 27.5.1
|
||||
dev: true
|
||||
|
||||
/@testing-library/jest-dom@6.9.1:
|
||||
resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==}
|
||||
engines: {node: '>=14', npm: '>=6', yarn: '>=1'}
|
||||
dependencies:
|
||||
'@adobe/css-tools': 4.5.0
|
||||
aria-query: 5.3.2
|
||||
css.escape: 1.5.1
|
||||
dom-accessibility-api: 0.6.3
|
||||
picocolors: 1.1.1
|
||||
redent: 3.0.0
|
||||
dev: true
|
||||
|
||||
/@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3):
|
||||
resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -2995,6 +3096,318 @@ packages:
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
dev: true
|
||||
|
||||
/@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1):
|
||||
resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==}
|
||||
engines: {node: '>=12', npm: '>=6'}
|
||||
peerDependencies:
|
||||
'@testing-library/dom': '>=7.21.4'
|
||||
dependencies:
|
||||
'@testing-library/dom': 10.4.1
|
||||
dev: true
|
||||
|
||||
/@tiptap/core@3.27.1(@tiptap/pm@3.27.1):
|
||||
resolution: {integrity: sha512-rV6Qn4wmC6BxfF+4mu6bqGWj9vA4oXXhsrpXaJL2uhjxeHAGofjwcHof2X84VYzeyXgdlsGmqKie4TAppVXZUQ==}
|
||||
peerDependencies:
|
||||
'@tiptap/pm': 3.27.1
|
||||
dependencies:
|
||||
'@tiptap/pm': 3.27.1
|
||||
dev: false
|
||||
|
||||
/@tiptap/extension-blockquote@3.27.1(@tiptap/core@3.27.1):
|
||||
resolution: {integrity: sha512-VMF7xJx6qEGiX6DTKNiL31NLqypOcd/4sNjFSe8rb41PwejBJh/nOqVIbBvWkiT6NMGFLxMhj7zJ8/zPo1hXeg==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.27.1
|
||||
dependencies:
|
||||
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
|
||||
dev: false
|
||||
|
||||
/@tiptap/extension-bold@3.27.1(@tiptap/core@3.27.1):
|
||||
resolution: {integrity: sha512-TlC5bsS+pqETTrlz4CZz9RO/cKBYtELGIxwtKeivUn3eNfnOxQbbu4WDsiwIfzRFyd0OMnKl6BPM2KnYEehoEQ==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.27.1
|
||||
dependencies:
|
||||
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
|
||||
dev: false
|
||||
|
||||
/@tiptap/extension-bubble-menu@3.27.1(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1):
|
||||
resolution: {integrity: sha512-j/j8Qp9Z5nViade2m7zjrO/CYH/Ca80Qj7aqo0eUaei6FZQ5izlF9o4XQU5EFMAutV6mwynsPUp8FVo5sCuYfw==}
|
||||
requiresBuild: true
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.27.1
|
||||
'@tiptap/pm': 3.27.1
|
||||
dependencies:
|
||||
'@floating-ui/dom': 1.7.5
|
||||
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
|
||||
'@tiptap/pm': 3.27.1
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@tiptap/extension-bullet-list@3.27.1(@tiptap/extension-list@3.27.1):
|
||||
resolution: {integrity: sha512-faCUHnRP47o9Zh9VZZX6EX/569udw9Vopm2PgEKPWuKLE2qaS5WBuUVU0iItdJmKUqaWiOZkpoW4jvnDmj0dfg==}
|
||||
peerDependencies:
|
||||
'@tiptap/extension-list': 3.27.1
|
||||
dependencies:
|
||||
'@tiptap/extension-list': 3.27.1(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1)
|
||||
dev: false
|
||||
|
||||
/@tiptap/extension-code-block@3.27.1(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1):
|
||||
resolution: {integrity: sha512-pHlzmZx2OlHfyQ0yRlT5UL4mGokz947DthZuYefN1OleVqOkHpWBG+2JQwqoNq6bmzMne92zbH32rhcJUEYSjA==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.27.1
|
||||
'@tiptap/pm': 3.27.1
|
||||
dependencies:
|
||||
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
|
||||
'@tiptap/pm': 3.27.1
|
||||
dev: false
|
||||
|
||||
/@tiptap/extension-code@3.27.1(@tiptap/core@3.27.1):
|
||||
resolution: {integrity: sha512-epOUpFfEmBzjvnqvjv2qHX7NAuLo5dlOGV690lWu+sAYMjibuJBeVvAiKPyFCfRCCTUxdbDB3jbaOA1yEcEJ7w==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.27.1
|
||||
dependencies:
|
||||
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
|
||||
dev: false
|
||||
|
||||
/@tiptap/extension-document@3.27.1(@tiptap/core@3.27.1):
|
||||
resolution: {integrity: sha512-8FbBTkfnRP4iVaoj+2h3iWa+H0eGDD3yTyVCwrmue/sQTkqUNUoSuAZa3GDG4Sd41xdPwTJxl9nUWGgM1qDCnw==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.27.1
|
||||
dependencies:
|
||||
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
|
||||
dev: false
|
||||
|
||||
/@tiptap/extension-dropcursor@3.27.1(@tiptap/extensions@3.27.1):
|
||||
resolution: {integrity: sha512-blFf9x9RG0Qr7P3FoAH/033ffa+mMLZn34trVs8Vi0Ppk6FmJAg5HpYFOtmYoeREdNDJ5rHJKV7SoACbOHgskQ==}
|
||||
peerDependencies:
|
||||
'@tiptap/extensions': 3.27.1
|
||||
dependencies:
|
||||
'@tiptap/extensions': 3.27.1(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1)
|
||||
dev: false
|
||||
|
||||
/@tiptap/extension-floating-menu@3.27.1(@floating-ui/dom@1.7.5)(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1):
|
||||
resolution: {integrity: sha512-BmJF1VqB7dSJkgAalrpVFj88WLhxKjcWPuWHOqf2ITrUU2832BhKLXKmxjWUy1gqV8PfNNVWtGfIERy7I0y0+Q==}
|
||||
requiresBuild: true
|
||||
peerDependencies:
|
||||
'@floating-ui/dom': ^1.0.0
|
||||
'@tiptap/core': 3.27.1
|
||||
'@tiptap/pm': 3.27.1
|
||||
dependencies:
|
||||
'@floating-ui/dom': 1.7.5
|
||||
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
|
||||
'@tiptap/pm': 3.27.1
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@tiptap/extension-gapcursor@3.27.1(@tiptap/extensions@3.27.1):
|
||||
resolution: {integrity: sha512-QoezN0wdvXIwLQ4ee2ccWDaX3RG0lzgQpIMpMz55oPDhpUVax1+19ApsS53LkcktpS4EbnPL4xO4DaJk0Vp7PQ==}
|
||||
peerDependencies:
|
||||
'@tiptap/extensions': 3.27.1
|
||||
dependencies:
|
||||
'@tiptap/extensions': 3.27.1(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1)
|
||||
dev: false
|
||||
|
||||
/@tiptap/extension-hard-break@3.27.1(@tiptap/core@3.27.1):
|
||||
resolution: {integrity: sha512-iv/m9hzl6jfSj9Q8UEjAxONvCoUDaP7M9SRCPx3PaLNxA230TTD6RE0Ye4zFJ8ze7ZVoJJMAqg9Qpq1iYg2JOQ==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.27.1
|
||||
dependencies:
|
||||
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
|
||||
dev: false
|
||||
|
||||
/@tiptap/extension-heading@3.27.1(@tiptap/core@3.27.1):
|
||||
resolution: {integrity: sha512-SrC4l1kEIyv9ZXFaI/8LQqU2MyMmjczw7XXsWUQOTN4YXv0JyVgMNR3cI/wz0d2xsTfBdZ1N85Tdng+Ga1t0Sg==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.27.1
|
||||
dependencies:
|
||||
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
|
||||
dev: false
|
||||
|
||||
/@tiptap/extension-horizontal-rule@3.27.1(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1):
|
||||
resolution: {integrity: sha512-QlKE7qn5qMnIGVGhXQlvYedvLtNJ9z0dmit5w8vPb8tKzW4Spk6M7N2kruprrDA8GBwHfeR5wmF+njfUm34qxg==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.27.1
|
||||
'@tiptap/pm': 3.27.1
|
||||
dependencies:
|
||||
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
|
||||
'@tiptap/pm': 3.27.1
|
||||
dev: false
|
||||
|
||||
/@tiptap/extension-italic@3.27.1(@tiptap/core@3.27.1):
|
||||
resolution: {integrity: sha512-jGGeyn9uRUnNjSTHpbqhiGsp6KaYTSbV09jDXPJI9cDwfV9hpugLvpaCZd0BMBbhU1B1W6kOfX0BE15qX/HQfA==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.27.1
|
||||
dependencies:
|
||||
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
|
||||
dev: false
|
||||
|
||||
/@tiptap/extension-link@3.27.1(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1):
|
||||
resolution: {integrity: sha512-/2jBfsxBZUDGJmpZifqRQPz7f1E5qpS1BckTZ39TADzUJX+feKy7RJ3DtQ02+8y6SSMzvP9loGVjrk6zEMTk4g==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.27.1
|
||||
'@tiptap/pm': 3.27.1
|
||||
dependencies:
|
||||
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
|
||||
'@tiptap/pm': 3.27.1
|
||||
linkifyjs: 4.3.3
|
||||
dev: false
|
||||
|
||||
/@tiptap/extension-list-item@3.27.1(@tiptap/extension-list@3.27.1):
|
||||
resolution: {integrity: sha512-zwRl01ETfCkWUvtvK5fw9bXtAajMPkvlkE3Cq6JvH3LF7XXJwDtNj5Tj7exacMpCaSZmlNc43vFb2rAYnrnwMA==}
|
||||
peerDependencies:
|
||||
'@tiptap/extension-list': 3.27.1
|
||||
dependencies:
|
||||
'@tiptap/extension-list': 3.27.1(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1)
|
||||
dev: false
|
||||
|
||||
/@tiptap/extension-list-keymap@3.27.1(@tiptap/extension-list@3.27.1):
|
||||
resolution: {integrity: sha512-OIMZNlzPSO8WRd4ic73Fxckzl4N1tesjjLL2XApaNA/uMpO0LoF6WSRPAWv+Z24Wp92ARRJAnRP7iZoI5+Jxig==}
|
||||
peerDependencies:
|
||||
'@tiptap/extension-list': 3.27.1
|
||||
dependencies:
|
||||
'@tiptap/extension-list': 3.27.1(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1)
|
||||
dev: false
|
||||
|
||||
/@tiptap/extension-list@3.27.1(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1):
|
||||
resolution: {integrity: sha512-c2Upru7lj0/ZV/Ibww6cNz6sUS8m6Dp/9uygFhYcZOd3X8M0xBIEk42c6m6SQehkPziVA8QOgNJz7sMqsbz1OQ==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.27.1
|
||||
'@tiptap/pm': 3.27.1
|
||||
dependencies:
|
||||
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
|
||||
'@tiptap/pm': 3.27.1
|
||||
dev: false
|
||||
|
||||
/@tiptap/extension-ordered-list@3.27.1(@tiptap/extension-list@3.27.1):
|
||||
resolution: {integrity: sha512-GYrKqD//9nHJ2r80uXqbDMzRnFpGzbaEQRTSGaO/SH7DvXWFMow8evkOdjQ7PCQO07jNjJo75+A85Jwu3Ov3AA==}
|
||||
peerDependencies:
|
||||
'@tiptap/extension-list': 3.27.1
|
||||
dependencies:
|
||||
'@tiptap/extension-list': 3.27.1(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1)
|
||||
dev: false
|
||||
|
||||
/@tiptap/extension-paragraph@3.27.1(@tiptap/core@3.27.1):
|
||||
resolution: {integrity: sha512-7K7eo1gruOgAsnbK+GCV23AUVUI0cL1bTig8HaPneoFMVbig7vddk8jNLKBWO8TXVbG7TuHdnDN4F98vdtwh5Q==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.27.1
|
||||
dependencies:
|
||||
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
|
||||
dev: false
|
||||
|
||||
/@tiptap/extension-strike@3.27.1(@tiptap/core@3.27.1):
|
||||
resolution: {integrity: sha512-Y3DW1jlSlCNCyMGHP3+3qBNNPS83wuFz4RTYGjZtvRRTCRh7apZme9XRWMq1rN5mJ2Cr7fKocA2/5Bs13KgN6Q==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.27.1
|
||||
dependencies:
|
||||
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
|
||||
dev: false
|
||||
|
||||
/@tiptap/extension-text-align@3.27.1(@tiptap/core@3.27.1):
|
||||
resolution: {integrity: sha512-EXawuJBO55wd8WcTbHTMoPhv0CGQxza4yCCPB5Hqz4ZPQwahIr3ej+8yp/kimIl0xokabwZ0/Fu8STQ4AkZv5g==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.27.1
|
||||
dependencies:
|
||||
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
|
||||
dev: false
|
||||
|
||||
/@tiptap/extension-text@3.27.1(@tiptap/core@3.27.1):
|
||||
resolution: {integrity: sha512-6ZwaZwSrDh+KFFv6V1J79oO37yPs7y1bFxvk1/9Ih2rn3Xr5AWz+eMS+n8RpH3djBVVAQpdIAeYQgcn+VCSsTg==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.27.1
|
||||
dependencies:
|
||||
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
|
||||
dev: false
|
||||
|
||||
/@tiptap/extension-underline@3.27.1(@tiptap/core@3.27.1):
|
||||
resolution: {integrity: sha512-N889J4nXN/TPfVt8uF9N1A0SY82E90zwc1y26lqOcw6KWNLmQrlhMh/9OD4ikLDbekmFpOBq/UicpHf/6S8hbQ==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.27.1
|
||||
dependencies:
|
||||
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
|
||||
dev: false
|
||||
|
||||
/@tiptap/extensions@3.27.1(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1):
|
||||
resolution: {integrity: sha512-1Tdx9faw8k0/83V6X+xCDVhV8yElGt95JxeW3YMkKQJI56QdlPz0xOdJPlMiSGJKinPyVier+x9LJD/YZUZIaw==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.27.1
|
||||
'@tiptap/pm': 3.27.1
|
||||
dependencies:
|
||||
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
|
||||
'@tiptap/pm': 3.27.1
|
||||
dev: false
|
||||
|
||||
/@tiptap/pm@3.27.1:
|
||||
resolution: {integrity: sha512-Ffjx+vimmBU7zH/KrpXzJid3+pziCe/VL2aexSTP63cyQwKQ65LkFkCKaIsSpFdQQuakVZBGWjCA5RoBV852pw==}
|
||||
dependencies:
|
||||
prosemirror-changeset: 2.4.1
|
||||
prosemirror-commands: 1.7.1
|
||||
prosemirror-dropcursor: 1.8.2
|
||||
prosemirror-gapcursor: 1.4.1
|
||||
prosemirror-history: 1.5.0
|
||||
prosemirror-inputrules: 1.5.1
|
||||
prosemirror-keymap: 1.2.3
|
||||
prosemirror-model: 1.25.9
|
||||
prosemirror-schema-list: 1.5.1
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-tables: 1.8.5
|
||||
prosemirror-transform: 1.12.0
|
||||
prosemirror-view: 1.41.9
|
||||
dev: false
|
||||
|
||||
/@tiptap/react@3.27.1(@floating-ui/dom@1.7.5)(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1)(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3):
|
||||
resolution: {integrity: sha512-/Wn2fc9zMtX08MXYScDFsm4wJ8lzfhfPEdbtls7WCDlbtrop48PWlkHDBBJrywARfAQTB2mFs9KiFy9yrQm5Lg==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.27.1
|
||||
'@tiptap/pm': 3.27.1
|
||||
'@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
'@types/react-dom': ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react: ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
dependencies:
|
||||
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
|
||||
'@tiptap/pm': 3.27.1
|
||||
'@types/react': 19.2.7
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.7)
|
||||
'@types/use-sync-external-store': 0.0.6
|
||||
fast-equals: 5.4.0
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
use-sync-external-store: 1.6.0(react@19.2.3)
|
||||
optionalDependencies:
|
||||
'@tiptap/extension-bubble-menu': 3.27.1(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1)
|
||||
'@tiptap/extension-floating-menu': 3.27.1(@floating-ui/dom@1.7.5)(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1)
|
||||
transitivePeerDependencies:
|
||||
- '@floating-ui/dom'
|
||||
dev: false
|
||||
|
||||
/@tiptap/starter-kit@3.27.1:
|
||||
resolution: {integrity: sha512-vfxRsqW8rCc0k4pzo0ilU3wobVi2wqVj88VZI2SlgZlNnUAkrDGDIAph7CTa9k9fshV+O1ivpEgPC5yC046jow==}
|
||||
dependencies:
|
||||
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
|
||||
'@tiptap/extension-blockquote': 3.27.1(@tiptap/core@3.27.1)
|
||||
'@tiptap/extension-bold': 3.27.1(@tiptap/core@3.27.1)
|
||||
'@tiptap/extension-bullet-list': 3.27.1(@tiptap/extension-list@3.27.1)
|
||||
'@tiptap/extension-code': 3.27.1(@tiptap/core@3.27.1)
|
||||
'@tiptap/extension-code-block': 3.27.1(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1)
|
||||
'@tiptap/extension-document': 3.27.1(@tiptap/core@3.27.1)
|
||||
'@tiptap/extension-dropcursor': 3.27.1(@tiptap/extensions@3.27.1)
|
||||
'@tiptap/extension-gapcursor': 3.27.1(@tiptap/extensions@3.27.1)
|
||||
'@tiptap/extension-hard-break': 3.27.1(@tiptap/core@3.27.1)
|
||||
'@tiptap/extension-heading': 3.27.1(@tiptap/core@3.27.1)
|
||||
'@tiptap/extension-horizontal-rule': 3.27.1(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1)
|
||||
'@tiptap/extension-italic': 3.27.1(@tiptap/core@3.27.1)
|
||||
'@tiptap/extension-link': 3.27.1(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1)
|
||||
'@tiptap/extension-list': 3.27.1(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1)
|
||||
'@tiptap/extension-list-item': 3.27.1(@tiptap/extension-list@3.27.1)
|
||||
'@tiptap/extension-list-keymap': 3.27.1(@tiptap/extension-list@3.27.1)
|
||||
'@tiptap/extension-ordered-list': 3.27.1(@tiptap/extension-list@3.27.1)
|
||||
'@tiptap/extension-paragraph': 3.27.1(@tiptap/core@3.27.1)
|
||||
'@tiptap/extension-strike': 3.27.1(@tiptap/core@3.27.1)
|
||||
'@tiptap/extension-text': 3.27.1(@tiptap/core@3.27.1)
|
||||
'@tiptap/extension-underline': 3.27.1(@tiptap/core@3.27.1)
|
||||
'@tiptap/extensions': 3.27.1(@tiptap/core@3.27.1)(@tiptap/pm@3.27.1)
|
||||
'@tiptap/pm': 3.27.1
|
||||
dev: false
|
||||
|
||||
/@tootallnate/once@2.0.0:
|
||||
resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==}
|
||||
engines: {node: '>= 10'}
|
||||
@@ -3299,7 +3712,6 @@ packages:
|
||||
'@types/react': ^19.2.0
|
||||
dependencies:
|
||||
'@types/react': 19.2.7
|
||||
dev: true
|
||||
|
||||
/@types/react@19.2.7:
|
||||
resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==}
|
||||
@@ -3332,6 +3744,10 @@ packages:
|
||||
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
|
||||
dev: false
|
||||
|
||||
/@types/use-sync-external-store@0.0.6:
|
||||
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
|
||||
dev: false
|
||||
|
||||
/@types/uuid@9.0.8:
|
||||
resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==}
|
||||
dev: true
|
||||
@@ -4232,7 +4648,6 @@ packages:
|
||||
/aria-query@5.3.2:
|
||||
resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
dev: false
|
||||
|
||||
/array-buffer-byte-length@1.0.2:
|
||||
resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==}
|
||||
@@ -4955,6 +5370,10 @@ packages:
|
||||
resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==}
|
||||
dev: false
|
||||
|
||||
/css.escape@1.5.1:
|
||||
resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==}
|
||||
dev: true
|
||||
|
||||
/cssstyle@4.6.0:
|
||||
resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -5232,6 +5651,10 @@ packages:
|
||||
resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
|
||||
dev: true
|
||||
|
||||
/dom-accessibility-api@0.6.3:
|
||||
resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
|
||||
dev: true
|
||||
|
||||
/dotenv-expand@11.0.7:
|
||||
resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -6198,6 +6621,11 @@ packages:
|
||||
/fast-deep-equal@3.1.3:
|
||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||
|
||||
/fast-equals@5.4.0:
|
||||
resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
dev: false
|
||||
|
||||
/fast-glob@3.3.3:
|
||||
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
|
||||
engines: {node: '>=8.6.0'}
|
||||
@@ -7528,6 +7956,10 @@ packages:
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
dev: false
|
||||
|
||||
/linkifyjs@4.3.3:
|
||||
resolution: {integrity: sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==}
|
||||
dev: false
|
||||
|
||||
/load-plugin@6.0.3:
|
||||
resolution: {integrity: sha512-kc0X2FEUZr145odl68frm+lMJuQ23+rTXYmR6TImqPtbpmXC4vVXbWKDQ9IzndA0HfyQamWfKLhzsqGSTxE63w==}
|
||||
dependencies:
|
||||
@@ -8143,7 +8575,6 @@ packages:
|
||||
/min-indent@1.0.1:
|
||||
resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
|
||||
engines: {node: '>=4'}
|
||||
dev: false
|
||||
|
||||
/minimatch@10.2.5:
|
||||
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
|
||||
@@ -8560,6 +8991,10 @@ packages:
|
||||
wcwidth: 1.0.1
|
||||
dev: true
|
||||
|
||||
/orderedmap@2.1.1:
|
||||
resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==}
|
||||
dev: false
|
||||
|
||||
/own-keys@1.0.1:
|
||||
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -8991,6 +9426,106 @@ packages:
|
||||
react-is: 16.13.1
|
||||
dev: false
|
||||
|
||||
/prosemirror-changeset@2.4.1:
|
||||
resolution: {integrity: sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==}
|
||||
dependencies:
|
||||
prosemirror-transform: 1.12.0
|
||||
dev: false
|
||||
|
||||
/prosemirror-commands@1.7.1:
|
||||
resolution: {integrity: sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==}
|
||||
dependencies:
|
||||
prosemirror-model: 1.25.9
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.12.0
|
||||
dev: false
|
||||
|
||||
/prosemirror-dropcursor@1.8.2:
|
||||
resolution: {integrity: sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==}
|
||||
dependencies:
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.12.0
|
||||
prosemirror-view: 1.41.9
|
||||
dev: false
|
||||
|
||||
/prosemirror-gapcursor@1.4.1:
|
||||
resolution: {integrity: sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==}
|
||||
dependencies:
|
||||
prosemirror-keymap: 1.2.3
|
||||
prosemirror-model: 1.25.9
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-view: 1.41.9
|
||||
dev: false
|
||||
|
||||
/prosemirror-history@1.5.0:
|
||||
resolution: {integrity: sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==}
|
||||
dependencies:
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.12.0
|
||||
prosemirror-view: 1.41.9
|
||||
rope-sequence: 1.3.4
|
||||
dev: false
|
||||
|
||||
/prosemirror-inputrules@1.5.1:
|
||||
resolution: {integrity: sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==}
|
||||
dependencies:
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.12.0
|
||||
dev: false
|
||||
|
||||
/prosemirror-keymap@1.2.3:
|
||||
resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==}
|
||||
dependencies:
|
||||
prosemirror-state: 1.4.4
|
||||
w3c-keyname: 2.2.8
|
||||
dev: false
|
||||
|
||||
/prosemirror-model@1.25.9:
|
||||
resolution: {integrity: sha512-pRTklkDDMMRopyoAcrr9wV/8g/RYgrLHBuJAb5hlEuYZRdm5yqmPjWId83fpBwPpSFqEdja0H7Dfd7z1X/npcA==}
|
||||
dependencies:
|
||||
orderedmap: 2.1.1
|
||||
dev: false
|
||||
|
||||
/prosemirror-schema-list@1.5.1:
|
||||
resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==}
|
||||
dependencies:
|
||||
prosemirror-model: 1.25.9
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.12.0
|
||||
dev: false
|
||||
|
||||
/prosemirror-state@1.4.4:
|
||||
resolution: {integrity: sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==}
|
||||
dependencies:
|
||||
prosemirror-model: 1.25.9
|
||||
prosemirror-transform: 1.12.0
|
||||
prosemirror-view: 1.41.9
|
||||
dev: false
|
||||
|
||||
/prosemirror-tables@1.8.5:
|
||||
resolution: {integrity: sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==}
|
||||
dependencies:
|
||||
prosemirror-keymap: 1.2.3
|
||||
prosemirror-model: 1.25.9
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.12.0
|
||||
prosemirror-view: 1.41.9
|
||||
dev: false
|
||||
|
||||
/prosemirror-transform@1.12.0:
|
||||
resolution: {integrity: sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==}
|
||||
dependencies:
|
||||
prosemirror-model: 1.25.9
|
||||
dev: false
|
||||
|
||||
/prosemirror-view@1.41.9:
|
||||
resolution: {integrity: sha512-clTunTX+eaLbr87L1V1QPheRlEQJyTlL3gXe9x3jQIk3rL0RVWxviDGz8tFaydwIVm+hKhYCyr+R/zBtWr9s6A==}
|
||||
dependencies:
|
||||
prosemirror-model: 1.25.9
|
||||
prosemirror-state: 1.4.4
|
||||
prosemirror-transform: 1.12.0
|
||||
dev: false
|
||||
|
||||
/protobufjs@7.6.1:
|
||||
resolution: {integrity: sha512-4K0myLaWL5EteuSAro91EGFgcfVgxb64Jx+7oDAY6GOkXD4M69yuSEljNcInGVCA5sOPxmZ/EqDLj2x0Q0+Ygg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@@ -9095,6 +9630,15 @@ packages:
|
||||
react: 19.2.3
|
||||
scheduler: 0.27.0
|
||||
|
||||
/react-hook-form@7.79.0(react@19.2.3):
|
||||
resolution: {integrity: sha512-mhYp/MTmXvzYX6AJcJVko0rktoIhhmRnEouObj4wF5i/tCttgJvnp1+9wRkpITZjDTqpo4IOSJqu0dBlPlV/Lw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17 || ^18 || ^19
|
||||
dependencies:
|
||||
react: 19.2.3
|
||||
dev: false
|
||||
|
||||
/react-i18next@15.7.4(i18next@24.2.3)(react-dom@19.2.3)(react@19.2.3)(typescript@5.5.4):
|
||||
resolution: {integrity: sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw==}
|
||||
peerDependencies:
|
||||
@@ -9321,6 +9865,14 @@ packages:
|
||||
tslib: 2.8.1
|
||||
dev: true
|
||||
|
||||
/redent@3.0.0:
|
||||
resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
|
||||
engines: {node: '>=8'}
|
||||
dependencies:
|
||||
indent-string: 4.0.0
|
||||
strip-indent: 3.0.0
|
||||
dev: true
|
||||
|
||||
/reflect.getprototypeof@1.0.10:
|
||||
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -9576,6 +10128,10 @@ packages:
|
||||
fsevents: 2.3.3
|
||||
dev: true
|
||||
|
||||
/rope-sequence@1.3.4:
|
||||
resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==}
|
||||
dev: false
|
||||
|
||||
/rrweb-cssom@0.8.0:
|
||||
resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==}
|
||||
dev: true
|
||||
@@ -10117,7 +10673,6 @@ packages:
|
||||
engines: {node: '>=8'}
|
||||
dependencies:
|
||||
min-indent: 1.0.1
|
||||
dev: false
|
||||
|
||||
/strip-indent@4.1.1:
|
||||
resolution: {integrity: sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==}
|
||||
@@ -10808,6 +11363,14 @@ packages:
|
||||
tslib: 2.8.1
|
||||
dev: false
|
||||
|
||||
/use-sync-external-store@1.6.0(react@19.2.3):
|
||||
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
dependencies:
|
||||
react: 19.2.3
|
||||
dev: false
|
||||
|
||||
/utf8-byte-length@1.0.5:
|
||||
resolution: {integrity: sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==}
|
||||
dev: true
|
||||
@@ -11148,6 +11711,10 @@ packages:
|
||||
/vuvuzela@1.0.3:
|
||||
resolution: {integrity: sha512-Tm7jR1xTzBbPW+6y1tknKiEhz04Wf/1iZkcTJjSFcpNko43+dFW6+OOeQe9taJIug3NdfUAjFKgUSyQrIKaDvQ==}
|
||||
|
||||
/w3c-keyname@2.2.8:
|
||||
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
|
||||
dev: false
|
||||
|
||||
/w3c-xmlserializer@5.0.0:
|
||||
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -11408,6 +11975,10 @@ packages:
|
||||
readable-stream: 3.6.2
|
||||
dev: true
|
||||
|
||||
/zod@3.25.76:
|
||||
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
||||
dev: false
|
||||
|
||||
/zwitch@2.0.4:
|
||||
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
|
||||
dev: false
|
||||
|
||||
Reference in New Issue
Block a user