refactor: migrate docs-dev from storybook to vitepress config and update devcontainer configuration
This commit is contained in:
@@ -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` |
|
||||
@@ -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;
|
||||
}
|
||||
```
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -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`.
|
||||
@@ -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`
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user