refactor: migrate docs-dev from storybook to vitepress config and update devcontainer configuration

This commit is contained in:
Firman Ramdhani
2026-06-24 13:14:44 +07:00
parent 385452bf36
commit ecb16c759d
26 changed files with 1892 additions and 1414 deletions
-477
View File
@@ -1,477 +0,0 @@
[← Back to Root](../../README.md)
# Enterprise API Engine (`@repo/core-api`)
The platform-agnostic API engine for the monorepo. Provides an isolated HTTP client factory, a unified observability pipeline (Grafana Faro + OpenTelemetry), and a generic data services engine.
**This package enforces App Autonomy (IoC).** The core provides the engine and interceptor pipelines, but the consuming applications (`apps/web`, `apps/landing`) inject their own specific configurations, authentication tokens, and error handling behaviors.
---
## Architecture Overview
```mermaid
graph TD
%% ─── Styling Definitions (Dark-Mode Friendly Enterprise Palette) ───
classDef appEntity fill:#dbeafe,stroke:#3b82f6,stroke-width:2px,color:#1e3a8a
classDef coreEngine fill:#6366f1,stroke:#4338ca,stroke-width:2px,color:#ffffff
classDef dataService fill:#10b981,stroke:#047857,stroke-width:2px,color:#ffffff
classDef observability fill:#f59e0b,stroke:#b45309,stroke-width:2px,color:#ffffff
classDef errorNode fill:#f43f5e,stroke:#be123c,stroke-width:2px,color:#ffffff
%% ─── Subgraphs ───
subgraph Apps ["apps/* (App Autonomy)"]
WEB([apps/web])
LAND([apps/landing])
DESK([apps/desktop])
end
subgraph Core ["@repo/core-api (Engine)"]
subgraph HTTP ["http-client"]
FACTORY[createHttpClient]
end
subgraph OBS ["observability"]
FARO[faroAdapter]
end
subgraph DATA ["data-services"]
BASE[BaseRemoteDataServices]
COMMON[CommonRemoteDataServices]
end
subgraph ERRORS ["errors"]
API_ERR[ApiError]
end
end
%% ─── Flow & Relationships ───
WEB & LAND & DESK ===>|instantiates| FACTORY
WEB & LAND & DESK ===>|extends| COMMON
COMMON --->|executes via| FACTORY
FACTORY -.->|reports via| FARO
FACTORY -.->|throws| API_ERR
%% ─── Apply Styles ───
class WEB,LAND,DESK appEntity;
class FACTORY coreEngine;
class BASE,COMMON dataService;
class FARO observability;
class API_ERR errorNode;
%% ─── Subgraph Backgrounds (Transparent for Native GitHub Support) ───
style Apps fill:transparent,stroke:#3b82f6,stroke-width:2px,stroke-dasharray: 5 5
style Core fill:transparent,stroke:#94a3b8,stroke-width:2px,stroke-dasharray: 5 5
%% Nested subgraphs also need transparent backgrounds to prevent glaring white boxes in dark mode
style HTTP fill:transparent,stroke:#cbd5e1,stroke-width:1px,stroke-dasharray: 5 5
style OBS fill:transparent,stroke:#cbd5e1,stroke-width:1px,stroke-dasharray: 5 5
style DATA fill:transparent,stroke:#cbd5e1,stroke-width:1px,stroke-dasharray: 5 5
style ERRORS fill:transparent,stroke:#cbd5e1,stroke-width:1px,stroke-dasharray: 5 5
```
### Data Flow Lifecycle
Every HTTP request flows through this precise interceptor pipeline:
```mermaid
sequenceDiagram
autonumber
%% ─── Dark-Mode Friendly RGBA Boxes ───
box rgba(59,130,246,0.1) App Layer - Consumers
participant C as UI Component
end
box rgba(148,163,184,0.1) Core Engine - @repo/core-api
participant S as Data Service
participant H as HTTP Client
participant F as Faro Adapter
end
box rgba(16,185,129,0.1) App Logic - IoC
participant A as App Hooks
end
box rgba(245,158,11,0.1) External
participant N as Network
end
%% ─── Execution Flow ───
C->>S: getMany()
S->>H: request()
H->>F: onRequestStart() (Log + Span)
H->>A: hooks.onRequest() (Inject Token)
A->>N: fetch/XHR
alt Success (2xx)
N-->>A: return Response
A->>F: onRequestEnd() (Close Span)
F->>A: hooks.onResponse()
A-->>S: return data
else Error (4xx / 5xx)
N-->>A: return Rejection
A->>F: onRequestError() (Log Error)
F->>A: hooks.onResponseError() (Redirect/Refresh)
A-->>S: throw ApiError
end
```
> [!IMPORTANT]
> Observability adapter errors are **caught internally** via try-catch in the interceptor chain. An adapter crash will never swallow or replace the original API error — the UI always receives the correct rejection.
---
## HTTP Client
### `createHttpClient(config, hooks?)`
Creates an **isolated** Axios instance. Each app receives its own interceptor chain — no globals are shared or mutated.
```typescript
import { createHttpClient } from '@repo/core-api/http-client';
import { faroAdapter } from '@repo/core-api/observability';
export const apiClient = createHttpClient(
{
baseURL: import.meta.env.VITE_API_URL ?? 'http://localhost:8080/api/v1',
timeout: 15000,
observability: faroAdapter,
},
{
onRequest: async (config) => {
const token = localStorage.getItem('access_token');
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
},
onResponseError: async (error) => {
if (error.response?.status === 401) {
localStorage.removeItem('access_token');
window.location.href = '/auth/login';
}
throw error;
},
},
);
```
### Configuration
| Property | Type | Default | Description |
|---|---|---|---|
| `baseURL` | `string` | *required* | Base URL for all requests |
| `timeout` | `number` | `15000` | Default request timeout (ms) |
| `defaultHeaders` | `Record<string, string>` | `{}` | Headers applied to every request |
| `observability` | `IObservabilityAdapter` | `noopAdapter` | Observability adapter (Faro or no-op) |
### Interceptor Hooks
| Hook | Signature | Purpose |
|---|---|---|
| `onRequest` | `(config) => config` | Inject auth tokens, tenant headers |
| `onResponse` | `(response) => response` | Transform response shapes |
| `onResponseError` | `(error) => never` | App-specific error handling (e.g., 401 redirect) |
---
## Observability
### Strategy: Opt-In Custom Spans + Faro/Loki Baseline
The observability layer operates in two complementary modes:
| Mode | Activation | What it does |
|---|---|---|
| **Baseline** (always on) | Automatic | Pushes structured logs to Faro/Loki on every request with `module.key`, `module.action`, HTTP method, and URL |
| **Custom Span** (opt-in) | Via `telemetryContext.customSpanName` | Creates an explicit OTel span with custom tags, visible in Grafana Tempo |
> [!NOTE]
> `trace.getActiveSpan()` returns `undefined` inside Axios interceptors due to browser XHR/Fetch lifecycle race conditions with Faro's `TracingInstrumentation`. The adapter does **not** attempt to enrich auto-instrumented spans. HTTP span capture is handled entirely by `TracingInstrumentation` auto-instrumentation.
### Initialization
Call `initTelemetry()` **once** at the top of your app's entry point, before any React code:
```typescript
import { initTelemetry } from '@repo/core-api/observability/setup';
initTelemetry({
appName: 'fe-monorepo-web',
appVersion: '1.0.0',
telemetryUrl: '[https://telemetry.eigen.co.id/collect](https://telemetry.eigen.co.id/collect)',
environment: 'production',
// Optional: direct OTLP export to Grafana Tempo
otlpTraceUrl: '[https://telemetry.eigen.co.id/v1/traces](https://telemetry.eigen.co.id/v1/traces)',
});
```
### `TelemetryConfig`
| Property | Type | Required | Description |
|---|---|---|---|
| `appName` | `string` | ✅ | Application name for Faro + OTel resource attributes |
| `appVersion` | `string` | ✅ | SemVer version |
| `telemetryUrl` | `string` | ✅ | Grafana Faro collector URL |
| `environment` | `string` | ✅ | Deployment environment (`production`, `staging`, `development`) |
| `otlpTraceUrl` | `string` | — | Separate OTLP trace endpoint for direct Tempo ingestion |
| `propagateTraceHeaderCorsUrls` | `Array<string \| RegExp>` | — | CORS patterns for W3C trace context propagation (default: `[/.*/]`) |
### Audit Headers
Every request dispatched through `BaseRemoteDataServices` automatically attaches two business audit headers:
| Header | Source | Purpose |
|---|---|---|
| `ex-module-key` | `DataServicesConfig.moduleKey` | Identifies the business module (e.g., `BOOKING`) |
| `ex-module-action` | `RequestDescriptor.action` | Identifies the operation (e.g., `READ`, `CREATE`) |
These headers are extracted by the `faroAdapter` and included in all Faro `pushLog`, `pushError`, and `pushEvent` calls as top-level context — making them directly queryable in **LogQL (Loki)**.
### Span Safety Guarantees
| Guarantee | Mechanism |
|---|---|
| **No span leaks** | `safeEndSpan()` always closes the span and detaches the reference from config |
| **No double-close on retry** | Span reference is deleted from config after `span.end()` |
| **No error swallowing** | All adapter calls are wrapped in try-catch in `create-http-client.ts` |
| **No crash on timeout** | `null`/`undefined` config guards on all `error.config` access |
---
## Data Services
### `CommonRemoteDataServices<E>`
A concrete, ready-to-use data services class that provides full CRUD and lifecycle operations. Extends `BaseRemoteDataServices<E>`.
```typescript
import { CommonRemoteDataServices } from '@repo/core-api/data-services';
import type { BaseEntity } from '@repo/core-api/data-services';
import { apiClient } from '@/lib/api-client';
interface BookingEntity extends BaseEntity {
bookingCode: string;
customerName: string;
status: 'pending' | 'confirmed' | 'cancelled';
}
export const bookingServices = new CommonRemoteDataServices<BookingEntity>(
apiClient,
{
apiUrl: '/bookings',
moduleKey: 'BOOKING',
},
);
```
### Available Operations
| Method | HTTP | URL Template | Description |
|---|---|---|---|
| `getMany(config?)` | GET | `/bookings` | Fetch paginated list |
| `getOne(id, config?)` | GET | `/bookings/:id` | Fetch single entity |
| `create(data, config?)` | POST | `/bookings` | Create new entity |
| `edit(id, data, config?)` | PUT | `/bookings/:id` | Update entity |
| `delete(id, config?)` | DELETE | `/bookings/:id` | Delete entity |
| `batchDelete(ids, config?)` | DELETE | `/bookings/batch` | Delete multiple |
| `activate(id)` | PATCH | `/bookings/:id/activate` | Activate entity |
| `deactivate(id)` | PATCH | `/bookings/:id/deactivate` | Deactivate entity |
| `confirmProcessData(id)` | PATCH | `/bookings/:id/confirm-process-data` | Confirm data processing |
| `confirmProcessTransaction(id)` | PATCH | `/bookings/:id/confirm-process-transaction` | Confirm transaction |
| `cancelProcessTransaction(id)` | PATCH | `/bookings/:id/cancel-process-transaction` | Cancel transaction |
| `rollbackProcessTransaction(id)` | PATCH | `/bookings/:id/rollback-process-transaction` | Rollback transaction |
| `holdProcessTransaction(id)` | PATCH | `/bookings/:id/hold-process-transaction` | Hold transaction |
All batch variants (`batchActivate`, `batchDeactivate`, etc.) are also available.
### Escape Hatch: `customRequest<T>(config)`
For non-standard endpoints that don't fit the CRUD pattern:
```typescript
const taxResult = await bookingServices.customRequest<TaxCalculation>({
url: '/bookings/42/calculate-tax',
method: 'POST',
data: { items: [...] },
});
```
---
## Application Setup Guide
### 1. Initialize Telemetry (Entry Point)
```typescript
// apps/web/src/main.tsx — MUST be the first import
import { initTelemetry } from '@repo/core-api/observability/setup';
initTelemetry({
appName: import.meta.env.VITE_APP_NAME || 'fe-monorepo-web',
appVersion: import.meta.env.VITE_APP_VERSION || '0.0.0',
telemetryUrl: import.meta.env.VITE_FARO_URL || '[https://telemetry.eigen.co.id/collect](https://telemetry.eigen.co.id/collect)',
otlpTraceUrl: import.meta.env.VITE_OTLP_TRACE_URL || '[https://telemetry.eigen.co.id/v1/traces](https://telemetry.eigen.co.id/v1/traces)',
environment: import.meta.env.VITE_ENV || 'development',
});
// ... rest of React bootstrap
```
### 2. Create the HTTP Client
```typescript
// apps/web/src/lib/api-client.ts
import { createHttpClient } from '@repo/core-api/http-client';
import { faroAdapter } from '@repo/core-api/observability';
export const apiClient = createHttpClient({
baseURL: import.meta.env.VITE_API_URL ?? 'http://localhost:8080/api/v1',
timeout: 15000,
observability: faroAdapter,
});
```
### 3. Create a Data Service
```typescript
// features/booking/data/booking.data-services.ts
import { CommonRemoteDataServices } from '@repo/core-api/data-services';
import type { BaseEntity } from '@repo/core-api/data-services';
import { apiClient } from '@/lib/api-client';
export interface BookingEntity extends BaseEntity {
bookingCode: string;
customerName: string;
status: 'pending' | 'confirmed' | 'cancelled';
totalAmount: number;
}
export const bookingServices = new CommonRemoteDataServices<BookingEntity>(
apiClient,
{ apiUrl: '/bookings', moduleKey: 'BOOKING' },
);
```
### 4. Consume in a React Component
```tsx
import { useState } from 'react';
import { bookingServices } from '../data/booking.data-services';
import type { BookingEntity } from '../data/booking.data-services';
import type { ApiResponse } from '@repo/core-api/http-client';
import { ApiError } from '@repo/core-api/errors';
export default function BookingSample() {
const [result, setResult] = useState<ApiResponse<BookingEntity[]> | null>(null);
const [error, setError] = useState<string | null>(null);
const handleFetch = async () => {
try {
const response = await bookingServices.getMany<BookingEntity[]>({
params: { page: 1, limit: 20 },
// Optional: Per-request telemetry escape hatch
telemetryContext: {
customSpanName: 'booking.list.fetch',
tags: { feature: 'booking', page: 1 },
pushEventOnSuccess: 'booking_list_loaded',
},
});
setResult(response);
} catch (err) {
if (err instanceof ApiError) {
setError(`[${err.code}] ${err.message} (HTTP ${err.status})`);
}
}
};
return <button onClick={handleFetch}>Fetch Bookings</button>;
}
```
---
## Per-Request Telemetry (Escape Hatch)
### `TelemetryContext`
Attach to any request via the `telemetryContext` property to push custom spans and business events:
```typescript
interface TelemetryContext {
/** Creates a custom OTel span wrapping this request (visible in Grafana Tempo). */
customSpanName?: string;
/** Custom tags enriching the span and Faro logs (prefixed with `custom.` on spans). */
tags?: Record<string, string | number | boolean>;
/** Pushes a named Faro event on success (visible in Grafana Faro dashboard). */
pushEventOnSuccess?: string;
}
```
### Precedence
`telemetryContext` can be provided at two levels. The top-level `ExecuteOptions.telemetryContext` takes precedence over `config.telemetryContext`:
```typescript
// Top-level (preferred)
await bookingServices.getMany({
telemetryContext: { customSpanName: 'booking.list.fetch' },
});
// Nested in config (also works)
await bookingServices.getMany({
params: { page: 1 },
telemetryContext: { customSpanName: 'booking.list.fetch' },
});
```
### What Happens at Each Stage
| Stage | Baseline (no telemetryContext) | With `customSpanName` |
|---|---|---|
| **Request Start** | Faro `pushLog` (DEBUG) with `module.key`, `module.action`, URL | + Creates OTel span with `http.method`, `http.url`, `custom.*` tags |
| **Request Success** | — | Closes span (OK). If `pushEventOnSuccess`, pushes Faro event |
| **Request Error** | Faro `pushError` + `pushLog` (ERROR) | + Closes span (ERROR), records exception |
---
## Error Handling
### `ApiError`
All non-2xx responses are normalized into structured `ApiError` instances:
```typescript
try {
await bookingServices.getOne('42');
} catch (err) {
if (err instanceof ApiError) {
err.code; // ApiErrorCode.NOT_FOUND
err.status; // 404
err.message; // "Booking not found"
err.data; // Raw server response body
err.toJSON(); // Serializable for logging
}
}
```
### Error Codes
| Code | HTTP Status | Description |
|---|---|---|
| `BAD_REQUEST` | 400 | Invalid request parameters |
| `UNAUTHORIZED` | 401 | Missing or expired token |
| `FORBIDDEN` | 403 | Insufficient permissions |
| `NOT_FOUND` | 404 | Resource not found |
| `TIMEOUT` | — | Request timed out (`ECONNABORTED`) |
| `CANCELLED` | — | Request was cancelled (`ERR_CANCELED`) |
| `NETWORK_ERROR` | — | No response received |
| `SERVER_ERROR` | 500+ | Internal server error |
---
## Package Exports
| Import Path | Contents |
|---|---|
| `@repo/core-api/http-client` | `createHttpClient`, `ApiResponse`, `TelemetryContext`, Axios type re-exports |
| `@repo/core-api/observability` | `faroAdapter`, `noopObservabilityAdapter`, `IObservabilityAdapter`, `initTelemetry`, `getFaro`, `TelemetryConfig` |
| `@repo/core-api/observability/setup` | `initTelemetry`, `getFaro`, `TelemetryConfig` |
| `@repo/core-api/data-services` | `BaseRemoteDataServices`, `CommonRemoteDataServices`, types, constants |
| `@repo/core-api/errors` | `ApiError`, `ApiErrorCode` |
-366
View File
@@ -1,366 +0,0 @@
[← Back to Root](../../README.md)
# Event Bus (`@repo/core-events`)
The Global Pub/Sub & Hardware Integration Blueprint.
> This module provides a strictly-typed, global event bus for the monorepo ecosystem. It decouples cross-component communication and manages real-time hardware signals (such as printers and POS peripherals), ensuring a reactive and memory-safe architecture across all applications.
---
## 🧠 System Overview
`@repo/core-events` is the **decoupled Nervous System** of the ERP. It provides a highly performant, strictly-typed Event Bus powered by `mitt` and custom React hooks.
**This package is a pure tool.** It ships zero application-specific events. Each consuming app (`apps/web`, `apps/desktop`, etc.) registers its own events autonomously using **TypeScript Declaration Merging** — the exact same Inversion of Control (IoC) pattern utilized by our `@repo/core-api` factory and `@repo/core-storage` engine.
### Architectural Topology
### 1. Conceptual Topology: The Pub/Sub Data Flow
This diagram illustrates the high-level concept of our decoupled architecture, demonstrating how application-specific types merge into the core bus.
```mermaid
graph LR
%% ─── Styling Definitions (Dark-Mode Friendly Enterprise Palette) ───
classDef appEntity fill:#dbeafe,stroke:#3b82f6,stroke-width:2px,color:#1e3a8a
classDef coreEntity fill:#e2e8f0,stroke:#64748b,stroke-width:2px,color:#0f172a
classDef busEntity fill:#10b981,stroke:#047857,stroke-width:2px,color:#ffffff
%% ─── Nodes ───
TYPES[[App-Specific Event Types]]
PUB([Publisher Component])
BUS{Global Event Bus 'mitt'}
SUB([Subscriber Component])
%% ─── Flow ───
TYPES -.->|Declaration Merging| BUS
PUB ===>|emit 'event', payload| BUS
BUS ===>|useAppEvent 'event'| SUB
%% ─── Apply Styles ───
class TYPES,PUB,SUB appEntity;
class BUS busEntity;
```
### 2. System Architecture: Core Engine vs. App Autonomy
This detailed diagram shows the exact boundaries between the @repo/core-events engine and the consuming application, highlighting real-world publishers (e.g., Cashier UI) and subscribers.
```mermaid
graph TD
%% ─── Styling Definitions (Dark-Mode Friendly Enterprise Palette) ───
classDef appComponent fill:#dbeafe,stroke:#3b82f6,stroke-width:2px,color:#1e3a8a
classDef injection fill:#f59e0b,stroke:#b45309,stroke-width:2px,color:#ffffff
classDef registry fill:#10b981,stroke:#047857,stroke-width:2px,color:#ffffff
classDef coreBus fill:#6366f1,stroke:#4338ca,stroke-width:2px,color:#ffffff
%% ─── Subgraphs ───
subgraph Core ["@repo/core-events (Pure Tool)"]
R[AppEventRegistry Empty Interface]
T[AppEvents Mapped Type]
E((Global Event Bus mitt))
H[Hooks: useAppEvent / usePublishEvent]
end
subgraph Apps ["apps/web (App Autonomy)"]
D[[events.d.ts Declaration Merging]]
%% Publishers
A([Cashier UI])
B([Profile Settings])
C([WebSocket Client])
%% Subscribers
X([Electron IPC Bridge])
Y([IndexedDB Sync])
Z([Stock Grid Row])
end
%% ─── Flow & Relationships ───
D -.->|Augments| R
R ---> T ---> E
E ---> H
%% Emitting Events
A ===>|DEVICE:PRINT_RECEIPT| E
B ===>|AUTH:PROFILE_UPDATED| E
C ===>|WS:STOCK_UPDATE| E
%% Subscribing to Events
E -.->|Triggers| X
E -.->|Triggers| Y
E -.->|Triggers| Z
%% ─── Apply Styles ───
class A,B,C,X,Y,Z appComponent;
class D injection;
class R,T registry;
class E,H coreBus;
%% ─── Subgraph Backgrounds (Transparent for Native GitHub Support) ───
style Core fill:transparent,stroke:#94a3b8,stroke-width:2px,stroke-dasharray: 5 5
style Apps fill:transparent,stroke:#3b82f6,stroke-width:2px,stroke-dasharray: 5 5
```
### Core Value Proposition
By routing communication through this centralized event bus, we achieve:
* **App Autonomy**: The core defines the engine. The app defines the contract. There is zero circular dependency.
* **Zero Coupling**: Publishers and subscribers do not need to import, reference, or know about each other's existence.
* **Extreme Performance**: Components can subscribe to high-frequency data streams (like WebSockets or hardware signals) and update their own local state *without* triggering massive React tree re-renders.
* **Memory Safety**: The provided `useAppEvent` hook automatically handles subscription cleanup on component unmount, proactively preventing the most common source of memory leaks in Single Page Architectures (SPAs).
---
## Defining Events (Module Augmentation)
> [!IMPORTANT]
> **Do NOT add application events to `packages/core-events/src/events.registry.ts`.**
> The core registry is intentionally empty. Each app owns its own event contract.
The core exports an open `AppEventRegistry` interface. Apps extend it using TypeScript's `declare module` syntax — the same pattern used for `@types/*` across the JS ecosystem.
### Step 1: Create an augmentation file in your app
> [!WARNING]
> The `import type {}` line is **mandatory**. Without it, TypeScript treats `declare module` as an ambient module declaration that **replaces** the module's types instead of merging into them. All actual exports (`useAppEvent`, `publish`, etc.) would become invisible.
```typescript
// apps/web/src/types/events.d.ts
// This import makes this file a module augmentation (merge)
// instead of an ambient declaration (replace).
import type {} from '@repo/core-events';
declare module '@repo/core-events' {
// Define your payload shapes
interface OrderPayload {
orderId: string;
total: number;
items: Array<{ sku: string; qty: number }>;
}
// Extend the registry
interface AppEventRegistry {
'STORE:ORDER_PLACED': OrderPayload;
'STORE:ORDER_CANCELLED': { orderId: string; reason: string };
'UI:SIDEBAR_TOGGLED': { collapsed: boolean };
// Explicit payloads for the examples below:
'DEVICE:PRINT_RECEIPT': { receiptId: string; items: any[]; total: number; cashierName: string; timestamp: number };
'WS:STOCK_UPDATE': { id: string; price: number };
'AUTH:PROFILE_UPDATED': { id: string; name: string; email: string; avatar: string; updatedAt: number };
'SYSTEM:ERROR': { source: string; error: Error };
}
}
```
### Step 2: Use it — autocomplete works immediately
```tsx
import { usePublishEvent, useAppEvent } from '@repo/core-events';
function CheckoutButton() {
const publish = usePublishEvent();
// ✅ 'STORE:ORDER_PLACED' autocompletes.
// ✅ Payload shape is enforced by TypeScript.
publish('STORE:ORDER_PLACED', { orderId: '123', total: 99, items: [] });
}
function OrderTracker() {
// ✅ payload is fully typed as OrderPayload
useAppEvent('STORE:ORDER_PLACED', (payload) => {
console.log(payload.orderId); // string
});
}
```
### Why this pattern?
| Concern | Old (Hardcoded) | New (Module Augmentation) |
|---|---|---|
| Core knows about app events? | ❌ Yes — violates IoC | ✅ No — core is a pure tool |
| Adding events requires editing core? | ❌ Yes | ✅ No — edit your app's `.d.ts` only |
| Multiple apps share the same registry? | ❌ Collision risk | ✅ Each app has its own `.d.ts` |
| Type safety / autocomplete | ✅ Works | ✅ Works identically |
---
## Usage Outside React (Vanilla TS)
For utility files, API interceptors, Web Workers, or vanilla functions where React hooks cannot be used, import the raw `eventBus` instance directly.
```ts
import { eventBus } from '@repo/core-events';
// Publishing
eventBus.publish('STORE:ORDER_CANCELLED', { orderId: '123', reason: 'Out of stock' });
// Subscribing
const handler = (payload) => {
console.log('Order cancelled:', payload.orderId);
};
eventBus.subscribe('STORE:ORDER_CANCELLED', handler);
// CRITICAL: Always unsubscribe when done to prevent memory leaks in non-React contexts!
eventBus.unsubscribe('STORE:ORDER_CANCELLED', handler);
```
---
## Usage Examples
Here are three real-world architectural patterns powered by the Event Bus. All event types below are registered in `apps/web/src/types/events.d.ts`, **not** in the core package.
### Example 1: Hardware Abstraction (Cross-Platform)
**Problem**: The web app needs to print receipts. If running in a browser, it should use `window.print()`. If running in the Electron wrapper, it must use the secure IPC bridge (`window.electronAPI.print()`). We don't want the UI components cluttered with platform-detection logic.
**Solution**: The UI publishes a blind event. A headless listener handles the platform routing.
**Publisher (Cashier UI)**:
```tsx
import { usePublishEvent } from '@repo/core-events';
export function CashierUI() {
const publish = usePublishEvent();
const handlePrint = () => {
// Fire and forget. Zero knowledge of how printing actually happens.
publish('DEVICE:PRINT_RECEIPT', {
receiptId: 'RCP-123',
items: [],
total: 45.00,
cashierName: 'Firman',
timestamp: Date.now(),
});
};
return <Button onClick={handlePrint}>Print Receipt</Button>;
}
```
**Subscriber (Headless Listener)**:
```tsx
import { useAppEvent } from '@repo/core-events';
export function PrinterListener() {
useAppEvent('DEVICE:PRINT_RECEIPT', (payload) => {
const isElectron = typeof window !== 'undefined' && !!window.electronAPI;
if (isElectron) {
// Route via secure Electron IPC bridge
window.electronAPI.print({ silent: true });
} else {
// Fallback to standard browser print dialog
window.print();
}
});
return null; // Renders nothing
}
```
---
### Example 2: Extreme Performance (High-Frequency Data)
**Problem**: A massive data grid (1,000+ rows) receives 50 WebSocket updates per second. If the parent grid holds the state and passes it down via props, React will attempt to re-render all 1,000 rows 50 times a second, crushing the browser.
**Solution**: The parent grid renders empty rows. Each row subscribes to the event bus and filters updates so it only re-renders when its specific data changes.
**Parent Grid (Never re-renders)**:
```tsx
export function LiveStockGrid() {
// Generates 1000 IDs once. No stock data is stored here!
const stockIds = generateStockIds(1000);
return (
<table>
<tbody>
{stockIds.map((id) => (
<StockRow key={id} stockId={id} />
))}
</tbody>
</table>
);
}
```
**Child Row (Targeted Updates)**:
```tsx
import { memo, useState } from 'react';
import { useAppEvent } from '@repo/core-events';
export const StockRow = memo(function StockRow({ stockId }) {
const [data, setData] = useState(null);
useAppEvent('WS:STOCK_UPDATE', (payload) => {
// CRITICAL: Filter out events for other rows.
// 999 out of 1000 rows will exit here instantly without causing a re-render.
if (payload.id !== stockId) return;
// Only the targeted row updates its local state
setData(payload);
});
return (
<tr>
<td>{stockId}</td>
<td>{data?.price}</td>
</tr>
);
});
```
---
### Example 3: Background Sync (Auth to IndexedDB)
**Problem**: When a user updates their profile, we need to persist it to the secure local IndexedDB. We don't want to tightly couple our UI forms to the `@repo/core-storage` package.
**Solution**: The UI form announces the profile update. A dedicated storage listener persists it in the background, properly escalating errors if the storage fails.
**Publisher (Profile UI)**:
```tsx
import { usePublishEvent } from '@repo/core-events';
export function ProfileSettingsUI() {
const publish = usePublishEvent();
const handleSave = () => {
publish('AUTH:PROFILE_UPDATED', {
id: 'user-1',
name: 'Firman',
email: 'firman@eigen.co.id',
avatar: '[https://example.com/avatar.png](https://example.com/avatar.png)',
updatedAt: Date.now(),
});
};
return <Button onClick={handleSave}>Save Profile</Button>;
}
```
**Subscriber (Storage Sync Listener)**:
```tsx
import { useAppEvent, usePublishEvent } from '@repo/core-events';
import { secureIndexedDB } from '@repo/core-storage';
export function StorageSyncListener() {
const publish = usePublishEvent();
useAppEvent('AUTH:PROFILE_UPDATED', (payload) => {
// Automatically encrypted at rest because 'user_profile'
// is defined in ENCRYPTED_KEYS in @repo/core-storage
secureIndexedDB.setItem('user_profile', payload).catch((error) => {
// Escalate to global error handler instead of swallowing it
publish('SYSTEM:ERROR', { source: 'StorageSyncListener', error });
});
});
return null;
}
```
-277
View File
@@ -1,277 +0,0 @@
[← Back to Root](../../README.md)
# i18n Architecture (`@repo/core-i18n`)
A highly decoupled, type-safe internationalization engine for the monorepo.
It uses a **Hybrid Namespace Strategy**:
1. **Centralized Engine**: Setup, local persistence orchestration, and global words (`common`).
2. **Decentralized Dictionaries**: Feature-specific translations (`booking`, `billing`) live inside the application modules and are lazy-loaded.
This architecture strictly adheres to **Inversion of Control (IoC)**. The core engine handles local state and performance, but leaves API, networking, and storage implementation decisions entirely to the consuming applications.
---
## Overview Architecture
```mermaid
graph TD
%% ─── Styling Definitions (Dark-Mode Friendly Enterprise Palette) ───
classDef appEntity fill:#dbeafe,stroke:#3b82f6,stroke-width:2px,color:#1e3a8a
classDef coreEngine fill:#6366f1,stroke:#4338ca,stroke-width:2px,color:#ffffff
classDef dataStore fill:#10b981,stroke:#047857,stroke-width:2px,color:#ffffff
classDef externalAPI fill:#f59e0b,stroke:#b45309,stroke-width:2px,color:#ffffff
%% ─── Subgraphs ───
subgraph Apps ["apps/* (App Autonomy)"]
UI([React Components])
DICT[[Feature Dictionaries: booking.json]]
end
subgraph Core ["@repo/core-i18n (Engine)"]
I18N[i18next Instance]
STORE[(core-storage)]
COMMON[Common Vocabulary]
end
subgraph Backend ["Backend API (External)"]
SYNC([Language Sync Endpoint])
TENANT([Tenant Config Endpoint])
end
%% ─── Flow & Relationships ───
UI ===>|uses useTranslation| I18N
DICT -.->|lazy loads| I18N
COMMON --->|preloads| I18N
I18N <===>|reads / persists| STORE
I18N --->|changeLanguage sync| SYNC
SYNC -.->|fails? rollback| I18N
TENANT -.->|applyTenantOverrides| I18N
%% ─── Apply Styles ───
class UI,DICT appEntity;
class I18N coreEngine;
class STORE,COMMON dataStore;
class SYNC,TENANT externalAPI;
%% ─── Subgraph Backgrounds (Transparent for Native GitHub Support) ───
style Apps fill:transparent,stroke:#3b82f6,stroke-width:2px,stroke-dasharray: 5 5
style Core fill:transparent,stroke:#94a3b8,stroke-width:2px,stroke-dasharray: 5 5
style Backend fill:transparent,stroke:#f59e0b,stroke-width:2px,stroke-dasharray: 5 5
```
---
## 1. App-Level Setup (Bootstrap)
Initialize the engine *before* your React application mounts to prevent UI flashing. Provide an `I18nStorageAdapter` using Dependency Injection so the core engine can persist the user's language without being tightly coupled to a specific storage implementation.
```tsx
// apps/web/src/main.tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { setupI18n } from '@repo/core-i18n';
import { secureStorage, AppStorageKey } from './core/storage';
import App from './app';
async function bootstrap() {
// Synchronously reads preferred language from injected storage & inits i18next
await setupI18n({
storageAdapter: {
getLanguage: async () => {
const stored = await secureStorage.getItem(AppStorageKey.LOCALE);
return typeof stored === 'string' ? stored : null;
},
setLanguage: async (lng: string) => {
await secureStorage.setItem(AppStorageKey.LOCALE, lng);
},
},
});
createRoot(document.getElementById('app')!).render(
<StrictMode><App /></StrictMode>,
);
}
bootstrap();
```
---
## 2. Module-Level Setup (Decentralized Dictionaries)
Dictionaries live right next to the UI components that use them.
### Folder Structure
```text
apps/web/src/apps/modules/booking/
├── presentation/BookingTable.tsx
└── locales/
├── id/booking.json
└── en/booking.json
```
### Lazy Loading & Type Safety
Register the namespace when the component mounts. To get native TypeScript autocomplete for nested keys (e.g., `header.title`), augment the global `react-i18next` types.
**1. Augment Types:**
```ts
// apps/web/src/types/i18next.d.ts
import 'react-i18next';
import type { resources as coreResources } from '@repo/core-i18n/src/setup';
import bookingEn from '../apps/modules/booking/locales/en/booking.json';
declare module 'react-i18next' {
interface CustomTypeOptions {
defaultNS: 'common';
resources: typeof coreResources['en'] & { booking: typeof bookingEn };
}
}
```
**2. Use in Component:**
```tsx
import { useEffect } from 'react';
import { i18n, useTranslation } from '@repo/core-i18n';
import bookingId from '../locales/id/booking.json';
import bookingEn from '../locales/en/booking.json';
export default function BookingFeature() {
const { t } = useTranslation(['common', 'booking']);
useEffect(() => {
i18n.addResourceBundle('id', 'booking', bookingId, true, false);
i18n.addResourceBundle('en', 'booking', bookingEn, true, false);
}, []);
return <h1>{t('booking:header.title')}</h1>; // Autocomplete works!
}
```
**3. Dynamic Variables (Interpolation):**
```json
// booking.json
{
"messages": {
"welcome": "Welcome back, {{name}}! You have {{count}} new bookings."
}
}
```
```tsx
// Inside component
<h1>{t('booking:messages.welcome', { name: 'Firman', count: 5 })}</h1>
```
---
## 3. Usage Outside React Components (Vanilla TS)
For utility files, API interceptors, or vanilla functions where React hooks cannot be used, import the raw `i18n` instance directly.
```ts
import { i18n } from '@repo/core-i18n';
// Must specify the namespace explicitly if it's not 'common'
export const getErrorMessage = (code: string) => {
return i18n.t(`booking:errors.${code}`, { defaultValue: 'Unknown Error' });
};
```
---
## 4. Real-World Implementation Flow
The engine supports robust flows for authenticated apps, including Tenant Vocabulary Overrides and Backend Synchronization.
### A. The Tenant Override Flow (After Login)
If "Company A" calls "Purchasing" -> "Procurement", they shouldn't need a custom build. The backend returns an override config, and the frontend dynamically merges it using `applyTenantOverrides`.
```tsx
// Example inside an AuthProvider or Post-Login useEffect
import { useEffect } from 'react';
import { applyTenantOverrides } from '@repo/core-i18n';
import { api } from '@/api';
export function AuthProvider({ children }) {
useEffect(() => {
async function fetchTenantConfig() {
try {
// 1. Fetch tenant-specific overrides from the API
const response = await api.get('/v1/tenant/i18n-config');
// 2. Inject into the engine.
// `deep: true` ensures only provided keys are overridden.
applyTenantOverrides(
response.data.namespace,
response.data.overrides
);
} catch (err) {
console.error("Failed to fetch tenant configuration", err);
}
}
fetchTenantConfig();
}, []);
return <>{children}</>;
}
```
### B. User Preference Sync (With Rollback)
When a logged-in user changes their language, we update the UI instantly, save it locally, and sync it to the backend. If the backend fails, the engine automatically rolls back.
```tsx
import { changeLanguage } from '@repo/core-i18n';
import { api } from '@/api';
const handleSwitch = async (newLng: string) => {
try {
await changeLanguage(newLng, async (lng) => {
// The core engine waits for this Promise.
// If it throws, the UI reverts to the previous language automatically.
await api.patch('/v1/user/profile', { language: lng });
});
toast.success('Language saved!');
} catch (err) {
toast.error('Sync failed. Reverted to previous language.');
}
};
```
> [!NOTE]
> For public pages (like `apps/landing`), simply call `changeLanguage('en')` without the callback function. It will update the UI and local storage instantly without hitting the network.
---
## 5. Backend API Contract (For Backend Engineers)
To support Dynamic Tenant Overrides, the backend must expose an endpoint (e.g., `GET /v1/tenant/i18n-config`).
### Identification
The backend **MUST identify the tenant via the `Authorization` (JWT) header**. The frontend will not send `tenantId` in the query payload to prevent spoofing.
### Expected JSON Response Format
The response must match the structural shape of the frontend dictionary. Because the frontend uses a **Deep Merge** strategy, the backend **only needs to return the specific keys the tenant wishes to override**.
If the frontend dictionary has `header.title` and `header.subtitle`, and the backend only sends `header.title`, the `subtitle` will safely remain intact.
**Example Request:**
`GET /v1/tenant/i18n-config`
*(Authorization: Bearer eyJhbG...)*
**Expected Response (200 OK):**
```json
{
"data": {
"namespace": "booking",
"overrides": {
"module_name": "Procurement",
"header": {
"title": "Procurement List"
}
}
}
}
```
-249
View File
@@ -1,249 +0,0 @@
[← Back to Root](../../README.md)
# Storage Engine (`@repo/core-storage`)
`@repo/core-storage` is the **Enterprise-grade, multi-tool storage engine** for the Eigen Monorepo.
It provides a unified set of strictly-typed, secure, and fault-tolerant storage mechanisms tailored for React applications. It enforces strict **Inversion of Control (IoC)**—the core engine knows absolutely nothing about your application's business domains; instead, the consuming apps inject their own configurations and types.
This package provides three primary storage solutions:
1. **Secure Local Storage** (Strict Key-Gatekeeping & AES encryption)
2. **Secure IndexedDB** (For larger key-value payloads)
3. **Offline-First PouchDB** (For document-oriented, bi-directional sync data)
---
## 🔒 Secure Key-Value Storage (LocalStorage & IndexedDB)
Browser storage is notoriously vulnerable to XSS attacks and pollution. The `LocalStorageService` and `IndexedDBService` implement a strict **Gatekeeper** pattern to solve this.
By forcing developers to register every key explicitly into either `plainTextKeys` or `encryptedKeys`, the engine guarantees:
1. No unapproved or rogue keys can ever be written or read (throws a `Security Exception`).
2. Highly sensitive tokens (e.g., JWTs) are automatically routed through the `@repo/utils` AES Encryption pipeline before touching the disk.
### Architecture
```mermaid
graph TD
%% ─── Styling Definitions (Dark-Mode Friendly Enterprise Palette) ───
classDef appEntity fill:#dbeafe,stroke:#3b82f6,stroke-width:2px,color:#1e3a8a
classDef coreEntity fill:#e2e8f0,stroke:#64748b,stroke-width:2px,color:#0f172a
classDef gatekeeper fill:#10b981,stroke:#047857,stroke-width:2px,color:#ffffff
classDef encrypt fill:#f59e0b,stroke:#b45309,stroke-width:2px,color:#ffffff
classDef error fill:#f43f5e,stroke:#be123c,stroke-width:2px,color:#ffffff
classDef database fill:#6366f1,stroke:#4338ca,stroke-width:2px,color:#ffffff
%% ─── Subgraphs ───
subgraph Apps ["apps/* (App Autonomy)"]
REG[[AppStorageKey Config]]
UI([React Components / API])
INST{{Storage Instances}}
end
subgraph Core ["@repo/core-storage"]
FAC[Factory: createStorage]
API[IStorageService API]
VAL{Runtime Gatekeeper}
ERR>Throws Security Exception]
ENC{{AES Encryption Pipeline}}
LOCAL[(LocalStorage Adapter)]
IDB[(IndexedDB Adapter)]
end
%% ─── Flow & Relationships ───
%% 1. Initialization Flow
REG -.->|Injects Keys & Config| FAC
FAC -.->|Returns| INST
%% 2. Runtime Execution Flow
UI ===>|getItem / setItem| INST
INST ---> API
API ---> VAL
%% 3. Gatekeeper Decision Tree
VAL -.->|Invalid Key| ERR
VAL ===>|Sensitive Key| ENC
VAL --->|Plain-text Key| LOCAL
VAL --->|Plain-text Key| IDB
%% 4. Post-Encryption Storage
ENC ===>|Encrypted Data| LOCAL
ENC ===>|Encrypted Data| IDB
%% ─── Apply Styles ───
class REG,UI,INST appEntity;
class FAC,API coreEntity;
class VAL gatekeeper;
class ENC encrypt;
class ERR error;
class LOCAL,IDB database;
%% ─── Subgraph Backgrounds (Transparent for Native GitHub Support) ───
style Apps fill:transparent,stroke:#818cf8,stroke-width:2px,stroke-dasharray: 5 5
style Core fill:transparent,stroke:#94a3b8,stroke-width:2px,stroke-dasharray: 5 5
```
### Usage & Implementation
```typescript
import { createLocalStorage, createIndexedDB } from '@repo/core-storage';
// 1. Define allowed keys (Strict Type Safety)
export type AppStorageKey = 'THEME' | 'ACCESS_TOKEN' | 'OFFLINE_CACHE';
// 2. Instantiate Local Storage
export const appStorage = createLocalStorage<AppStorageKey>({
plainTextKeys: new Set(['THEME']),
encryptedKeys: new Set(['ACCESS_TOKEN']), // Auto AES encrypted
});
// 3. Usage
await appStorage.setItem('ACCESS_TOKEN', 'ey...'); // Encrypted on disk
const theme = await appStorage.getItem('THEME'); // Plaintext on disk
```
### ✅ Do's and ❌ Don'ts
* **✅ DO use TypeScript Literal Types** for your storage keys (`type Keys = 'A' | 'B'`) to get full IntelliSense.
* **✅ DO place Session/Auth tokens** exclusively inside the `encryptedKeys` Set.
* **❌ DON'T use native `window.localStorage` directly** anywhere in your React components. It bypasses our encryption and gatekeeper logic.
* **❌ DON'T mix domain data.** Keep UI preferences (Theme, Sidebar state) in LocalStorage, and large datasets (Offline Caches) in IndexedDB.
---
## 🔄 Offline-First Document Storage (PouchDB & CouchDB)
For complex, document-oriented data that requires fault-tolerance, offline support, and bi-directional cloud synchronization, we use the `PouchDatabaseManager`.
### Architecture
```mermaid
graph TD
%% ─── Styling Definitions (Dark-Mode Friendly Enterprise Palette) ───
classDef appEntity fill:#dbeafe,stroke:#3b82f6,stroke-width:2px,color:#1e3a8a
classDef coreEntity fill:#e2e8f0,stroke:#64748b,stroke-width:2px,color:#0f172a
classDef localDb fill:#6366f1,stroke:#4338ca,stroke-width:2px,color:#ffffff
classDef remoteDb fill:#f59e0b,stroke:#b45309,stroke-width:2px,color:#ffffff
%% ─── Subgraphs ───
subgraph UI ["Consuming App (apps/*)"]
COMP([React Components / Forms])
end
subgraph CoreStorage ["@repo/core-storage Engine"]
MGR[PouchDatabaseManager Factory]
L_SALES[(Local PouchDB: Sales)]
L_INV[(Local PouchDB: Inventory)]
end
subgraph RemoteServer ["CouchDB Cluster"]
R_SALES[(Remote CouchDB: sales_db)]
R_INV[(Remote CouchDB: inventory_db)]
end
%% ─── Flow & Relationships ───
COMP ===>|Read / Write| L_SALES
COMP ===>|Read / Write| L_INV
MGR -.->|Instantiates Multi-DB| L_SALES
MGR -.->|Instantiates Multi-DB| L_INV
L_SALES <===>|Native Sync Live and Retry| R_SALES
L_INV <===>|Native Sync Live and Retry| R_INV
%% ─── Apply Styles ───
class COMP appEntity;
class MGR coreEntity;
class L_SALES,L_INV localDb;
class R_SALES,R_INV remoteDb;
%% ─── Subgraph Backgrounds (Transparent for Native GitHub Support) ───
style UI fill:transparent,stroke:#3b82f6,stroke-width:2px,stroke-dasharray: 5 5
style CoreStorage fill:transparent,stroke:#94a3b8,stroke-width:2px,stroke-dasharray: 5 5
style RemoteServer fill:transparent,stroke:#f59e0b,stroke-width:2px,stroke-dasharray: 5 5
```
### 1. Initialization (IoC Factory)
The `PouchDatabaseManager` acts as a central singleton. It registers and manages all database instances. If a remote URL is provided, it automatically handles background synchronization.
```typescript
import { PouchDatabaseManager } from '@repo/core-storage';
import type { Item } from './types';
export const dbManager = new PouchDatabaseManager();
export const itemDB = dbManager.register<Item>({
localName: 'items_db',
remoteUrl: 'http://admin:password@localhost:5984/items_db'
});
```
### 2. CRUD & MongoDB-style Queries
The registered database returns a `PouchDatabaseWrapper`. This wrapper fully abstracts the raw PouchDB API into clean, Promise-based helpers, auto-handling `_rev` conflicts.
| Method | Description |
|---|---|
| `create(data)` | Inserts a new document. Auto-generates `_id` if omitted. |
| `update(id, data)` | Auto-fetches the latest `_rev` to merge payloads cleanly. |
| `delete(id)` | Auto-fetches the latest `_rev` to safely remove the document. |
| `getAll()` | Retrieves all documents (filters out internal `_design/` docs). |
| `find(options)` | Queries using MongoDB-style selectors (via `pouchdb-find`). |
```typescript
// Example: Querying data using selectors
const expensiveItems = await itemDB.find({
selector: { price: { $gt: 100 }, category: 'electronics' }
});
```
### 3. Real-Time Reactivity (`onChange` Pub/Sub)
We implemented a **Publisher-Subscriber (Pub/Sub)** pattern inside the wrapper to handle real-time data changes efficiently. The wrapper maintains a *single* background connection to the changes feed and broadcasts events to all React subscribers.
```tsx
import { useEffect, useCallback, useState } from 'react';
import { itemDB } from '../core/db';
export function InventoryList() {
const [items, setItems] = useState([]);
const loadData = useCallback(async () => {
const data = await itemDB.getAll();
setItems(data);
}, []);
useEffect(() => {
loadData();
// Subscribe to background sync mutations
const unsubscribe = itemDB.onChange(() => {
loadData();
});
// CRITICAL: Prevent memory leaks
return () => unsubscribe();
}, [loadData]);
}
```
### ✅ Do's and ❌ Don'ts for PouchDB
* **✅ DO use `.onChange()`** to make your UI reactive to background cloud syncs.
* **✅ DO return the `unsubscribe` function** in your `useEffect` cleanup block to prevent severe memory leaks.
* **❌ DON'T use `db.raw.changes()`** inside your React components. It creates zombie WebSocket connections and tightly couples your UI to PouchDB's specific API.
* **❌ DON'T pass the `_rev` property** manually when updating or deleting. The wrapper's `update()` and `delete()` methods handle revision fetching automatically.
---
## ⚠️ Troubleshooting
### CouchDB CORS Infinite Retries
By providing a `remoteUrl`, the engine runs bi-directional sync in the background (`live: true, retry: true`). Fault tolerance is guaranteed: if CouchDB crashes, local reads/writes continue uninterrupted.
However, if your browser blocks CouchDB sync with a **CORS error**, PouchDB will misinterpret this as a network failure and enter an infinite retry loop, flooding your Network tab.
> **DO NOT try to fix this in the frontend Vite config or proxy!**
> This is strictly a CouchDB server policy issue. You must enable CORS directly on the CouchDB cluster (editing its `local.ini` or via its dashboard) to allow `origins`, `credentials`, and `headers`.
-80
View File
@@ -1,80 +0,0 @@
# @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`
-530
View File
@@ -1,530 +0,0 @@
# Core App Shell — Layout Engine Architecture & Usage Guide
> **Package**: `@repo/ui` · **Module Path**: `@repo/ui/components`
> **Dependencies**: React 18+, Mantine v8 (`AppShell`), `@mantine/hooks`
---
## Table of Contents
- [Overview](#overview)
- [Architecture](#architecture)
- [Composition Model](#composition-model)
- [File Structure](#file-structure)
- [API Reference](#api-reference)
- [CoreAppShellConfig](#coreappshellconfig)
- [Layout Variants](#layout-variants)
- [Features](#features)
- [Dimensions](#dimensions)
- [Slots](#slots)
- [Context API](#context-api)
- [Usage Examples](#usage-examples)
- [Minimal Setup](#minimal-setup)
- [Header-First with Utility Bar](#header-first-with-utility-bar)
- [Double Sidebar (Rail + Panel)](#double-sidebar-rail--panel)
- [Interactive Config Builder](#interactive-config-builder)
- [CorePageContainer](#corepagecontainer)
- [Design Decisions & Caveats](#design-decisions--caveats)
---
## Overview
The **Core App Shell** is a configuration-driven layout engine that wraps Mantine's `AppShell` component. It provides a single `<CoreAppShell>` component that renders enterprise-grade application frames — complete with headers, sidebars, aside panels, utility bars, and footers — controlled entirely through a declarative `config` object and slot-based content injection.
Key capabilities:
- **Three layout variants** — `header-first`, `sidebar-first`, and `top-nav` — covering the most common enterprise SaaS patterns
- **Double sidebar** — Google-style rail + contextual panel navigation
- **Smart defaults** — Slots auto-detect presence; no explicit feature flags needed for basic layouts
- **Responsive out-of-the-box** — Mobile drawer, desktop collapse, and mini-sidebar are all built-in
- **State persistence** — Optional `localStorage`-backed sidebar state via `@mantine/hooks`
- **Context API** — All layout toggle methods (`toggleMobile`, `toggleDesktop`, `setSidebarVariant`, etc.) are available to any descendant component via `useCoreAppShell()`
---
## Architecture
### Composition Model
The layout engine uses a **Provider → Inner** composition pattern:
```
CoreAppShell (Public API)
└── CoreAppShellProvider (Context — state management)
└── CoreAppShellInner (Layout rendering — consumes context)
└── Mantine <AppShell> (CSS Grid engine)
├── AppShell.Header ← slots.utilityBar + slots.header
├── AppShell.Navbar ← slots.sidebar | slots.sidebarRail + slots.sidebarPanel
├── AppShell.Main ← children
├── AppShell.Aside ← slots.aside
└── AppShell.Footer ← slots.footer
```
The outer `CoreAppShell` is a thin wrapper that instantiates the provider and passes config down. The inner component subscribes to context and derives all layout calculations (navbar width, header height, collapse states) from the live config + user interactions.
### File Structure
```
packages/ui/src/components/core-app-shell/
├── types.ts # All TypeScript interfaces and union types
├── core-app-shell-context.tsx # Context provider + useCoreAppShell hook
├── core-app-shell.tsx # Main component (Public API + Inner renderer)
├── core-page-container.tsx # Companion page-level content wrapper
└── index.ts # Barrel exports
```
**Source**: [`core-app-shell/`](../src/components/core-app-shell/)
---
## API Reference
### CoreAppShellConfig
The top-level configuration object that controls the entire layout:
```tsx
interface CoreAppShellConfig {
variant: LayoutVariant;
dimensions?: CoreAppShellDimensions;
features?: CoreAppShellFeatures;
}
```
| Property | Type | Required | Description |
|---|---|---|---|
| `variant` | `LayoutVariant` | ✅ | Determines the structural layout mode |
| `dimensions` | `CoreAppShellDimensions` | — | Override default pixel dimensions |
| `features` | `CoreAppShellFeatures` | — | Toggle optional layout regions and behaviors |
---
### Layout Variants
```tsx
type LayoutVariant = 'header-first' | 'sidebar-first' | 'top-nav';
```
| Variant | Mantine `layout` | Visual Description |
|---|---|---|
| `header-first` | `default` | Header spans the full viewport width. Sidebar and aside sit **below** the header, stretching to the bottom of the screen. Footer is inset between the sidebar and aside. This is the most common enterprise/dashboard pattern (e.g., Azure Portal, Jira). |
| `sidebar-first` | `alt` | Sidebar spans the full viewport height. Header sits **to the right** of the sidebar. Produces a "desktop application" feel (e.g., VS Code, Slack). Footer spans full width beneath the sidebar. |
| `top-nav` | `default` | Header-only layout with **no visible desktop sidebar**. The sidebar is hidden on desktop but remains accessible as a mobile drawer on small screens. Ideal for documentation sites or marketing pages. |
> [!IMPORTANT]
> When `variant` is set to `top-nav`, the desktop navbar is visually hidden via `collapsed.desktop: true` and width `0`. However, the `<AppShell.Navbar>` DOM element remains mounted with responsive width props so the mobile drawer continues to function. This is an intentional design choice to avoid conditional DOM removal.
---
### Features
```tsx
interface CoreAppShellFeatures {
desktopCollapseVariant?: DesktopCollapseVariant;
withUtilityBar?: boolean;
withAside?: boolean;
withFooter?: boolean;
withDoubleSidebar?: boolean;
persistState?: boolean;
zIndex?: number;
disabled?: boolean;
}
```
| Property | Type | Default | Description |
|---|---|---|---|
| `desktopCollapseVariant` | `'hide' \| 'mini'` | `'hide'` | **`hide`**: Sidebar slides out completely (collapsed width = 0). **`mini`**: Sidebar shrinks to `sidebarMiniWidth` showing only icons. |
| `withUtilityBar` | `boolean` | Auto-detected | Show the utility bar above the header. If omitted, the bar renders when a `utilityBar` slot is provided. Set explicitly to `false` to suppress. |
| `withAside` | `boolean` | Auto-detected | Show the right-hand aside panel. Same auto-detection logic as `withUtilityBar`. |
| `withFooter` | `boolean` | Auto-detected | Show the bottom footer. Same auto-detection logic. |
| `withDoubleSidebar` | `boolean` | `false` | Enable the **Rail + Panel** double sidebar mode. When `true`, the navbar renders `sidebarRail` and `sidebarPanel` slots instead of the single `sidebar` slot. |
| `persistState` | `boolean` | `true` (implied) | Persist sidebar variant (`expanded`/`mini`/`hidden`) to `localStorage` via `useLocalStorage`. Set to `false` for demos or ephemeral layouts. |
| `zIndex` | `number` | `200` | Base z-index passed to Mantine's `AppShell`. |
| `disabled` | `boolean` | `false` | Disables the AppShell layout entirely (renders children without structural chrome). |
> [!TIP]
> **Smart defaults**: You rarely need to set `withUtilityBar`, `withAside`, or `withFooter` explicitly. The engine auto-detects presence by checking if the corresponding slot is provided and truthy. Only set them to `false` when you want to **suppress** a slot that is being passed.
---
### Dimensions
```tsx
interface CoreAppShellDimensions {
utilityBarHeight?: number | string;
headerHeight?: number | string;
sidebarWidth?: number | string;
sidebarMiniWidth?: number | string;
sidebarRailWidth?: number | string;
asideWidth?: number | string;
}
```
| Property | Type | Default | Description |
|---|---|---|---|
| `utilityBarHeight` | `number \| string` | `32` | Height of the utility bar strip above the header |
| `headerHeight` | `number \| string` | `60` | Height of the main header |
| `sidebarWidth` | `number \| string` | `260` | Width of the expanded sidebar |
| `sidebarMiniWidth` | `number \| string` | `80` | Width of the sidebar in `mini` collapse mode |
| `sidebarRailWidth` | `number \| string` | `54` | Width of the icon rail in double-sidebar mode |
| `asideWidth` | `number \| string` | `260` | Width of the right-hand aside panel |
> [!NOTE]
> All dimension values accept both pixel numbers (e.g., `260`) and CSS strings (e.g., `'20rem'`). When both `headerHeight` and `utilityBarHeight` are numbers, they are summed directly. When either is a string, the engine wraps them in a `calc()` expression automatically.
---
### Slots
Content is injected via the `slots` prop — a flat object of named `ReactNode` values:
```tsx
interface CoreAppShellSlots {
utilityBar?: ReactNode;
header?: ReactNode;
sidebar?: ReactNode;
sidebarMobile?: ReactNode;
sidebarRail?: ReactNode;
sidebarPanel?: ReactNode;
aside?: ReactNode;
footer?: ReactNode;
}
```
| Slot | Location | Notes |
|---|---|---|
| `utilityBar` | Above the header, hidden on mobile (`display: none` below `sm`) | Typically used for environment banners, announcements, or top-level links. |
| `header` | Main application header | Must contain its own `<Burger>` for mobile toggle (use `useCoreAppShell()` context). |
| `sidebar` | Desktop navbar body (single-sidebar mode) | Ignored when `withDoubleSidebar` is `true` — use `sidebarRail` + `sidebarPanel` instead. |
| `sidebarMobile` | Mobile drawer content | Falls back to `sidebar` if not provided. Use this to render a simplified mobile-specific navigation. |
| `sidebarRail` | Narrow icon rail (double-sidebar mode) | Only rendered when `withDoubleSidebar` is `true`. Separated from `sidebarPanel` by a 1px border. |
| `sidebarPanel` | Contextual panel beside the rail (double-sidebar mode) | Collapsible via `toggleNavbarPanel()`. Only rendered when `withDoubleSidebar` is `true` and `navbarPanelOpened` is `true`. |
| `aside` | Right-hand panel | Collapsible via `toggleAside()`. Only rendered when `withAside` is enabled. |
| `footer` | Bottom application footer | In `header-first` mode, the footer is inset between sidebar and aside. In `sidebar-first` mode, it spans the full width. |
---
### Context API
The `useCoreAppShell()` hook provides access to all layout state and toggle methods from any descendant component:
```tsx
import { useCoreAppShell } from '@repo/ui/components';
```
| Property / Method | Type | Description |
|---|---|---|
| `mobileOpened` | `boolean` | Whether the mobile drawer is currently open |
| `desktopOpened` | `boolean` | Whether the desktop sidebar is expanded (only applies when `desktopCollapseVariant` is `'hide'`) |
| `sidebarVariant` | `SidebarVariant` | Current sidebar mode: `'expanded'` \| `'mini'` \| `'hidden'` |
| `asideOpened` | `boolean` | Whether the aside panel is currently visible |
| `navbarPanelOpened` | `boolean` | Whether the secondary panel in double-sidebar mode is expanded |
| `config` | `CoreAppShellConfig` | Read-only access to the current layout configuration |
| `toggleMobile()` | `() => void` | Toggle the mobile drawer open/closed |
| `toggleDesktop()` | `() => void` | Toggle the desktop sidebar open/closed |
| `toggleAside()` | `() => void` | Toggle the aside panel visibility |
| `toggleNavbarPanel()` | `() => void` | Toggle the double-sidebar panel open/closed |
| `setSidebarVariant()` | `(variant: SidebarVariant) => void` | Programmatically set the sidebar to `'expanded'`, `'mini'`, or `'hidden'` |
> [!WARNING]
> `useCoreAppShell()` **must** be called from within a `<CoreAppShell>` subtree. Calling it outside the provider will throw: `"useCoreAppShell must be used within CoreAppShellProvider"`. If you need context access in the header slot, pass a component (not inline JSX) so it mounts inside the provider tree.
---
## Usage Examples
### Minimal Setup
The simplest possible layout — a header and sidebar with all defaults:
```tsx
import { CoreAppShell, CoreAppShellConfig, useCoreAppShell } from '@repo/ui/components';
import { Group, Text, Box, Stack, Button, Burger } from '@repo/ui/components';
function MyHeader() {
const { mobileOpened, toggleMobile } = useCoreAppShell();
return (
<Group h="100%" px="md">
<Burger opened={mobileOpened} onClick={toggleMobile} hiddenFrom="sm" size="sm" />
<Text fw={700}>My Application</Text>
</Group>
);
}
const config: CoreAppShellConfig = {
variant: 'header-first',
};
function App() {
return (
<CoreAppShell
config={config}
slots={{
header: <MyHeader />,
sidebar: (
<Stack p="md" gap="xs">
<Button variant="subtle" fullWidth>Dashboard</Button>
<Button variant="subtle" fullWidth>Settings</Button>
</Stack>
),
}}
>
<Text>Main content area</Text>
</CoreAppShell>
);
}
```
---
### Header-First with Utility Bar
A full enterprise layout with utility bar, aside, and footer:
```tsx
import { CoreAppShell, CoreAppShellConfig, useCoreAppShell } from '@repo/ui/components';
import { Group, Text, Box, Burger } from '@repo/ui/components';
function AppHeader() {
const { mobileOpened, toggleMobile } = useCoreAppShell();
return (
<Group h="100%" px="md" justify="space-between">
<Group>
<Burger opened={mobileOpened} onClick={toggleMobile} hiddenFrom="sm" size="sm" />
<Text fw={700} size="lg">Enterprise Dashboard</Text>
</Group>
</Group>
);
}
const config: CoreAppShellConfig = {
variant: 'header-first',
features: {
desktopCollapseVariant: 'hide',
persistState: true,
},
dimensions: {
headerHeight: 60,
utilityBarHeight: 32,
sidebarWidth: 280,
asideWidth: 300,
},
};
function App() {
return (
<CoreAppShell
config={config}
slots={{
utilityBar: (
<Group h="100%" px="md" justify="flex-end">
<Text size="xs">v2.4.1 · Production</Text>
</Group>
),
header: <AppHeader />,
sidebar: <MySidebar />,
aside: <MyAside />,
footer: (
<Group h="100%" px="md">
<Text size="sm">© 2026 Acme Corp</Text>
</Group>
),
}}
>
<MyPageContent />
</CoreAppShell>
);
}
```
---
### Double Sidebar (Rail + Panel)
Google-style navigation with an icon rail and a collapsible contextual panel:
```tsx
import { CoreAppShell, CoreAppShellConfig, useCoreAppShell } from '@repo/ui/components';
import { Stack, Box, Text, Burger, Group } from '@repo/ui/components';
import { Home, Settings, BarChart2 } from 'lucide-react';
function AppHeader() {
const { mobileOpened, toggleMobile } = useCoreAppShell();
return (
<Group h="100%" px="md">
<Burger opened={mobileOpened} onClick={toggleMobile} hiddenFrom="sm" size="sm" />
<Text fw={700}>Admin Panel</Text>
</Group>
);
}
const config: CoreAppShellConfig = {
variant: 'sidebar-first',
features: {
withDoubleSidebar: true,
},
dimensions: {
sidebarRailWidth: 54,
sidebarWidth: 260,
},
};
function App() {
return (
<CoreAppShell
config={config}
slots={{
header: <AppHeader />,
sidebarRail: (
<Stack align="center" gap="lg" pt="md">
<Home size={24} />
<BarChart2 size={24} />
<Settings size={24} />
</Stack>
),
sidebarPanel: (
<Box p="md">
<Text fw={700} mb="sm">Navigation</Text>
{/* Contextual links based on active rail icon */}
</Box>
),
sidebarMobile: (
<Box p="md">
<Text fw={700}>Mobile Nav</Text>
{/* Simplified mobile navigation */}
</Box>
),
}}
>
<Text>Main content</Text>
</CoreAppShell>
);
}
```
> [!NOTE]
> When `withDoubleSidebar` is `true`, the `sidebar` slot is ignored on desktop. The navbar renders `sidebarRail` (fixed-width icon column) and `sidebarPanel` (collapsible contextual panel) side-by-side. On mobile, `sidebarMobile` takes priority, falling back to `sidebar` if not provided.
---
### Interactive Config Builder
The showcase demo at `apps/web/src/apps/showcase/shell-demo/` demonstrates a live, interactive config builder where every feature toggle and variant switch updates the layout in real-time. The key pattern is managing `config` state externally and passing it as a prop:
```tsx
import { useState, useMemo } from 'react';
import { CoreAppShell, CoreAppShellConfig, LayoutVariant, DesktopCollapseVariant } from '@repo/ui/components';
function ShellDemo() {
const [layoutVariant, setLayoutVariant] = useState<LayoutVariant>('header-first');
const [collapseVariant, setCollapseVariant] = useState<DesktopCollapseVariant>('hide');
const [withDoubleSidebar, setWithDoubleSidebar] = useState(false);
const config: CoreAppShellConfig = useMemo(() => ({
variant: layoutVariant,
features: {
desktopCollapseVariant: collapseVariant,
withDoubleSidebar,
persistState: false,
},
}), [layoutVariant, collapseVariant, withDoubleSidebar]);
return (
<CoreAppShell config={config} slots={{ header: <MyHeader />, sidebar: <MySidebar /> }}>
{/* Config controls live here — they can use useCoreAppShell() for toggle methods */}
</CoreAppShell>
);
}
```
---
## CorePageContainer
A companion component for structuring page-level content within the `<AppShell.Main>` area. It provides a sticky page header and a contained, padded content region.
```tsx
import { CorePageContainer } from '@repo/ui/components';
```
### Props
```tsx
interface CorePageContainerProps extends ContainerProps {
headerSlot?: ReactNode;
children: ReactNode;
stickyHeader?: boolean;
}
```
| Prop | Type | Default | Description |
|---|---|---|---|
| `headerSlot` | `ReactNode` | — | Page-level header content (title, breadcrumbs, action buttons). Rendered above the main content with a bottom border. |
| `stickyHeader` | `boolean` | `false` | When `true`, the page header sticks to the top of the scroll area, offset by the AppShell header height via `var(--app-shell-header-offset)`. |
| `px` | `MantineSpacing` | `'md'` | Horizontal padding for both the header and content areas |
| `py` | `MantineSpacing` | `'md'` | Vertical padding for both the header and content areas |
| _...rest_ | `ContainerProps` | — | All other Mantine `Container` props are forwarded to the content region |
### Usage
```tsx
<CoreAppShell config={config} slots={slots}>
<CorePageContainer
stickyHeader
headerSlot={
<Group justify="space-between">
<Text component="h1" size="xl" fw={700}>Users</Text>
<Button>Add User</Button>
</Group>
}
>
<UserTable />
</CorePageContainer>
</CoreAppShell>
```
---
## Design Decisions & Caveats
### Mobile Navbar Lifecycle
The `<AppShell.Navbar>` DOM element is **always mounted**, even when the layout variant is `top-nav`. The desktop content is hidden via `visibleFrom="sm"` and mobile content via `hiddenFrom="sm"`. This ensures Mantine's native drawer engine works correctly on mobile without conditional DOM removal breaking the transition animations.
### Footer Positioning in `header-first` Mode
In `header-first` mode, the footer is **inset** between the sidebar and aside using CSS custom properties:
```css
left: var(--app-shell-navbar-offset, 0px);
right: var(--app-shell-aside-offset, 0px);
```
In `sidebar-first` mode, the footer spans the full viewport width (`left: 0; right: 0`).
### Z-Index Strategy
| Element | `header-first` | `sidebar-first` |
|---|---|---|
| AppShell (base) | `200` (default) | `200` (default) |
| Navbar | `105` | `100` |
| Aside | `105` | `100` |
| Footer | `100` | `100` |
The elevated `105` z-index for navbar/aside in `header-first` mode ensures they render above the footer, which is positioned at `100`.
### Sidebar Width Calculation
The navbar width is dynamically computed based on multiple state variables:
```
navbarWidth.sm =
isTopNav → 0
isDoubleSidebar + panelOpen → sidebarWidth
isDoubleSidebar + panelClosed → sidebarRailWidth
sidebarVariant === 'mini' → sidebarMiniWidth
default → sidebarWidth
```
On mobile and `xs` breakpoints, the width is always `100%` and `sidebarWidth` respectively, regardless of variant.
-915
View File
@@ -1,915 +0,0 @@
# 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.