refactor: improve code formatting and consistency across multiple files

- Standardized import statements and removed unnecessary line breaks for better readability in various components.
- Enhanced error handling and logging in the useElectronPrinter hook.
- Updated sample data formatting in AgGridShowcase for improved clarity.
- Refactored JSX elements for consistent indentation and structure in LandingSample, AuthPage, and EventsPage components.
- Consolidated and simplified conditional rendering logic in several components.

These changes aim to enhance code maintainability and readability throughout the project.
This commit is contained in:
shancheas
2026-08-25 17:50:48 +07:00
parent f2f0be111a
commit 67ae5b6c11
94 changed files with 1742 additions and 849 deletions
+1 -2
View File
@@ -52,8 +52,7 @@ console.log(` Destination : ${DEST_DIR}`);
// ── Validate ───────────────────────────────────────────────────
if (!existsSync(SOURCE_DIR)) {
console.error(
`\n❌ Build output not found at: ${SOURCE_DIR}\n` +
` Run "pnpm build --filter=${targetApp}" first.\n`,
`\n❌ Build output not found at: ${SOURCE_DIR}\n` + ` Run "pnpm build --filter=${targetApp}" first.\n`,
);
process.exit(1);
}
+3 -14
View File
@@ -1,11 +1,4 @@
import {
app,
shell,
BrowserWindow,
ipcMain,
protocol,
session,
} from 'electron';
import { app, shell, BrowserWindow, ipcMain, protocol, session } from 'electron';
import { autoUpdater } from 'electron-updater';
import { join, extname, normalize } from 'path';
import { readFileSync, existsSync, statSync } from 'fs';
@@ -227,8 +220,7 @@ function setupCorsBypass(): void {
// a "normal" request or a null origin
if (
requestHeaders['Origin'] &&
(requestHeaders['Origin'].startsWith('app://') ||
requestHeaders['Origin'].startsWith('file://'))
(requestHeaders['Origin'].startsWith('app://') || requestHeaders['Origin'].startsWith('file://'))
) {
delete requestHeaders['Origin'];
}
@@ -427,10 +419,7 @@ if (!gotTheLock) {
app.on('web-contents-created', (_event, contents) => {
contents.on('will-navigate', (event, url) => {
// Allow navigation within the app protocol and dev server
if (
url.startsWith('app://') ||
(IS_DEV && url.startsWith(DEV_SERVER_URL))
) {
if (url.startsWith('app://') || (IS_DEV && url.startsWith(DEV_SERVER_URL))) {
return;
}
event.preventDefault();
+3 -3
View File
@@ -1,6 +1,6 @@
import DefaultTheme from 'vitepress/theme'
import './custom.css'
import DefaultTheme from 'vitepress/theme';
import './custom.css';
export default {
extends: DefaultTheme,
}
};
@@ -29,7 +29,7 @@ export default function LandingSample() {
telemetryContext: {
customSpanName: 'public.content.fetch',
tags: {
'feature': 'landing',
feature: 'landing',
'ui.component': 'LandingSample',
'ui.action': 'content_fetch',
},
@@ -74,14 +74,19 @@ export default function LandingSample() {
{loading ? 'Fetching…' : 'Test Public Fetch'}
</button>
{error && (
<pre style={{ color: '#ef4444', marginTop: 16, whiteSpace: 'pre-wrap' }}>
{error}
</pre>
)}
{error && <pre style={{ color: '#ef4444', marginTop: 16, whiteSpace: 'pre-wrap' }}> {error}</pre>}
{result && (
<pre style={{ marginTop: 16, background: '#1e1e2e', color: '#a6e3a1', padding: 16, borderRadius: 8, overflow: 'auto' }}>
<pre
style={{
marginTop: 16,
background: '#1e1e2e',
color: '#a6e3a1',
padding: 16,
borderRadius: 8,
overflow: 'auto',
}}
>
{JSON.stringify(result, null, 2)}
</pre>
)}
+23 -19
View File
@@ -21,25 +21,29 @@ export default function App() {
const colorScheme = useThemeStore((s) => s.colorScheme);
const [density, setDensity] = useState<DensityType>('compact');
const router = useMemo(() => createBrowserRouter([
{
path: "/",
element: <ShowcaseLayout density={density} setDensity={setDensity} />,
children: [
{ index: true, element: <Navigate to="/ui-components" replace /> },
{ path: "ui-components", element: <UiComponentsPage /> },
{ path: "forms", element: <FormsPage /> },
{ path: "storage", element: <StoragePage /> },
{ path: "events", element: <EventsPage /> },
{ path: "hardware", element: <HardwarePage /> },
{ path: "rbac", element: <RbacPage /> },
{ path: "auth", element: <AuthPage /> },
{ path: "action-tools", element: <ActionToolsPage /> },
{ path: "ag-grid", element: <AgGridPage /> },
]
},
{ path: "/shell-demo", element: <ShellDemoPage /> }
]), [density]);
const router = useMemo(
() =>
createBrowserRouter([
{
path: '/',
element: <ShowcaseLayout density={density} setDensity={setDensity} />,
children: [
{ index: true, element: <Navigate to="/ui-components" replace /> },
{ path: 'ui-components', element: <UiComponentsPage /> },
{ path: 'forms', element: <FormsPage /> },
{ path: 'storage', element: <StoragePage /> },
{ path: 'events', element: <EventsPage /> },
{ path: 'hardware', element: <HardwarePage /> },
{ path: 'rbac', element: <RbacPage /> },
{ path: 'auth', element: <AuthPage /> },
{ path: 'action-tools', element: <ActionToolsPage /> },
{ path: 'ag-grid', element: <AgGridPage /> },
],
},
{ path: '/shell-demo', element: <ShellDemoPage /> },
]),
[density],
);
return (
<ThemeProvider colorScheme={colorScheme} density={density}>
@@ -74,30 +74,27 @@ export function useElectronPrinter(): UseElectronPrinterReturn {
}
}, []);
const print = useCallback(
async (options?: ElectronPrintOptions): Promise<ElectronPrintResult> => {
if (!window.electronAPI) {
return { success: false, failureReason: 'Not running in Electron' };
}
const print = useCallback(async (options?: ElectronPrintOptions): Promise<ElectronPrintResult> => {
if (!window.electronAPI) {
return { success: false, failureReason: 'Not running in Electron' };
}
setLoading(true);
setError(null);
try {
const result = await window.electronAPI.print(options);
if (!result.success && result.failureReason) {
setError(result.failureReason);
}
return result;
} catch (err) {
const message = err instanceof Error ? err.message : 'Print failed';
setError(message);
return { success: false, failureReason: message };
} finally {
setLoading(false);
setLoading(true);
setError(null);
try {
const result = await window.electronAPI.print(options);
if (!result.success && result.failureReason) {
setError(result.failureReason);
}
},
[],
);
return result;
} catch (err) {
const message = err instanceof Error ? err.message : 'Print failed';
setError(message);
return { success: false, failureReason: message };
} finally {
setLoading(false);
}
}, []);
return {
printers,
@@ -3,14 +3,7 @@ import { useIsElectron } from './use-is-electron';
// ─── Types ──────────────────────────────────────────────────────
export type UpdateStatus =
| 'idle'
| 'checking'
| 'available'
| 'not-available'
| 'downloading'
| 'ready'
| 'error';
export type UpdateStatus = 'idle' | 'checking' | 'available' | 'not-available' | 'downloading' | 'ready' | 'error';
export interface UseElectronUpdaterReturn {
/** Current status of the auto-updater lifecycle */
+38 -16
View File
@@ -34,17 +34,28 @@ export default function ShowcaseLayout({ density, setDensity }: ShowcaseLayoutPr
const currentPath = location.pathname.replace('/', '');
switch (currentPath) {
case 'rbac': return 'Role-Based Access Control and Permissions';
case 'storage': return 'Offline-First PouchDB Synchronization';
case 'auth': return 'Authentication & Security Layers';
case 'ui-components': return 'Theme, Typography, Forms & Data Grids';
case 'forms': return 'Enterprise Form Engine & Zod Validation';
case 'events': return 'Global Event Bus Synchronization';
case 'hardware': return 'Hardware Integration & Printers';
case 'layout-engine': return 'Core Layout Engine & Variants';
case 'action-tools': return 'Showcase for PageActions and RowActions components';
case 'ag-grid': return 'Enterprise Data Grid with Mantine Theme Integration';
default: return 'Architecture Showcase';
case 'rbac':
return 'Role-Based Access Control and Permissions';
case 'storage':
return 'Offline-First PouchDB Synchronization';
case 'auth':
return 'Authentication & Security Layers';
case 'ui-components':
return 'Theme, Typography, Forms & Data Grids';
case 'forms':
return 'Enterprise Form Engine & Zod Validation';
case 'events':
return 'Global Event Bus Synchronization';
case 'hardware':
return 'Hardware Integration & Printers';
case 'layout-engine':
return 'Core Layout Engine & Variants';
case 'action-tools':
return 'Showcase for PageActions and RowActions components';
case 'ag-grid':
return 'Enterprise Data Grid with Mantine Theme Integration';
default:
return 'Architecture Showcase';
}
};
@@ -58,8 +69,12 @@ export default function ShowcaseLayout({ density, setDensity }: ShowcaseLayoutPr
>
<AppShell.Navbar p="md">
<Box mb="xl">
<Title order={3} c="brand.7">Eigen ERP</Title>
<Text size="xs" c="dimmed">Architecture Showcase</Text>
<Title order={3} c="brand.7">
Eigen ERP
</Title>
<Text size="xs" c="dimmed">
Architecture Showcase
</Text>
</Box>
<Box style={{ flex: 1 }}>
{navItems.map((item) => (
@@ -74,7 +89,7 @@ export default function ShowcaseLayout({ density, setDensity }: ShowcaseLayoutPr
/>
))}
</Box>
<Box mt="auto" pt="md" style={{ borderTop: '1px solid var(--mantine-color-default-border)' }}>
<Select
label="Color Scheme"
@@ -101,11 +116,18 @@ export default function ShowcaseLayout({ density, setDensity }: ShowcaseLayoutPr
</AppShell.Navbar>
<AppShell.Main style={{ display: 'flex', flexDirection: 'column', height: '100vh', overflow: 'hidden' }}>
<Paper p="md" radius={0} withBorder style={{ borderTop: 0, borderLeft: 0, borderRight: 0, zIndex: 10, flexShrink: 0 }}>
<Paper
p="md"
radius={0}
withBorder
style={{ borderTop: 0, borderLeft: 0, borderRight: 0, zIndex: 10, flexShrink: 0 }}
>
<Group justify="space-between">
<Box>
<Title order={3}>Architecture Showcase</Title>
<Text size="sm" c="dimmed">{getSubtitle()}</Text>
<Text size="sm" c="dimmed">
{getSubtitle()}
</Text>
</Box>
<Select
w={180}
+160 -17
View File
@@ -20,18 +20,162 @@ interface OrderRow {
}
const SAMPLE_DATA: OrderRow[] = [
{ id: '1', orderCode: 'ORD-2026-001', customer: 'Acme Corp', email: 'acme@example.com', product: 'Widget A', quantity: 120, unitPrice: 24.5, total: 2940, status: 'Delivered', createdAt: '2026-01-15', region: 'North America' },
{ id: '2', orderCode: 'ORD-2026-002', customer: 'Globex Inc', email: 'info@globex.com', product: 'Widget B', quantity: 50, unitPrice: 89.99, total: 4499.5, status: 'Shipped', createdAt: '2026-02-03', region: 'Europe' },
{ id: '3', orderCode: 'ORD-2026-003', customer: 'Initech', email: 'orders@initech.com', product: 'Gadget Pro', quantity: 200, unitPrice: 15.0, total: 3000, status: 'Processing', createdAt: '2026-03-21', region: 'Asia Pacific' },
{ id: '4', orderCode: 'ORD-2026-004', customer: 'Umbrella Ltd', email: 'sales@umbrella.co', product: 'Widget A', quantity: 75, unitPrice: 24.5, total: 1837.5, status: 'Pending', createdAt: '2026-04-10', region: 'Europe' },
{ id: '5', orderCode: 'ORD-2026-005', customer: 'Stark Industries', email: 'tony@stark.io', product: 'Gadget Elite', quantity: 10, unitPrice: 499.0, total: 4990, status: 'Delivered', createdAt: '2026-04-18', region: 'North America' },
{ id: '6', orderCode: 'ORD-2026-006', customer: 'Wayne Enterprises', email: 'bruce@wayne.com', product: 'Widget C', quantity: 300, unitPrice: 12.75, total: 3825, status: 'Draft', createdAt: '2026-05-02', region: 'North America' },
{ id: '7', orderCode: 'ORD-2026-007', customer: 'Cyberdyne', email: 'info@cyberdyne.jp', product: 'Gadget Pro', quantity: 150, unitPrice: 15.0, total: 2250, status: 'Cancelled', createdAt: '2026-05-15', region: 'Asia Pacific' },
{ id: '8', orderCode: 'ORD-2026-008', customer: 'Oscorp', email: 'orders@oscorp.com', product: 'Widget B', quantity: 90, unitPrice: 89.99, total: 8099.1, status: 'Shipped', createdAt: '2026-06-01', region: 'North America' },
{ id: '9', orderCode: 'ORD-2026-009', customer: 'LexCorp', email: 'lex@lexcorp.com', product: 'Gadget Elite', quantity: 5, unitPrice: 499.0, total: 2495, status: 'Processing', createdAt: '2026-06-12', region: 'Europe' },
{ id: '10', orderCode: 'ORD-2026-010', customer: 'Pied Piper', email: 'richard@piedpiper.io', product: 'Widget A', quantity: 500, unitPrice: 24.5, total: 12250, status: 'Pending', createdAt: '2026-06-20', region: 'North America' },
{ id: '11', orderCode: 'ORD-2026-011', customer: 'Hooli', email: 'gavin@hooli.com', product: 'Gadget Pro', quantity: 1000, unitPrice: 15.0, total: 15000, status: 'Delivered', createdAt: '2026-07-01', region: 'North America' },
{ id: '12', orderCode: 'ORD-2026-012', customer: 'Massive Dynamic', email: 'nina@massive.com', product: 'Widget C', quantity: 60, unitPrice: 12.75, total: 765, status: 'Shipped', createdAt: '2026-07-08', region: 'Europe' },
{
id: '1',
orderCode: 'ORD-2026-001',
customer: 'Acme Corp',
email: 'acme@example.com',
product: 'Widget A',
quantity: 120,
unitPrice: 24.5,
total: 2940,
status: 'Delivered',
createdAt: '2026-01-15',
region: 'North America',
},
{
id: '2',
orderCode: 'ORD-2026-002',
customer: 'Globex Inc',
email: 'info@globex.com',
product: 'Widget B',
quantity: 50,
unitPrice: 89.99,
total: 4499.5,
status: 'Shipped',
createdAt: '2026-02-03',
region: 'Europe',
},
{
id: '3',
orderCode: 'ORD-2026-003',
customer: 'Initech',
email: 'orders@initech.com',
product: 'Gadget Pro',
quantity: 200,
unitPrice: 15.0,
total: 3000,
status: 'Processing',
createdAt: '2026-03-21',
region: 'Asia Pacific',
},
{
id: '4',
orderCode: 'ORD-2026-004',
customer: 'Umbrella Ltd',
email: 'sales@umbrella.co',
product: 'Widget A',
quantity: 75,
unitPrice: 24.5,
total: 1837.5,
status: 'Pending',
createdAt: '2026-04-10',
region: 'Europe',
},
{
id: '5',
orderCode: 'ORD-2026-005',
customer: 'Stark Industries',
email: 'tony@stark.io',
product: 'Gadget Elite',
quantity: 10,
unitPrice: 499.0,
total: 4990,
status: 'Delivered',
createdAt: '2026-04-18',
region: 'North America',
},
{
id: '6',
orderCode: 'ORD-2026-006',
customer: 'Wayne Enterprises',
email: 'bruce@wayne.com',
product: 'Widget C',
quantity: 300,
unitPrice: 12.75,
total: 3825,
status: 'Draft',
createdAt: '2026-05-02',
region: 'North America',
},
{
id: '7',
orderCode: 'ORD-2026-007',
customer: 'Cyberdyne',
email: 'info@cyberdyne.jp',
product: 'Gadget Pro',
quantity: 150,
unitPrice: 15.0,
total: 2250,
status: 'Cancelled',
createdAt: '2026-05-15',
region: 'Asia Pacific',
},
{
id: '8',
orderCode: 'ORD-2026-008',
customer: 'Oscorp',
email: 'orders@oscorp.com',
product: 'Widget B',
quantity: 90,
unitPrice: 89.99,
total: 8099.1,
status: 'Shipped',
createdAt: '2026-06-01',
region: 'North America',
},
{
id: '9',
orderCode: 'ORD-2026-009',
customer: 'LexCorp',
email: 'lex@lexcorp.com',
product: 'Gadget Elite',
quantity: 5,
unitPrice: 499.0,
total: 2495,
status: 'Processing',
createdAt: '2026-06-12',
region: 'Europe',
},
{
id: '10',
orderCode: 'ORD-2026-010',
customer: 'Pied Piper',
email: 'richard@piedpiper.io',
product: 'Widget A',
quantity: 500,
unitPrice: 24.5,
total: 12250,
status: 'Pending',
createdAt: '2026-06-20',
region: 'North America',
},
{
id: '11',
orderCode: 'ORD-2026-011',
customer: 'Hooli',
email: 'gavin@hooli.com',
product: 'Gadget Pro',
quantity: 1000,
unitPrice: 15.0,
total: 15000,
status: 'Delivered',
createdAt: '2026-07-01',
region: 'North America',
},
{
id: '12',
orderCode: 'ORD-2026-012',
customer: 'Massive Dynamic',
email: 'nina@massive.com',
product: 'Widget C',
quantity: 60,
unitPrice: 12.75,
total: 765,
status: 'Shipped',
createdAt: '2026-07-08',
region: 'Europe',
},
];
/* ─── Status Badge Renderer ─────────────────────────────────────── */
@@ -164,11 +308,10 @@ export default function AgGridShowcase() {
AG Grid Enterprise Mantine Integration
</Title>
<Text size="sm" c="dimmed" mb="md">
This showcase demonstrates AG Grid integrated with Mantine's theme system. The grid
automatically adapts to dark/light mode changes, inherits brand colors, typography, and
border radius from the Mantine theme — all via AG Grid's built-in CSS variable system with
zero custom stylesheets. Toggle the color scheme in the Theme Controls above to see it in
action.
This showcase demonstrates AG Grid integrated with Mantine's theme system. The grid automatically adapts to
dark/light mode changes, inherits brand colors, typography, and border radius from the Mantine theme — all
via AG Grid's built-in CSS variable system with zero custom stylesheets. Toggle the color scheme in the
Theme Controls above to see it in action.
</Text>
<Group>
<NumberInput
+3 -1
View File
@@ -5,7 +5,9 @@ export default function AuthPage() {
<Container size="xl" m={0} p={0}>
<Stack gap="xl">
<Card withBorder shadow="sm" radius="md" p="md">
<Title order={4} mb="md">Auth & Security</Title>
<Title order={4} mb="md">
Auth & Security
</Title>
<Text c="dimmed">Auth Demo Component Coming Soon...</Text>
</Card>
</Stack>
@@ -1,12 +1,5 @@
import { useState, useRef, useEffect } from 'react';
import {
Card,
Title,
Text,
Stack,
Badge,
Divider,
} from '@repo/ui/components';
import { Card, Title, Text, Stack, Badge, Divider } from '@repo/ui/components';
// ── Showcase Components ──────────────────────────────────────────
import { CashierUI } from './printer/cashier.ui';
@@ -65,8 +58,8 @@ export default function EventsDemoPage() {
🖨 Showcase 1: Cross-Platform Printer Abstraction
</Title>
<Text size="sm" c="dimmed" mb="md">
The CashierUI publishes a <code>{DEVICE_EVENTS.PRINT_RECEIPT}</code> event.
The PrinterListener listens for it and simulates interacting with a physical printer.
The CashierUI publishes a <code>{DEVICE_EVENTS.PRINT_RECEIPT}</code> event. The PrinterListener listens for it
and simulates interacting with a physical printer.
</Text>
{/* Headless listener — renders nothing visible */}
@@ -110,9 +103,8 @@ export default function EventsDemoPage() {
📈 Showcase 2: High-Frequency Real-Time Data (50 updates/sec)
</Title>
<Text size="sm" c="dimmed" mb="md">
A mock WebSocket fires <code>WS:STOCK_UPDATE</code> every 20ms.
Each StockRow subscribes to the global event but only updates when{' '}
<code>payload.id === row.id</code>. The parent grid never re-renders.
A mock WebSocket fires <code>WS:STOCK_UPDATE</code> every 20ms. Each StockRow subscribes to the global event
but only updates when <code>payload.id === row.id</code>. The parent grid never re-renders.
</Text>
<LiveStockGrid />
@@ -126,8 +118,8 @@ export default function EventsDemoPage() {
💾 Showcase 3: Auth/Profile Sync with <code>@repo/core-storage</code>
</Title>
<Text size="sm" c="dimmed" mb="md">
ProfileSettingsUI publishes <code>{AUTH_EVENTS.PROFILE_UPDATED}</code>.
StorageSyncListener silently catches it in the background and saves to IndexedDB via <code>secureIndexedDB</code>.
ProfileSettingsUI publishes <code>{AUTH_EVENTS.PROFILE_UPDATED}</code>. StorageSyncListener silently catches
it in the background and saves to IndexedDB via <code>secureIndexedDB</code>.
</Text>
{/* Headless listener — renders nothing visible */}
@@ -1,15 +1,7 @@
import { useState } from 'react';
import { usePublishEvent } from '@repo/core-events';
import type { ReceiptItem } from '@repo/core-events';
import {
Button,
Group,
Stack,
Text,
TextInput,
Table,
Badge,
} from '@repo/ui/components';
import { Button, Group, Stack, Text, TextInput, Table, Badge } from '@repo/ui/components';
import { DEVICE_EVENTS } from '../../../../../core/constants/events';
// ─── Mock Receipt Data ──────────────────────────────────────────
@@ -38,8 +38,8 @@ export function PrinterListener({ onLog }: PrinterListenerProps) {
// Uses the preload-exposed API. The Electron main process
// handles the actual OS-level print job via `webContents.print()`.
onLog(`[Electron] Sending receipt ${payload.receiptId} to OS printer via IPC bridge...`);
window.electronAPI!
.print({ silent: true, printBackground: true })
window
.electronAPI!.print({ silent: true, printBackground: true })
.then((result) => {
if (result.success) {
onLog(`[Electron] ✅ Receipt ${payload.receiptId} printed successfully.`);
@@ -55,7 +55,9 @@ export function PrinterListener({ onLog }: PrinterListenerProps) {
// Opens the native browser print dialog. In production, you'd
// likely render a hidden print-optimized iframe first.
onLog(`[Browser] 🖨️ Receipt ${payload.receiptId} — opening browser print dialog...`);
onLog(` → Cashier: ${payload.cashierName} | Items: ${payload.items.length} | Total: $${payload.total.toFixed(2)}`);
onLog(
` → Cashier: ${payload.cashierName} | Items: ${payload.items.length} | Total: $${payload.total.toFixed(2)}`,
);
window.print();
}
});
@@ -8,9 +8,26 @@ import { WS_EVENTS } from '../../../../../core/constants/events';
* We generate 1000 unique IDs from these base tickers + numeric suffix.
*/
const BASE_TICKERS = [
'AAPL', 'GOOG', 'MSFT', 'AMZN', 'META', 'NVDA', 'TSLA', 'AMD',
'NFLX', 'ORCL', 'CRM', 'INTC', 'PYPL', 'ADBE', 'CSCO', 'QCOM',
'AVGO', 'TXN', 'MU', 'SHOP',
'AAPL',
'GOOG',
'MSFT',
'AMZN',
'META',
'NVDA',
'TSLA',
'AMD',
'NFLX',
'ORCL',
'CRM',
'INTC',
'PYPL',
'ADBE',
'CSCO',
'QCOM',
'AVGO',
'TXN',
'MU',
'SHOP',
];
/**
@@ -58,18 +58,12 @@ export const StockRow = memo(function StockRow({ stockId }: StockRowProps) {
: '#fa5252' // red
: undefined;
const changeArrow = data
? data.change >= 0
? '▲'
: '▼'
: '';
const changeArrow = data ? (data.change >= 0 ? '▲' : '▼') : '';
return (
<tr style={{ fontSize: 12, fontFamily: 'monospace' }}>
<td style={{ padding: '2px 8px', fontWeight: 600 }}>{stockId}</td>
<td style={{ padding: '2px 8px', textAlign: 'right' }}>
{data ? `$${data.price.toFixed(2)}` : '—'}
</td>
<td style={{ padding: '2px 8px', textAlign: 'right' }}>{data ? `$${data.price.toFixed(2)}` : '—'}</td>
<td
style={{
padding: '2px 8px',
@@ -80,12 +74,8 @@ export const StockRow = memo(function StockRow({ stockId }: StockRowProps) {
>
{data ? `${changeArrow} ${data.change >= 0 ? '+' : ''}${data.change.toFixed(2)}` : '—'}
</td>
<td style={{ padding: '2px 8px', textAlign: 'right' }}>
{data ? data.volume.toLocaleString() : ''}
</td>
<td style={{ padding: '2px 8px', textAlign: 'right', color: '#868e96' }}>
{renderCountRef.current}
</td>
<td style={{ padding: '2px 8px', textAlign: 'right' }}>{data ? data.volume.toLocaleString() : '—'}</td>
<td style={{ padding: '2px 8px', textAlign: 'right', color: '#868e96' }}>{renderCountRef.current}</td>
</tr>
);
});
@@ -151,9 +151,7 @@ export class AdvancedBookingTransformer extends BookingTransformer {
return super.transformGetManyResponse(dtos).map((entity) => ({
...entity,
// Normalize 'cancelled' vs 'canceled' from different API versions
status: entity.status === ('canceled' as BookingEntity['status'])
? 'cancelled'
: entity.status,
status: entity.status === ('canceled' as BookingEntity['status']) ? 'cancelled' : entity.status,
}));
}
}
@@ -55,4 +55,3 @@ export const bookingServices = new CommonRemoteDataServices<BookingEntity, Booki
moduleKey: 'BOOKING',
transformer: new BookingTransformer(),
});
@@ -30,10 +30,10 @@ export default function BookingSample() {
telemetryContext: {
customSpanName: 'booking.list.fetch',
tags: {
'feature': 'booking',
feature: 'booking',
'ui.component': 'BookingSample',
'ui.action': 'list_fetch',
'page': 1,
page: 1,
},
pushEventOnSuccess: 'booking_list_loaded',
},
@@ -76,14 +76,19 @@ export default function BookingSample() {
{loading ? 'Fetching…' : 'Test Fetch Bookings'}
</button>
{error && (
<pre style={{ color: '#ef4444', marginTop: 16, whiteSpace: 'pre-wrap' }}>
{error}
</pre>
)}
{error && <pre style={{ color: '#ef4444', marginTop: 16, whiteSpace: 'pre-wrap' }}> {error}</pre>}
{result && (
<pre style={{ marginTop: 16, background: '#1e1e2e', color: '#a6e3a1', padding: 16, borderRadius: 8, overflow: 'auto' }}>
<pre
style={{
marginTop: 16,
background: '#1e1e2e',
color: '#a6e3a1',
padding: 16,
borderRadius: 8,
overflow: 'auto',
}}
>
{JSON.stringify(result, null, 2)}
</pre>
)}
+3 -1
View File
@@ -7,7 +7,9 @@ export default function EventsPage() {
<Container size="xl" m={0} p={0}>
<Stack gap="xl">
<Card withBorder shadow="sm" radius="md" p="md">
<Title order={4} mb="md">Nested Showcase Example</Title>
<Title order={4} mb="md">
Nested Showcase Example
</Title>
<Text mb="md">This is an example of a nested showcase component.</Text>
<ExamplePage />
</Card>
@@ -1,13 +1,31 @@
import { useForm } from 'react-hook-form';
import { Button, Paper, Title, Group, Stack, Code, Divider, Text, Chip, Radio } from '@repo/ui/components';
import {
FieldTextInput, FieldPasswordInput, FieldTextarea, FieldNumberInput,
FieldJsonInput, FieldPinInput, FieldAutocomplete, FieldSelect,
FieldMultiSelect, FieldNativeSelect, FieldTagsInput, FieldCheckbox,
FieldRadioGroup, FieldSwitch, FieldChipGroup, FieldSegmentedControl,
FieldSlider, FieldRangeSlider, FieldRating, FieldColorInput,
FieldColorPicker, FieldFileInput, FieldLocalSelect, FieldAsyncSelect,
FieldRichTextEditor
import {
FieldTextInput,
FieldPasswordInput,
FieldTextarea,
FieldNumberInput,
FieldJsonInput,
FieldPinInput,
FieldAutocomplete,
FieldSelect,
FieldMultiSelect,
FieldNativeSelect,
FieldTagsInput,
FieldCheckbox,
FieldRadioGroup,
FieldSwitch,
FieldChipGroup,
FieldSegmentedControl,
FieldSlider,
FieldRangeSlider,
FieldRating,
FieldColorInput,
FieldColorPicker,
FieldFileInput,
FieldLocalSelect,
FieldAsyncSelect,
FieldRichTextEditor,
} from '@repo/ui/form';
import type { LoadOptionsFn } from '@repo/ui/form';
import { useFormDemoTranslation } from '../i18n/useFormDemoTranslation';
@@ -48,13 +66,15 @@ const MOCK_VENDORS = [
const loadMockVendorsOptions: LoadOptionsFn<any> = async (search, _page) => {
await new Promise((resolve) => setTimeout(resolve, 500));
const filtered = MOCK_VENDORS.filter(v => v.name.toLowerCase().includes(search.toLowerCase()) || v.code.toLowerCase().includes(search.toLowerCase()));
const filtered = MOCK_VENDORS.filter(
(v) => v.name.toLowerCase().includes(search.toLowerCase()) || v.code.toLowerCase().includes(search.toLowerCase()),
);
return { options: filtered, hasMore: false };
};
export default function AllFieldsDemo() {
const t = useFormDemoTranslation();
const { control, handleSubmit, watch } = useForm<any>({
defaultValues: {
customerName: '',
@@ -86,17 +106,18 @@ export default function AllFieldsDemo() {
asyncSelectPrefilled: { id: 999, code: 'ASYNC-99', name: 'Pre-loaded Async Vendor' },
localMultiPrefilled: [
{ id: 'V1', code: 'VN-01', name: 'Vendor One' },
{ id: 'V2', code: 'VN-02', name: 'Vendor Two' }
{ id: 'V2', code: 'VN-02', name: 'Vendor Two' },
],
asyncMultiPrefilled: [
{ id: 888, code: 'ASYNC-88', name: 'Ghost Async Vendor 1' },
{ id: 999, code: 'ASYNC-99', name: 'Ghost Async Vendor 2' }
{ id: 999, code: 'ASYNC-99', name: 'Ghost Async Vendor 2' },
],
richTextEmpty: "",
richTextPrefilled: "<h2 style=\"text-align: center\">ERP Release Notes</h2><p>This is a <b>highly important</b> update. Please observe the following:</p><ul><li>System maintenance at <i>midnight</i>.</li><li><u style=\"text-align: justify\">All users must log out.</u></li></ul><p style=\"text-align: justify\">Thank you for your cooperation.</p>",
richTextEmpty: '',
richTextPrefilled:
'<h2 style="text-align: center">ERP Release Notes</h2><p>This is a <b>highly important</b> update. Please observe the following:</p><ul><li>System maintenance at <i>midnight</i>.</li><li><u style="text-align: justify">All users must log out.</u></li></ul><p style="text-align: justify">Thank you for your cooperation.</p>',
realPokeSelect: null,
multiRealPokeSelect: []
}
multiRealPokeSelect: [],
},
});
const onSubmit = (data: any) => console.log('All Fields Submitted:', data);
@@ -109,7 +130,9 @@ export default function AllFieldsDemo() {
<Stack gap="xl">
{/* --- Text & Numbers --- */}
<div>
<Title order={5} mb="sm" c="brand">{t.sections.textAndNumbers}</Title>
<Title order={5} mb="sm" c="brand">
{t.sections.textAndNumbers}
</Title>
<Divider mb="md" />
<Group grow align="flex-start" mb="md">
<FieldTextInput name="customerName" control={control} label={t.fields.customerName} />
@@ -124,41 +147,40 @@ export default function AllFieldsDemo() {
<FieldJsonInput name="jsonConfig" control={control} label={t.fields.jsonConfig} formatOnBlur />
</Group>
<div>
<Text size="sm" fw={500} mb={3}>{t.fields.pin}</Text>
<Text size="sm" fw={500} mb={3}>
{t.fields.pin}
</Text>
<FieldPinInput name="pin" control={control} length={6} />
</div>
</div>
{/* --- Selections --- */}
<div>
<Title order={5} mb="sm" c="brand">{t.sections.selections}</Title>
<Title order={5} mb="sm" c="brand">
{t.sections.selections}
</Title>
<Divider mb="md" />
<Group grow align="flex-start" mb="md">
<FieldSelect
name="orderType"
control={control}
label={t.fields.orderType}
data={['BULK', 'RETAIL']}
/>
<FieldNativeSelect
name="nativeOrderType"
control={control}
label={`Native ${t.fields.orderType}`}
data={['BULK', 'RETAIL']}
<FieldSelect name="orderType" control={control} label={t.fields.orderType} data={['BULK', 'RETAIL']} />
<FieldNativeSelect
name="nativeOrderType"
control={control}
label={`Native ${t.fields.orderType}`}
data={['BULK', 'RETAIL']}
/>
</Group>
<Group grow align="flex-start" mb="md">
<FieldAutocomplete
name="country"
control={control}
label={t.fields.country}
data={['Indonesia', 'Singapore', 'Malaysia']}
<FieldAutocomplete
name="country"
control={control}
label={t.fields.country}
data={['Indonesia', 'Singapore', 'Malaysia']}
/>
<FieldMultiSelect
name="categories"
control={control}
label={t.fields.categories}
data={['Electronics', 'Fashion', 'Food']}
<FieldMultiSelect
name="categories"
control={control}
label={t.fields.categories}
data={['Electronics', 'Fashion', 'Food']}
/>
</Group>
<Group grow align="flex-start" mb="md">
@@ -170,7 +192,7 @@ export default function AllFieldsDemo() {
options={[
{ id: 1, name: 'Apple', type: 'Fruit' },
{ id: 2, name: 'Carrot', type: 'Vegetable' },
{ id: 3, name: 'Banana', type: 'Fruit' }
{ id: 3, name: 'Banana', type: 'Fruit' },
]}
valueKey="id"
labelKey="name"
@@ -185,7 +207,7 @@ export default function AllFieldsDemo() {
options={[
{ id: 1, name: 'Red', hex: '#f00' },
{ id: 2, name: 'Green', hex: '#0f0' },
{ id: 3, name: 'Blue', hex: '#00f' }
{ id: 3, name: 'Blue', hex: '#00f' },
]}
valueKey="id"
renderLabel={(item) => `${item.name} (${item.hex})`}
@@ -240,7 +262,9 @@ export default function AllFieldsDemo() {
</Group>
<FieldTagsInput name="tags" control={control} label={t.fields.tags} />
<Title order={5} mb="sm" mt="lg" c="brand">{t.sections.advancedObjectSelects}</Title>
<Title order={5} mb="sm" mt="lg" c="brand">
{t.sections.advancedObjectSelects}
</Title>
<Divider mb="md" />
<Group grow align="flex-start" mb="md">
<FieldLocalSelect
@@ -284,7 +308,9 @@ export default function AllFieldsDemo() {
/>
</Group>
<Title order={5} mb="sm" mt="lg" c="brand">{t.sections.multiSelectEditMode}</Title>
<Title order={5} mb="sm" mt="lg" c="brand">
{t.sections.multiSelectEditMode}
</Title>
<Divider mb="md" />
<Group grow align="flex-start" mb="md">
<FieldLocalSelect
@@ -309,7 +335,9 @@ export default function AllFieldsDemo() {
/>
</Group>
<Title order={5} mb="sm" mt="lg" c="brand">{t.sections.richTextEditor}</Title>
<Title order={5} mb="sm" mt="lg" c="brand">
{t.sections.richTextEditor}
</Title>
<Divider mb="md" />
<FieldRichTextEditor
name="richTextEmpty"
@@ -329,39 +357,35 @@ export default function AllFieldsDemo() {
{/* --- Toggles & Choices --- */}
<div>
<Title order={5} mb="sm" c="brand">{t.sections.togglesAndChoices}</Title>
<Title order={5} mb="sm" c="brand">
{t.sections.togglesAndChoices}
</Title>
<Divider mb="md" />
<Group mb="md">
<FieldCheckbox name="terms" control={control} label={t.fields.terms} />
<FieldSwitch name="receiveEmails" control={control} label={t.fields.receiveEmails} />
</Group>
<FieldRadioGroup
name="priority"
control={control}
label={t.fields.priority}
mb="md"
>
<FieldRadioGroup name="priority" control={control} label={t.fields.priority} mb="md">
<Group mt="xs">
<Radio value="low" label="Low" />
<Radio value="high" label="High" />
</Group>
</FieldRadioGroup>
<FieldSegmentedControl
name="segmentedPriority"
control={control}
<FieldSegmentedControl
name="segmentedPriority"
control={control}
label={t.fields.priority}
data={[
{ label: 'Normal', value: 'normal' },
{ label: 'Urgent', value: 'urgent' }
{ label: 'Urgent', value: 'urgent' },
]}
mb="md"
/>
<div>
<Text size="sm" fw={500} mb={3}>Chip Selection</Text>
<FieldChipGroup
name="chipSelection"
control={control}
>
<Text size="sm" fw={500} mb={3}>
Chip Selection
</Text>
<FieldChipGroup name="chipSelection" control={control}>
<Group>
<Chip value="1">Option 1</Chip>
<Chip value="2">Option 2</Chip>
@@ -372,7 +396,9 @@ export default function AllFieldsDemo() {
{/* --- Ranges & Specialized --- */}
<div>
<Title order={5} mb="sm" c="brand">{t.sections.rangesAndSpecialized}</Title>
<Title order={5} mb="sm" c="brand">
{t.sections.rangesAndSpecialized}
</Title>
<Divider mb="md" />
<Group grow align="flex-start" mb="md">
<FieldSlider name="satisfaction" control={control} label={t.fields.rating} />
@@ -384,23 +410,31 @@ export default function AllFieldsDemo() {
</Group>
<Group grow align="flex-start" mb="md">
<div>
<Text size="sm" fw={500} mb={3}>{t.fields.themeColor} Picker</Text>
<Text size="sm" fw={500} mb={3}>
{t.fields.themeColor} Picker
</Text>
<FieldColorPicker name="colorPicker" control={control} />
</div>
<div>
<Text size="sm" fw={500} mb={3}>{t.fields.rating}</Text>
<Text size="sm" fw={500} mb={3}>
{t.fields.rating}
</Text>
<FieldRating name="rating" control={control} />
</div>
</Group>
</div>
<Button type="submit" mt="md">{t.common.submit}</Button>
<Button type="submit" mt="md">
{t.common.submit}
</Button>
</Stack>
</form>
</Paper>
<Paper p="md" withBorder radius="md" bg="var(--mantine-color-gray-0)">
<Title order={6} mb="xs">{t.common.submittedData}</Title>
<Title order={6} mb="xs">
{t.common.submittedData}
</Title>
<Code block>{JSON.stringify(data, null, 2)}</Code>
</Paper>
</Stack>
@@ -2,7 +2,14 @@ import { useForm, useWatch } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button, Paper, Title, Divider, Stack, Code, Alert, TypographyStylesProvider } from '@repo/ui/components';
import { FieldTextInput, FieldSelect, FieldSwitch, FieldLocalSelect, FieldAsyncSelect, FieldRichTextEditor } from '@repo/ui/form';
import {
FieldTextInput,
FieldSelect,
FieldSwitch,
FieldLocalSelect,
FieldAsyncSelect,
FieldRichTextEditor,
} from '@repo/ui/form';
import { useConditionalField } from '@repo/ui/hooks';
import { compose, required, emailValidator } from '@repo/ui/validators';
import { useFormDemoTranslation } from '../i18n/useFormDemoTranslation';
@@ -23,7 +30,7 @@ interface Warehouse {
const REGIONS: Region[] = [
{ id: 'R1', code: 'APAC', taxRate: 0.1 },
{ id: 'R2', code: 'EMEA', taxRate: 0.2 }
{ id: 'R2', code: 'EMEA', taxRate: 0.2 },
];
const mockFetchWarehouses = async (regionIds: string[], search: string, page: number) => {
@@ -34,15 +41,17 @@ const mockFetchWarehouses = async (regionIds: string[], search: string, page: nu
{ id: 'W3', regionId: 'R2', name: 'London Central' },
{ id: 'W4', regionId: 'R2', name: 'Berlin Storage' },
];
const filtered = allWarehouses.filter(w => regionIds.includes(w.regionId) && w.name.toLowerCase().includes(search.toLowerCase()));
const filtered = allWarehouses.filter(
(w) => regionIds.includes(w.regionId) && w.name.toLowerCase().includes(search.toLowerCase()),
);
const pageSize = 10;
const start = (page - 1) * pageSize;
const paginated = filtered.slice(start, start + pageSize);
return {
options: paginated,
hasMore: start + pageSize < filtered.length
hasMore: start + pageSize < filtered.length,
};
};
@@ -55,44 +64,48 @@ export default function ReactiveWatchDemo() {
const newsletterEmailValidator = compose(z.string(), required(t.fields.email), emailValidator());
const roleValidator = compose(z.string(), required(t.fields.role));
const reactiveSchema = useMemo(() => z
.object({
userType: z.enum(['PERSONAL', 'CORPORATE']),
corporateTaxId: z.string().optional(),
hasSpouse: z.boolean(),
spouseName: z.string().optional(),
newsletter: z.boolean(),
newsletterEmail: z.string().optional(),
department: z.string().optional(),
role: z.string().optional(),
regions: z.array(z.object({ id: z.string(), code: z.string(), taxRate: z.number() })).optional(),
warehouses: z.array(z.object({ id: z.string(), regionId: z.string(), name: z.string() })).optional(),
richTextLive: z.string().optional(),
})
.and(
z.discriminatedUnion('userType', [
z.object({ userType: z.literal('PERSONAL') }),
z.object({ userType: z.literal('CORPORATE'), corporateTaxId: taxIdValidator }),
]),
)
.and(
z.union([
z.object({ hasSpouse: z.literal(false) }),
z.object({ hasSpouse: z.literal(true), spouseName: spouseNameValidator }),
]),
)
.and(
z.union([
z.object({ newsletter: z.literal(false) }),
z.object({ newsletter: z.literal(true), newsletterEmail: newsletterEmailValidator }),
]),
)
.and(
z.union([
z.object({ department: z.string().min(1), role: roleValidator }),
z.object({ department: z.union([z.literal(''), z.undefined(), z.null()]).optional() }),
]),
), [t, taxIdValidator, spouseNameValidator, newsletterEmailValidator, roleValidator]);
const reactiveSchema = useMemo(
() =>
z
.object({
userType: z.enum(['PERSONAL', 'CORPORATE']),
corporateTaxId: z.string().optional(),
hasSpouse: z.boolean(),
spouseName: z.string().optional(),
newsletter: z.boolean(),
newsletterEmail: z.string().optional(),
department: z.string().optional(),
role: z.string().optional(),
regions: z.array(z.object({ id: z.string(), code: z.string(), taxRate: z.number() })).optional(),
warehouses: z.array(z.object({ id: z.string(), regionId: z.string(), name: z.string() })).optional(),
richTextLive: z.string().optional(),
})
.and(
z.discriminatedUnion('userType', [
z.object({ userType: z.literal('PERSONAL') }),
z.object({ userType: z.literal('CORPORATE'), corporateTaxId: taxIdValidator }),
]),
)
.and(
z.union([
z.object({ hasSpouse: z.literal(false) }),
z.object({ hasSpouse: z.literal(true), spouseName: spouseNameValidator }),
]),
)
.and(
z.union([
z.object({ newsletter: z.literal(false) }),
z.object({ newsletter: z.literal(true), newsletterEmail: newsletterEmailValidator }),
]),
)
.and(
z.union([
z.object({ department: z.string().min(1), role: roleValidator }),
z.object({ department: z.union([z.literal(''), z.undefined(), z.null()]).optional() }),
]),
),
[t, taxIdValidator, spouseNameValidator, newsletterEmailValidator, roleValidator],
);
const { control, handleSubmit, setValue, unregister, clearErrors } = useForm<any>({
resolver: zodResolver(reactiveSchema as any),
@@ -108,9 +121,9 @@ export default function ReactiveWatchDemo() {
regions: [{ id: 'R1', code: 'APAC', taxRate: 0.1 }],
warehouses: [
{ id: 'W-99', regionId: 'R1', name: 'APAC Central Hub' },
{ id: 'W-98', regionId: 'R1', name: 'APAC Backup Hub' }
{ id: 'W-98', regionId: 'R1', name: 'APAC Backup Hub' },
],
richTextLive: "<p>Start typing to see the live preview...</p>",
richTextLive: '<p>Start typing to see the live preview...</p>',
},
});
@@ -184,18 +197,18 @@ export default function ReactiveWatchDemo() {
const isMounted = useRef(false);
const prevRegionIds = useRef<string[]>(regions?.map((r: Region) => r.id) || []);
useEffect(() => {
if (!isMounted.current) {
isMounted.current = true;
return;
}
const currentIds = regions?.map((r: Region) => r.id) || [];
const prevIds = prevRegionIds.current;
const hasChanged = currentIds.length !== prevIds.length || currentIds.some((id: string) => !prevIds.includes(id));
if (hasChanged) {
setValue('warehouses', []);
clearErrors('warehouses');
@@ -297,7 +310,7 @@ export default function ReactiveWatchDemo() {
{t.sections.reactiveWatchCascading}
</Title>
<Divider mb="sm" />
<FieldLocalSelect<Region>
multiple
name="regions"
@@ -308,7 +321,7 @@ export default function ReactiveWatchDemo() {
labelKey="code"
clearable
/>
<FieldAsyncSelect<Warehouse>
multiple
key={`warehouse-select-${regions?.map((r: any) => r.id).join(',')}`}
@@ -316,18 +329,26 @@ export default function ReactiveWatchDemo() {
control={control as any}
label={t.fields.warehouses}
disabled={!regions || regions.length === 0}
loadOptions={useCallback(async (search, page) => {
if (!regions || regions.length === 0) return { options: [], hasMore: false };
return mockFetchWarehouses(regions.map((r: any) => r.id), search, page);
}, [regions])}
loadOptions={useCallback(
async (search, page) => {
if (!regions || regions.length === 0) return { options: [], hasMore: false };
return mockFetchWarehouses(
regions.map((r: any) => r.id),
search,
page,
);
},
[regions],
)}
valueKey="id"
renderLabel={(item) => `[${item.id}] ${item.name}`}
clearable
/>
{regions && regions.length > 0 && (
<Alert mt="sm" color="teal">
{t.descriptions.selectedRegionsTax} {regions.map((r: any) => `${r.code} (${(r.taxRate * 100).toFixed(0)}%)`).join(', ')}
{t.descriptions.selectedRegionsTax}{' '}
{regions.map((r: any) => `${r.code} (${(r.taxRate * 100).toFixed(0)}%)`).join(', ')}
</Alert>
)}
@@ -335,16 +356,18 @@ export default function ReactiveWatchDemo() {
{t.sections.reactiveRichTextPreview}
</Title>
<Divider mb="sm" />
<FieldRichTextEditor
name="richTextLive"
control={control as any}
label={t.fields.liveEditor}
description={t.descriptions.typeToSeePreview}
/>
<Paper p="md" withBorder radius="md" mt="sm">
<Title order={6} mb="xs">{t.sections.liveHtmlPreview}</Title>
<Title order={6} mb="xs">
{t.sections.liveHtmlPreview}
</Title>
<TypographyStylesProvider>
<div dangerouslySetInnerHTML={{ __html: watchedRichTextLive }} />
</TypographyStylesProvider>
+3 -1
View File
@@ -5,7 +5,9 @@ export default function RbacPage() {
<Container size="xl" m={0} p={0}>
<Stack gap="xl">
<Card withBorder shadow="sm" radius="md" p="md">
<Title order={4} mb="md">RBAC Engine</Title>
<Title order={4} mb="md">
RBAC Engine
</Title>
<Text c="dimmed">RBAC Demo Component Coming Soon...</Text>
</Card>
</Stack>
+67 -16
View File
@@ -1,4 +1,24 @@
import { Stack, Card, Title, Group, Select, Text, Badge, Divider, Button, TextInput, NumberInput, PasswordInput, Textarea, Checkbox, Switch, Table, StatusBadge, STATUS_DATA, Container } from '@repo/ui/components';
import {
Stack,
Card,
Title,
Group,
Select,
Text,
Badge,
Divider,
Button,
TextInput,
NumberInput,
PasswordInput,
Textarea,
Checkbox,
Switch,
Table,
StatusBadge,
STATUS_DATA,
Container,
} from '@repo/ui/components';
import { useThemeStore } from '../../core/stores/theme.store';
import { ColorSchemeType } from '@repo/ui/provider';
@@ -16,7 +36,9 @@ export default function UiComponentsPage() {
<Stack gap="xl">
{/* Control Panel */}
<Card withBorder shadow="sm" radius="md" p="md">
<Title order={4} mb="md">Theme Controls</Title>
<Title order={4} mb="md">
Theme Controls
</Title>
<Group grow align="flex-end">
<Select
label="Color Scheme"
@@ -34,18 +56,30 @@ export default function UiComponentsPage() {
<Card withBorder shadow="sm" radius="md" p="md">
<Stack gap="lg">
<div>
<Title order={4} mb="xs">Typography & Badges</Title>
<Text size="sm" c="dimmed">This is dimmed small text indicating a subtitle.</Text>
<Title order={4} mb="xs">
Typography & Badges
</Title>
<Text size="sm" c="dimmed">
This is dimmed small text indicating a subtitle.
</Text>
<Group mt="md">
<Badge color="brand">Brand Badge</Badge>
<Badge color="success" variant="light">Success Status</Badge>
<Badge color="error" variant="outline">Error State</Badge>
<Badge color="success" variant="light">
Success Status
</Badge>
<Badge color="error" variant="outline">
Error State
</Badge>
</Group>
</div>
<Divider />
<div>
<Title order={4} mb="xs">Enterprise Status Badges</Title>
<Text size="sm" c="dimmed" mb="md">Pre-configured status badges for transaction and master data.</Text>
<Title order={4} mb="xs">
Enterprise Status Badges
</Title>
<Text size="sm" c="dimmed" mb="md">
Pre-configured status badges for transaction and master data.
</Text>
<Group>
<StatusBadge status={STATUS_DATA.DRAFT} />
<StatusBadge status={STATUS_DATA.PENDING} />
@@ -59,12 +93,22 @@ export default function UiComponentsPage() {
</div>
<Divider />
<div>
<Title order={4} mb="md">Buttons</Title>
<Title order={4} mb="md">
Buttons
</Title>
<Group>
<Button variant="filled" color="brand">Filled Button</Button>
<Button variant="outline" color="brand">Outline Button</Button>
<Button variant="light" color="info">Light Info</Button>
<Button variant="subtle" color="error">Cancel</Button>
<Button variant="filled" color="brand">
Filled Button
</Button>
<Button variant="outline" color="brand">
Outline Button
</Button>
<Button variant="light" color="info">
Light Info
</Button>
<Button variant="subtle" color="error">
Cancel
</Button>
</Group>
</div>
</Stack>
@@ -72,7 +116,9 @@ export default function UiComponentsPage() {
{/* Forms */}
<Card withBorder shadow="sm" radius="md" p="md">
<Title order={4} mb="md">Form Elements</Title>
<Title order={4} mb="md">
Form Elements
</Title>
<Stack gap="md">
<Group grow align="flex-start">
<TextInput label="First Name" placeholder="Enter your first name" withAsterisk />
@@ -92,7 +138,9 @@ export default function UiComponentsPage() {
{/* Data Grid */}
<Card withBorder shadow="sm" radius="md" p="md">
<Title order={4} mb="md">Data Grid</Title>
<Title order={4} mb="md">
Data Grid
</Title>
<Table striped highlightOnHover withTableBorder withColumnBorders>
<Table.Thead>
<Table.Tr>
@@ -108,7 +156,10 @@ export default function UiComponentsPage() {
<Table.Td>{row.id}</Table.Td>
<Table.Td>{row.customer}</Table.Td>
<Table.Td>
<Badge size="sm" color={row.status === 'Delivered' ? 'success' : row.status === 'Shipped' ? 'info' : 'warning'}>
<Badge
size="sm"
color={row.status === 'Delivered' ? 'success' : row.status === 'Shipped' ? 'info' : 'warning'}
>
{row.status}
</Badge>
</Table.Td>
-1
View File
@@ -25,7 +25,6 @@
import type {} from '@repo/core-events';
declare module '@repo/core-events' {
// ─── Payload Types ──────────────────────────────────────────
interface ReceiptItem {
+1 -1
View File
@@ -2,7 +2,7 @@ VITE_APP_ENV=development
VITE_APP_NAME=development_fe-monorepo-web
VITE_APP_VERSION=0.0.1
VITE_API_BASE_URL=http://localhost:8000/api
VITE_API_BASE_URL=http://localhost:3346
VITE_COUCHDB_BASE_URL=http://202.146.229.134:7700
@@ -0,0 +1,81 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { AxiosInstance } from '@repo/core-api/http-client';
import { AuthRemoteDataServices } from './auth.remote.service';
function createMockClient() {
return {
request: vi.fn(),
} as unknown as AxiosInstance & { request: ReturnType<typeof vi.fn> };
}
describe('AuthRemoteDataServices', () => {
let httpClient: ReturnType<typeof createMockClient>;
let service: AuthRemoteDataServices;
beforeEach(() => {
httpClient = createMockClient();
service = new AuthRemoteDataServices(httpClient);
});
it('posts credentials to /auth/login', async () => {
const tokens = { accessToken: 'a', refreshToken: 'r' };
httpClient.request.mockResolvedValue({ data: tokens, status: 200 });
const result = await service.login({ username: 'alice', password: 'password123' });
expect(httpClient.request).toHaveBeenCalledWith(
expect.objectContaining({
url: '/auth/login',
method: 'POST',
data: { username: 'alice', password: 'password123' },
}),
);
expect(result).toEqual(tokens);
});
it('gets the current user from /auth/me', async () => {
const me = {
id: 'user-1',
username: 'alice',
isSuperadmin: false,
privilege: null,
permissions: {},
};
httpClient.request.mockResolvedValue({ data: me, status: 200 });
const result = await service.me();
expect(httpClient.request).toHaveBeenCalledWith(expect.objectContaining({ url: '/auth/me', method: 'GET' }));
expect(result).toEqual(me);
});
it('posts the refresh token to /auth/refresh', async () => {
const tokens = { accessToken: 'a2', refreshToken: 'r2' };
httpClient.request.mockResolvedValue({ data: tokens, status: 200 });
const result = await service.refresh('refresh-1');
expect(httpClient.request).toHaveBeenCalledWith(
expect.objectContaining({
url: '/auth/refresh',
method: 'POST',
data: { refreshToken: 'refresh-1' },
}),
);
expect(result).toEqual(tokens);
});
it('posts the refresh token to /auth/revoke', async () => {
httpClient.request.mockResolvedValue({ data: undefined, status: 204 });
await service.revoke('refresh-1');
expect(httpClient.request).toHaveBeenCalledWith(
expect.objectContaining({
url: '/auth/revoke',
method: 'POST',
data: { refreshToken: 'refresh-1' },
}),
);
});
});
@@ -0,0 +1,44 @@
import { BaseRemoteDataServices } from '@repo/core-api/data-services';
import type { AxiosInstance } from '@repo/core-api/http-client';
import { API_URL } from '../../../core/constants/api-url';
import type { AuthUser, LoginPayload, TokenPair } from '../domain/entities/auth.entity';
export class AuthRemoteDataServices extends BaseRemoteDataServices {
constructor(httpClient: AxiosInstance) {
super(httpClient, { apiUrl: '/auth', moduleKey: 'AUTH' });
}
async login(payload: LoginPayload): Promise<TokenPair> {
const { data } = await this.customRequest<TokenPair>({
url: API_URL.AUTH_LOGIN,
method: 'POST',
data: payload,
});
return data;
}
async refresh(refreshToken: string): Promise<TokenPair> {
const { data } = await this.customRequest<TokenPair>({
url: API_URL.AUTH_REFRESH,
method: 'POST',
data: { refreshToken },
});
return data;
}
async revoke(refreshToken: string): Promise<void> {
await this.customRequest({
url: API_URL.AUTH_REVOKE,
method: 'POST',
data: { refreshToken },
});
}
async me(): Promise<AuthUser> {
const { data } = await this.customRequest<AuthUser>({
url: API_URL.AUTH_ME,
method: 'GET',
});
return data;
}
}
@@ -0,0 +1,7 @@
export type {
AuthPermissionFlags,
AuthPrivilege,
AuthUser,
LoginPayload,
TokenPair,
} from '../../../../core/lib/auth.types';
@@ -0,0 +1,4 @@
import { apiClient } from '../../../../core/lib/api-client';
import { AuthRemoteDataServices } from '../../data/auth.remote.service';
export const authDataService = new AuthRemoteDataServices(apiClient);
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import { createLoginSchema } from './login.validator';
const t = (key: string) => key;
describe('createLoginSchema', () => {
const schema = createLoginSchema(t);
it('rejects empty username and password', () => {
const result = schema.safeParse({ username: '', password: '' });
expect(result.success).toBe(false);
});
it('rejects a username shorter than 3 characters', () => {
const result = schema.safeParse({ username: 'ab', password: 'password123' });
expect(result.success).toBe(false);
});
it('rejects a username longer than 32 characters', () => {
const result = schema.safeParse({
username: 'a'.repeat(33),
password: 'password123',
});
expect(result.success).toBe(false);
});
it('rejects a password shorter than 8 characters', () => {
const result = schema.safeParse({ username: 'alice', password: 'short' });
expect(result.success).toBe(false);
});
it('rejects a password longer than 72 characters', () => {
const result = schema.safeParse({ username: 'alice', password: 'p'.repeat(73) });
expect(result.success).toBe(false);
});
it('accepts credentials within the API length limits', () => {
const result = schema.safeParse({ username: 'alice', password: 'password123' });
expect(result.success).toBe(true);
});
});
@@ -0,0 +1,14 @@
import { z } from 'zod';
import { compose, required, rangeLength } from '@repo/ui/validators';
export const createLoginSchema = (t: (key: string) => string) => {
return z.object({
username: compose(z.string(), required(t('username_label')), rangeLength(3, 32, t('username_label'))),
password: compose(z.string(), required(t('password_label')), rangeLength(8, 72, t('password_label'))),
});
};
export type LoginFormValues = {
username: string;
password: string;
};
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import { getLoginErrorKey } from './get-login-error-key';
describe('getLoginErrorKey', () => {
it('maps 401 to invalidCredentials', () => {
expect(getLoginErrorKey({ response: { status: 401 } })).toBe('invalidCredentials');
});
it('maps 429 to rateLimited', () => {
expect(getLoginErrorKey({ response: { status: 429 } })).toBe('rateLimited');
});
it('maps unknown errors to generic', () => {
expect(getLoginErrorKey(new Error('network'))).toBe('generic');
expect(getLoginErrorKey({ status: 500 })).toBe('generic');
});
});
@@ -0,0 +1,10 @@
import { getHttpErrorStatus } from '../../../core/lib/get-http-error-status';
export type LoginErrorKey = 'invalidCredentials' | 'rateLimited' | 'generic';
export function getLoginErrorKey(error: unknown): LoginErrorKey {
const status = getHttpErrorStatus(error);
if (status === 401) return 'invalidCredentials';
if (status === 429) return 'rateLimited';
return 'generic';
}
+15 -12
View File
@@ -16,22 +16,20 @@ import { useTranslation, registerModuleNamespace } from '@repo/core-i18n';
import { AppStorageKey, appStorage } from '../../../core/storage/local';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { createLoginSchema } from './validators/login.validator';
import { createLoginSchema } from '../domain/validators/login.validator';
import { useMemo, useState } from 'react';
import loginEn from './languages/en/login.json';
import loginId from './languages/id/login.json';
import { CommonRemoteDataServices } from '@repo/core-api/data-services';
import { apiClient } from '../../../core/lib/api-client';
import { initiateAuthSession } from '../../../core/lib/auth.helper';
import { authDataService } from '../domain/factories';
import { initiateAuthSession, persistTokenPair, terminateAuthSession } from '../../../core/lib/auth.helper';
import { getLoginErrorKey } from './get-login-error-key';
registerModuleNamespace('auth-login', {
en: loginEn,
id: loginId,
});
export const authDataService = new CommonRemoteDataServices(apiClient, {});
export default function LoginPage() {
const { t, i18n } = useTranslation('auth-login');
const [loadingLogin, setLoadingLogin] = useState<boolean>(false);
@@ -46,16 +44,21 @@ export default function LoginPage() {
});
const { control } = formControl;
const onSubmit = async (data: any) => {
const onSubmit = async (data: { username?: string; password?: string }) => {
setLoadingLogin(true);
try {
const response = await authDataService.customRequest({ url: '/api/v1/auth', method: 'POST', data });
await initiateAuthSession(response.data);
} catch (error: any) {
const message = error?.response?.data?.message;
const tokens = await authDataService.login({
username: data.username ?? '',
password: data.password ?? '',
});
await persistTokenPair(tokens);
const me = await authDataService.me();
await initiateAuthSession(tokens, me);
} catch (error) {
await terminateAuthSession({ preserveRedirect: false });
notifications.show({
title: t('common:notifications.errorTitle'),
message: message ?? error?.message ?? 'Login failed',
message: t(getLoginErrorKey(error)),
color: 'red',
});
} finally {
@@ -8,5 +8,8 @@
"remember_me": "Remember me",
"forgot_password": "Forgot Password ?",
"login_button": "Login",
"or_login_with": "Or login with"
"or_login_with": "Or login with",
"invalidCredentials": "Invalid username or password",
"rateLimited": "Too many attempts. Please try again later.",
"generic": "Unable to sign in. Please try again."
}
@@ -8,5 +8,8 @@
"remember_me": "Ingat saya",
"forgot_password": "Lupa Kata Sandi ?",
"login_button": "Masuk",
"or_login_with": "Atau login dengan"
"or_login_with": "Atau login dengan",
"invalidCredentials": "Username atau password tidak valid",
"rateLimited": "Terlalu banyak percobaan. Silakan coba lagi nanti.",
"generic": "Tidak dapat masuk. Silakan coba lagi."
}
@@ -1,9 +0,0 @@
import { z } from 'zod';
import { compose, required } from '@repo/ui/validators';
export const createLoginSchema = (t: any) => {
return z.object({
username: compose(z.string(), required(t('username_label'))),
password: compose(z.string(), required(t('password_label'))),
});
};
@@ -15,12 +15,12 @@ import {
Button,
} from '@repo/ui/components';
import { Globe, Bell, HelpCircle, Settings, User, Briefcase, LogOut, ChevronDown, Sun, Moon } from 'lucide-react';
import { AppStorageKey, appStorage } from '../../../../core/storage/local';
import { AppDatabaseKey, AppStorageKey, appDatabase, appStorage } from '../../../../core/storage/local';
import { useThemeStore } from '../../../../core/stores/theme.store';
import { NotificationDropdown } from './notifications/notification-dropdown';
import { Link } from 'react-router-dom';
import { useEffect, useState } from 'react';
import { terminateAuthSession } from '../../../../core/lib/auth.helper';
import { logoutAuthSession } from '../../../../core/lib/auth.helper';
// Dummy user data for preview
const USER = {
@@ -67,7 +67,7 @@ export default function HeaderLayout() {
size="xs"
onClick={() => {
modals.close('logout-confirmation');
terminateAuthSession();
logoutAuthSession();
}}
>
{t('common:signOut')}
@@ -79,12 +79,22 @@ export default function HeaderLayout() {
}
async function initProfile() {
// FIXME: Replace the hardcoded `USER` mock data with the actual profile data.
// Uncomment the database fetch below and update the state using the retrieved profile.
const profile = await appDatabase.getItem<{
name?: string;
username?: string;
label?: string;
avatar?: string | null;
}>(AppDatabaseKey.USER_PROFILE);
// const profile = await appDatabase.getItem(AppDatabaseKey.USER_PROFILE);
if (profile) {
setUserProfile({
name: profile.name ?? profile.username ?? '',
label: profile.label ?? '',
avatar: profile.avatar ?? null,
});
return;
}
// TODO: Change this to setUserProfile(profile);
setUserProfile(USER);
}
@@ -17,8 +17,7 @@ export default function FullPagePageDetail() {
],
}}
>
<DetailGeneral/>
<DetailGeneral />
</EnterpriseDetailPageProvider>
);
}
@@ -17,7 +17,12 @@ export function NotificationSetting({ onDirtyChange }: { onDirtyChange: (isDirty
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const { control, handleSubmit, reset, formState: { isDirty } } = useForm<NotificationSettings>({
const {
control,
handleSubmit,
reset,
formState: { isDirty },
} = useForm<NotificationSettings>({
resolver: zodResolver(notificationSchema),
defaultValues: {
email: false,
@@ -40,7 +45,7 @@ export function NotificationSetting({ onDirtyChange }: { onDirtyChange: (isDirty
setLoading(true);
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 500));
await new Promise((resolve) => setTimeout(resolve, 500));
// Reset form to clear dirty state
reset(data);
} catch (err) {
@@ -54,9 +59,13 @@ export function NotificationSetting({ onDirtyChange }: { onDirtyChange: (isDirty
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md">
<Box>
<Text fw={500} size="md" mb="xs">{t('setting:notification.title')}</Text>
<Text c="dimmed" size="sm" mb="md">{t('setting:notification.desc')}</Text>
<Text fw={500} size="md" mb="xs">
{t('setting:notification.title')}
</Text>
<Text c="dimmed" size="sm" mb="md">
{t('setting:notification.desc')}
</Text>
<Stack gap="sm">
<FieldSwitch
control={control}
+7 -1
View File
@@ -1 +1,7 @@
export const API_URL = {};
export const API_URL = {
AUTH_LOGIN: '/auth/login',
AUTH_REGISTER: '/auth/register',
AUTH_REFRESH: '/auth/refresh',
AUTH_REVOKE: '/auth/revoke',
AUTH_ME: '/auth/me',
} as const;
+19 -22
View File
@@ -74,30 +74,27 @@ export function useElectronPrinter(): UseElectronPrinterReturn {
}
}, []);
const print = useCallback(
async (options?: ElectronPrintOptions): Promise<ElectronPrintResult> => {
if (!window.electronAPI) {
return { success: false, failureReason: 'Not running in Electron' };
}
const print = useCallback(async (options?: ElectronPrintOptions): Promise<ElectronPrintResult> => {
if (!window.electronAPI) {
return { success: false, failureReason: 'Not running in Electron' };
}
setLoading(true);
setError(null);
try {
const result = await window.electronAPI.print(options);
if (!result.success && result.failureReason) {
setError(result.failureReason);
}
return result;
} catch (err) {
const message = err instanceof Error ? err.message : 'Print failed';
setError(message);
return { success: false, failureReason: message };
} finally {
setLoading(false);
setLoading(true);
setError(null);
try {
const result = await window.electronAPI.print(options);
if (!result.success && result.failureReason) {
setError(result.failureReason);
}
},
[],
);
return result;
} catch (err) {
const message = err instanceof Error ? err.message : 'Print failed';
setError(message);
return { success: false, failureReason: message };
} finally {
setLoading(false);
}
}, []);
return {
printers,
@@ -3,14 +3,7 @@ import { useIsElectron } from './use-is-electron';
// ─── Types ──────────────────────────────────────────────────────
export type UpdateStatus =
| 'idle'
| 'checking'
| 'available'
| 'not-available'
| 'downloading'
| 'ready'
| 'error';
export type UpdateStatus = 'idle' | 'checking' | 'available' | 'not-available' | 'downloading' | 'ready' | 'error';
export interface UseElectronUpdaterReturn {
/** Current status of the auto-updater lifecycle */
+14 -8
View File
@@ -2,14 +2,15 @@ import { createHttpClient } from '@repo/core-api/http-client';
import { faroAdapter } from '@repo/core-api/observability';
import { ENV } from '../environment';
import { AppDatabaseKey, AppStorageKey, appDatabase, appStorage } from '../storage/local';
import { terminateAuthSession } from './auth.helper';
import { refreshAuthSession, terminateAuthSession } from './auth.helper';
import { handleUnauthorized, isPublicAuthRequest } from './handle-unauthorized';
/**
* Enterprise HTTP client for `apps/web`.
*
* - Full Faro observability via the shared `faroAdapter`
* - Automatic Bearer token injection from localStorage
* - 401 redirect to `/auth/login`
* - 401 refresh-once + retry, then redirect to `/auth/login`
* - Supports per-request `telemetryContext` for custom spans/tags
*
* All interceptors (auth, observability, error normalization)
@@ -23,7 +24,6 @@ export const apiClient = createHttpClient(
observability: faroAdapter,
},
{
// ── Auth Interceptor ──────────────────────────────────────────
onRequest: async (config) => {
config.headers['ex-app-name'] = ENV.APP_NAME;
config.headers['ex-app-version'] = ENV.APP_VERSION;
@@ -33,18 +33,24 @@ export const apiClient = createHttpClient(
const language = await appStorage.getItem<string>(AppStorageKey.LANGUAGE);
config.headers['ex-language'] = language ?? ENV.DEFAULT_LANGUAGE;
const token = await appDatabase.getItem<string>(AppDatabaseKey.ACCESS_TOKEN);
if (token) config.headers.Authorization = `Bearer ${token}`;
if (!isPublicAuthRequest(config.url)) {
const token = await appDatabase.getItem<string>(AppDatabaseKey.ACCESS_TOKEN);
if (token) config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
// ── Error Interceptor ─────────────────────────────────────────
onResponseError: async (error) => {
const status = error.response?.status;
// Catch 401 (Unauthorized) on request
if (status === 401) if (status === 401) await terminateAuthSession({ preserveRedirect: true });
if (status === 401) {
return handleUnauthorized(error, {
refresh: refreshAuthSession,
terminate: () => terminateAuthSession({ preserveRedirect: true }),
retry: (config) => apiClient.request(config),
}) as Promise<never>;
}
throw error;
},
+40 -21
View File
@@ -1,7 +1,7 @@
import { useEffect, useState, ReactNode } from 'react';
import { useNavigate } from 'react-router-dom';
import { appDatabase, AppDatabaseKey } from '../storage/local';
import { terminateAuthSession } from './auth.helper';
import { refreshAuthSession, terminateAuthSession } from './auth.helper';
/**
* Basic JWT decoder to check expiration.
@@ -12,16 +12,30 @@ function isTokenExpired(token: string): boolean {
if (payload.exp) {
return payload.exp * 1000 < Date.now();
}
return false; // If no exp, assume valid
} catch (e) {
return true; // Invalid token format -> treat as expired
return false;
} catch {
return true;
}
}
async function restoreSessionWithRefresh(): Promise<boolean> {
const refreshToken = await appDatabase.getItem<string>(AppDatabaseKey.REFRESH_TOKEN);
if (!refreshToken) {
return false;
}
try {
await refreshAuthSession();
return true;
} catch {
return false;
}
}
/**
* For Auth Page (Login/Register).
* - If valid token exists -> Redirect to main application.
* - If expired token exists -> Clear session, stay on auth page.
* - If expired token exists -> Try refresh, otherwise clear session and stay on auth page.
*/
export function AuthPageGuard({ children }: { children: ReactNode }) {
const navigate = useNavigate();
@@ -31,15 +45,18 @@ export function AuthPageGuard({ children }: { children: ReactNode }) {
async function checkCredential() {
try {
const token = await appDatabase.getItem<string>(AppDatabaseKey.ACCESS_TOKEN);
if (token && !isTokenExpired(token)) {
navigate('/app', { replace: true });
return;
}
if (await restoreSessionWithRefresh()) {
navigate('/app', { replace: true });
return;
}
if (token) {
if (!isTokenExpired(token)) {
// Valid token exists, redirect to dashboard/main app
navigate('/app', { replace: true });
return;
} else {
// Token exists but is expired. Terminate session without redirecting (we are already in Auth).
await terminateAuthSession({ preserveRedirect: false });
}
await terminateAuthSession({ preserveRedirect: false });
}
setIsChecking(false);
} catch (error) {
@@ -59,30 +76,32 @@ export function AuthPageGuard({ children }: { children: ReactNode }) {
/**
* For Root/Main App.
* - Monitors token status.
* - If expired -> Immediately terminate session and redirect to login.
* - If expired -> refresh first; terminate only when refresh is missing or fails.
*/
export function GlobalCredentialChecker({ children }: { children: ReactNode }) {
useEffect(() => {
let intervalId: any;
let intervalId: ReturnType<typeof setInterval>;
async function checkCredential() {
try {
const token = await appDatabase.getItem<string>(AppDatabaseKey.ACCESS_TOKEN);
if (!token || isTokenExpired(token)) {
// Token is missing or expired -> clear session and redirect to login
await terminateAuthSession({ preserveRedirect: true });
if (token && !isTokenExpired(token)) {
return;
}
if (await restoreSessionWithRefresh()) {
return;
}
await terminateAuthSession({ preserveRedirect: true });
} catch (error) {
console.error('Failed to check credentials in GlobalCredentialChecker:', error);
await terminateAuthSession({ preserveRedirect: true });
}
}
// Immediate check on mount
checkCredential();
// Periodic check every 1 minute
// eslint-disable-next-line prefer-const
intervalId = setInterval(checkCredential, 60000);
return () => clearInterval(intervalId);
+147
View File
@@ -0,0 +1,147 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../storage/local', () => ({
appDatabase: {
setItem: vi.fn().mockResolvedValue(undefined),
getItem: vi.fn(),
removeItem: vi.fn().mockResolvedValue(undefined),
},
appStorage: {
setItem: vi.fn().mockResolvedValue(undefined),
removeItem: vi.fn().mockResolvedValue(undefined),
},
AppDatabaseKey: {
ACCESS_TOKEN: 'access_token',
REFRESH_TOKEN: 'refresh_token',
USER_PROFILE: 'user_profile',
USER_PRIVILEGE: 'user_privilege',
},
AppStorageKey: {
USER_ID: 'uid',
},
}));
vi.mock('./api-client', () => ({
apiClient: {
request: vi.fn().mockResolvedValue({ status: 204, data: undefined }),
},
}));
import { appDatabase, appStorage } from '../storage/local';
import { apiClient } from './api-client';
import { initiateAuthSession, logoutAuthSession, persistTokenPair, terminateAuthSession } from './auth.helper';
const tokens = { accessToken: 'access-1', refreshToken: 'refresh-1' };
const me = {
id: 'user-1',
username: 'alice',
isSuperadmin: false,
privilege: { id: 'priv-1', name: 'Admin', code: 'ADMIN' },
permissions: {
PRIVILEGES: { view: true, create: false, update: false, delete: false },
},
};
function stubLocation(pathname: string, search = '') {
const replace = vi.fn();
vi.stubGlobal('window', {
location: { pathname, search, replace },
});
return replace;
}
describe('auth.helper', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('persists the token pair', async () => {
await persistTokenPair(tokens);
expect(appDatabase.setItem).toHaveBeenCalledWith('access_token', 'access-1');
expect(appDatabase.setItem).toHaveBeenCalledWith('refresh_token', 'refresh-1');
});
it('persists tokens, profile, privileges and redirects after login', async () => {
const replace = stubLocation('/auth/login', '');
await initiateAuthSession(tokens, me);
expect(appStorage.setItem).toHaveBeenCalledWith('uid', 'user-1');
expect(appDatabase.setItem).toHaveBeenCalledWith('access_token', 'access-1');
expect(appDatabase.setItem).toHaveBeenCalledWith('refresh_token', 'refresh-1');
expect(appDatabase.setItem).toHaveBeenCalledWith(
'user_profile',
expect.objectContaining({
id: 'user-1',
username: 'alice',
name: 'alice',
label: 'Admin',
isSuperadmin: false,
}),
);
expect(appDatabase.setItem).toHaveBeenCalledWith(
'user_privilege',
expect.objectContaining({
PRIVILEGES: expect.objectContaining({ ALLOW_VIEW: true, ALLOW_CREATE: false }),
}),
);
expect(replace).toHaveBeenCalledWith('/app');
});
it('honours the redirect query param after login', async () => {
const replace = stubLocation('/auth/login', '?redirect=%2Fapp%2Fcustomers');
await initiateAuthSession(tokens, me);
expect(replace).toHaveBeenCalledWith('/app/customers');
});
it('clears the refresh token when terminating a session', async () => {
const replace = stubLocation('/app/customers');
await terminateAuthSession({ preserveRedirect: true });
expect(appDatabase.removeItem).toHaveBeenCalledWith('user_privilege');
expect(appDatabase.removeItem).toHaveBeenCalledWith('user_profile');
expect(appDatabase.removeItem).toHaveBeenCalledWith('access_token');
expect(appDatabase.removeItem).toHaveBeenCalledWith('refresh_token');
expect(appStorage.removeItem).toHaveBeenCalledWith('uid');
expect(replace).toHaveBeenCalledWith('/auth/login?redirect=%2Fapp%2Fcustomers');
});
it('does not redirect when already on the login page', async () => {
const replace = stubLocation('/auth/login');
await terminateAuthSession({ preserveRedirect: true });
expect(replace).not.toHaveBeenCalled();
});
it('revokes the refresh token then terminates on logout', async () => {
stubLocation('/app');
vi.mocked(appDatabase.getItem).mockResolvedValue('refresh-1');
await logoutAuthSession();
expect(apiClient.request).toHaveBeenCalledWith(
expect.objectContaining({
url: '/auth/revoke',
method: 'POST',
data: { refreshToken: 'refresh-1' },
}),
);
expect(appDatabase.removeItem).toHaveBeenCalledWith('refresh_token');
});
it('still terminates when revoke fails', async () => {
stubLocation('/app');
vi.mocked(appDatabase.getItem).mockResolvedValue('refresh-1');
vi.mocked(apiClient.request).mockRejectedValueOnce(new Error('network'));
await logoutAuthSession();
expect(appDatabase.removeItem).toHaveBeenCalledWith('access_token');
expect(appDatabase.removeItem).toHaveBeenCalledWith('refresh_token');
});
});
+89 -55
View File
@@ -1,6 +1,7 @@
import { lodash } from '@repo/utils';
import { appDatabase, AppDatabaseKey, appStorage, AppStorageKey } from '../storage/local';
import { PrivilegeEntity } from '@repo/ui/foundations';
import { API_URL } from '../constants/api-url';
import { mapUserPrivileges } from './map-user-privileges';
import type { AuthUser, TokenPair } from './auth.types';
interface TerminateOptions {
/** When true, appends ?redirect= so the user returns to their page after re-login.
@@ -9,78 +10,111 @@ interface TerminateOptions {
preserveRedirect?: boolean;
}
export async function terminateAuthSession(options: TerminateOptions = {}) {
export async function persistTokenPair(tokens: TokenPair): Promise<void> {
await appDatabase.setItem(AppDatabaseKey.ACCESS_TOKEN, tokens.accessToken);
await appDatabase.setItem(AppDatabaseKey.REFRESH_TOKEN, tokens.refreshToken);
}
export async function persistUserSession(me: AuthUser): Promise<void> {
const label = me.isSuperadmin ? 'Superadmin' : (me.privilege?.name ?? me.username);
await appStorage.setItem(AppStorageKey.USER_ID, me.id);
await appDatabase.setItem(AppDatabaseKey.USER_PROFILE, {
id: me.id,
username: me.username,
isSuperadmin: me.isSuperadmin,
privilege: me.privilege,
name: me.username,
label,
});
await appDatabase.setItem(AppDatabaseKey.USER_PRIVILEGE, mapUserPrivileges(me.permissions, me.isSuperadmin));
}
export async function terminateAuthSession(options: TerminateOptions = {}): Promise<void> {
const { preserveRedirect = false } = options;
/**
* Delete invalid tokens (Clear local state)
*/
await appDatabase.removeItem(AppDatabaseKey.USER_PRIVILEGE);
await appDatabase.removeItem(AppDatabaseKey.USER_PROFILE);
await appDatabase.removeItem(AppDatabaseKey.ACCESS_TOKEN);
await appDatabase.removeItem(AppDatabaseKey.REFRESH_TOKEN);
await appStorage.removeItem(AppStorageKey.USER_ID);
/**
* Build the login URL
*/
let loginUrl = '/auth/login';
// Check if the current page is NOT the login page
const isNotLoginPage = !window.location.pathname.includes('/auth/login');
if (isNotLoginPage) {
// Set redirect parameters only if requested AND the user is not currently on the login page
if (preserveRedirect) {
const currentPath = window.location.pathname + window.location.search;
loginUrl += `?redirect=${encodeURIComponent(currentPath)}`;
}
/**
* Redirect without saving history to prevent infinite back-loops
*/
window.location.replace(loginUrl);
}
}
export async function initiateAuthSession(respLogin: any) {
try {
const data = respLogin?.data ?? {};
const { token, id } = data;
const userProfile = lodash.omit(data, ['token']);
if (!token) throw new Error('Login response missing token');
// FIXME: Populate this with the mapped privilege data.
// NOTE: The assignment below needs to be updated. Replace the empty object `{}`
// with the actual mapped data (e.g., from mappingUserPrivilege(data)).
// Expected structure (Record<string, PrivilegeEntity>):
// {
// "TRANSACTION_BOOKING": {
// ALLOW_CREATE: true,
// ALLOW_VIEW: true,
// // ...
// }
// }
const userPrivilege: Record<string, PrivilegeEntity> = {};
/**
* Store credentials in local storage
*/
await appStorage.setItem(AppStorageKey.USER_ID, id);
await appDatabase.setItem(AppDatabaseKey.ACCESS_TOKEN, token);
await appDatabase.setItem(AppDatabaseKey.USER_PROFILE, { ...userProfile, label: userProfile.role });
await appDatabase.setItem(AppDatabaseKey.USER_PRIVILEGE, userPrivilege);
/**
* Determine redirect destination
* If the user was redirected here from a protected page, honour that URL.
* Otherwise fall back to the default app entry point.
*/
const params = new URLSearchParams(window.location.search);
const redirectTo = params.get('redirect');
window.location.replace(redirectTo ? decodeURIComponent(redirectTo) : '/app');
} catch (error) {
throw error as any;
export async function initiateAuthSession(tokens: TokenPair, me: AuthUser): Promise<void> {
if (!tokens.accessToken) {
throw new Error('Login response missing access token');
}
await persistTokenPair(tokens);
await persistUserSession(me);
const params = new URLSearchParams(window.location.search);
const redirectTo = params.get('redirect');
window.location.replace(redirectTo ? decodeURIComponent(redirectTo) : '/app');
}
let refreshInFlight: Promise<TokenPair> | null = null;
export async function refreshAuthSession(): Promise<TokenPair> {
if (!refreshInFlight) {
refreshInFlight = rotateRefreshToken().finally(() => {
refreshInFlight = null;
});
}
return refreshInFlight;
}
async function rotateRefreshToken(): Promise<TokenPair> {
const refreshToken = await appDatabase.getItem<string>(AppDatabaseKey.REFRESH_TOKEN);
if (!refreshToken) {
throw new Error('Missing refresh token');
}
const { apiClient } = await import('./api-client');
const response = await apiClient.request<TokenPair>({
url: API_URL.AUTH_REFRESH,
method: 'POST',
data: { refreshToken },
skipAuthRefresh: true,
});
const tokens: TokenPair = {
accessToken: response.data.accessToken,
refreshToken: response.data.refreshToken,
};
await persistTokenPair(tokens);
return tokens;
}
export async function logoutAuthSession(): Promise<void> {
const refreshToken = await appDatabase.getItem<string>(AppDatabaseKey.REFRESH_TOKEN);
try {
if (refreshToken) {
const { apiClient } = await import('./api-client');
await apiClient.request({
url: API_URL.AUTH_REVOKE,
method: 'POST',
data: { refreshToken },
skipAuthRefresh: true,
});
}
} catch {
// Always clear the local session, even if the server revoke call fails.
}
await terminateAuthSession({ preserveRedirect: false });
}
+31
View File
@@ -0,0 +1,31 @@
export interface LoginPayload {
username: string;
password: string;
}
export interface TokenPair {
accessToken: string;
refreshToken: string;
}
export interface AuthPrivilege {
id: string;
name: string;
code: string;
}
export interface AuthPermissionFlags {
view?: boolean;
create?: boolean;
update?: boolean;
delete?: boolean;
import?: boolean;
}
export interface AuthUser {
id: string;
username: string;
isSuperadmin: boolean;
privilege: AuthPrivilege | null;
permissions: Record<string, AuthPermissionFlags>;
}
@@ -0,0 +1,58 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { defaultPrivileges, noPrivileges } from '@repo/ui/foundations';
vi.mock('../storage/local', () => ({
appDatabase: {
getItem: vi.fn(),
setItem: vi.fn(),
},
AppDatabaseKey: {
USER_PRIVILEGE: 'user_privilege',
USER_PROFILE: 'user_profile',
OFFLINE_DRAFT: 'offline_draft',
},
}));
import { appDatabase } from '../storage/local';
import { enterpriseStorageAdapter } from './enterprise-storage-adapter';
describe('enterpriseStorageAdapter', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns defaultPrivileges for any module when the user is superadmin', async () => {
vi.mocked(appDatabase.getItem).mockImplementation(async (key) => {
if (key === 'user_profile') return { isSuperadmin: true };
return null;
});
await expect(enterpriseStorageAdapter.getPrivileges('PRIVILEGES')).resolves.toEqual(defaultPrivileges);
await expect(enterpriseStorageAdapter.getPrivileges('UNKNOWN')).resolves.toEqual(defaultPrivileges);
});
it('returns stored module privileges for a regular user', async () => {
vi.mocked(appDatabase.getItem).mockImplementation(async (key) => {
if (key === 'user_profile') return { isSuperadmin: false };
if (key === 'user_privilege') {
return { PRIVILEGES: { ...noPrivileges, ALLOW_VIEW: true } };
}
return null;
});
await expect(enterpriseStorageAdapter.getPrivileges('PRIVILEGES')).resolves.toEqual({
...noPrivileges,
ALLOW_VIEW: true,
});
});
it('returns null when the module key is missing', async () => {
vi.mocked(appDatabase.getItem).mockImplementation(async (key) => {
if (key === 'user_profile') return { isSuperadmin: false };
if (key === 'user_privilege') return {};
return null;
});
await expect(enterpriseStorageAdapter.getPrivileges('MISSING')).resolves.toBeNull();
});
});
@@ -1,7 +1,11 @@
import type { EnterpriseStorageAdapter } from '@repo/ui/foundations';
import type { PrivilegeEntity } from '@repo/ui/foundations';
import { defaultPrivileges, type PrivilegeEntity } from '@repo/ui/foundations';
import { appDatabase, AppDatabaseKey } from '../storage/local';
interface StoredUserProfile {
isSuperadmin?: boolean;
}
/**
* Concrete storage adapter that wires the Enterprise Module framework
* to this app's IndexedDB instance.
@@ -10,7 +14,12 @@ import { appDatabase, AppDatabaseKey } from '../storage/local';
*/
export const enterpriseStorageAdapter: EnterpriseStorageAdapter = {
async getPrivileges(moduleKey: string): Promise<PrivilegeEntity | null> {
const allPrivileges: any = await appDatabase.getItem(AppDatabaseKey.USER_PRIVILEGE);
const profile = await appDatabase.getItem<StoredUserProfile>(AppDatabaseKey.USER_PROFILE);
if (profile?.isSuperadmin) {
return defaultPrivileges;
}
const allPrivileges = await appDatabase.getItem<Record<string, PrivilegeEntity>>(AppDatabaseKey.USER_PRIVILEGE);
if (!allPrivileges) return null;
return allPrivileges[moduleKey] ?? null;
},
@@ -0,0 +1,8 @@
export function getHttpErrorStatus(error: unknown): number | undefined {
if (!error || typeof error !== 'object') {
return undefined;
}
const candidate = error as { response?: { status?: number }; status?: number };
return candidate.response?.status ?? candidate.status;
}
@@ -0,0 +1,73 @@
import { describe, expect, it, vi } from 'vitest';
import type { AxiosError, AxiosResponse, InternalAxiosRequestConfig } from '@repo/core-api/http-client';
import { handleUnauthorized, isPublicAuthRequest } from './handle-unauthorized';
function createAxiosError(status: number, url: string, skipAuthRefresh?: boolean): AxiosError {
return {
response: { status } as AxiosError['response'],
config: { url, skipAuthRefresh } as InternalAxiosRequestConfig,
isAxiosError: true,
name: 'AxiosError',
message: 'Request failed',
toJSON: () => ({}),
} as AxiosError;
}
describe('isPublicAuthRequest', () => {
it('treats login, register, refresh, and revoke as public', () => {
expect(isPublicAuthRequest('/auth/login')).toBe(true);
expect(isPublicAuthRequest('http://localhost:3346/auth/refresh')).toBe(true);
expect(isPublicAuthRequest('/customers')).toBe(false);
});
});
describe('handleUnauthorized', () => {
it('does not refresh or terminate for public auth routes', async () => {
const refresh = vi.fn();
const terminate = vi.fn();
const retry = vi.fn();
const error = createAxiosError(401, '/auth/login');
await expect(handleUnauthorized(error, { refresh, terminate, retry })).rejects.toBe(error);
expect(refresh).not.toHaveBeenCalled();
expect(terminate).not.toHaveBeenCalled();
expect(retry).not.toHaveBeenCalled();
});
it('refreshes once and retries the original request', async () => {
const refresh = vi.fn().mockResolvedValue(undefined);
const terminate = vi.fn();
const retried = { status: 200, data: { ok: true } } as AxiosResponse;
const retry = vi.fn().mockResolvedValue(retried);
const error = createAxiosError(401, '/customers');
const result = await handleUnauthorized(error, { refresh, terminate, retry });
expect(refresh).toHaveBeenCalledTimes(1);
expect(retry).toHaveBeenCalledWith(expect.objectContaining({ url: '/customers', skipAuthRefresh: true }));
expect(terminate).not.toHaveBeenCalled();
expect(result).toBe(retried);
});
it('terminates when skipAuthRefresh is set', async () => {
const refresh = vi.fn();
const terminate = vi.fn().mockResolvedValue(undefined);
const retry = vi.fn();
const error = createAxiosError(401, '/customers', true);
await expect(handleUnauthorized(error, { refresh, terminate, retry })).rejects.toBe(error);
expect(refresh).not.toHaveBeenCalled();
expect(terminate).toHaveBeenCalledTimes(1);
});
it('terminates when refresh fails', async () => {
const refresh = vi.fn().mockRejectedValue(new Error('invalid refresh'));
const terminate = vi.fn().mockResolvedValue(undefined);
const retry = vi.fn();
const error = createAxiosError(401, '/customers');
await expect(handleUnauthorized(error, { refresh, terminate, retry })).rejects.toBe(error);
expect(terminate).toHaveBeenCalledTimes(1);
expect(retry).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,36 @@
import type { AxiosError, AxiosResponse, InternalAxiosRequestConfig } from '@repo/core-api/http-client';
const PUBLIC_AUTH_PATHS = ['/auth/login', '/auth/register', '/auth/refresh', '/auth/revoke'];
export function isPublicAuthRequest(url?: string): boolean {
if (!url) return false;
return PUBLIC_AUTH_PATHS.some((path) => url.includes(path));
}
export interface UnauthorizedHandlerDeps {
refresh: () => Promise<unknown>;
terminate: () => Promise<unknown>;
retry: (config: InternalAxiosRequestConfig) => Promise<AxiosResponse>;
}
export async function handleUnauthorized(error: AxiosError, deps: UnauthorizedHandlerDeps): Promise<AxiosResponse> {
const config = error.config;
const url = config?.url;
if (isPublicAuthRequest(url)) {
throw error;
}
if (!config || config.skipAuthRefresh) {
await deps.terminate();
throw error;
}
try {
await deps.refresh();
return await deps.retry({ ...config, skipAuthRefresh: true });
} catch {
await deps.terminate();
throw error;
}
}
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest';
import { noPrivileges } from '@repo/ui/foundations';
import { mapUserPrivileges } from './map-user-privileges';
describe('mapUserPrivileges', () => {
it('maps view/create/update/delete and ignores extra API flags', () => {
const result = mapUserPrivileges(
{
PRIVILEGES: {
view: true,
create: true,
update: false,
delete: false,
import: true,
},
},
false,
);
expect(result.PRIVILEGES).toEqual({
...noPrivileges,
ALLOW_VIEW: true,
ALLOW_CREATE: true,
ALLOW_EDIT: false,
ALLOW_DELETE: false,
});
});
it('defaults missing ALLOW flags to false', () => {
const result = mapUserPrivileges({ CUSTOMERS: { view: true } }, false);
expect(result.CUSTOMERS).toEqual({
...noPrivileges,
ALLOW_VIEW: true,
ALLOW_CREATE: false,
ALLOW_EDIT: false,
ALLOW_DELETE: false,
});
});
it('returns an empty map for superadmin (adapter grants full privileges)', () => {
const result = mapUserPrivileges(
{
PRIVILEGES: { view: true, create: true, update: true, delete: true },
},
true,
);
expect(result).toEqual({});
});
it('returns an empty map when permissions are missing', () => {
expect(mapUserPrivileges(undefined, false)).toEqual({});
});
});
@@ -0,0 +1,25 @@
import { noPrivileges, type PrivilegeEntity } from '@repo/ui/foundations';
import type { AuthPermissionFlags, AuthUser } from './auth.types';
export function mapUserPrivileges(
permissions: AuthUser['permissions'] | undefined,
isSuperadmin: boolean,
): Record<string, PrivilegeEntity> {
if (isSuperadmin || !permissions) {
return {};
}
return Object.fromEntries(
Object.entries(permissions).map(([moduleKey, flags]) => [moduleKey, mapPermissionFlags(flags)]),
);
}
function mapPermissionFlags(flags: AuthPermissionFlags = {}): PrivilegeEntity {
return {
...noPrivileges,
ALLOW_VIEW: flags.view ?? false,
ALLOW_CREATE: flags.create ?? false,
ALLOW_EDIT: flags.update ?? false,
ALLOW_DELETE: flags.delete ?? false,
};
}
-1
View File
@@ -25,7 +25,6 @@
import type {} from '@repo/core-events';
declare module '@repo/core-events' {
// ─── Payload Types ──────────────────────────────────────────
interface ReceiptItem {
+5 -1
View File
@@ -1,4 +1,4 @@
import { defineConfig } from 'vite';
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
@@ -18,4 +18,8 @@ export default defineConfig({
events: 'events',
},
},
test: {
environment: 'node',
globals: false,
},
});
@@ -420,7 +420,7 @@ describe('BaseRemoteDataServices — Data Transformer Integration', () => {
id: '42',
bookingCode: 'BK042',
customerName: 'Alice',
}
},
});
expect(result.status).toBe(200);
});
@@ -432,7 +432,7 @@ describe('BaseRemoteDataServices — Data Transformer Integration', () => {
{ id: '1', booking_code: 'BK001', customer_name: 'Alice' },
{ id: '2', booking_code: 'BK002', customer_name: 'Bob' },
],
meta: { currentPage: 1, itemsPerPage: 15, totalItems: 2, totalPages: 1 }
meta: { currentPage: 1, itemsPerPage: 15, totalItems: 2, totalPages: 1 },
},
status: 200,
});
@@ -444,7 +444,7 @@ describe('BaseRemoteDataServices — Data Transformer Integration', () => {
{ id: '1', bookingCode: 'BK001', customerName: 'Alice' },
{ id: '2', bookingCode: 'BK002', customerName: 'Bob' },
],
meta: { page: 1, limit: 15, total: 2, totalPages: 1 }
meta: { page: 1, limit: 15, total: 2, totalPages: 1 },
});
});
@@ -458,7 +458,7 @@ describe('BaseRemoteDataServices — Data Transformer Integration', () => {
expect(result.data).toEqual({
data: [],
meta: { page: 1, limit: 10, total: 0, totalPages: 0 }
meta: { page: 1, limit: 10, total: 0, totalPages: 0 },
});
});
@@ -541,7 +541,7 @@ describe('BaseRemoteDataServices — Data Transformer Integration', () => {
id: '42',
bookingCode: 'BK042',
customerName: 'ALICE', // uppercased by custom hook
}
},
});
});
@@ -558,7 +558,7 @@ describe('BaseRemoteDataServices — Data Transformer Integration', () => {
expect(result.data).toEqual({
data: [{ id: '1', bookingCode: 'LIST-BK001', customerName: 'Alice' }],
meta: { page: 1, limit: 10, total: 1, totalPages: 1 }
meta: { page: 1, limit: 10, total: 1, totalPages: 1 },
});
});
@@ -280,7 +280,11 @@ export abstract class BaseRemoteDataServices<E extends BaseEntity = BaseEntity,
}
/** Delete multiple entities by IDs. Optionally sends form data as `meta` in the request body. */
batchDelete(ids: EntityId[], meta?: Record<string, unknown>, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
batchDelete(
ids: EntityId[],
meta?: Record<string, unknown>,
config?: AxiosRequestConfig,
): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchDelete, {
config: { ...config, data: { ids, ...(meta ? { meta } : {}) } },
});
@@ -297,7 +301,11 @@ export abstract class BaseRemoteDataServices<E extends BaseEntity = BaseEntity,
}
/** Activate multiple entities. Optionally sends form data as `meta` in the request body. */
batchActivate(ids: EntityId[], meta?: Record<string, unknown>, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
batchActivate(
ids: EntityId[],
meta?: Record<string, unknown>,
config?: AxiosRequestConfig,
): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchActivate, {
config: { ...config, data: { ids, ...(meta ? { meta } : {}) } },
});
@@ -312,7 +320,11 @@ export abstract class BaseRemoteDataServices<E extends BaseEntity = BaseEntity,
}
/** Deactivate multiple entities. Optionally sends form data as `meta` in the request body. */
batchDeactivate(ids: EntityId[], meta?: Record<string, unknown>, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
batchDeactivate(
ids: EntityId[],
meta?: Record<string, unknown>,
config?: AxiosRequestConfig,
): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchDeactivate, {
config: { ...config, data: { ids, ...(meta ? { meta } : {}) } },
});
@@ -329,7 +341,11 @@ export abstract class BaseRemoteDataServices<E extends BaseEntity = BaseEntity,
}
/** Confirm processing of multiple data records. Optionally sends form data as `meta` in the request body. */
batchConfirmData(ids: EntityId[], meta?: Record<string, unknown>, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
batchConfirmData(
ids: EntityId[],
meta?: Record<string, unknown>,
config?: AxiosRequestConfig,
): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchConfirmData, {
config: { ...config, data: { ids, ...(meta ? { meta } : {}) } },
});
@@ -344,7 +360,11 @@ export abstract class BaseRemoteDataServices<E extends BaseEntity = BaseEntity,
}
/** Cancel processing of multiple data records. Optionally sends form data as `meta` in the request body. */
batchCancelData(ids: EntityId[], meta?: Record<string, unknown>, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
batchCancelData(
ids: EntityId[],
meta?: Record<string, unknown>,
config?: AxiosRequestConfig,
): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchCancelData, {
config: { ...config, data: { ids, ...(meta ? { meta } : {}) } },
});
@@ -361,7 +381,11 @@ export abstract class BaseRemoteDataServices<E extends BaseEntity = BaseEntity,
}
/** Rollback multiple transactions. Optionally sends form data as `meta` in the request body. */
batchRollbackData(ids: EntityId[], meta?: Record<string, unknown>, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
batchRollbackData(
ids: EntityId[],
meta?: Record<string, unknown>,
config?: AxiosRequestConfig,
): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchRollbackData, {
config: { ...config, data: { ids, ...(meta ? { meta } : {}) } },
});
@@ -376,7 +400,11 @@ export abstract class BaseRemoteDataServices<E extends BaseEntity = BaseEntity,
}
/** Hold multiple transactions. Optionally sends form data as `meta` in the request body. */
batchHoldData(ids: EntityId[], meta?: Record<string, unknown>, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
batchHoldData(
ids: EntityId[],
meta?: Record<string, unknown>,
config?: AxiosRequestConfig,
): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchHoldData, {
config: { ...config, data: { ids, ...(meta ? { meta } : {}) } },
});
@@ -52,8 +52,7 @@ import { BaseRemoteDataServices } from './base-remote.data-services';
* }
* ```
*/
export class CommonRemoteDataServices<
E extends BaseEntity = BaseEntity,
TDTO = E,
> extends BaseRemoteDataServices<E, TDTO> {}
export class CommonRemoteDataServices<E extends BaseEntity = BaseEntity, TDTO = E> extends BaseRemoteDataServices<
E,
TDTO
> {}
+5 -29
View File
@@ -33,13 +33,7 @@ export class ApiError extends Error {
/** The original Axios error, preserved for debugging. */
readonly cause: AxiosError | undefined;
constructor(
message: string,
code: ApiErrorCode,
status: number,
data?: unknown,
cause?: AxiosError,
) {
constructor(message: string, code: ApiErrorCode, status: number, data?: unknown, cause?: AxiosError) {
super(message);
this.name = 'ApiError';
this.code = code;
@@ -59,30 +53,12 @@ export class ApiError extends Error {
// Network error (no response received)
if (!error.response) {
if (error.code === 'ECONNABORTED') {
return new ApiError(
'Request timed out',
ApiErrorCode.TIMEOUT,
0,
undefined,
error,
);
return new ApiError('Request timed out', ApiErrorCode.TIMEOUT, 0, undefined, error);
}
if (error.code === 'ERR_CANCELED') {
return new ApiError(
'Request was cancelled',
ApiErrorCode.CANCELLED,
0,
undefined,
error,
);
return new ApiError('Request was cancelled', ApiErrorCode.CANCELLED, 0, undefined, error);
}
return new ApiError(
error.message || 'Network error',
ApiErrorCode.NETWORK_ERROR,
0,
undefined,
error,
);
return new ApiError(error.message || 'Network error', ApiErrorCode.NETWORK_ERROR, 0, undefined, error);
}
// Server responded with an error status
@@ -93,7 +69,7 @@ export class ApiError extends Error {
// Extract message from common server response formats
const serverMessage =
(data && typeof data === 'object' && 'message' in data)
data && typeof data === 'object' && 'message' in data
? String((data as Record<string, unknown>).message)
: `Request failed with status ${status}`;
+20 -10
View File
@@ -28,15 +28,25 @@ export enum ApiErrorCode {
*/
export function httpStatusToErrorCode(status: number): ApiErrorCode {
switch (status) {
case 400: return ApiErrorCode.BAD_REQUEST;
case 401: return ApiErrorCode.UNAUTHORIZED;
case 403: return ApiErrorCode.FORBIDDEN;
case 404: return ApiErrorCode.NOT_FOUND;
case 409: return ApiErrorCode.CONFLICT;
case 422: return ApiErrorCode.UNPROCESSABLE_ENTITY;
case 429: return ApiErrorCode.TOO_MANY_REQUESTS;
case 500: return ApiErrorCode.INTERNAL_SERVER_ERROR;
case 503: return ApiErrorCode.SERVICE_UNAVAILABLE;
default: return ApiErrorCode.UNKNOWN;
case 400:
return ApiErrorCode.BAD_REQUEST;
case 401:
return ApiErrorCode.UNAUTHORIZED;
case 403:
return ApiErrorCode.FORBIDDEN;
case 404:
return ApiErrorCode.NOT_FOUND;
case 409:
return ApiErrorCode.CONFLICT;
case 422:
return ApiErrorCode.UNPROCESSABLE_ENTITY;
case 429:
return ApiErrorCode.TOO_MANY_REQUESTS;
case 500:
return ApiErrorCode.INTERNAL_SERVER_ERROR;
case 503:
return ApiErrorCode.SERVICE_UNAVAILABLE;
default:
return ApiErrorCode.UNKNOWN;
}
}
@@ -37,10 +37,7 @@ import { ApiError } from '../errors/api-error';
* });
* ```
*/
export function createHttpClient(
config: HttpClientConfig,
hooks?: InterceptorHooks,
): AxiosInstance {
export function createHttpClient(config: HttpClientConfig, hooks?: InterceptorHooks): AxiosInstance {
const observability = config.observability ?? noopObservabilityAdapter;
// ── Create isolated instance ──────────────────────────────────
@@ -49,7 +46,7 @@ export function createHttpClient(
timeout: config.timeout ?? 15000,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
Accept: 'application/json',
...(config.defaultHeaders ?? {}),
},
});
+5 -15
View File
@@ -1,8 +1,4 @@
import type {
AxiosError,
AxiosResponse,
InternalAxiosRequestConfig,
} from 'axios';
import type { AxiosError, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
import type { IObservabilityAdapter } from '../observability/types';
// ─── Factory Configuration ──────────────────────────────────────
@@ -41,9 +37,7 @@ export interface InterceptorHooks {
* Called before every request is dispatched.
* Use this to inject authentication tokens, tenant headers, etc.
*/
onRequest?: (
config: InternalAxiosRequestConfig,
) => Promise<InternalAxiosRequestConfig> | InternalAxiosRequestConfig;
onRequest?: (config: InternalAxiosRequestConfig) => Promise<InternalAxiosRequestConfig> | InternalAxiosRequestConfig;
/**
* Called on every successful response (2xx status).
@@ -105,13 +99,7 @@ export interface TelemetryContext {
// ─── Re-export Axios types consumers frequently need ────────────
export type {
AxiosInstance,
AxiosError,
AxiosResponse,
AxiosRequestConfig,
InternalAxiosRequestConfig,
} from 'axios';
export type { AxiosInstance, AxiosError, AxiosResponse, AxiosRequestConfig, InternalAxiosRequestConfig } from 'axios';
// ─── Augment Axios to carry TelemetryContext ────────────────────
@@ -119,5 +107,7 @@ declare module 'axios' {
interface AxiosRequestConfig {
/** Per-request telemetry context for custom spans, tags, events. */
telemetryContext?: TelemetryContext;
/** Skip the 401 refresh-and-retry interceptor for this request. */
skipAuthRefresh?: boolean;
}
}
@@ -70,10 +70,7 @@ function makeRequestConfig(
} as InternalAxiosRequestConfig;
}
function makeAxiosResponse(
config: InternalAxiosRequestConfig,
overrides: Partial<AxiosResponse> = {},
): AxiosResponse {
function makeAxiosResponse(config: InternalAxiosRequestConfig, overrides: Partial<AxiosResponse> = {}): AxiosResponse {
return {
data: {},
status: 200,
@@ -43,9 +43,7 @@ function getTelemetryContext(config: unknown): TelemetryContext | undefined {
/** Convert TelemetryContext tags to a string record for Faro context. */
function tagsToFaroContext(tags?: Record<string, string | number | boolean>): Record<string, string> {
if (!tags) return {};
return Object.fromEntries(
Object.entries(tags).map(([k, v]) => [k, String(v)]),
);
return Object.fromEntries(Object.entries(tags).map(([k, v]) => [k, String(v)]));
}
/**
@@ -135,10 +133,7 @@ export const faroAdapter: IObservabilityAdapter = {
const faro = getFaro();
if (faro) {
const baseContext = buildBaseContext(method, url, moduleKey, action, ctx?.tags);
faro.api.pushLog(
[`[core-api] ${method} ${url}`],
{ level: LogLevel.DEBUG, context: baseContext },
);
faro.api.pushLog([`[core-api] ${method} ${url}`], { level: LogLevel.DEBUG, context: baseContext });
}
},
@@ -203,10 +198,10 @@ export const faroAdapter: IObservabilityAdapter = {
context: errorContext,
});
faro.api.pushLog(
[`[core-api] ERROR ${method} ${url}${status}`],
{ level: LogLevel.ERROR, context: errorContext },
);
faro.api.pushLog([`[core-api] ERROR ${method} ${url}${status}`], {
level: LogLevel.ERROR,
context: errorContext,
});
}
},
};
+2 -7
View File
@@ -17,11 +17,7 @@
* ```
*/
import {
getWebInstrumentations,
initializeFaro,
type Faro,
} from '@grafana/faro-react';
import { getWebInstrumentations, initializeFaro, type Faro } from '@grafana/faro-react';
import { TracingInstrumentation } from '@grafana/faro-web-tracing';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-web';
@@ -125,8 +121,7 @@ export function initTelemetry(config: TelemetryConfig): Faro {
new TracingInstrumentation({
...tracingOptions,
instrumentationOptions: {
propagateTraceHeaderCorsUrls:
config.propagateTraceHeaderCorsUrls ?? [/.*/],
propagateTraceHeaderCorsUrls: config.propagateTraceHeaderCorsUrls ?? [/.*/],
fetchInstrumentationOptions: {
applyCustomAttributesOnSpan(span) {
span.setAttribute('app.synthetic_request', 'false');
+1 -2
View File
@@ -242,8 +242,7 @@ describe('useAppEvent (React Hook)', () => {
const offSpy = vi.spyOn(eventBus, 'off');
const { rerender } = renderHook(
({ handler }: { handler: () => void }) =>
useAppEvent('TEST:INITIALIZED', handler),
({ handler }: { handler: () => void }) => useAppEvent('TEST:INITIALIZED', handler),
{ initialProps: { handler: vi.fn() } },
);
+2 -8
View File
@@ -31,10 +31,7 @@ export const eventBus = mitt<AppEvents>();
* publish('AUTH:PROFILE_UPDATED', { id: '1', name: 'Firman', ... });
* ```
*/
export function publish<K extends keyof AppEvents>(
type: K,
event: AppEvents[K],
): void {
export function publish<K extends keyof AppEvents>(type: K, event: AppEvents[K]): void {
eventBus.emit(type, event);
}
@@ -58,10 +55,7 @@ export function publish<K extends keyof AppEvents>(
* unsub();
* ```
*/
export function subscribe<K extends keyof AppEvents>(
type: K,
handler: (event: AppEvents[K]) => void,
): () => void {
export function subscribe<K extends keyof AppEvents>(type: K, handler: (event: AppEvents[K]) => void): () => void {
eventBus.on(type, handler);
return () => eventBus.off(type, handler);
}
+1 -4
View File
@@ -22,10 +22,7 @@ import { eventBus, publish as busPublish } from './event-bus';
* });
* ```
*/
export function useAppEvent<K extends keyof AppEvents>(
type: K,
handler: (event: AppEvents[K]) => void,
): void {
export function useAppEvent<K extends keyof AppEvents>(type: K, handler: (event: AppEvents[K]) => void): void {
// Always keep the latest handler in a ref to avoid stale closures
// and prevent re-subscription on every render.
const handlerRef = useRef(handler);
+1 -1
View File
@@ -4,6 +4,6 @@ import type { resources } from './setup';
declare module 'react-i18next' {
interface CustomTypeOptions {
defaultNS: 'common';
resources: typeof resources['en'];
resources: (typeof resources)['en'];
}
}
@@ -189,7 +189,7 @@ export class IndexedDBService<TKey extends string> implements IStorageService<TK
for (const rawKey of rawKeys) {
const isGlobalKey = this.encryptedKeys.has(rawKey as TKey) || this.plainTextKeys.has(rawKey as TKey);
if (isGlobalKey && !this.personalizedKeys?.has(rawKey as TKey)) {
resultSet.add(rawKey as TKey);
}
@@ -35,9 +35,7 @@ function createTestDB(name: string) {
const results: (PouchDB.Core.Response | PouchDB.Core.Error)[] = [];
for (let i = 0; i < dataList.length; i += batchSize) {
const batch = dataList.slice(i, i + batchSize);
const response = await raw.bulkDocs(
batch as PouchDB.Core.Document<T>[],
);
const response = await raw.bulkDocs(batch as PouchDB.Core.Document<T>[]);
results.push(...response);
}
return results;
@@ -65,16 +63,12 @@ function createTestDB(name: string) {
// ─── Reads ────────────────────────────────────────────────
async getOne<T>(id: string) {
return raw.get<T>(id) as Promise<
T & PouchDB.Core.IdMeta & PouchDB.Core.RevisionIdMeta
>;
return raw.get<T>(id) as Promise<T & PouchDB.Core.IdMeta & PouchDB.Core.RevisionIdMeta>;
},
async getAll<T>() {
const result = await raw.allDocs({ include_docs: true });
return result.rows
.filter((row) => !row.id.startsWith('_design/'))
.map((row) => row.doc as unknown as T);
return result.rows.filter((row) => !row.id.startsWith('_design/')).map((row) => row.doc as unknown as T);
},
async getSome<T>(ids: string[]) {
@@ -85,9 +79,7 @@ function createTestDB(name: string) {
},
async find<T extends object>(options: PouchDB.Find.FindRequest<T>) {
const result = await raw.find(
options as PouchDB.Find.FindRequest<object>,
);
const result = await raw.find(options as PouchDB.Find.FindRequest<object>);
return result.docs as unknown as T[];
},
@@ -146,9 +138,7 @@ describe('PouchBase — Core CRUD Operations', () => {
let db: ReturnType<typeof createTestDB>;
beforeEach(() => {
db = createTestDB(
`test_base_${Date.now()}_${Math.random().toString(36).slice(2)}`,
);
db = createTestDB(`test_base_${Date.now()}_${Math.random().toString(36).slice(2)}`);
});
afterEach(async () => {
@@ -170,9 +160,7 @@ describe('PouchBase — Core CRUD Operations', () => {
it('should throw a conflict if creating with a duplicate _id', async () => {
await db.create({ _id: 'dup-001', name: 'First' });
await expect(
db.create({ _id: 'dup-001', name: 'Second' }),
).rejects.toThrow();
await expect(db.create({ _id: 'dup-001', name: 'Second' })).rejects.toThrow();
});
});
@@ -212,9 +200,7 @@ describe('PouchBase — Core CRUD Operations', () => {
it('should retrieve a document by id', async () => {
await db.create({ _id: 'fetch-001', product: 'Widget', price: 9.99 });
const doc = await db.getOne<{ product: string; price: number }>(
'fetch-001',
);
const doc = await db.getOne<{ product: string; price: number }>('fetch-001');
expect(doc._id).toBe('fetch-001');
expect(doc.product).toBe('Widget');
expect(doc.price).toBe(9.99);
@@ -87,7 +87,11 @@ describe('FieldAsyncSelect', () => {
const { control, handleSubmit } = useForm({ defaultValues: { vendor: null } });
return (
<MantineProvider>
<form onSubmit={handleSubmit((data) => { capturedData = data; })}>
<form
onSubmit={handleSubmit((data) => {
capturedData = data;
})}
>
<FieldAsyncSelect
name="vendor"
control={control}
@@ -103,7 +107,7 @@ describe('FieldAsyncSelect', () => {
}
render(<TestForm />);
await waitFor(() => {
expect(mockLoadOptions).toHaveBeenCalled();
});
@@ -157,7 +161,7 @@ describe('FieldAsyncSelect', () => {
it('debounces search input and refetches', async () => {
const user = userEvent.setup();
const mockLoadOptions = createMockLoadOptions();
function TestForm() {
const { control } = useForm({ defaultValues: { vendor: null } });
return (
@@ -232,7 +236,7 @@ describe('FieldAsyncSelect', () => {
// Should only render "Vendor 1" once, ignoring the duplicate with id=1
const vendor1Options = screen.getAllByText('Vendor 1');
expect(vendor1Options.length).toBe(1);
// The duplicate name should NOT be rendered
expect(screen.queryByText('Vendor 1 (Duplicate)')).not.toBeInTheDocument();
});
@@ -49,11 +49,7 @@ describe('FieldCheckbox', () => {
const { control } = useForm({ defaultValues: { acceptTerms: false } });
return (
<MantineProvider>
<FieldCheckbox
name="acceptTerms"
control={control}
label="I accept the terms and conditions"
/>
<FieldCheckbox name="acceptTerms" control={control} label="I accept the terms and conditions" />
</MantineProvider>
);
}
@@ -69,11 +65,7 @@ describe('FieldCheckbox', () => {
const { control } = useForm({ defaultValues: { acceptTerms: false } });
return (
<MantineProvider>
<FieldCheckbox
name="acceptTerms"
control={control}
label="Accept Terms"
/>
<FieldCheckbox name="acceptTerms" control={control} label="Accept Terms" />
</MantineProvider>
);
}
@@ -114,10 +106,7 @@ describe('FieldCheckbox', () => {
await user.click(screen.getByText('Submit'));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
{ acceptTerms: true },
expect.anything(),
);
expect(onSubmit).toHaveBeenCalledWith({ acceptTerms: true }, expect.anything());
});
});
@@ -70,7 +70,11 @@ describe('FieldLocalSelect', () => {
const { control, handleSubmit } = useForm({ defaultValues: { vendor: null } });
return (
<MantineProvider>
<form onSubmit={handleSubmit((data) => { capturedData = data; })}>
<form
onSubmit={handleSubmit((data) => {
capturedData = data;
})}
>
<FieldLocalSelect
name="vendor"
control={control}
@@ -86,13 +90,13 @@ describe('FieldLocalSelect', () => {
}
render(<TestForm />);
// Open dropdown
await user.click(screen.getByPlaceholderText('Select vendor'));
// Click Vendor 2
await user.click(screen.getByText('Vendor 2'));
// Submit
await user.click(screen.getByText('Submit'));
@@ -102,7 +106,7 @@ describe('FieldLocalSelect', () => {
it('renders with renderLabel for compound labels', async () => {
const user = userEvent.setup();
function TestForm() {
const { control } = useForm({ defaultValues: { vendor: null } });
return (
@@ -121,16 +125,14 @@ describe('FieldLocalSelect', () => {
render(<TestForm />);
await user.click(screen.getByPlaceholderText('Select vendor'));
// Compound label should be visible
expect(screen.getByText('V1 - Vendor 1')).toBeInTheDocument();
});
it('filterOption excludes items from dropdown', async () => {
const user = userEvent.setup();
function TestForm() {
const { control } = useForm({ defaultValues: { vendor: null } });
return (
@@ -150,7 +152,7 @@ describe('FieldLocalSelect', () => {
render(<TestForm />);
await user.click(screen.getByPlaceholderText('Select vendor'));
// Active vendors should be present
expect(screen.getByText('Vendor 1')).toBeInTheDocument();
expect(screen.getByText('Vendor 3')).toBeInTheDocument();
@@ -166,7 +168,11 @@ describe('FieldLocalSelect', () => {
const { control, handleSubmit } = useForm({ defaultValues: { vendors: [] } });
return (
<MantineProvider>
<form onSubmit={handleSubmit((data) => { capturedData = data; })}>
<form
onSubmit={handleSubmit((data) => {
capturedData = data;
})}
>
<FieldLocalSelect
multiple
name="vendors"
@@ -186,7 +192,7 @@ describe('FieldLocalSelect', () => {
await user.click(screen.getByPlaceholderText('Select vendors'));
await user.click(screen.getByText('Vendor 1'));
await user.click(screen.getByText('Vendor 3'));
await user.click(screen.getByText('Submit'));
expect(capturedData).toEqual({ vendors: [VENDORS[0], VENDORS[2]] });
});
@@ -199,7 +205,11 @@ describe('FieldLocalSelect', () => {
const { control, handleSubmit } = useForm({ defaultValues: { vendors: [VENDORS[0]] } });
return (
<MantineProvider>
<form onSubmit={handleSubmit((data) => { capturedData = data; })}>
<form
onSubmit={handleSubmit((data) => {
capturedData = data;
})}
>
<FieldLocalSelect
multiple
name="vendors"
@@ -216,8 +226,10 @@ describe('FieldLocalSelect', () => {
}
const { container } = render(<TestForm />);
const clearButton = container.querySelector('.mantine-CloseButton-root') || container.querySelector('button[aria-label="Clear value"]');
const clearButton =
container.querySelector('.mantine-CloseButton-root') ||
container.querySelector('button[aria-label="Clear value"]');
expect(clearButton).not.toBeNull();
await user.click(clearButton!);
@@ -32,10 +32,7 @@ vi.mock('@repo/core-i18n', () => ({
// ---------------------------------------------------------------------------
const loginSchema = z.object({
username: z
.string()
.min(1, 'Username cannot be empty')
.min(3, 'Username must be at least 3 characters'),
username: z.string().min(1, 'Username cannot be empty').min(3, 'Username must be at least 3 characters'),
email: z.string().email('Please enter a valid email address'),
});
@@ -57,12 +54,7 @@ describe('FieldTextInput', () => {
});
return (
<MantineProvider>
<FieldTextInput
name="username"
control={control}
label="Username"
placeholder="Enter username"
/>
<FieldTextInput name="username" control={control} label="Username" placeholder="Enter username" />
</MantineProvider>
);
}
@@ -143,10 +135,7 @@ describe('FieldTextInput', () => {
await user.click(screen.getByText('Login'));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
{ username: 'john', email: 'john@example.com' },
expect.anything(),
);
expect(onSubmit).toHaveBeenCalledWith({ username: 'john', email: 'john@example.com' }, expect.anything());
});
});
@@ -177,10 +166,7 @@ describe('FieldTextInput', () => {
await user.click(screen.getByText('Login'));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
{ username: 'johndoe', email: 'john@example.com' },
expect.anything(),
);
expect(onSubmit).toHaveBeenCalledWith({ username: 'johndoe', email: 'john@example.com' }, expect.anything());
});
});
});
@@ -41,11 +41,7 @@ interface FormTestWrapperProps {
onSubmit?: (data: Record<string, unknown>) => void;
}
function FormTestWrapper({
children,
defaultValues = {},
onSubmit = () => {},
}: FormTestWrapperProps) {
function FormTestWrapper({ children, defaultValues = {}, onSubmit = () => {} }: FormTestWrapperProps) {
const methods = useForm({ defaultValues });
return (
@@ -64,10 +60,7 @@ function FormTestWrapper({
// Create a test field component using the HOC
// ---------------------------------------------------------------------------
const TestFieldTextInput = withRHF<React.ComponentProps<typeof TextInput>>(
'TestFieldTextInput',
TextInput,
);
const TestFieldTextInput = withRHF<React.ComponentProps<typeof TextInput>>('TestFieldTextInput', TextInput);
// ---------------------------------------------------------------------------
// Tests
@@ -114,7 +107,11 @@ describe('withRHF HOC', () => {
const { control, handleSubmit } = useForm({ defaultValues: { email: '' } });
return (
<MantineProvider>
<form onSubmit={handleSubmit((data) => { capturedData = data; })}>
<form
onSubmit={handleSubmit((data) => {
capturedData = data;
})}
>
<TestFieldTextInput name="email" control={control} label="Email" />
<button type="submit">Submit</button>
</form>
@@ -215,16 +212,12 @@ describe('withRHF HOC', () => {
// The fallback should be the raw JSON string since neither namespace has the key.
// Our mock t() returns defaultValue when key is unknown, which is the raw JSON.
const errorElements = screen.getAllByText((content) =>
content.includes('validation.unknown_key'),
);
const errorElements = screen.getAllByText((content) => content.includes('validation.unknown_key'));
expect(errorElements.length).toBeGreaterThan(0);
});
it('has the correct displayName for React DevTools', () => {
expect(
(TestFieldTextInput as unknown as { displayName: string }).displayName,
).toBe('TestFieldTextInput');
expect((TestFieldTextInput as unknown as { displayName: string }).displayName).toBe('TestFieldTextInput');
});
it('forwards additional Mantine props (placeholder, etc.)', () => {
@@ -232,12 +225,7 @@ describe('withRHF HOC', () => {
const { control } = useForm({ defaultValues: { search: '' } });
return (
<MantineProvider>
<TestFieldTextInput
name="search"
control={control}
label="Search"
placeholder="Type to search..."
/>
<TestFieldTextInput name="search" control={control} label="Search" placeholder="Type to search..." />
</MantineProvider>
);
}
@@ -210,9 +210,7 @@ function AsyncSelectInner<T extends Record<string, any>>(props: AsyncSelectProps
// Disable Mantine's internal frontend filtering.
// The backend handles the search query, so we always display what the backend returns.
const mantineFilter = filterOption
? ({ options: opts }: any) => opts
: undefined;
const mantineFilter = filterOption ? ({ options: opts }: any) => opts : undefined;
// ----- Multi-select mode -----
if (multiple) {
@@ -25,8 +25,8 @@ type ManagedSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'filt
type ManagedMultiSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'filter' | 'name' | 'onSelect';
/** Props for single-select mode */
export type LocalSelectSingleProps<T extends Record<string, any>> =
LocalSelectBaseProps<T> & Omit<SelectProps, ManagedSelectProps> & {
export type LocalSelectSingleProps<T extends Record<string, any>> = LocalSelectBaseProps<T> &
Omit<SelectProps, ManagedSelectProps> & {
multiple?: false;
/** Controlled value — the full object or null */
value?: T | null;
@@ -35,8 +35,8 @@ export type LocalSelectSingleProps<T extends Record<string, any>> =
};
/** Props for multi-select mode */
export type LocalSelectMultiProps<T extends Record<string, any>> =
LocalSelectBaseProps<T> & Omit<MultiSelectProps, ManagedMultiSelectProps> & {
export type LocalSelectMultiProps<T extends Record<string, any>> = LocalSelectBaseProps<T> &
Omit<MultiSelectProps, ManagedMultiSelectProps> & {
multiple: true;
/** Controlled value — array of full objects */
value?: T[];
@@ -45,9 +45,7 @@ export type LocalSelectMultiProps<T extends Record<string, any>> =
};
/** Discriminated union — the component narrows based on `multiple` */
export type LocalSelectProps<T extends Record<string, any>> =
| LocalSelectSingleProps<T>
| LocalSelectMultiProps<T>;
export type LocalSelectProps<T extends Record<string, any>> = LocalSelectSingleProps<T> | LocalSelectMultiProps<T>;
// ---------------------------------------------------------------------------
// Helper: Resolve label for a data item
@@ -70,9 +68,7 @@ function resolveLabel<T extends Record<string, any>>(
// Component Implementation
// ---------------------------------------------------------------------------
function LocalSelectInner<T extends Record<string, any>>(
props: LocalSelectProps<T>,
) {
function LocalSelectInner<T extends Record<string, any>>(props: LocalSelectProps<T>) {
const {
options,
valueKey,
@@ -132,10 +128,7 @@ function LocalSelectInner<T extends Record<string, any>>(
// Passthrough filter — we handle filtering ourselves via filterOption in useMemo.
// This prevents Mantine from double-filtering.
const mantineFilter = filterOption
? ({ options: opts }: { options: ComboboxItem[] }) => opts
: undefined;
const mantineFilter = filterOption ? ({ options: opts }: { options: ComboboxItem[] }) => opts : undefined;
// ----- Multi-select mode -----
if (multiple) {
@@ -166,7 +159,7 @@ function LocalSelectInner<T extends Record<string, any>>(
const currentValue = value ? String((value as T)[valueKey]) : null;
const handleSingleChange = (val: string | null) => {
const obj = val ? lookupMap.get(val) ?? null : null;
const obj = val ? (lookupMap.get(val) ?? null) : null;
(onChange as ((v: T | null) => void) | undefined)?.(obj);
onSelectCallback?.(obj);
};
@@ -117,11 +117,7 @@ export interface LoadOptionsResponse<T> {
* };
* ```
*/
export type LoadOptionsFn<T> = (
search: string,
page: number,
prevOptions: T[],
) => Promise<LoadOptionsResponse<T>>;
export type LoadOptionsFn<T> = (search: string, page: number, prevOptions: T[]) => Promise<LoadOptionsResponse<T>>;
/**
* Internal cache entry for the search-keyed options cache.
@@ -1,10 +1,5 @@
import React from 'react';
import {
useController,
type FieldPath,
type FieldValues,
type UseControllerProps,
} from 'react-hook-form';
import { useController, type FieldPath, type FieldValues, type UseControllerProps } from 'react-hook-form';
import type { SelectProps, MultiSelectProps } from '@mantine/core';
import { AsyncSelect } from '../custom/selects/AsyncSelect';
import type { AsyncSelectBaseProps, LoadOptionsFn } from '../custom/selects/types';
@@ -26,8 +21,26 @@ import { useTranslatedError } from '../useTranslatedError';
// ---------------------------------------------------------------------------
/** Mantine props we manage ourselves */
type ManagedSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'onBlur' | 'error' | 'filter' | 'name' | 'onSelect';
type ManagedMultiSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'onBlur' | 'error' | 'filter' | 'name' | 'onSelect';
type ManagedSelectProps =
| 'data'
| 'value'
| 'defaultValue'
| 'onChange'
| 'onBlur'
| 'error'
| 'filter'
| 'name'
| 'onSelect';
type ManagedMultiSelectProps =
| 'data'
| 'value'
| 'defaultValue'
| 'onChange'
| 'onBlur'
| 'error'
| 'filter'
| 'name'
| 'onSelect';
/** Async-specific props (IoC pattern) */
interface AsyncExtraProps<T> {
@@ -69,9 +82,7 @@ export type FieldAsyncSelectProps<
T extends Record<string, any>,
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> =
| FieldAsyncSelectSingleProps<T, TFieldValues, TName>
| FieldAsyncSelectMultiProps<T, TFieldValues, TName>;
> = FieldAsyncSelectSingleProps<T, TFieldValues, TName> | FieldAsyncSelectMultiProps<T, TFieldValues, TName>;
// ---------------------------------------------------------------------------
// Component Implementation
@@ -149,12 +160,7 @@ function FieldAsyncSelectInner<
}
return (
<AsyncSelect<T>
{...engineProps}
value={field.value ?? null}
onChange={handleChange}
{...(mantineProps as any)}
/>
<AsyncSelect<T> {...engineProps} value={field.value ?? null} onChange={handleChange} {...(mantineProps as any)} />
);
}
@@ -1,10 +1,5 @@
import React from 'react';
import {
useController,
type FieldPath,
type FieldValues,
type UseControllerProps,
} from 'react-hook-form';
import { useController, type FieldPath, type FieldValues, type UseControllerProps } from 'react-hook-form';
import type { SelectProps, MultiSelectProps } from '@mantine/core';
import { LocalSelect } from '../custom/selects/LocalSelect';
import type { LocalSelectBaseProps } from '../custom/selects/types';
@@ -28,8 +23,26 @@ import { useTranslatedError } from '../useTranslatedError';
// ---------------------------------------------------------------------------
/** Mantine props we manage ourselves */
type ManagedSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'onBlur' | 'error' | 'filter' | 'name' | 'onSelect';
type ManagedMultiSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'onBlur' | 'error' | 'filter' | 'name' | 'onSelect';
type ManagedSelectProps =
| 'data'
| 'value'
| 'defaultValue'
| 'onChange'
| 'onBlur'
| 'error'
| 'filter'
| 'name'
| 'onSelect';
type ManagedMultiSelectProps =
| 'data'
| 'value'
| 'defaultValue'
| 'onChange'
| 'onBlur'
| 'error'
| 'filter'
| 'name'
| 'onSelect';
/** Single-select RHF props — stores T | null */
export type FieldLocalSelectSingleProps<
@@ -58,9 +71,7 @@ export type FieldLocalSelectProps<
T extends Record<string, any>,
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> =
| FieldLocalSelectSingleProps<T, TFieldValues, TName>
| FieldLocalSelectMultiProps<T, TFieldValues, TName>;
> = FieldLocalSelectSingleProps<T, TFieldValues, TName> | FieldLocalSelectMultiProps<T, TFieldValues, TName>;
// ---------------------------------------------------------------------------
// Component Implementation
@@ -22,17 +22,7 @@ function FieldRichTextEditorComponent<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>(props: FieldRichTextEditorProps<TFieldValues, TName>) {
const {
name,
control,
rules,
shouldUnregister,
defaultValue,
disabled,
label,
description,
withAsterisk,
} = props;
const { name, control, rules, shouldUnregister, defaultValue, disabled, label, description, withAsterisk } = props;
const {
field,
@@ -71,12 +61,7 @@ function FieldRichTextEditorComponent<
}, [field.value, editor]);
return (
<Input.Wrapper
label={label}
description={description}
withAsterisk={withAsterisk}
error={translatedError}
>
<Input.Wrapper label={label} description={description} withAsterisk={withAsterisk} error={translatedError}>
<RichTextEditor editor={editor}>
<RichTextEditor.Toolbar sticky stickyOffset={60}>
<RichTextEditor.ControlsGroup>
@@ -9,8 +9,6 @@ export interface FieldSegmentedControlProps extends SegmentedControlProps {
// SegmentedControl does NOT have a native `error` prop.
// The HOC wraps it in Input.Wrapper to display validation errors.
export const FieldSegmentedControl = withRHF<FieldSegmentedControlProps>(
'FieldSegmentedControl',
SegmentedControl,
{ requiresWrapper: true },
);
export const FieldSegmentedControl = withRHF<FieldSegmentedControlProps>('FieldSegmentedControl', SegmentedControl, {
requiresWrapper: true,
});
+3 -12
View File
@@ -1,9 +1,5 @@
import type { ComponentType } from 'react';
import type {
FieldPath,
FieldValues,
UseControllerProps,
} from 'react-hook-form';
import type { FieldPath, FieldValues, UseControllerProps } from 'react-hook-form';
// ---------------------------------------------------------------------------
// Zod i18n JSON payload shape
@@ -42,8 +38,7 @@ export type WithRHFProps<
TComponentProps,
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = Omit<TComponentProps, ManagedProps> &
UseControllerProps<TFieldValues, TName>;
> = Omit<TComponentProps, ManagedProps> & UseControllerProps<TFieldValues, TName>;
// ---------------------------------------------------------------------------
// Value transform — for components with non-standard value semantics
@@ -84,8 +79,4 @@ export interface WithRHFOptions {
// Utility: Extract the component's ref type for forwardRef
// ---------------------------------------------------------------------------
export type ExtractRef<T> = T extends ComponentType<infer P>
? P extends { ref?: infer R }
? R
: never
: never;
export type ExtractRef<T> = T extends ComponentType<infer P> ? (P extends { ref?: infer R } ? R : never) : never;
+2 -16
View File
@@ -1,10 +1,5 @@
import React, { type ComponentType, type Ref } from 'react';
import {
useController,
type FieldPath,
type FieldValues,
type UseControllerProps,
} from 'react-hook-form';
import { useController, type FieldPath, type FieldValues, type UseControllerProps } from 'react-hook-form';
import { Input } from '@mantine/core';
import type { WithRHFOptions } from './types';
import { useTranslatedError } from './useTranslatedError';
@@ -73,16 +68,7 @@ export function withRHF<TComponentProps extends Record<string, any>>(
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>(props: Props<TFieldValues, TName>) {
const {
name,
control,
rules,
shouldUnregister,
defaultValue,
disabled,
ref,
...mantineProps
} = props;
const { name, control, rules, shouldUnregister, defaultValue, disabled, ref, ...mantineProps } = props;
const {
field,
@@ -1,4 +1,4 @@
export * from './types';
export * from './utils';
export * from './page-actions';
export * from './row-actions';
export * from './row-actions';
@@ -6,16 +6,16 @@ export interface StatusPageProps {
heading?: string;
description?: string;
icon?: React.ReactNode;
showActionsBack?: boolean;
backButtonLabel?: string;
onClickGoBack?(): void;
showActionsHome?: boolean;
homeButtonLabel?: string;
onClickBackToHome?(): void;
homeUrl?: string;
height?: StyleProp<React.CSSProperties['height']>;
}
@@ -94,10 +94,13 @@ export function TableFilterDrawer({
setPreviousValues(form.getValues());
// Ensure all fields are explicitly cleared
const cleared = Object.keys(form.getValues()).reduce((acc, key) => {
acc[key] = '';
return acc;
}, {} as Record<string, unknown>);
const cleared = Object.keys(form.getValues()).reduce(
(acc, key) => {
acc[key] = '';
return acc;
},
{} as Record<string, unknown>,
);
form.reset({ ...cleared, ...(config?.defaultValues || {}) });
setHasReset(true);
+9 -24
View File
@@ -12,28 +12,20 @@ export interface UseConditionalFieldOptions<TFieldValues extends FieldValues> {
}
/**
* Automatically cleans up a conditionally rendered React Hook Form field
* Automatically cleans up a conditionally rendered React Hook Form field
* when its parent condition becomes false.
*
*
* @param options Configuration object for the conditional field behavior
*/
export function useConditionalField<TFieldValues extends FieldValues>(
options: UseConditionalFieldOptions<TFieldValues>
options: UseConditionalFieldOptions<TFieldValues>,
) {
// Destructure with default values
const {
condition,
name,
setValue,
unregister,
clearErrors,
defaultValue,
mode = 'unregister'
} = options;
const { condition, name, setValue, unregister, clearErrors, defaultValue, mode = 'unregister' } = options;
const config = options;
// Stabilize defaultValue using useRef to prevent infinite render loops
// Stabilize defaultValue using useRef to prevent infinite render loops
// if developers pass inline arrays/objects (e.g. defaultValue: [])
const defaultValueRef = useRef(defaultValue);
useEffect(() => {
@@ -49,7 +41,7 @@ export function useConditionalField<TFieldValues extends FieldValues>(
config.setValue(config.name, targetValue, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: true
shouldValidate: true,
});
// 2. Execute the appropriate side-effect strategy based on the active mode
@@ -59,17 +51,10 @@ export function useConditionalField<TFieldValues extends FieldValues>(
config.unregister(config.name);
} else if (mode === 'reset' && config.clearErrors) {
// Reset Mode: Keeps the field active in the DOM (e.g. cascading or disabled dependencies).
// Wipes the value and clears active validation errors so the user can interact
// Wipes the value and clears active validation errors so the user can interact
// with a fresh state, but keeps the property in the payload.
config.clearErrors(config.name);
}
}
}, [
condition,
name,
setValue,
unregister,
clearErrors,
mode
]);
}
}, [condition, name, setValue, unregister, clearErrors, mode]);
}
+3 -3
View File
@@ -1,7 +1,7 @@
export const radius = {
xs: '0.125rem', // 2px
sm: '0.25rem', // 4px
sm: '0.25rem', // 4px
md: '0.375rem', // 6px (Input radius)
lg: '0.5rem', // 8px
xl: '0.75rem', // 12px
lg: '0.5rem', // 8px
xl: '0.75rem', // 12px
};
@@ -22,7 +22,7 @@ describe('Validator Registry', () => {
const schema = compose(z.string(), required('Password'), complexPassword(8));
const res = schema.safeParse('Weak');
expect(res.success).toBe(false);
const res2 = schema.safeParse('StrongPass1!');
expect(res2.success).toBe(true);
});
@@ -34,7 +34,7 @@ describe('Validator Registry', () => {
const res = schema.safeParse('');
expect(res.success).toBe(false);
expect(res.error?.issues[0].message).toBe(
JSON.stringify({ key: 'validation:required', values: { field: 'TestField' } })
JSON.stringify({ key: 'validation:required', values: { field: 'TestField' } }),
);
});
@@ -42,9 +42,7 @@ describe('Validator Registry', () => {
const schema = compose(z.string(), emailValidator());
const res = schema.safeParse('invalid-email');
expect(res.success).toBe(false);
expect(res.error?.issues[0].message).toBe(
JSON.stringify({ key: 'validation:invalid_email' })
);
expect(res.error?.issues[0].message).toBe(JSON.stringify({ key: 'validation:invalid_email' }));
});
});
@@ -54,7 +52,7 @@ describe('Validator Registry', () => {
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' } })
JSON.stringify({ key: 'validation:min_val', values: { min: 10, field: 'Age' } }),
);
});
@@ -63,7 +61,7 @@ describe('Validator Registry', () => {
const res = schema.safeParse(105);
expect(res.success).toBe(false);
expect(res.error?.issues[0].message).toBe(
JSON.stringify({ key: 'validation:max_val', values: { max: 100, field: 'Percentage' } })
JSON.stringify({ key: 'validation:max_val', values: { max: 100, field: 'Percentage' } }),
);
});
@@ -72,7 +70,7 @@ describe('Validator Registry', () => {
const res = schema.safeParse(5);
expect(res.success).toBe(false);
expect(res.error?.issues[0].message).toBe(
JSON.stringify({ key: 'validation:range_val', values: { min: 10, max: 20, field: 'Range' } })
JSON.stringify({ key: 'validation:range_val', values: { min: 10, max: 20, field: 'Range' } }),
);
});
@@ -81,7 +79,7 @@ describe('Validator Registry', () => {
const res = schema.safeParse(-5);
expect(res.success).toBe(false);
expect(res.error?.issues[0].message).toBe(
JSON.stringify({ key: 'validation:must_be_positive', values: { field: 'Amount' } })
JSON.stringify({ key: 'validation:must_be_positive', values: { field: 'Amount' } }),
);
});
});
@@ -92,7 +90,7 @@ describe('Validator Registry', () => {
const res = schema.safeParse('abc');
expect(res.success).toBe(false);
expect(res.error?.issues[0].message).toBe(
JSON.stringify({ key: 'validation:min_len', values: { min: 5, field: 'Username' } })
JSON.stringify({ key: 'validation:min_len', values: { min: 5, field: 'Username' } }),
);
});
@@ -101,7 +99,7 @@ describe('Validator Registry', () => {
const res = schema.safeParse('thisisaverylongusername');
expect(res.success).toBe(false);
expect(res.error?.issues[0].message).toBe(
JSON.stringify({ key: 'validation:max_len', values: { max: 10, field: 'Username' } })
JSON.stringify({ key: 'validation:max_len', values: { max: 10, field: 'Username' } }),
);
});
@@ -110,7 +108,7 @@ describe('Validator Registry', () => {
const res = schema.safeParse('ab');
expect(res.success).toBe(false);
expect(res.error?.issues[0].message).toBe(
JSON.stringify({ key: 'validation:range_len', values: { min: 3, max: 5, field: 'Code' } })
JSON.stringify({ key: 'validation:range_len', values: { min: 3, max: 5, field: 'Code' } }),
);
});
});
@@ -121,7 +119,7 @@ describe('Validator Registry', () => {
const res = schema.safeParse('short');
expect(res.success).toBe(false);
expect(res.error?.issues[0].message).toBe(
JSON.stringify({ key: 'validation:invalid_password_simple', values: { min: 6 } })
JSON.stringify({ key: 'validation:invalid_password_simple', values: { min: 6 } }),
);
expect(schema.safeParse('longenough').success).toBe(true);
});
@@ -131,7 +129,7 @@ describe('Validator Registry', () => {
expect(schema.safeParse('weakpassword').success).toBe(false);
expect(schema.safeParse('NoSpecial1').success).toBe(false);
expect(schema.safeParse('ValidPass1!').success).toBe(true);
const res = schema.safeParse('short');
expect(res.success).toBe(false);
});
@@ -142,12 +140,10 @@ describe('Validator Registry', () => {
const schema = compose(z.string(), phoneValidator());
expect(schema.safeParse('08123456789').success).toBe(false);
expect(schema.safeParse('+628123456789').success).toBe(true);
const res = schema.safeParse('invalid');
expect(res.success).toBe(false);
expect(res.error?.issues[0].message).toBe(
JSON.stringify({ key: 'validation:invalid_phone' })
);
expect(res.error?.issues[0].message).toBe(JSON.stringify({ key: 'validation:invalid_phone' }));
});
});
});
@@ -2,10 +2,7 @@ import type { ZodString, ZodNumber, ZodTypeAny } from 'zod';
// ─── UTILITIES ─────────────────────────────────────────────────────────────
export const compose = <T extends ZodTypeAny>(
base: T,
...modifiers: ((schema: any) => any)[]
): any => {
export const compose = <T extends ZodTypeAny>(base: T, ...modifiers: ((schema: any) => any)[]): any => {
return modifiers.reduce((acc, curr) => curr(acc), base);
};
@@ -71,20 +68,24 @@ export const rangeLength = (min: number, max: number, field?: string) => (schema
// ─── SECURITY ──────────────────────────────────────────────────────────────
export const simplePassword = (min: number = 8) => (schema: ZodString) => {
return schema.min(min, {
message: JSON.stringify({ key: 'validation:invalid_password_simple', values: { min } }),
});
};
export const simplePassword =
(min: number = 8) =>
(schema: ZodString) => {
return schema.min(min, {
message: JSON.stringify({ key: 'validation:invalid_password_simple', values: { min } }),
});
};
export const complexPassword = (min: number = 8) => (schema: ZodString) => {
return schema
.min(min, { message: JSON.stringify({ key: 'validation:invalid_password_complex' }) })
.regex(/[A-Z]/, { message: JSON.stringify({ key: 'validation:invalid_password_complex' }) })
.regex(/[a-z]/, { message: JSON.stringify({ key: 'validation:invalid_password_complex' }) })
.regex(/[0-9]/, { message: JSON.stringify({ key: 'validation:invalid_password_complex' }) })
.regex(/[^A-Za-z0-9]/, { message: JSON.stringify({ key: 'validation:invalid_password_complex' }) });
};
export const complexPassword =
(min: number = 8) =>
(schema: ZodString) => {
return schema
.min(min, { message: JSON.stringify({ key: 'validation:invalid_password_complex' }) })
.regex(/[A-Z]/, { message: JSON.stringify({ key: 'validation:invalid_password_complex' }) })
.regex(/[a-z]/, { message: JSON.stringify({ key: 'validation:invalid_password_complex' }) })
.regex(/[0-9]/, { message: JSON.stringify({ key: 'validation:invalid_password_complex' }) })
.regex(/[^A-Za-z0-9]/, { message: JSON.stringify({ key: 'validation:invalid_password_complex' }) });
};
// ─── TECHNICAL ─────────────────────────────────────────────────────────────