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`
|
### 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
|
* Ensures consistent design across all applications
|
||||||
* Designed to be consumed by both web apps and Storybook
|
* 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"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@hookform/resolvers": "^5.0.1",
|
||||||
"@repo/core-api": "workspace:*",
|
"@repo/core-api": "workspace:*",
|
||||||
"@repo/core-events": "workspace:*",
|
"@repo/core-events": "workspace:*",
|
||||||
"@repo/core-i18n": "workspace:*",
|
"@repo/core-i18n": "workspace:*",
|
||||||
@@ -26,9 +27,11 @@
|
|||||||
"lucide-react": "^1.17.0",
|
"lucide-react": "^1.17.0",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
|
"react-hook-form": "^7.56.4",
|
||||||
"react-i18next": "^15.4.0",
|
"react-i18next": "^15.4.0",
|
||||||
"react-router-dom": "^7.11.0",
|
"react-router-dom": "^7.11.0",
|
||||||
"tailwindcss": "^4.1.18"
|
"tailwindcss": "^4.1.18",
|
||||||
|
"zod": "^3.25.36"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@repo/eslint-config": "workspace:*",
|
"@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,
|
Box,
|
||||||
Paper,
|
Paper,
|
||||||
} from '@repo/ui/components';
|
} 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 PrinterList from './printer-list';
|
||||||
import ExamplePage from './example/example.page';
|
import ExamplePage from './example/example.page';
|
||||||
import EventsDemoPage from './events-demo';
|
import EventsDemoPage from './events-demo';
|
||||||
import PouchSample from './pouch-sample';
|
import PouchSample from './pouch-sample';
|
||||||
|
import FormDemoView from './example/features/form-demo';
|
||||||
|
|
||||||
interface ShowcaseViewProps {
|
interface ShowcaseViewProps {
|
||||||
colorScheme: ColorSchemeType;
|
colorScheme: ColorSchemeType;
|
||||||
@@ -37,6 +40,7 @@ interface ShowcaseViewProps {
|
|||||||
|
|
||||||
export default function ShowcaseView({ colorScheme, setColorScheme, density, setDensity }: ShowcaseViewProps) {
|
export default function ShowcaseView({ colorScheme, setColorScheme, density, setDensity }: ShowcaseViewProps) {
|
||||||
const [activeTab, setActiveTab] = useState<string | null>('ui-components');
|
const [activeTab, setActiveTab] = useState<string | null>('ui-components');
|
||||||
|
const { i18n } = useTranslation();
|
||||||
|
|
||||||
// Mock data for the table
|
// Mock data for the table
|
||||||
const tableData = [
|
const tableData = [
|
||||||
@@ -55,6 +59,8 @@ export default function ShowcaseView({ colorScheme, setColorScheme, density, set
|
|||||||
return 'Authentication & Security Layers';
|
return 'Authentication & Security Layers';
|
||||||
case 'ui-components':
|
case 'ui-components':
|
||||||
return 'Theme, Typography, Forms & Data Grids';
|
return 'Theme, Typography, Forms & Data Grids';
|
||||||
|
case 'forms':
|
||||||
|
return 'Enterprise Form Engine & Zod Validation';
|
||||||
case 'events':
|
case 'events':
|
||||||
return 'Global Event Bus Synchronization';
|
return 'Global Event Bus Synchronization';
|
||||||
case 'hardware':
|
case 'hardware':
|
||||||
@@ -100,6 +106,9 @@ export default function ShowcaseView({ colorScheme, setColorScheme, density, set
|
|||||||
<Tabs.Tab value="ui-components" leftSection={<Layout size={18} />}>
|
<Tabs.Tab value="ui-components" leftSection={<Layout size={18} />}>
|
||||||
UI Components
|
UI Components
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="forms" leftSection={<FileText size={18} />}>
|
||||||
|
Form Engine
|
||||||
|
</Tabs.Tab>
|
||||||
<Tabs.Tab value="storage" leftSection={<Database size={18} />}>
|
<Tabs.Tab value="storage" leftSection={<Database size={18} />}>
|
||||||
Offline Storage
|
Offline Storage
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
@@ -138,6 +147,18 @@ export default function ShowcaseView({ colorScheme, setColorScheme, density, set
|
|||||||
{getSubtitle()}
|
{getSubtitle()}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</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>
|
</Group>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
@@ -277,6 +298,13 @@ export default function ShowcaseView({ colorScheme, setColorScheme, density, set
|
|||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* --- FORMS TAB --- */}
|
||||||
|
{activeTab === 'forms' && (
|
||||||
|
<Stack gap="xl">
|
||||||
|
<FormDemoView />
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* --- STORAGE TAB --- */}
|
{/* --- STORAGE TAB --- */}
|
||||||
{activeTab === 'storage' && (
|
{activeTab === 'storage' && (
|
||||||
<Stack gap="xl">
|
<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 { initReactI18next } from 'react-i18next';
|
||||||
import commonEn from './locales/en/common.json';
|
import commonEn from './locales/en/common.json';
|
||||||
import commonId from './locales/id/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 DEFAULT_LANGUAGE = 'id';
|
||||||
const SUPPORTED_LANGUAGES = ['en', 'id'] as const;
|
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 type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number];
|
||||||
|
|
||||||
export const resources = {
|
export const resources = {
|
||||||
en: { common: commonEn.common },
|
en: { common: commonEn.common, validation: validationEn.validation },
|
||||||
id: { common: commonId.common },
|
id: { common: commonId.common, validation: validationId.validation },
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export interface I18nStorageAdapter {
|
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": {
|
"exports": {
|
||||||
"./theme.css": "./src/theme.css",
|
"./theme.css": "./src/theme.css",
|
||||||
"./components": "./src/components/index.ts",
|
"./components": "./src/components/index.ts",
|
||||||
|
"./form": "./src/components/Form/index.ts",
|
||||||
"./hooks": "./src/hooks/index.ts",
|
"./hooks": "./src/hooks/index.ts",
|
||||||
"./provider": "./src/provider/index.ts"
|
"./provider": "./src/provider/index.ts",
|
||||||
|
"./validators": "./src/validators/index.ts"
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -18,22 +20,37 @@
|
|||||||
"react-dom": "^19.2.3"
|
"react-dom": "^19.2.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@hookform/resolvers": "^5.0.1",
|
||||||
"@mantine/core": "^8.3.15",
|
"@mantine/core": "^8.3.15",
|
||||||
"@mantine/hooks": "^8.3.15",
|
"@mantine/hooks": "^8.3.15",
|
||||||
|
"@mantine/tiptap": "^9.3.2",
|
||||||
|
"@repo/core-i18n": "workspace:*",
|
||||||
"@repo/utils": "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",
|
"dayjs": "^1.11.19",
|
||||||
|
"react-hook-form": "^7.56.4",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwind-variants": "^3.2.2",
|
"tailwind-variants": "^3.2.2",
|
||||||
"tailwindcss": "^4.1.18"
|
"tailwindcss": "^4.1.18",
|
||||||
|
"zod": "^3.25.36"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@repo/eslint-config": "workspace:*",
|
"@repo/eslint-config": "workspace:*",
|
||||||
"@repo/typescript-config": "workspace:*",
|
"@repo/typescript-config": "workspace:*",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@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": "^19.2.7",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.1.2",
|
"@vitejs/plugin-react": "^5.1.2",
|
||||||
"eslint": "^8.57.1",
|
"eslint": "^8.57.1",
|
||||||
|
"jsdom": "^26.1.0",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
"typescript": "5.5.4",
|
"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 * from '@mantine/core';
|
||||||
|
export {
|
||||||
|
List,
|
||||||
|
TypographyStylesProvider,
|
||||||
|
} from '@mantine/core';
|
||||||
|
|
||||||
|
export * from './Form';
|
||||||
export * from './system-pages/coming-soon';
|
export * from './system-pages/coming-soon';
|
||||||
export * from './system-pages/forbidden';
|
export * from './system-pages/forbidden';
|
||||||
export * from './system-pages/maintenance';
|
export * from './system-pages/maintenance';
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export * from '@mantine/hooks';
|
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,
|
standard: standardDensity,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ThemeProvider({ children, colorScheme = 'light', density = 'standard' }: ThemeProviderProps) {
|
export function ThemeProvider({ children, colorScheme = 'light', density = 'compact' }: ThemeProviderProps) {
|
||||||
const baseTheme = createTheme({
|
const baseTheme = createTheme({
|
||||||
colors: {
|
colors: {
|
||||||
brand: brandColors,
|
brand: brandColors,
|
||||||
@@ -39,11 +39,7 @@ export function ThemeProvider({ children, colorScheme = 'light', density = 'stan
|
|||||||
const mergedTheme = mergeThemeOverrides(baseTheme, selectedDensity);
|
const mergedTheme = mergeThemeOverrides(baseTheme, selectedDensity);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MantineProvider
|
<MantineProvider theme={mergedTheme} forceColorScheme={colorScheme} defaultColorScheme={colorScheme}>
|
||||||
theme={mergedTheme}
|
|
||||||
forceColorScheme={colorScheme}
|
|
||||||
defaultColorScheme={colorScheme}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</MantineProvider>
|
</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/core/styles.css';
|
||||||
|
@import '@mantine/tiptap/styles.css';
|
||||||
|
|
||||||
|
/* Initialize Tailwind CSS v4 engine */
|
||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
|
|
||||||
|
/* Instruct Tailwind to scan the src directory for utility class usage */
|
||||||
@source "../src";
|
@source "../src";
|
||||||
|
|
||||||
@theme {
|
@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;
|
--base-font-size: 13px;
|
||||||
--font-sans: var(--mantine-font-family);
|
--font-sans: var(--mantine-font-family);
|
||||||
--font-mono: var(--mantine-font-family-monospace);
|
--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-100: var(--mantine-color-brand-1);
|
||||||
--color-brand-200: var(--mantine-color-brand-2);
|
--color-brand-200: var(--mantine-color-brand-2);
|
||||||
--color-brand-300: var(--mantine-color-brand-3);
|
--color-brand-300: var(--mantine-color-brand-3);
|
||||||
@@ -24,7 +40,8 @@
|
|||||||
--color-brand-800: var(--mantine-color-brand-8);
|
--color-brand-800: var(--mantine-color-brand-8);
|
||||||
--color-brand-900: var(--mantine-color-brand-9);
|
--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-100: var(--mantine-color-error-1);
|
||||||
--color-error-200: var(--mantine-color-error-2);
|
--color-error-200: var(--mantine-color-error-2);
|
||||||
--color-error-300: var(--mantine-color-error-3);
|
--color-error-300: var(--mantine-color-error-3);
|
||||||
@@ -35,7 +52,8 @@
|
|||||||
--color-error-800: var(--mantine-color-error-8);
|
--color-error-800: var(--mantine-color-error-8);
|
||||||
--color-error-900: var(--mantine-color-error-9);
|
--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-100: var(--mantine-color-warning-1);
|
||||||
--color-warning-200: var(--mantine-color-warning-2);
|
--color-warning-200: var(--mantine-color-warning-2);
|
||||||
--color-warning-300: var(--mantine-color-warning-3);
|
--color-warning-300: var(--mantine-color-warning-3);
|
||||||
@@ -46,7 +64,8 @@
|
|||||||
--color-warning-800: var(--mantine-color-warning-8);
|
--color-warning-800: var(--mantine-color-warning-8);
|
||||||
--color-warning-900: var(--mantine-color-warning-9);
|
--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-100: var(--mantine-color-success-1);
|
||||||
--color-success-200: var(--mantine-color-success-2);
|
--color-success-200: var(--mantine-color-success-2);
|
||||||
--color-success-300: var(--mantine-color-success-3);
|
--color-success-300: var(--mantine-color-success-3);
|
||||||
@@ -57,7 +76,8 @@
|
|||||||
--color-success-800: var(--mantine-color-success-8);
|
--color-success-800: var(--mantine-color-success-8);
|
||||||
--color-success-900: var(--mantine-color-success-9);
|
--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-100: var(--mantine-color-info-1);
|
||||||
--color-info-200: var(--mantine-color-info-2);
|
--color-info-200: var(--mantine-color-info-2);
|
||||||
--color-info-300: var(--mantine-color-info-3);
|
--color-info-300: var(--mantine-color-info-3);
|
||||||
@@ -69,22 +89,26 @@
|
|||||||
--color-info-900: var(--mantine-color-info-9);
|
--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-xs: var(--mantine-spacing-xs);
|
||||||
--spacing-sm: var(--mantine-spacing-sm);
|
--spacing-sm: var(--mantine-spacing-sm);
|
||||||
--spacing-md: var(--mantine-spacing-md);
|
--spacing-md: var(--mantine-spacing-md);
|
||||||
--spacing-lg: var(--mantine-spacing-lg);
|
--spacing-lg: var(--mantine-spacing-lg);
|
||||||
--spacing-xl: var(--mantine-spacing-xl);
|
--spacing-xl: var(--mantine-spacing-xl);
|
||||||
|
|
||||||
|
/* Standard Tailwind responsive breakpoints and container sizes */
|
||||||
--breakpoint-sm: 40rem;
|
--breakpoint-sm: 40rem;
|
||||||
--breakpoint-md: 48rem;
|
--breakpoint-md: 48rem;
|
||||||
--breakpoint-lg: 64rem;
|
--breakpoint-lg: 64rem;
|
||||||
--breakpoint-xl: 80rem;
|
--breakpoint-xl: 80rem;
|
||||||
--breakpoint-2xl: 96rem;
|
--breakpoint-2xl: 96rem;
|
||||||
|
|
||||||
--container-3xs: 16rem;
|
--container-3xs: 16rem;
|
||||||
--container-2xs: 18rem;
|
--container-2xs: 18rem;
|
||||||
--container-xs: 20rem;
|
--container-xs: 20rem;
|
||||||
@@ -100,17 +124,22 @@
|
|||||||
--container-7xl: 80rem;
|
--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: var(--mantine-font-size-xs);
|
||||||
--text-xs--line-height: calc(1 / 0.75);
|
--text-xs--line-height: calc(1 / 0.75);
|
||||||
|
|
||||||
--text-sm: var(--mantine-font-size-sm);
|
--text-sm: var(--mantine-font-size-sm);
|
||||||
--text-sm--line-height: calc(1.25 / 0.875);
|
--text-sm--line-height: calc(1.25 / 0.875);
|
||||||
|
|
||||||
--text-base: var(--mantine-font-size-md);
|
--text-base: var(--mantine-font-size-md);
|
||||||
--text-base--line-height: calc(1.5 / 1);
|
--text-base--line-height: calc(1.5 / 1);
|
||||||
|
|
||||||
--text-lg: var(--mantine-font-size-lg);
|
--text-lg: var(--mantine-font-size-lg);
|
||||||
--text-lg--line-height: calc(1.75 / 1.125);
|
--text-lg--line-height: calc(1.75 / 1.125);
|
||||||
|
|
||||||
--text-xl: var(--mantine-font-size-xl);
|
--text-xl: var(--mantine-font-size-xl);
|
||||||
--text-xl--line-height: calc(1.75 / 1.25);
|
--text-xl--line-height: calc(1.75 / 1.25);
|
||||||
|
|
||||||
@@ -131,6 +160,7 @@
|
|||||||
--text-9xl: 8rem;
|
--text-9xl: 8rem;
|
||||||
--text-9xl--line-height: 1;
|
--text-9xl--line-height: 1;
|
||||||
|
|
||||||
|
/* Font Weights */
|
||||||
--font-weight-thin: 100;
|
--font-weight-thin: 100;
|
||||||
--font-weight-extralight: 200;
|
--font-weight-extralight: 200;
|
||||||
--font-weight-light: 300;
|
--font-weight-light: 300;
|
||||||
@@ -141,6 +171,7 @@
|
|||||||
--font-weight-extrabold: 800;
|
--font-weight-extrabold: 800;
|
||||||
--font-weight-black: 900;
|
--font-weight-black: 900;
|
||||||
|
|
||||||
|
/* Letter Spacing (Tracking) */
|
||||||
--tracking-tighter: -0.05em;
|
--tracking-tighter: -0.05em;
|
||||||
--tracking-tight: -0.025em;
|
--tracking-tight: -0.025em;
|
||||||
--tracking-normal: 0em;
|
--tracking-normal: 0em;
|
||||||
@@ -148,6 +179,7 @@
|
|||||||
--tracking-wider: 0.05em;
|
--tracking-wider: 0.05em;
|
||||||
--tracking-widest: 0.1em;
|
--tracking-widest: 0.1em;
|
||||||
|
|
||||||
|
/* Line Height (Leading) */
|
||||||
--leading-tight: 1.25;
|
--leading-tight: 1.25;
|
||||||
--leading-snug: 1.375;
|
--leading-snug: 1.375;
|
||||||
--leading-normal: 1.5;
|
--leading-normal: 1.5;
|
||||||
@@ -155,19 +187,23 @@
|
|||||||
--leading-loose: 2;
|
--leading-loose: 2;
|
||||||
|
|
||||||
/* =========================================
|
/* =========================================
|
||||||
4. RADIUS
|
6. BORDER RADIUS
|
||||||
|
Inherits exact corner rounding from Mantine.
|
||||||
========================================= */
|
========================================= */
|
||||||
--radius-xs: var(--mantine-radius-xs);
|
--radius-xs: var(--mantine-radius-xs);
|
||||||
--radius-sm: var(--mantine-radius-sm);
|
--radius-sm: var(--mantine-radius-sm);
|
||||||
--radius-md: var(--mantine-radius-md);
|
--radius-md: var(--mantine-radius-md);
|
||||||
--radius-lg: var(--mantine-radius-lg);
|
--radius-lg: var(--mantine-radius-lg);
|
||||||
--radius-xl: var(--mantine-radius-xl);
|
--radius-xl: var(--mantine-radius-xl);
|
||||||
|
|
||||||
--radius-2xl: 1rem;
|
--radius-2xl: 1rem;
|
||||||
--radius-3xl: 1.5rem;
|
--radius-3xl: 1.5rem;
|
||||||
--radius-4xl: 2rem;
|
--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-2xs: 0 1px rgb(0 0 0 / 0.05);
|
||||||
--shadow-xs: var(--mantine-shadow-xs);
|
--shadow-xs: var(--mantine-shadow-xs);
|
||||||
@@ -203,7 +239,7 @@
|
|||||||
--blur-3xl: 64px;
|
--blur-3xl: 64px;
|
||||||
|
|
||||||
/* =========================================
|
/* =========================================
|
||||||
6. MISCELLANEOUS (Aspect, Anim, Perspective)
|
8. MISCELLANEOUS & ANIMATIONS
|
||||||
========================================= */
|
========================================= */
|
||||||
--perspective-dramatic: 100px;
|
--perspective-dramatic: 100px;
|
||||||
--perspective-near: 300px;
|
--perspective-near: 300px;
|
||||||
@@ -217,31 +253,26 @@
|
|||||||
--ease-out: cubic-bezier(0, 0, 0.2, 1);
|
--ease-out: cubic-bezier(0, 0, 0.2, 1);
|
||||||
--ease-in-out: cubic-bezier(0.4, 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-spin: spin 1s linear infinite;
|
||||||
--animate-ping: ping 1s cubic-bezier(0, 0, 0.2, 1) 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-pulse: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||||
--animate-bounce: bounce 1s infinite;
|
--animate-bounce: bounce 1s infinite;
|
||||||
|
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
to {
|
to { transform: rotate(360deg); }
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@keyframes ping {
|
@keyframes ping {
|
||||||
75%,
|
75%, 100% {
|
||||||
100% {
|
|
||||||
transform: scale(2);
|
transform: scale(2);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
50% {
|
50% { opacity: 0.5; }
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@keyframes bounce {
|
@keyframes bounce {
|
||||||
0%,
|
0%, 100% {
|
||||||
100% {
|
|
||||||
transform: translateY(-25%);
|
transform: translateY(-25%);
|
||||||
animation-timing-function: cubic-bezier(0.8, 0, 1, 1);
|
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 {
|
@layer base {
|
||||||
body {
|
body {
|
||||||
font-size: var(--base-font-size);
|
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:
|
apps/web:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@hookform/resolvers':
|
||||||
|
specifier: ^5.0.1
|
||||||
|
version: 5.4.0(react-hook-form@7.79.0)
|
||||||
'@repo/core-api':
|
'@repo/core-api':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/core-api
|
version: link:../../packages/core-api
|
||||||
@@ -200,6 +203,9 @@ importers:
|
|||||||
react-dom:
|
react-dom:
|
||||||
specifier: ^19.2.3
|
specifier: ^19.2.3
|
||||||
version: 19.2.3(react@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:
|
react-i18next:
|
||||||
specifier: ^15.4.0
|
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)
|
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:
|
tailwindcss:
|
||||||
specifier: ^4.1.18
|
specifier: ^4.1.18
|
||||||
version: 4.1.18
|
version: 4.1.18
|
||||||
|
zod:
|
||||||
|
specifier: ^3.25.36
|
||||||
|
version: 3.25.76
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@repo/eslint-config':
|
'@repo/eslint-config':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
@@ -236,7 +245,7 @@ importers:
|
|||||||
version: 5.4.17(@types/node@22.19.3)
|
version: 5.4.17(@types/node@22.19.3)
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^4.0.17
|
specifier: ^4.0.17
|
||||||
version: 4.0.17(jsdom@26.1.0)
|
version: 4.0.17(@opentelemetry/api@1.9.1)
|
||||||
|
|
||||||
packages/configs/eslint:
|
packages/configs/eslint:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -433,22 +442,52 @@ importers:
|
|||||||
version: 5.5.4
|
version: 5.5.4
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^4.0.17
|
specifier: ^4.0.17
|
||||||
version: 4.0.17(jsdom@26.1.0)
|
version: 4.0.17(@opentelemetry/api@1.9.1)
|
||||||
|
|
||||||
packages/ui:
|
packages/ui:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@hookform/resolvers':
|
||||||
|
specifier: ^5.0.1
|
||||||
|
version: 5.4.0(react-hook-form@7.79.0)
|
||||||
'@mantine/core':
|
'@mantine/core':
|
||||||
specifier: ^8.3.15
|
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)
|
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':
|
'@mantine/hooks':
|
||||||
specifier: ^8.3.15
|
specifier: ^8.3.15
|
||||||
version: 8.3.15(react@19.2.3)
|
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':
|
'@repo/utils':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../utils
|
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:
|
dayjs:
|
||||||
specifier: ^1.11.19
|
specifier: ^1.11.19
|
||||||
version: 1.11.19
|
version: 1.11.19
|
||||||
|
react-hook-form:
|
||||||
|
specifier: ^7.56.4
|
||||||
|
version: 7.79.0(react@19.2.3)
|
||||||
tailwind-merge:
|
tailwind-merge:
|
||||||
specifier: ^3.4.0
|
specifier: ^3.4.0
|
||||||
version: 3.4.0
|
version: 3.4.0
|
||||||
@@ -458,6 +497,9 @@ importers:
|
|||||||
tailwindcss:
|
tailwindcss:
|
||||||
specifier: ^4.1.18
|
specifier: ^4.1.18
|
||||||
version: 4.1.18
|
version: 4.1.18
|
||||||
|
zod:
|
||||||
|
specifier: ^3.25.36
|
||||||
|
version: 3.25.76
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@repo/eslint-config':
|
'@repo/eslint-config':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
@@ -468,6 +510,15 @@ importers:
|
|||||||
'@tailwindcss/vite':
|
'@tailwindcss/vite':
|
||||||
specifier: ^4.1.18
|
specifier: ^4.1.18
|
||||||
version: 4.1.18(vite@5.4.17)
|
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':
|
'@types/react':
|
||||||
specifier: ^19.2.7
|
specifier: ^19.2.7
|
||||||
version: 19.2.7
|
version: 19.2.7
|
||||||
@@ -480,6 +531,9 @@ importers:
|
|||||||
eslint:
|
eslint:
|
||||||
specifier: ^8.57.1
|
specifier: ^8.57.1
|
||||||
version: 8.57.1
|
version: 8.57.1
|
||||||
|
jsdom:
|
||||||
|
specifier: ^26.1.0
|
||||||
|
version: 26.1.0
|
||||||
react:
|
react:
|
||||||
specifier: ^19.2.3
|
specifier: ^19.2.3
|
||||||
version: 19.2.3
|
version: 19.2.3
|
||||||
@@ -522,7 +576,7 @@ importers:
|
|||||||
version: 5.5.4
|
version: 5.5.4
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^4.0.17
|
specifier: ^4.0.17
|
||||||
version: 4.0.17(jsdom@26.1.0)
|
version: 4.0.17(@opentelemetry/api@1.9.1)
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
@@ -530,6 +584,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==}
|
resolution: {integrity: sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/@adobe/css-tools@4.5.0:
|
||||||
|
resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
/@asamuzakjp/css-color@3.2.0:
|
/@asamuzakjp/css-color@3.2.0:
|
||||||
resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==}
|
resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==}
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -1464,6 +1522,15 @@ packages:
|
|||||||
- supports-color
|
- supports-color
|
||||||
dev: false
|
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:
|
/@humanwhocodes/config-array@0.13.0:
|
||||||
resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==}
|
resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==}
|
||||||
engines: {node: '>=10.10.0'}
|
engines: {node: '>=10.10.0'}
|
||||||
@@ -1601,6 +1668,24 @@ packages:
|
|||||||
react: 19.2.3
|
react: 19.2.3
|
||||||
dev: false
|
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):
|
/@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.3):
|
||||||
resolution: {integrity: sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==}
|
resolution: {integrity: sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -2452,6 +2537,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||||
dev: true
|
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):
|
/@storybook/addon-actions@8.6.14(storybook@8.6.15):
|
||||||
resolution: {integrity: sha512-mDQxylxGGCQSK7tJPkD144J8jWh9IU9ziJMHfB84PKpI/V5ZgqMDnpr2bssTrUaGDqU5e1/z8KcRF+Melhs9pQ==}
|
resolution: {integrity: sha512-mDQxylxGGCQSK7tJPkD144J8jWh9IU9ziJMHfB84PKpI/V5ZgqMDnpr2bssTrUaGDqU5e1/z8KcRF+Melhs9pQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -2972,6 +3061,18 @@ packages:
|
|||||||
pretty-format: 27.5.1
|
pretty-format: 27.5.1
|
||||||
dev: true
|
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):
|
/@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==}
|
resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -2995,6 +3096,318 @@ packages:
|
|||||||
react-dom: 19.2.3(react@19.2.3)
|
react-dom: 19.2.3(react@19.2.3)
|
||||||
dev: true
|
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:
|
/@tootallnate/once@2.0.0:
|
||||||
resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==}
|
resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
@@ -3299,7 +3712,6 @@ packages:
|
|||||||
'@types/react': ^19.2.0
|
'@types/react': ^19.2.0
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/react': 19.2.7
|
'@types/react': 19.2.7
|
||||||
dev: true
|
|
||||||
|
|
||||||
/@types/react@19.2.7:
|
/@types/react@19.2.7:
|
||||||
resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==}
|
resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==}
|
||||||
@@ -3332,6 +3744,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
|
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
|
||||||
dev: false
|
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:
|
/@types/uuid@9.0.8:
|
||||||
resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==}
|
resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==}
|
||||||
dev: true
|
dev: true
|
||||||
@@ -4232,7 +4648,6 @@ packages:
|
|||||||
/aria-query@5.3.2:
|
/aria-query@5.3.2:
|
||||||
resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
|
resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
dev: false
|
|
||||||
|
|
||||||
/array-buffer-byte-length@1.0.2:
|
/array-buffer-byte-length@1.0.2:
|
||||||
resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==}
|
resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==}
|
||||||
@@ -4955,6 +5370,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==}
|
resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/css.escape@1.5.1:
|
||||||
|
resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
/cssstyle@4.6.0:
|
/cssstyle@4.6.0:
|
||||||
resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==}
|
resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -5232,6 +5651,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
|
resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/dom-accessibility-api@0.6.3:
|
||||||
|
resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
/dotenv-expand@11.0.7:
|
/dotenv-expand@11.0.7:
|
||||||
resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==}
|
resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -6198,6 +6621,11 @@ packages:
|
|||||||
/fast-deep-equal@3.1.3:
|
/fast-deep-equal@3.1.3:
|
||||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
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:
|
/fast-glob@3.3.3:
|
||||||
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
|
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
|
||||||
engines: {node: '>=8.6.0'}
|
engines: {node: '>=8.6.0'}
|
||||||
@@ -7528,6 +7956,10 @@ packages:
|
|||||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/linkifyjs@4.3.3:
|
||||||
|
resolution: {integrity: sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/load-plugin@6.0.3:
|
/load-plugin@6.0.3:
|
||||||
resolution: {integrity: sha512-kc0X2FEUZr145odl68frm+lMJuQ23+rTXYmR6TImqPtbpmXC4vVXbWKDQ9IzndA0HfyQamWfKLhzsqGSTxE63w==}
|
resolution: {integrity: sha512-kc0X2FEUZr145odl68frm+lMJuQ23+rTXYmR6TImqPtbpmXC4vVXbWKDQ9IzndA0HfyQamWfKLhzsqGSTxE63w==}
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -8143,7 +8575,6 @@ packages:
|
|||||||
/min-indent@1.0.1:
|
/min-indent@1.0.1:
|
||||||
resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
|
resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
dev: false
|
|
||||||
|
|
||||||
/minimatch@10.2.5:
|
/minimatch@10.2.5:
|
||||||
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
|
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
|
||||||
@@ -8560,6 +8991,10 @@ packages:
|
|||||||
wcwidth: 1.0.1
|
wcwidth: 1.0.1
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/orderedmap@2.1.1:
|
||||||
|
resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/own-keys@1.0.1:
|
/own-keys@1.0.1:
|
||||||
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
|
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -8991,6 +9426,106 @@ packages:
|
|||||||
react-is: 16.13.1
|
react-is: 16.13.1
|
||||||
dev: false
|
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:
|
/protobufjs@7.6.1:
|
||||||
resolution: {integrity: sha512-4K0myLaWL5EteuSAro91EGFgcfVgxb64Jx+7oDAY6GOkXD4M69yuSEljNcInGVCA5sOPxmZ/EqDLj2x0Q0+Ygg==}
|
resolution: {integrity: sha512-4K0myLaWL5EteuSAro91EGFgcfVgxb64Jx+7oDAY6GOkXD4M69yuSEljNcInGVCA5sOPxmZ/EqDLj2x0Q0+Ygg==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
@@ -9095,6 +9630,15 @@ packages:
|
|||||||
react: 19.2.3
|
react: 19.2.3
|
||||||
scheduler: 0.27.0
|
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):
|
/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==}
|
resolution: {integrity: sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -9321,6 +9865,14 @@ packages:
|
|||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
dev: true
|
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:
|
/reflect.getprototypeof@1.0.10:
|
||||||
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
|
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -9576,6 +10128,10 @@ packages:
|
|||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/rope-sequence@1.3.4:
|
||||||
|
resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/rrweb-cssom@0.8.0:
|
/rrweb-cssom@0.8.0:
|
||||||
resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==}
|
resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==}
|
||||||
dev: true
|
dev: true
|
||||||
@@ -10117,7 +10673,6 @@ packages:
|
|||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
dependencies:
|
dependencies:
|
||||||
min-indent: 1.0.1
|
min-indent: 1.0.1
|
||||||
dev: false
|
|
||||||
|
|
||||||
/strip-indent@4.1.1:
|
/strip-indent@4.1.1:
|
||||||
resolution: {integrity: sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==}
|
resolution: {integrity: sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==}
|
||||||
@@ -10808,6 +11363,14 @@ packages:
|
|||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
dev: false
|
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:
|
/utf8-byte-length@1.0.5:
|
||||||
resolution: {integrity: sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==}
|
resolution: {integrity: sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==}
|
||||||
dev: true
|
dev: true
|
||||||
@@ -11148,6 +11711,10 @@ packages:
|
|||||||
/vuvuzela@1.0.3:
|
/vuvuzela@1.0.3:
|
||||||
resolution: {integrity: sha512-Tm7jR1xTzBbPW+6y1tknKiEhz04Wf/1iZkcTJjSFcpNko43+dFW6+OOeQe9taJIug3NdfUAjFKgUSyQrIKaDvQ==}
|
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:
|
/w3c-xmlserializer@5.0.0:
|
||||||
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
|
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -11408,6 +11975,10 @@ packages:
|
|||||||
readable-stream: 3.6.2
|
readable-stream: 3.6.2
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/zod@3.25.76:
|
||||||
|
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/zwitch@2.0.4:
|
/zwitch@2.0.4:
|
||||||
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
|
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|||||||
Reference in New Issue
Block a user