feat: implement comprehensive Form UI library with React Hook Form integration, Zod validation, and i18n support.
This commit is contained in:
@@ -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,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,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,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,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,65 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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,240 @@
|
||||
import React, { type ComponentType, type Ref, useMemo } from 'react';
|
||||
import {
|
||||
useController,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
type UseControllerProps,
|
||||
} from 'react-hook-form';
|
||||
import { Input } from '@mantine/core';
|
||||
import { useTranslation } from '@repo/core-i18n';
|
||||
import type { ZodI18nPayload, WithRHFOptions } from './types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: Attempt to parse a Zod error message as a JSON i18n payload
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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,6 @@
|
||||
export * from '@mantine/core';
|
||||
|
||||
export * from './Form';
|
||||
export * from './system-pages/coming-soon';
|
||||
export * from './system-pages/forbidden';
|
||||
export * from './system-pages/maintenance';
|
||||
|
||||
Reference in New Issue
Block a user