feat: implement comprehensive Form UI library with React Hook Form integration, Zod validation, and i18n support.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user