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 ─────────────────────────────────────────────────── // ── Validate ───────────────────────────────────────────────────
if (!existsSync(SOURCE_DIR)) { if (!existsSync(SOURCE_DIR)) {
console.error( console.error(
`\n❌ Build output not found at: ${SOURCE_DIR}\n` + `\n❌ Build output not found at: ${SOURCE_DIR}\n` + ` Run "pnpm build --filter=${targetApp}" first.\n`,
` Run "pnpm build --filter=${targetApp}" first.\n`,
); );
process.exit(1); process.exit(1);
} }
+3 -14
View File
@@ -1,11 +1,4 @@
import { import { app, shell, BrowserWindow, ipcMain, protocol, session } from 'electron';
app,
shell,
BrowserWindow,
ipcMain,
protocol,
session,
} from 'electron';
import { autoUpdater } from 'electron-updater'; import { autoUpdater } from 'electron-updater';
import { join, extname, normalize } from 'path'; import { join, extname, normalize } from 'path';
import { readFileSync, existsSync, statSync } from 'fs'; import { readFileSync, existsSync, statSync } from 'fs';
@@ -227,8 +220,7 @@ function setupCorsBypass(): void {
// a "normal" request or a null origin // a "normal" request or a null origin
if ( if (
requestHeaders['Origin'] && requestHeaders['Origin'] &&
(requestHeaders['Origin'].startsWith('app://') || (requestHeaders['Origin'].startsWith('app://') || requestHeaders['Origin'].startsWith('file://'))
requestHeaders['Origin'].startsWith('file://'))
) { ) {
delete requestHeaders['Origin']; delete requestHeaders['Origin'];
} }
@@ -427,10 +419,7 @@ if (!gotTheLock) {
app.on('web-contents-created', (_event, contents) => { app.on('web-contents-created', (_event, contents) => {
contents.on('will-navigate', (event, url) => { contents.on('will-navigate', (event, url) => {
// Allow navigation within the app protocol and dev server // Allow navigation within the app protocol and dev server
if ( if (url.startsWith('app://') || (IS_DEV && url.startsWith(DEV_SERVER_URL))) {
url.startsWith('app://') ||
(IS_DEV && url.startsWith(DEV_SERVER_URL))
) {
return; return;
} }
event.preventDefault(); event.preventDefault();
+3 -3
View File
@@ -1,6 +1,6 @@
import DefaultTheme from 'vitepress/theme' import DefaultTheme from 'vitepress/theme';
import './custom.css' import './custom.css';
export default { export default {
extends: DefaultTheme, extends: DefaultTheme,
} };
@@ -29,7 +29,7 @@ export default function LandingSample() {
telemetryContext: { telemetryContext: {
customSpanName: 'public.content.fetch', customSpanName: 'public.content.fetch',
tags: { tags: {
'feature': 'landing', feature: 'landing',
'ui.component': 'LandingSample', 'ui.component': 'LandingSample',
'ui.action': 'content_fetch', 'ui.action': 'content_fetch',
}, },
@@ -74,14 +74,19 @@ export default function LandingSample() {
{loading ? 'Fetching…' : 'Test Public Fetch'} {loading ? 'Fetching…' : 'Test Public Fetch'}
</button> </button>
{error && ( {error && <pre style={{ color: '#ef4444', marginTop: 16, whiteSpace: 'pre-wrap' }}> {error}</pre>}
<pre style={{ color: '#ef4444', marginTop: 16, whiteSpace: 'pre-wrap' }}>
{error}
</pre>
)}
{result && ( {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)} {JSON.stringify(result, null, 2)}
</pre> </pre>
)} )}
+18 -14
View File
@@ -21,25 +21,29 @@ export default function App() {
const colorScheme = useThemeStore((s) => s.colorScheme); const colorScheme = useThemeStore((s) => s.colorScheme);
const [density, setDensity] = useState<DensityType>('compact'); const [density, setDensity] = useState<DensityType>('compact');
const router = useMemo(() => createBrowserRouter([ const router = useMemo(
() =>
createBrowserRouter([
{ {
path: "/", path: '/',
element: <ShowcaseLayout density={density} setDensity={setDensity} />, element: <ShowcaseLayout density={density} setDensity={setDensity} />,
children: [ children: [
{ index: true, element: <Navigate to="/ui-components" replace /> }, { index: true, element: <Navigate to="/ui-components" replace /> },
{ path: "ui-components", element: <UiComponentsPage /> }, { path: 'ui-components', element: <UiComponentsPage /> },
{ path: "forms", element: <FormsPage /> }, { path: 'forms', element: <FormsPage /> },
{ path: "storage", element: <StoragePage /> }, { path: 'storage', element: <StoragePage /> },
{ path: "events", element: <EventsPage /> }, { path: 'events', element: <EventsPage /> },
{ path: "hardware", element: <HardwarePage /> }, { path: 'hardware', element: <HardwarePage /> },
{ path: "rbac", element: <RbacPage /> }, { path: 'rbac', element: <RbacPage /> },
{ path: "auth", element: <AuthPage /> }, { path: 'auth', element: <AuthPage /> },
{ path: "action-tools", element: <ActionToolsPage /> }, { path: 'action-tools', element: <ActionToolsPage /> },
{ path: "ag-grid", element: <AgGridPage /> }, { path: 'ag-grid', element: <AgGridPage /> },
] ],
}, },
{ path: "/shell-demo", element: <ShellDemoPage /> } { path: '/shell-demo', element: <ShellDemoPage /> },
]), [density]); ]),
[density],
);
return ( return (
<ThemeProvider colorScheme={colorScheme} density={density}> <ThemeProvider colorScheme={colorScheme} density={density}>
@@ -74,8 +74,7 @@ export function useElectronPrinter(): UseElectronPrinterReturn {
} }
}, []); }, []);
const print = useCallback( const print = useCallback(async (options?: ElectronPrintOptions): Promise<ElectronPrintResult> => {
async (options?: ElectronPrintOptions): Promise<ElectronPrintResult> => {
if (!window.electronAPI) { if (!window.electronAPI) {
return { success: false, failureReason: 'Not running in Electron' }; return { success: false, failureReason: 'Not running in Electron' };
} }
@@ -95,9 +94,7 @@ export function useElectronPrinter(): UseElectronPrinterReturn {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, }, []);
[],
);
return { return {
printers, printers,
@@ -3,14 +3,7 @@ import { useIsElectron } from './use-is-electron';
// ─── Types ────────────────────────────────────────────────────── // ─── Types ──────────────────────────────────────────────────────
export type UpdateStatus = export type UpdateStatus = 'idle' | 'checking' | 'available' | 'not-available' | 'downloading' | 'ready' | 'error';
| 'idle'
| 'checking'
| 'available'
| 'not-available'
| 'downloading'
| 'ready'
| 'error';
export interface UseElectronUpdaterReturn { export interface UseElectronUpdaterReturn {
/** Current status of the auto-updater lifecycle */ /** Current status of the auto-updater lifecycle */
+37 -15
View File
@@ -34,17 +34,28 @@ export default function ShowcaseLayout({ density, setDensity }: ShowcaseLayoutPr
const currentPath = location.pathname.replace('/', ''); const currentPath = location.pathname.replace('/', '');
switch (currentPath) { switch (currentPath) {
case 'rbac': return 'Role-Based Access Control and Permissions'; case 'rbac':
case 'storage': return 'Offline-First PouchDB Synchronization'; return 'Role-Based Access Control and Permissions';
case 'auth': return 'Authentication & Security Layers'; case 'storage':
case 'ui-components': return 'Theme, Typography, Forms & Data Grids'; return 'Offline-First PouchDB Synchronization';
case 'forms': return 'Enterprise Form Engine & Zod Validation'; case 'auth':
case 'events': return 'Global Event Bus Synchronization'; return 'Authentication & Security Layers';
case 'hardware': return 'Hardware Integration & Printers'; case 'ui-components':
case 'layout-engine': return 'Core Layout Engine & Variants'; return 'Theme, Typography, Forms & Data Grids';
case 'action-tools': return 'Showcase for PageActions and RowActions components'; case 'forms':
case 'ag-grid': return 'Enterprise Data Grid with Mantine Theme Integration'; return 'Enterprise Form Engine & Zod Validation';
default: return 'Architecture Showcase'; 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"> <AppShell.Navbar p="md">
<Box mb="xl"> <Box mb="xl">
<Title order={3} c="brand.7">Eigen ERP</Title> <Title order={3} c="brand.7">
<Text size="xs" c="dimmed">Architecture Showcase</Text> Eigen ERP
</Title>
<Text size="xs" c="dimmed">
Architecture Showcase
</Text>
</Box> </Box>
<Box style={{ flex: 1 }}> <Box style={{ flex: 1 }}>
{navItems.map((item) => ( {navItems.map((item) => (
@@ -101,11 +116,18 @@ export default function ShowcaseLayout({ density, setDensity }: ShowcaseLayoutPr
</AppShell.Navbar> </AppShell.Navbar>
<AppShell.Main style={{ display: 'flex', flexDirection: 'column', height: '100vh', overflow: 'hidden' }}> <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"> <Group justify="space-between">
<Box> <Box>
<Title order={3}>Architecture Showcase</Title> <Title order={3}>Architecture Showcase</Title>
<Text size="sm" c="dimmed">{getSubtitle()}</Text> <Text size="sm" c="dimmed">
{getSubtitle()}
</Text>
</Box> </Box>
<Select <Select
w={180} w={180}
+160 -17
View File
@@ -20,18 +20,162 @@ interface OrderRow {
} }
const SAMPLE_DATA: 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: '1',
{ 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' }, orderCode: 'ORD-2026-001',
{ 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' }, customer: 'Acme Corp',
{ 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' }, email: 'acme@example.com',
{ 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' }, product: 'Widget A',
{ 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' }, quantity: 120,
{ 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' }, unitPrice: 24.5,
{ 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' }, total: 2940,
{ 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' }, status: 'Delivered',
{ 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' }, createdAt: '2026-01-15',
{ 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' }, 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 ─────────────────────────────────────── */ /* ─── Status Badge Renderer ─────────────────────────────────────── */
@@ -164,11 +308,10 @@ export default function AgGridShowcase() {
AG Grid Enterprise Mantine Integration AG Grid Enterprise Mantine Integration
</Title> </Title>
<Text size="sm" c="dimmed" mb="md"> <Text size="sm" c="dimmed" mb="md">
This showcase demonstrates AG Grid integrated with Mantine's theme system. The grid This showcase demonstrates AG Grid integrated with Mantine's theme system. The grid automatically adapts to
automatically adapts to dark/light mode changes, inherits brand colors, typography, and dark/light mode changes, inherits brand colors, typography, and border radius from the Mantine theme — all
border radius from the Mantine theme — all via AG Grid's built-in CSS variable system with via AG Grid's built-in CSS variable system with zero custom stylesheets. Toggle the color scheme in the
zero custom stylesheets. Toggle the color scheme in the Theme Controls above to see it in Theme Controls above to see it in action.
action.
</Text> </Text>
<Group> <Group>
<NumberInput <NumberInput
+3 -1
View File
@@ -5,7 +5,9 @@ export default function AuthPage() {
<Container size="xl" m={0} p={0}> <Container size="xl" m={0} p={0}>
<Stack gap="xl"> <Stack gap="xl">
<Card withBorder shadow="sm" radius="md" p="md"> <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> <Text c="dimmed">Auth Demo Component Coming Soon...</Text>
</Card> </Card>
</Stack> </Stack>
@@ -1,12 +1,5 @@
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect } from 'react';
import { import { Card, Title, Text, Stack, Badge, Divider } from '@repo/ui/components';
Card,
Title,
Text,
Stack,
Badge,
Divider,
} from '@repo/ui/components';
// ── Showcase Components ────────────────────────────────────────── // ── Showcase Components ──────────────────────────────────────────
import { CashierUI } from './printer/cashier.ui'; import { CashierUI } from './printer/cashier.ui';
@@ -65,8 +58,8 @@ export default function EventsDemoPage() {
🖨 Showcase 1: Cross-Platform Printer Abstraction 🖨 Showcase 1: Cross-Platform Printer Abstraction
</Title> </Title>
<Text size="sm" c="dimmed" mb="md"> <Text size="sm" c="dimmed" mb="md">
The CashierUI publishes a <code>{DEVICE_EVENTS.PRINT_RECEIPT}</code> event. The CashierUI publishes a <code>{DEVICE_EVENTS.PRINT_RECEIPT}</code> event. The PrinterListener listens for it
The PrinterListener listens for it and simulates interacting with a physical printer. and simulates interacting with a physical printer.
</Text> </Text>
{/* Headless listener — renders nothing visible */} {/* Headless listener — renders nothing visible */}
@@ -110,9 +103,8 @@ export default function EventsDemoPage() {
📈 Showcase 2: High-Frequency Real-Time Data (50 updates/sec) 📈 Showcase 2: High-Frequency Real-Time Data (50 updates/sec)
</Title> </Title>
<Text size="sm" c="dimmed" mb="md"> <Text size="sm" c="dimmed" mb="md">
A mock WebSocket fires <code>WS:STOCK_UPDATE</code> every 20ms. A mock WebSocket fires <code>WS:STOCK_UPDATE</code> every 20ms. Each StockRow subscribes to the global event
Each StockRow subscribes to the global event but only updates when{' '} but only updates when <code>payload.id === row.id</code>. The parent grid never re-renders.
<code>payload.id === row.id</code>. The parent grid never re-renders.
</Text> </Text>
<LiveStockGrid /> <LiveStockGrid />
@@ -126,8 +118,8 @@ export default function EventsDemoPage() {
💾 Showcase 3: Auth/Profile Sync with <code>@repo/core-storage</code> 💾 Showcase 3: Auth/Profile Sync with <code>@repo/core-storage</code>
</Title> </Title>
<Text size="sm" c="dimmed" mb="md"> <Text size="sm" c="dimmed" mb="md">
ProfileSettingsUI publishes <code>{AUTH_EVENTS.PROFILE_UPDATED}</code>. ProfileSettingsUI publishes <code>{AUTH_EVENTS.PROFILE_UPDATED}</code>. StorageSyncListener silently catches
StorageSyncListener silently catches it in the background and saves to IndexedDB via <code>secureIndexedDB</code>. it in the background and saves to IndexedDB via <code>secureIndexedDB</code>.
</Text> </Text>
{/* Headless listener — renders nothing visible */} {/* Headless listener — renders nothing visible */}
@@ -1,15 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { usePublishEvent } from '@repo/core-events'; import { usePublishEvent } from '@repo/core-events';
import type { ReceiptItem } from '@repo/core-events'; import type { ReceiptItem } from '@repo/core-events';
import { import { Button, Group, Stack, Text, TextInput, Table, Badge } from '@repo/ui/components';
Button,
Group,
Stack,
Text,
TextInput,
Table,
Badge,
} from '@repo/ui/components';
import { DEVICE_EVENTS } from '../../../../../core/constants/events'; import { DEVICE_EVENTS } from '../../../../../core/constants/events';
// ─── Mock Receipt Data ────────────────────────────────────────── // ─── Mock Receipt Data ──────────────────────────────────────────
@@ -38,8 +38,8 @@ export function PrinterListener({ onLog }: PrinterListenerProps) {
// Uses the preload-exposed API. The Electron main process // Uses the preload-exposed API. The Electron main process
// handles the actual OS-level print job via `webContents.print()`. // handles the actual OS-level print job via `webContents.print()`.
onLog(`[Electron] Sending receipt ${payload.receiptId} to OS printer via IPC bridge...`); onLog(`[Electron] Sending receipt ${payload.receiptId} to OS printer via IPC bridge...`);
window.electronAPI! window
.print({ silent: true, printBackground: true }) .electronAPI!.print({ silent: true, printBackground: true })
.then((result) => { .then((result) => {
if (result.success) { if (result.success) {
onLog(`[Electron] ✅ Receipt ${payload.receiptId} printed successfully.`); 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 // Opens the native browser print dialog. In production, you'd
// likely render a hidden print-optimized iframe first. // likely render a hidden print-optimized iframe first.
onLog(`[Browser] 🖨️ Receipt ${payload.receiptId} — opening browser print dialog...`); 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(); window.print();
} }
}); });
@@ -8,9 +8,26 @@ import { WS_EVENTS } from '../../../../../core/constants/events';
* We generate 1000 unique IDs from these base tickers + numeric suffix. * We generate 1000 unique IDs from these base tickers + numeric suffix.
*/ */
const BASE_TICKERS = [ const BASE_TICKERS = [
'AAPL', 'GOOG', 'MSFT', 'AMZN', 'META', 'NVDA', 'TSLA', 'AMD', 'AAPL',
'NFLX', 'ORCL', 'CRM', 'INTC', 'PYPL', 'ADBE', 'CSCO', 'QCOM', 'GOOG',
'AVGO', 'TXN', 'MU', 'SHOP', '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 : '#fa5252' // red
: undefined; : undefined;
const changeArrow = data const changeArrow = data ? (data.change >= 0 ? '▲' : '▼') : '';
? data.change >= 0
? '▲'
: '▼'
: '';
return ( return (
<tr style={{ fontSize: 12, fontFamily: 'monospace' }}> <tr style={{ fontSize: 12, fontFamily: 'monospace' }}>
<td style={{ padding: '2px 8px', fontWeight: 600 }}>{stockId}</td> <td style={{ padding: '2px 8px', fontWeight: 600 }}>{stockId}</td>
<td style={{ padding: '2px 8px', textAlign: 'right' }}> <td style={{ padding: '2px 8px', textAlign: 'right' }}>{data ? `$${data.price.toFixed(2)}` : '—'}</td>
{data ? `$${data.price.toFixed(2)}` : '—'}
</td>
<td <td
style={{ style={{
padding: '2px 8px', padding: '2px 8px',
@@ -80,12 +74,8 @@ export const StockRow = memo(function StockRow({ stockId }: StockRowProps) {
> >
{data ? `${changeArrow} ${data.change >= 0 ? '+' : ''}${data.change.toFixed(2)}` : '—'} {data ? `${changeArrow} ${data.change >= 0 ? '+' : ''}${data.change.toFixed(2)}` : '—'}
</td> </td>
<td style={{ padding: '2px 8px', textAlign: 'right' }}> <td style={{ padding: '2px 8px', textAlign: 'right' }}>{data ? data.volume.toLocaleString() : '—'}</td>
{data ? data.volume.toLocaleString() : ''} <td style={{ padding: '2px 8px', textAlign: 'right', color: '#868e96' }}>{renderCountRef.current}</td>
</td>
<td style={{ padding: '2px 8px', textAlign: 'right', color: '#868e96' }}>
{renderCountRef.current}
</td>
</tr> </tr>
); );
}); });
@@ -151,9 +151,7 @@ export class AdvancedBookingTransformer extends BookingTransformer {
return super.transformGetManyResponse(dtos).map((entity) => ({ return super.transformGetManyResponse(dtos).map((entity) => ({
...entity, ...entity,
// Normalize 'cancelled' vs 'canceled' from different API versions // Normalize 'cancelled' vs 'canceled' from different API versions
status: entity.status === ('canceled' as BookingEntity['status']) status: entity.status === ('canceled' as BookingEntity['status']) ? 'cancelled' : entity.status,
? 'cancelled'
: entity.status,
})); }));
} }
} }
@@ -55,4 +55,3 @@ export const bookingServices = new CommonRemoteDataServices<BookingEntity, Booki
moduleKey: 'BOOKING', moduleKey: 'BOOKING',
transformer: new BookingTransformer(), transformer: new BookingTransformer(),
}); });
@@ -30,10 +30,10 @@ export default function BookingSample() {
telemetryContext: { telemetryContext: {
customSpanName: 'booking.list.fetch', customSpanName: 'booking.list.fetch',
tags: { tags: {
'feature': 'booking', feature: 'booking',
'ui.component': 'BookingSample', 'ui.component': 'BookingSample',
'ui.action': 'list_fetch', 'ui.action': 'list_fetch',
'page': 1, page: 1,
}, },
pushEventOnSuccess: 'booking_list_loaded', pushEventOnSuccess: 'booking_list_loaded',
}, },
@@ -76,14 +76,19 @@ export default function BookingSample() {
{loading ? 'Fetching…' : 'Test Fetch Bookings'} {loading ? 'Fetching…' : 'Test Fetch Bookings'}
</button> </button>
{error && ( {error && <pre style={{ color: '#ef4444', marginTop: 16, whiteSpace: 'pre-wrap' }}> {error}</pre>}
<pre style={{ color: '#ef4444', marginTop: 16, whiteSpace: 'pre-wrap' }}>
{error}
</pre>
)}
{result && ( {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)} {JSON.stringify(result, null, 2)}
</pre> </pre>
)} )}
+3 -1
View File
@@ -7,7 +7,9 @@ export default function EventsPage() {
<Container size="xl" m={0} p={0}> <Container size="xl" m={0} p={0}>
<Stack gap="xl"> <Stack gap="xl">
<Card withBorder shadow="sm" radius="md" p="md"> <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> <Text mb="md">This is an example of a nested showcase component.</Text>
<ExamplePage /> <ExamplePage />
</Card> </Card>
@@ -1,13 +1,31 @@
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { Button, Paper, Title, Group, Stack, Code, Divider, Text, Chip, Radio } from '@repo/ui/components'; import { Button, Paper, Title, Group, Stack, Code, Divider, Text, Chip, Radio } from '@repo/ui/components';
import { import {
FieldTextInput, FieldPasswordInput, FieldTextarea, FieldNumberInput, FieldTextInput,
FieldJsonInput, FieldPinInput, FieldAutocomplete, FieldSelect, FieldPasswordInput,
FieldMultiSelect, FieldNativeSelect, FieldTagsInput, FieldCheckbox, FieldTextarea,
FieldRadioGroup, FieldSwitch, FieldChipGroup, FieldSegmentedControl, FieldNumberInput,
FieldSlider, FieldRangeSlider, FieldRating, FieldColorInput, FieldJsonInput,
FieldColorPicker, FieldFileInput, FieldLocalSelect, FieldAsyncSelect, FieldPinInput,
FieldRichTextEditor FieldAutocomplete,
FieldSelect,
FieldMultiSelect,
FieldNativeSelect,
FieldTagsInput,
FieldCheckbox,
FieldRadioGroup,
FieldSwitch,
FieldChipGroup,
FieldSegmentedControl,
FieldSlider,
FieldRangeSlider,
FieldRating,
FieldColorInput,
FieldColorPicker,
FieldFileInput,
FieldLocalSelect,
FieldAsyncSelect,
FieldRichTextEditor,
} from '@repo/ui/form'; } from '@repo/ui/form';
import type { LoadOptionsFn } from '@repo/ui/form'; import type { LoadOptionsFn } from '@repo/ui/form';
import { useFormDemoTranslation } from '../i18n/useFormDemoTranslation'; import { useFormDemoTranslation } from '../i18n/useFormDemoTranslation';
@@ -48,7 +66,9 @@ const MOCK_VENDORS = [
const loadMockVendorsOptions: LoadOptionsFn<any> = async (search, _page) => { const loadMockVendorsOptions: LoadOptionsFn<any> = async (search, _page) => {
await new Promise((resolve) => setTimeout(resolve, 500)); 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 }; return { options: filtered, hasMore: false };
}; };
@@ -86,17 +106,18 @@ export default function AllFieldsDemo() {
asyncSelectPrefilled: { id: 999, code: 'ASYNC-99', name: 'Pre-loaded Async Vendor' }, asyncSelectPrefilled: { id: 999, code: 'ASYNC-99', name: 'Pre-loaded Async Vendor' },
localMultiPrefilled: [ localMultiPrefilled: [
{ id: 'V1', code: 'VN-01', name: 'Vendor One' }, { 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: [ asyncMultiPrefilled: [
{ id: 888, code: 'ASYNC-88', name: 'Ghost Async Vendor 1' }, { 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: "", 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>", 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, realPokeSelect: null,
multiRealPokeSelect: [] multiRealPokeSelect: [],
} },
}); });
const onSubmit = (data: any) => console.log('All Fields Submitted:', data); const onSubmit = (data: any) => console.log('All Fields Submitted:', data);
@@ -109,7 +130,9 @@ export default function AllFieldsDemo() {
<Stack gap="xl"> <Stack gap="xl">
{/* --- Text & Numbers --- */} {/* --- Text & Numbers --- */}
<div> <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" /> <Divider mb="md" />
<Group grow align="flex-start" mb="md"> <Group grow align="flex-start" mb="md">
<FieldTextInput name="customerName" control={control} label={t.fields.customerName} /> <FieldTextInput name="customerName" control={control} label={t.fields.customerName} />
@@ -124,22 +147,21 @@ export default function AllFieldsDemo() {
<FieldJsonInput name="jsonConfig" control={control} label={t.fields.jsonConfig} formatOnBlur /> <FieldJsonInput name="jsonConfig" control={control} label={t.fields.jsonConfig} formatOnBlur />
</Group> </Group>
<div> <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} /> <FieldPinInput name="pin" control={control} length={6} />
</div> </div>
</div> </div>
{/* --- Selections --- */} {/* --- Selections --- */}
<div> <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" /> <Divider mb="md" />
<Group grow align="flex-start" mb="md"> <Group grow align="flex-start" mb="md">
<FieldSelect <FieldSelect name="orderType" control={control} label={t.fields.orderType} data={['BULK', 'RETAIL']} />
name="orderType"
control={control}
label={t.fields.orderType}
data={['BULK', 'RETAIL']}
/>
<FieldNativeSelect <FieldNativeSelect
name="nativeOrderType" name="nativeOrderType"
control={control} control={control}
@@ -170,7 +192,7 @@ export default function AllFieldsDemo() {
options={[ options={[
{ id: 1, name: 'Apple', type: 'Fruit' }, { id: 1, name: 'Apple', type: 'Fruit' },
{ id: 2, name: 'Carrot', type: 'Vegetable' }, { id: 2, name: 'Carrot', type: 'Vegetable' },
{ id: 3, name: 'Banana', type: 'Fruit' } { id: 3, name: 'Banana', type: 'Fruit' },
]} ]}
valueKey="id" valueKey="id"
labelKey="name" labelKey="name"
@@ -185,7 +207,7 @@ export default function AllFieldsDemo() {
options={[ options={[
{ id: 1, name: 'Red', hex: '#f00' }, { id: 1, name: 'Red', hex: '#f00' },
{ id: 2, name: 'Green', hex: '#0f0' }, { id: 2, name: 'Green', hex: '#0f0' },
{ id: 3, name: 'Blue', hex: '#00f' } { id: 3, name: 'Blue', hex: '#00f' },
]} ]}
valueKey="id" valueKey="id"
renderLabel={(item) => `${item.name} (${item.hex})`} renderLabel={(item) => `${item.name} (${item.hex})`}
@@ -240,7 +262,9 @@ export default function AllFieldsDemo() {
</Group> </Group>
<FieldTagsInput name="tags" control={control} label={t.fields.tags} /> <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" /> <Divider mb="md" />
<Group grow align="flex-start" mb="md"> <Group grow align="flex-start" mb="md">
<FieldLocalSelect <FieldLocalSelect
@@ -284,7 +308,9 @@ export default function AllFieldsDemo() {
/> />
</Group> </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" /> <Divider mb="md" />
<Group grow align="flex-start" mb="md"> <Group grow align="flex-start" mb="md">
<FieldLocalSelect <FieldLocalSelect
@@ -309,7 +335,9 @@ export default function AllFieldsDemo() {
/> />
</Group> </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" /> <Divider mb="md" />
<FieldRichTextEditor <FieldRichTextEditor
name="richTextEmpty" name="richTextEmpty"
@@ -329,18 +357,15 @@ export default function AllFieldsDemo() {
{/* --- Toggles & Choices --- */} {/* --- Toggles & Choices --- */}
<div> <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" /> <Divider mb="md" />
<Group mb="md"> <Group mb="md">
<FieldCheckbox name="terms" control={control} label={t.fields.terms} /> <FieldCheckbox name="terms" control={control} label={t.fields.terms} />
<FieldSwitch name="receiveEmails" control={control} label={t.fields.receiveEmails} /> <FieldSwitch name="receiveEmails" control={control} label={t.fields.receiveEmails} />
</Group> </Group>
<FieldRadioGroup <FieldRadioGroup name="priority" control={control} label={t.fields.priority} mb="md">
name="priority"
control={control}
label={t.fields.priority}
mb="md"
>
<Group mt="xs"> <Group mt="xs">
<Radio value="low" label="Low" /> <Radio value="low" label="Low" />
<Radio value="high" label="High" /> <Radio value="high" label="High" />
@@ -352,16 +377,15 @@ export default function AllFieldsDemo() {
label={t.fields.priority} label={t.fields.priority}
data={[ data={[
{ label: 'Normal', value: 'normal' }, { label: 'Normal', value: 'normal' },
{ label: 'Urgent', value: 'urgent' } { label: 'Urgent', value: 'urgent' },
]} ]}
mb="md" mb="md"
/> />
<div> <div>
<Text size="sm" fw={500} mb={3}>Chip Selection</Text> <Text size="sm" fw={500} mb={3}>
<FieldChipGroup Chip Selection
name="chipSelection" </Text>
control={control} <FieldChipGroup name="chipSelection" control={control}>
>
<Group> <Group>
<Chip value="1">Option 1</Chip> <Chip value="1">Option 1</Chip>
<Chip value="2">Option 2</Chip> <Chip value="2">Option 2</Chip>
@@ -372,7 +396,9 @@ export default function AllFieldsDemo() {
{/* --- Ranges & Specialized --- */} {/* --- Ranges & Specialized --- */}
<div> <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" /> <Divider mb="md" />
<Group grow align="flex-start" mb="md"> <Group grow align="flex-start" mb="md">
<FieldSlider name="satisfaction" control={control} label={t.fields.rating} /> <FieldSlider name="satisfaction" control={control} label={t.fields.rating} />
@@ -384,23 +410,31 @@ export default function AllFieldsDemo() {
</Group> </Group>
<Group grow align="flex-start" mb="md"> <Group grow align="flex-start" mb="md">
<div> <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} /> <FieldColorPicker name="colorPicker" control={control} />
</div> </div>
<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} /> <FieldRating name="rating" control={control} />
</div> </div>
</Group> </Group>
</div> </div>
<Button type="submit" mt="md">{t.common.submit}</Button> <Button type="submit" mt="md">
{t.common.submit}
</Button>
</Stack> </Stack>
</form> </form>
</Paper> </Paper>
<Paper p="md" withBorder radius="md" bg="var(--mantine-color-gray-0)"> <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> <Code block>{JSON.stringify(data, null, 2)}</Code>
</Paper> </Paper>
</Stack> </Stack>
@@ -2,7 +2,14 @@ import { useForm, useWatch } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
import { Button, Paper, Title, Divider, Stack, Code, Alert, TypographyStylesProvider } from '@repo/ui/components'; 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 { useConditionalField } from '@repo/ui/hooks';
import { compose, required, emailValidator } from '@repo/ui/validators'; import { compose, required, emailValidator } from '@repo/ui/validators';
import { useFormDemoTranslation } from '../i18n/useFormDemoTranslation'; import { useFormDemoTranslation } from '../i18n/useFormDemoTranslation';
@@ -23,7 +30,7 @@ interface Warehouse {
const REGIONS: Region[] = [ const REGIONS: Region[] = [
{ id: 'R1', code: 'APAC', taxRate: 0.1 }, { 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) => { const mockFetchWarehouses = async (regionIds: string[], search: string, page: number) => {
@@ -35,14 +42,16 @@ const mockFetchWarehouses = async (regionIds: string[], search: string, page: nu
{ id: 'W4', regionId: 'R2', name: 'Berlin Storage' }, { 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 pageSize = 10;
const start = (page - 1) * pageSize; const start = (page - 1) * pageSize;
const paginated = filtered.slice(start, start + pageSize); const paginated = filtered.slice(start, start + pageSize);
return { return {
options: paginated, options: paginated,
hasMore: start + pageSize < filtered.length hasMore: start + pageSize < filtered.length,
}; };
}; };
@@ -55,7 +64,9 @@ export default function ReactiveWatchDemo() {
const newsletterEmailValidator = compose(z.string(), required(t.fields.email), emailValidator()); const newsletterEmailValidator = compose(z.string(), required(t.fields.email), emailValidator());
const roleValidator = compose(z.string(), required(t.fields.role)); const roleValidator = compose(z.string(), required(t.fields.role));
const reactiveSchema = useMemo(() => z const reactiveSchema = useMemo(
() =>
z
.object({ .object({
userType: z.enum(['PERSONAL', 'CORPORATE']), userType: z.enum(['PERSONAL', 'CORPORATE']),
corporateTaxId: z.string().optional(), corporateTaxId: z.string().optional(),
@@ -92,7 +103,9 @@ export default function ReactiveWatchDemo() {
z.object({ department: z.string().min(1), role: roleValidator }), z.object({ department: z.string().min(1), role: roleValidator }),
z.object({ department: z.union([z.literal(''), z.undefined(), z.null()]).optional() }), z.object({ department: z.union([z.literal(''), z.undefined(), z.null()]).optional() }),
]), ]),
), [t, taxIdValidator, spouseNameValidator, newsletterEmailValidator, roleValidator]); ),
[t, taxIdValidator, spouseNameValidator, newsletterEmailValidator, roleValidator],
);
const { control, handleSubmit, setValue, unregister, clearErrors } = useForm<any>({ const { control, handleSubmit, setValue, unregister, clearErrors } = useForm<any>({
resolver: zodResolver(reactiveSchema as any), resolver: zodResolver(reactiveSchema as any),
@@ -108,9 +121,9 @@ export default function ReactiveWatchDemo() {
regions: [{ id: 'R1', code: 'APAC', taxRate: 0.1 }], regions: [{ id: 'R1', code: 'APAC', taxRate: 0.1 }],
warehouses: [ warehouses: [
{ id: 'W-99', regionId: 'R1', name: 'APAC Central Hub' }, { 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>',
}, },
}); });
@@ -316,10 +329,17 @@ export default function ReactiveWatchDemo() {
control={control as any} control={control as any}
label={t.fields.warehouses} label={t.fields.warehouses}
disabled={!regions || regions.length === 0} disabled={!regions || regions.length === 0}
loadOptions={useCallback(async (search, page) => { loadOptions={useCallback(
async (search, page) => {
if (!regions || regions.length === 0) return { options: [], hasMore: false }; if (!regions || regions.length === 0) return { options: [], hasMore: false };
return mockFetchWarehouses(regions.map((r: any) => r.id), search, page); return mockFetchWarehouses(
}, [regions])} regions.map((r: any) => r.id),
search,
page,
);
},
[regions],
)}
valueKey="id" valueKey="id"
renderLabel={(item) => `[${item.id}] ${item.name}`} renderLabel={(item) => `[${item.id}] ${item.name}`}
clearable clearable
@@ -327,7 +347,8 @@ export default function ReactiveWatchDemo() {
{regions && regions.length > 0 && ( {regions && regions.length > 0 && (
<Alert mt="sm" color="teal"> <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> </Alert>
)} )}
@@ -344,7 +365,9 @@ export default function ReactiveWatchDemo() {
/> />
<Paper p="md" withBorder radius="md" mt="sm"> <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> <TypographyStylesProvider>
<div dangerouslySetInnerHTML={{ __html: watchedRichTextLive }} /> <div dangerouslySetInnerHTML={{ __html: watchedRichTextLive }} />
</TypographyStylesProvider> </TypographyStylesProvider>
+3 -1
View File
@@ -5,7 +5,9 @@ export default function RbacPage() {
<Container size="xl" m={0} p={0}> <Container size="xl" m={0} p={0}>
<Stack gap="xl"> <Stack gap="xl">
<Card withBorder shadow="sm" radius="md" p="md"> <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> <Text c="dimmed">RBAC Demo Component Coming Soon...</Text>
</Card> </Card>
</Stack> </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 { useThemeStore } from '../../core/stores/theme.store';
import { ColorSchemeType } from '@repo/ui/provider'; import { ColorSchemeType } from '@repo/ui/provider';
@@ -16,7 +36,9 @@ export default function UiComponentsPage() {
<Stack gap="xl"> <Stack gap="xl">
{/* Control Panel */} {/* Control Panel */}
<Card withBorder shadow="sm" radius="md" p="md"> <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"> <Group grow align="flex-end">
<Select <Select
label="Color Scheme" label="Color Scheme"
@@ -34,18 +56,30 @@ export default function UiComponentsPage() {
<Card withBorder shadow="sm" radius="md" p="md"> <Card withBorder shadow="sm" radius="md" p="md">
<Stack gap="lg"> <Stack gap="lg">
<div> <div>
<Title order={4} mb="xs">Typography & Badges</Title> <Title order={4} mb="xs">
<Text size="sm" c="dimmed">This is dimmed small text indicating a subtitle.</Text> Typography & Badges
</Title>
<Text size="sm" c="dimmed">
This is dimmed small text indicating a subtitle.
</Text>
<Group mt="md"> <Group mt="md">
<Badge color="brand">Brand Badge</Badge> <Badge color="brand">Brand Badge</Badge>
<Badge color="success" variant="light">Success Status</Badge> <Badge color="success" variant="light">
<Badge color="error" variant="outline">Error State</Badge> Success Status
</Badge>
<Badge color="error" variant="outline">
Error State
</Badge>
</Group> </Group>
</div> </div>
<Divider /> <Divider />
<div> <div>
<Title order={4} mb="xs">Enterprise Status Badges</Title> <Title order={4} mb="xs">
<Text size="sm" c="dimmed" mb="md">Pre-configured status badges for transaction and master data.</Text> Enterprise Status Badges
</Title>
<Text size="sm" c="dimmed" mb="md">
Pre-configured status badges for transaction and master data.
</Text>
<Group> <Group>
<StatusBadge status={STATUS_DATA.DRAFT} /> <StatusBadge status={STATUS_DATA.DRAFT} />
<StatusBadge status={STATUS_DATA.PENDING} /> <StatusBadge status={STATUS_DATA.PENDING} />
@@ -59,12 +93,22 @@ export default function UiComponentsPage() {
</div> </div>
<Divider /> <Divider />
<div> <div>
<Title order={4} mb="md">Buttons</Title> <Title order={4} mb="md">
Buttons
</Title>
<Group> <Group>
<Button variant="filled" color="brand">Filled Button</Button> <Button variant="filled" color="brand">
<Button variant="outline" color="brand">Outline Button</Button> Filled Button
<Button variant="light" color="info">Light Info</Button> </Button>
<Button variant="subtle" color="error">Cancel</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> </Group>
</div> </div>
</Stack> </Stack>
@@ -72,7 +116,9 @@ export default function UiComponentsPage() {
{/* Forms */} {/* Forms */}
<Card withBorder shadow="sm" radius="md" p="md"> <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"> <Stack gap="md">
<Group grow align="flex-start"> <Group grow align="flex-start">
<TextInput label="First Name" placeholder="Enter your first name" withAsterisk /> <TextInput label="First Name" placeholder="Enter your first name" withAsterisk />
@@ -92,7 +138,9 @@ export default function UiComponentsPage() {
{/* Data Grid */} {/* Data Grid */}
<Card withBorder shadow="sm" radius="md" p="md"> <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 striped highlightOnHover withTableBorder withColumnBorders>
<Table.Thead> <Table.Thead>
<Table.Tr> <Table.Tr>
@@ -108,7 +156,10 @@ export default function UiComponentsPage() {
<Table.Td>{row.id}</Table.Td> <Table.Td>{row.id}</Table.Td>
<Table.Td>{row.customer}</Table.Td> <Table.Td>{row.customer}</Table.Td>
<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} {row.status}
</Badge> </Badge>
</Table.Td> </Table.Td>
-1
View File
@@ -25,7 +25,6 @@
import type {} from '@repo/core-events'; import type {} from '@repo/core-events';
declare module '@repo/core-events' { declare module '@repo/core-events' {
// ─── Payload Types ────────────────────────────────────────── // ─── Payload Types ──────────────────────────────────────────
interface ReceiptItem { interface ReceiptItem {
+1 -1
View File
@@ -2,7 +2,7 @@ VITE_APP_ENV=development
VITE_APP_NAME=development_fe-monorepo-web VITE_APP_NAME=development_fe-monorepo-web
VITE_APP_VERSION=0.0.1 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 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 { AppStorageKey, appStorage } from '../../../core/storage/local';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; 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 { useMemo, useState } from 'react';
import loginEn from './languages/en/login.json'; import loginEn from './languages/en/login.json';
import loginId from './languages/id/login.json'; import loginId from './languages/id/login.json';
import { CommonRemoteDataServices } from '@repo/core-api/data-services'; import { authDataService } from '../domain/factories';
import { apiClient } from '../../../core/lib/api-client'; import { initiateAuthSession, persistTokenPair, terminateAuthSession } from '../../../core/lib/auth.helper';
import { initiateAuthSession } from '../../../core/lib/auth.helper'; import { getLoginErrorKey } from './get-login-error-key';
registerModuleNamespace('auth-login', { registerModuleNamespace('auth-login', {
en: loginEn, en: loginEn,
id: loginId, id: loginId,
}); });
export const authDataService = new CommonRemoteDataServices(apiClient, {});
export default function LoginPage() { export default function LoginPage() {
const { t, i18n } = useTranslation('auth-login'); const { t, i18n } = useTranslation('auth-login');
const [loadingLogin, setLoadingLogin] = useState<boolean>(false); const [loadingLogin, setLoadingLogin] = useState<boolean>(false);
@@ -46,16 +44,21 @@ export default function LoginPage() {
}); });
const { control } = formControl; const { control } = formControl;
const onSubmit = async (data: any) => { const onSubmit = async (data: { username?: string; password?: string }) => {
setLoadingLogin(true); setLoadingLogin(true);
try { try {
const response = await authDataService.customRequest({ url: '/api/v1/auth', method: 'POST', data }); const tokens = await authDataService.login({
await initiateAuthSession(response.data); username: data.username ?? '',
} catch (error: any) { password: data.password ?? '',
const message = error?.response?.data?.message; });
await persistTokenPair(tokens);
const me = await authDataService.me();
await initiateAuthSession(tokens, me);
} catch (error) {
await terminateAuthSession({ preserveRedirect: false });
notifications.show({ notifications.show({
title: t('common:notifications.errorTitle'), title: t('common:notifications.errorTitle'),
message: message ?? error?.message ?? 'Login failed', message: t(getLoginErrorKey(error)),
color: 'red', color: 'red',
}); });
} finally { } finally {
@@ -8,5 +8,8 @@
"remember_me": "Remember me", "remember_me": "Remember me",
"forgot_password": "Forgot Password ?", "forgot_password": "Forgot Password ?",
"login_button": "Login", "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", "remember_me": "Ingat saya",
"forgot_password": "Lupa Kata Sandi ?", "forgot_password": "Lupa Kata Sandi ?",
"login_button": "Masuk", "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, Button,
} from '@repo/ui/components'; } from '@repo/ui/components';
import { Globe, Bell, HelpCircle, Settings, User, Briefcase, LogOut, ChevronDown, Sun, Moon } from 'lucide-react'; 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 { useThemeStore } from '../../../../core/stores/theme.store';
import { NotificationDropdown } from './notifications/notification-dropdown'; import { NotificationDropdown } from './notifications/notification-dropdown';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { terminateAuthSession } from '../../../../core/lib/auth.helper'; import { logoutAuthSession } from '../../../../core/lib/auth.helper';
// Dummy user data for preview // Dummy user data for preview
const USER = { const USER = {
@@ -67,7 +67,7 @@ export default function HeaderLayout() {
size="xs" size="xs"
onClick={() => { onClick={() => {
modals.close('logout-confirmation'); modals.close('logout-confirmation');
terminateAuthSession(); logoutAuthSession();
}} }}
> >
{t('common:signOut')} {t('common:signOut')}
@@ -79,12 +79,22 @@ export default function HeaderLayout() {
} }
async function initProfile() { async function initProfile() {
// FIXME: Replace the hardcoded `USER` mock data with the actual profile data. const profile = await appDatabase.getItem<{
// Uncomment the database fetch below and update the state using the retrieved profile. 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); setUserProfile(USER);
} }
@@ -17,8 +17,7 @@ export default function FullPagePageDetail() {
], ],
}} }}
> >
<DetailGeneral />
<DetailGeneral/>
</EnterpriseDetailPageProvider> </EnterpriseDetailPageProvider>
); );
} }
@@ -17,7 +17,12 @@ export function NotificationSetting({ onDirtyChange }: { onDirtyChange: (isDirty
const { t } = useTranslation(); const { t } = useTranslation();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const { control, handleSubmit, reset, formState: { isDirty } } = useForm<NotificationSettings>({ const {
control,
handleSubmit,
reset,
formState: { isDirty },
} = useForm<NotificationSettings>({
resolver: zodResolver(notificationSchema), resolver: zodResolver(notificationSchema),
defaultValues: { defaultValues: {
email: false, email: false,
@@ -40,7 +45,7 @@ export function NotificationSetting({ onDirtyChange }: { onDirtyChange: (isDirty
setLoading(true); setLoading(true);
try { try {
// Simulate API call // Simulate API call
await new Promise(resolve => setTimeout(resolve, 500)); await new Promise((resolve) => setTimeout(resolve, 500));
// Reset form to clear dirty state // Reset form to clear dirty state
reset(data); reset(data);
} catch (err) { } catch (err) {
@@ -54,8 +59,12 @@ export function NotificationSetting({ onDirtyChange }: { onDirtyChange: (isDirty
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md"> <Stack gap="md">
<Box> <Box>
<Text fw={500} size="md" mb="xs">{t('setting:notification.title')}</Text> <Text fw={500} size="md" mb="xs">
<Text c="dimmed" size="sm" mb="md">{t('setting:notification.desc')}</Text> {t('setting:notification.title')}
</Text>
<Text c="dimmed" size="sm" mb="md">
{t('setting:notification.desc')}
</Text>
<Stack gap="sm"> <Stack gap="sm">
<FieldSwitch <FieldSwitch
+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;
@@ -74,8 +74,7 @@ export function useElectronPrinter(): UseElectronPrinterReturn {
} }
}, []); }, []);
const print = useCallback( const print = useCallback(async (options?: ElectronPrintOptions): Promise<ElectronPrintResult> => {
async (options?: ElectronPrintOptions): Promise<ElectronPrintResult> => {
if (!window.electronAPI) { if (!window.electronAPI) {
return { success: false, failureReason: 'Not running in Electron' }; return { success: false, failureReason: 'Not running in Electron' };
} }
@@ -95,9 +94,7 @@ export function useElectronPrinter(): UseElectronPrinterReturn {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, }, []);
[],
);
return { return {
printers, printers,
@@ -3,14 +3,7 @@ import { useIsElectron } from './use-is-electron';
// ─── Types ────────────────────────────────────────────────────── // ─── Types ──────────────────────────────────────────────────────
export type UpdateStatus = export type UpdateStatus = 'idle' | 'checking' | 'available' | 'not-available' | 'downloading' | 'ready' | 'error';
| 'idle'
| 'checking'
| 'available'
| 'not-available'
| 'downloading'
| 'ready'
| 'error';
export interface UseElectronUpdaterReturn { export interface UseElectronUpdaterReturn {
/** Current status of the auto-updater lifecycle */ /** Current status of the auto-updater lifecycle */
+12 -6
View File
@@ -2,14 +2,15 @@ import { createHttpClient } from '@repo/core-api/http-client';
import { faroAdapter } from '@repo/core-api/observability'; import { faroAdapter } from '@repo/core-api/observability';
import { ENV } from '../environment'; import { ENV } from '../environment';
import { AppDatabaseKey, AppStorageKey, appDatabase, appStorage } from '../storage/local'; 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`. * Enterprise HTTP client for `apps/web`.
* *
* - Full Faro observability via the shared `faroAdapter` * - Full Faro observability via the shared `faroAdapter`
* - Automatic Bearer token injection from localStorage * - 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 * - Supports per-request `telemetryContext` for custom spans/tags
* *
* All interceptors (auth, observability, error normalization) * All interceptors (auth, observability, error normalization)
@@ -23,7 +24,6 @@ export const apiClient = createHttpClient(
observability: faroAdapter, observability: faroAdapter,
}, },
{ {
// ── Auth Interceptor ──────────────────────────────────────────
onRequest: async (config) => { onRequest: async (config) => {
config.headers['ex-app-name'] = ENV.APP_NAME; config.headers['ex-app-name'] = ENV.APP_NAME;
config.headers['ex-app-version'] = ENV.APP_VERSION; config.headers['ex-app-version'] = ENV.APP_VERSION;
@@ -33,18 +33,24 @@ export const apiClient = createHttpClient(
const language = await appStorage.getItem<string>(AppStorageKey.LANGUAGE); const language = await appStorage.getItem<string>(AppStorageKey.LANGUAGE);
config.headers['ex-language'] = language ?? ENV.DEFAULT_LANGUAGE; config.headers['ex-language'] = language ?? ENV.DEFAULT_LANGUAGE;
if (!isPublicAuthRequest(config.url)) {
const token = await appDatabase.getItem<string>(AppDatabaseKey.ACCESS_TOKEN); const token = await appDatabase.getItem<string>(AppDatabaseKey.ACCESS_TOKEN);
if (token) config.headers.Authorization = `Bearer ${token}`; if (token) config.headers.Authorization = `Bearer ${token}`;
}
return config; return config;
}, },
// ── Error Interceptor ─────────────────────────────────────────
onResponseError: async (error) => { onResponseError: async (error) => {
const status = error.response?.status; const status = error.response?.status;
// Catch 401 (Unauthorized) on request if (status === 401) {
if (status === 401) if (status === 401) await terminateAuthSession({ preserveRedirect: true }); return handleUnauthorized(error, {
refresh: refreshAuthSession,
terminate: () => terminateAuthSession({ preserveRedirect: true }),
retry: (config) => apiClient.request(config),
}) as Promise<never>;
}
throw error; throw error;
}, },
+38 -19
View File
@@ -1,7 +1,7 @@
import { useEffect, useState, ReactNode } from 'react'; import { useEffect, useState, ReactNode } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { appDatabase, AppDatabaseKey } from '../storage/local'; import { appDatabase, AppDatabaseKey } from '../storage/local';
import { terminateAuthSession } from './auth.helper'; import { refreshAuthSession, terminateAuthSession } from './auth.helper';
/** /**
* Basic JWT decoder to check expiration. * Basic JWT decoder to check expiration.
@@ -12,16 +12,30 @@ function isTokenExpired(token: string): boolean {
if (payload.exp) { if (payload.exp) {
return payload.exp * 1000 < Date.now(); return payload.exp * 1000 < Date.now();
} }
return false; // If no exp, assume valid return false;
} catch (e) { } catch {
return true; // Invalid token format -> treat as expired 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). * For Auth Page (Login/Register).
* - If valid token exists -> Redirect to main application. * - 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 }) { export function AuthPageGuard({ children }: { children: ReactNode }) {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -31,15 +45,18 @@ export function AuthPageGuard({ children }: { children: ReactNode }) {
async function checkCredential() { async function checkCredential() {
try { try {
const token = await appDatabase.getItem<string>(AppDatabaseKey.ACCESS_TOKEN); const token = await appDatabase.getItem<string>(AppDatabaseKey.ACCESS_TOKEN);
if (token) { if (token && !isTokenExpired(token)) {
if (!isTokenExpired(token)) {
// Valid token exists, redirect to dashboard/main app
navigate('/app', { replace: true }); navigate('/app', { replace: true });
return; return;
} else {
// Token exists but is expired. Terminate session without redirecting (we are already in Auth).
await terminateAuthSession({ preserveRedirect: false });
} }
if (await restoreSessionWithRefresh()) {
navigate('/app', { replace: true });
return;
}
if (token) {
await terminateAuthSession({ preserveRedirect: false });
} }
setIsChecking(false); setIsChecking(false);
} catch (error) { } catch (error) {
@@ -59,30 +76,32 @@ export function AuthPageGuard({ children }: { children: ReactNode }) {
/** /**
* For Root/Main App. * For Root/Main App.
* - Monitors token status. * - 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 }) { export function GlobalCredentialChecker({ children }: { children: ReactNode }) {
useEffect(() => { useEffect(() => {
let intervalId: any; let intervalId: ReturnType<typeof setInterval>;
async function checkCredential() { async function checkCredential() {
try { try {
const token = await appDatabase.getItem<string>(AppDatabaseKey.ACCESS_TOKEN); const token = await appDatabase.getItem<string>(AppDatabaseKey.ACCESS_TOKEN);
if (!token || isTokenExpired(token)) { if (token && !isTokenExpired(token)) {
// Token is missing or expired -> clear session and redirect to login return;
await terminateAuthSession({ preserveRedirect: true });
} }
if (await restoreSessionWithRefresh()) {
return;
}
await terminateAuthSession({ preserveRedirect: true });
} catch (error) { } catch (error) {
console.error('Failed to check credentials in GlobalCredentialChecker:', error); console.error('Failed to check credentials in GlobalCredentialChecker:', error);
await terminateAuthSession({ preserveRedirect: true }); await terminateAuthSession({ preserveRedirect: true });
} }
} }
// Immediate check on mount
checkCredential(); checkCredential();
// Periodic check every 1 minute
// eslint-disable-next-line prefer-const
intervalId = setInterval(checkCredential, 60000); intervalId = setInterval(checkCredential, 60000);
return () => clearInterval(intervalId); 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');
});
});
+84 -50
View File
@@ -1,6 +1,7 @@
import { lodash } from '@repo/utils';
import { appDatabase, AppDatabaseKey, appStorage, AppStorageKey } from '../storage/local'; 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 { interface TerminateOptions {
/** When true, appends ?redirect= so the user returns to their page after re-login. /** When true, appends ?redirect= so the user returns to their page after re-login.
@@ -9,78 +10,111 @@ interface TerminateOptions {
preserveRedirect?: boolean; 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; const { preserveRedirect = false } = options;
/**
* Delete invalid tokens (Clear local state)
*/
await appDatabase.removeItem(AppDatabaseKey.USER_PRIVILEGE); await appDatabase.removeItem(AppDatabaseKey.USER_PRIVILEGE);
await appDatabase.removeItem(AppDatabaseKey.USER_PROFILE); await appDatabase.removeItem(AppDatabaseKey.USER_PROFILE);
await appDatabase.removeItem(AppDatabaseKey.ACCESS_TOKEN); await appDatabase.removeItem(AppDatabaseKey.ACCESS_TOKEN);
await appDatabase.removeItem(AppDatabaseKey.REFRESH_TOKEN);
await appStorage.removeItem(AppStorageKey.USER_ID); await appStorage.removeItem(AppStorageKey.USER_ID);
/**
* Build the login URL
*/
let loginUrl = '/auth/login'; let loginUrl = '/auth/login';
// Check if the current page is NOT the login page
const isNotLoginPage = !window.location.pathname.includes('/auth/login'); const isNotLoginPage = !window.location.pathname.includes('/auth/login');
if (isNotLoginPage) { if (isNotLoginPage) {
// Set redirect parameters only if requested AND the user is not currently on the login page
if (preserveRedirect) { if (preserveRedirect) {
const currentPath = window.location.pathname + window.location.search; const currentPath = window.location.pathname + window.location.search;
loginUrl += `?redirect=${encodeURIComponent(currentPath)}`; loginUrl += `?redirect=${encodeURIComponent(currentPath)}`;
} }
/**
* Redirect without saving history to prevent infinite back-loops
*/
window.location.replace(loginUrl); window.location.replace(loginUrl);
} }
} }
export async function initiateAuthSession(respLogin: any) { export async function initiateAuthSession(tokens: TokenPair, me: AuthUser): Promise<void> {
try { if (!tokens.accessToken) {
const data = respLogin?.data ?? {}; throw new Error('Login response missing access token');
const { token, id } = data; }
const userProfile = lodash.omit(data, ['token']);
if (!token) throw new Error('Login response missing token'); await persistTokenPair(tokens);
await persistUserSession(me);
// 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 params = new URLSearchParams(window.location.search);
const redirectTo = params.get('redirect'); const redirectTo = params.get('redirect');
window.location.replace(redirectTo ? decodeURIComponent(redirectTo) : '/app'); window.location.replace(redirectTo ? decodeURIComponent(redirectTo) : '/app');
} catch (error) { }
throw error as any;
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 { 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'; import { appDatabase, AppDatabaseKey } from '../storage/local';
interface StoredUserProfile {
isSuperadmin?: boolean;
}
/** /**
* Concrete storage adapter that wires the Enterprise Module framework * Concrete storage adapter that wires the Enterprise Module framework
* to this app's IndexedDB instance. * to this app's IndexedDB instance.
@@ -10,7 +14,12 @@ import { appDatabase, AppDatabaseKey } from '../storage/local';
*/ */
export const enterpriseStorageAdapter: EnterpriseStorageAdapter = { export const enterpriseStorageAdapter: EnterpriseStorageAdapter = {
async getPrivileges(moduleKey: string): Promise<PrivilegeEntity | null> { 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; if (!allPrivileges) return null;
return allPrivileges[moduleKey] ?? 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'; import type {} from '@repo/core-events';
declare module '@repo/core-events' { declare module '@repo/core-events' {
// ─── Payload Types ────────────────────────────────────────── // ─── Payload Types ──────────────────────────────────────────
interface ReceiptItem { 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 react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite'; import tailwindcss from '@tailwindcss/vite';
@@ -18,4 +18,8 @@ export default defineConfig({
events: 'events', events: 'events',
}, },
}, },
test: {
environment: 'node',
globals: false,
},
}); });
@@ -420,7 +420,7 @@ describe('BaseRemoteDataServices — Data Transformer Integration', () => {
id: '42', id: '42',
bookingCode: 'BK042', bookingCode: 'BK042',
customerName: 'Alice', customerName: 'Alice',
} },
}); });
expect(result.status).toBe(200); expect(result.status).toBe(200);
}); });
@@ -432,7 +432,7 @@ describe('BaseRemoteDataServices — Data Transformer Integration', () => {
{ id: '1', booking_code: 'BK001', customer_name: 'Alice' }, { id: '1', booking_code: 'BK001', customer_name: 'Alice' },
{ id: '2', booking_code: 'BK002', customer_name: 'Bob' }, { 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, status: 200,
}); });
@@ -444,7 +444,7 @@ describe('BaseRemoteDataServices — Data Transformer Integration', () => {
{ id: '1', bookingCode: 'BK001', customerName: 'Alice' }, { id: '1', bookingCode: 'BK001', customerName: 'Alice' },
{ id: '2', bookingCode: 'BK002', customerName: 'Bob' }, { 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({ expect(result.data).toEqual({
data: [], 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', id: '42',
bookingCode: 'BK042', bookingCode: 'BK042',
customerName: 'ALICE', // uppercased by custom hook customerName: 'ALICE', // uppercased by custom hook
} },
}); });
}); });
@@ -558,7 +558,7 @@ describe('BaseRemoteDataServices — Data Transformer Integration', () => {
expect(result.data).toEqual({ expect(result.data).toEqual({
data: [{ id: '1', bookingCode: 'LIST-BK001', customerName: 'Alice' }], 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. */ /** 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, { return this.execute<void>(DESCRIPTORS.batchDelete, {
config: { ...config, data: { ids, ...(meta ? { meta } : {}) } }, 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. */ /** 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, { return this.execute<void>(DESCRIPTORS.batchActivate, {
config: { ...config, data: { ids, ...(meta ? { meta } : {}) } }, 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. */ /** 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, { return this.execute<void>(DESCRIPTORS.batchDeactivate, {
config: { ...config, data: { ids, ...(meta ? { meta } : {}) } }, 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. */ /** 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, { return this.execute<void>(DESCRIPTORS.batchConfirmData, {
config: { ...config, data: { ids, ...(meta ? { meta } : {}) } }, 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. */ /** 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, { return this.execute<void>(DESCRIPTORS.batchCancelData, {
config: { ...config, data: { ids, ...(meta ? { meta } : {}) } }, 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. */ /** 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, { return this.execute<void>(DESCRIPTORS.batchRollbackData, {
config: { ...config, data: { ids, ...(meta ? { meta } : {}) } }, 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. */ /** 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, { return this.execute<void>(DESCRIPTORS.batchHoldData, {
config: { ...config, data: { ids, ...(meta ? { meta } : {}) } }, config: { ...config, data: { ids, ...(meta ? { meta } : {}) } },
}); });
@@ -52,8 +52,7 @@ import { BaseRemoteDataServices } from './base-remote.data-services';
* } * }
* ``` * ```
*/ */
export class CommonRemoteDataServices< export class CommonRemoteDataServices<E extends BaseEntity = BaseEntity, TDTO = E> extends BaseRemoteDataServices<
E extends BaseEntity = BaseEntity, E,
TDTO = E, TDTO
> extends BaseRemoteDataServices<E, TDTO> {} > {}
+5 -29
View File
@@ -33,13 +33,7 @@ export class ApiError extends Error {
/** The original Axios error, preserved for debugging. */ /** The original Axios error, preserved for debugging. */
readonly cause: AxiosError | undefined; readonly cause: AxiosError | undefined;
constructor( constructor(message: string, code: ApiErrorCode, status: number, data?: unknown, cause?: AxiosError) {
message: string,
code: ApiErrorCode,
status: number,
data?: unknown,
cause?: AxiosError,
) {
super(message); super(message);
this.name = 'ApiError'; this.name = 'ApiError';
this.code = code; this.code = code;
@@ -59,30 +53,12 @@ export class ApiError extends Error {
// Network error (no response received) // Network error (no response received)
if (!error.response) { if (!error.response) {
if (error.code === 'ECONNABORTED') { if (error.code === 'ECONNABORTED') {
return new ApiError( return new ApiError('Request timed out', ApiErrorCode.TIMEOUT, 0, undefined, error);
'Request timed out',
ApiErrorCode.TIMEOUT,
0,
undefined,
error,
);
} }
if (error.code === 'ERR_CANCELED') { if (error.code === 'ERR_CANCELED') {
return new ApiError( return new ApiError('Request was cancelled', ApiErrorCode.CANCELLED, 0, undefined, error);
'Request was cancelled',
ApiErrorCode.CANCELLED,
0,
undefined,
error,
);
} }
return new ApiError( return new ApiError(error.message || 'Network error', ApiErrorCode.NETWORK_ERROR, 0, undefined, error);
error.message || 'Network error',
ApiErrorCode.NETWORK_ERROR,
0,
undefined,
error,
);
} }
// Server responded with an error status // Server responded with an error status
@@ -93,7 +69,7 @@ export class ApiError extends Error {
// Extract message from common server response formats // Extract message from common server response formats
const serverMessage = const serverMessage =
(data && typeof data === 'object' && 'message' in data) data && typeof data === 'object' && 'message' in data
? String((data as Record<string, unknown>).message) ? String((data as Record<string, unknown>).message)
: `Request failed with status ${status}`; : `Request failed with status ${status}`;
+20 -10
View File
@@ -28,15 +28,25 @@ export enum ApiErrorCode {
*/ */
export function httpStatusToErrorCode(status: number): ApiErrorCode { export function httpStatusToErrorCode(status: number): ApiErrorCode {
switch (status) { switch (status) {
case 400: return ApiErrorCode.BAD_REQUEST; case 400:
case 401: return ApiErrorCode.UNAUTHORIZED; return ApiErrorCode.BAD_REQUEST;
case 403: return ApiErrorCode.FORBIDDEN; case 401:
case 404: return ApiErrorCode.NOT_FOUND; return ApiErrorCode.UNAUTHORIZED;
case 409: return ApiErrorCode.CONFLICT; case 403:
case 422: return ApiErrorCode.UNPROCESSABLE_ENTITY; return ApiErrorCode.FORBIDDEN;
case 429: return ApiErrorCode.TOO_MANY_REQUESTS; case 404:
case 500: return ApiErrorCode.INTERNAL_SERVER_ERROR; return ApiErrorCode.NOT_FOUND;
case 503: return ApiErrorCode.SERVICE_UNAVAILABLE; case 409:
default: return ApiErrorCode.UNKNOWN; 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( export function createHttpClient(config: HttpClientConfig, hooks?: InterceptorHooks): AxiosInstance {
config: HttpClientConfig,
hooks?: InterceptorHooks,
): AxiosInstance {
const observability = config.observability ?? noopObservabilityAdapter; const observability = config.observability ?? noopObservabilityAdapter;
// ── Create isolated instance ────────────────────────────────── // ── Create isolated instance ──────────────────────────────────
@@ -49,7 +46,7 @@ export function createHttpClient(
timeout: config.timeout ?? 15000, timeout: config.timeout ?? 15000,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Accept': 'application/json', Accept: 'application/json',
...(config.defaultHeaders ?? {}), ...(config.defaultHeaders ?? {}),
}, },
}); });
+5 -15
View File
@@ -1,8 +1,4 @@
import type { import type { AxiosError, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
AxiosError,
AxiosResponse,
InternalAxiosRequestConfig,
} from 'axios';
import type { IObservabilityAdapter } from '../observability/types'; import type { IObservabilityAdapter } from '../observability/types';
// ─── Factory Configuration ────────────────────────────────────── // ─── Factory Configuration ──────────────────────────────────────
@@ -41,9 +37,7 @@ export interface InterceptorHooks {
* Called before every request is dispatched. * Called before every request is dispatched.
* Use this to inject authentication tokens, tenant headers, etc. * Use this to inject authentication tokens, tenant headers, etc.
*/ */
onRequest?: ( onRequest?: (config: InternalAxiosRequestConfig) => Promise<InternalAxiosRequestConfig> | InternalAxiosRequestConfig;
config: InternalAxiosRequestConfig,
) => Promise<InternalAxiosRequestConfig> | InternalAxiosRequestConfig;
/** /**
* Called on every successful response (2xx status). * Called on every successful response (2xx status).
@@ -105,13 +99,7 @@ export interface TelemetryContext {
// ─── Re-export Axios types consumers frequently need ──────────── // ─── Re-export Axios types consumers frequently need ────────────
export type { export type { AxiosInstance, AxiosError, AxiosResponse, AxiosRequestConfig, InternalAxiosRequestConfig } from 'axios';
AxiosInstance,
AxiosError,
AxiosResponse,
AxiosRequestConfig,
InternalAxiosRequestConfig,
} from 'axios';
// ─── Augment Axios to carry TelemetryContext ──────────────────── // ─── Augment Axios to carry TelemetryContext ────────────────────
@@ -119,5 +107,7 @@ declare module 'axios' {
interface AxiosRequestConfig { interface AxiosRequestConfig {
/** Per-request telemetry context for custom spans, tags, events. */ /** Per-request telemetry context for custom spans, tags, events. */
telemetryContext?: TelemetryContext; telemetryContext?: TelemetryContext;
/** Skip the 401 refresh-and-retry interceptor for this request. */
skipAuthRefresh?: boolean;
} }
} }
@@ -70,10 +70,7 @@ function makeRequestConfig(
} as InternalAxiosRequestConfig; } as InternalAxiosRequestConfig;
} }
function makeAxiosResponse( function makeAxiosResponse(config: InternalAxiosRequestConfig, overrides: Partial<AxiosResponse> = {}): AxiosResponse {
config: InternalAxiosRequestConfig,
overrides: Partial<AxiosResponse> = {},
): AxiosResponse {
return { return {
data: {}, data: {},
status: 200, status: 200,
@@ -43,9 +43,7 @@ function getTelemetryContext(config: unknown): TelemetryContext | undefined {
/** Convert TelemetryContext tags to a string record for Faro context. */ /** Convert TelemetryContext tags to a string record for Faro context. */
function tagsToFaroContext(tags?: Record<string, string | number | boolean>): Record<string, string> { function tagsToFaroContext(tags?: Record<string, string | number | boolean>): Record<string, string> {
if (!tags) return {}; if (!tags) return {};
return Object.fromEntries( return Object.fromEntries(Object.entries(tags).map(([k, v]) => [k, String(v)]));
Object.entries(tags).map(([k, v]) => [k, String(v)]),
);
} }
/** /**
@@ -135,10 +133,7 @@ export const faroAdapter: IObservabilityAdapter = {
const faro = getFaro(); const faro = getFaro();
if (faro) { if (faro) {
const baseContext = buildBaseContext(method, url, moduleKey, action, ctx?.tags); const baseContext = buildBaseContext(method, url, moduleKey, action, ctx?.tags);
faro.api.pushLog( faro.api.pushLog([`[core-api] ${method} ${url}`], { level: LogLevel.DEBUG, context: baseContext });
[`[core-api] ${method} ${url}`],
{ level: LogLevel.DEBUG, context: baseContext },
);
} }
}, },
@@ -203,10 +198,10 @@ export const faroAdapter: IObservabilityAdapter = {
context: errorContext, context: errorContext,
}); });
faro.api.pushLog( faro.api.pushLog([`[core-api] ERROR ${method} ${url}${status}`], {
[`[core-api] ERROR ${method} ${url}${status}`], level: LogLevel.ERROR,
{ level: LogLevel.ERROR, context: errorContext }, context: errorContext,
); });
} }
}, },
}; };
+2 -7
View File
@@ -17,11 +17,7 @@
* ``` * ```
*/ */
import { import { getWebInstrumentations, initializeFaro, type Faro } from '@grafana/faro-react';
getWebInstrumentations,
initializeFaro,
type Faro,
} from '@grafana/faro-react';
import { TracingInstrumentation } from '@grafana/faro-web-tracing'; import { TracingInstrumentation } from '@grafana/faro-web-tracing';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-web'; import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-web';
@@ -125,8 +121,7 @@ export function initTelemetry(config: TelemetryConfig): Faro {
new TracingInstrumentation({ new TracingInstrumentation({
...tracingOptions, ...tracingOptions,
instrumentationOptions: { instrumentationOptions: {
propagateTraceHeaderCorsUrls: propagateTraceHeaderCorsUrls: config.propagateTraceHeaderCorsUrls ?? [/.*/],
config.propagateTraceHeaderCorsUrls ?? [/.*/],
fetchInstrumentationOptions: { fetchInstrumentationOptions: {
applyCustomAttributesOnSpan(span) { applyCustomAttributesOnSpan(span) {
span.setAttribute('app.synthetic_request', 'false'); 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 offSpy = vi.spyOn(eventBus, 'off');
const { rerender } = renderHook( const { rerender } = renderHook(
({ handler }: { handler: () => void }) => ({ handler }: { handler: () => void }) => useAppEvent('TEST:INITIALIZED', handler),
useAppEvent('TEST:INITIALIZED', handler),
{ initialProps: { handler: vi.fn() } }, { 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', ... }); * publish('AUTH:PROFILE_UPDATED', { id: '1', name: 'Firman', ... });
* ``` * ```
*/ */
export function publish<K extends keyof AppEvents>( export function publish<K extends keyof AppEvents>(type: K, event: AppEvents[K]): void {
type: K,
event: AppEvents[K],
): void {
eventBus.emit(type, event); eventBus.emit(type, event);
} }
@@ -58,10 +55,7 @@ export function publish<K extends keyof AppEvents>(
* unsub(); * unsub();
* ``` * ```
*/ */
export function subscribe<K extends keyof AppEvents>( export function subscribe<K extends keyof AppEvents>(type: K, handler: (event: AppEvents[K]) => void): () => void {
type: K,
handler: (event: AppEvents[K]) => void,
): () => void {
eventBus.on(type, handler); eventBus.on(type, handler);
return () => eventBus.off(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>( export function useAppEvent<K extends keyof AppEvents>(type: K, handler: (event: AppEvents[K]) => void): void {
type: K,
handler: (event: AppEvents[K]) => void,
): void {
// Always keep the latest handler in a ref to avoid stale closures // Always keep the latest handler in a ref to avoid stale closures
// and prevent re-subscription on every render. // and prevent re-subscription on every render.
const handlerRef = useRef(handler); const handlerRef = useRef(handler);
+1 -1
View File
@@ -4,6 +4,6 @@ import type { resources } from './setup';
declare module 'react-i18next' { declare module 'react-i18next' {
interface CustomTypeOptions { interface CustomTypeOptions {
defaultNS: 'common'; defaultNS: 'common';
resources: typeof resources['en']; resources: (typeof resources)['en'];
} }
} }
@@ -35,9 +35,7 @@ function createTestDB(name: string) {
const results: (PouchDB.Core.Response | PouchDB.Core.Error)[] = []; const results: (PouchDB.Core.Response | PouchDB.Core.Error)[] = [];
for (let i = 0; i < dataList.length; i += batchSize) { for (let i = 0; i < dataList.length; i += batchSize) {
const batch = dataList.slice(i, i + batchSize); const batch = dataList.slice(i, i + batchSize);
const response = await raw.bulkDocs( const response = await raw.bulkDocs(batch as PouchDB.Core.Document<T>[]);
batch as PouchDB.Core.Document<T>[],
);
results.push(...response); results.push(...response);
} }
return results; return results;
@@ -65,16 +63,12 @@ function createTestDB(name: string) {
// ─── Reads ──────────────────────────────────────────────── // ─── Reads ────────────────────────────────────────────────
async getOne<T>(id: string) { async getOne<T>(id: string) {
return raw.get<T>(id) as Promise< return raw.get<T>(id) as Promise<T & PouchDB.Core.IdMeta & PouchDB.Core.RevisionIdMeta>;
T & PouchDB.Core.IdMeta & PouchDB.Core.RevisionIdMeta
>;
}, },
async getAll<T>() { async getAll<T>() {
const result = await raw.allDocs({ include_docs: true }); const result = await raw.allDocs({ include_docs: true });
return result.rows return result.rows.filter((row) => !row.id.startsWith('_design/')).map((row) => row.doc as unknown as T);
.filter((row) => !row.id.startsWith('_design/'))
.map((row) => row.doc as unknown as T);
}, },
async getSome<T>(ids: string[]) { async getSome<T>(ids: string[]) {
@@ -85,9 +79,7 @@ function createTestDB(name: string) {
}, },
async find<T extends object>(options: PouchDB.Find.FindRequest<T>) { async find<T extends object>(options: PouchDB.Find.FindRequest<T>) {
const result = await raw.find( const result = await raw.find(options as PouchDB.Find.FindRequest<object>);
options as PouchDB.Find.FindRequest<object>,
);
return result.docs as unknown as T[]; return result.docs as unknown as T[];
}, },
@@ -146,9 +138,7 @@ describe('PouchBase — Core CRUD Operations', () => {
let db: ReturnType<typeof createTestDB>; let db: ReturnType<typeof createTestDB>;
beforeEach(() => { beforeEach(() => {
db = createTestDB( db = createTestDB(`test_base_${Date.now()}_${Math.random().toString(36).slice(2)}`);
`test_base_${Date.now()}_${Math.random().toString(36).slice(2)}`,
);
}); });
afterEach(async () => { afterEach(async () => {
@@ -170,9 +160,7 @@ describe('PouchBase — Core CRUD Operations', () => {
it('should throw a conflict if creating with a duplicate _id', async () => { it('should throw a conflict if creating with a duplicate _id', async () => {
await db.create({ _id: 'dup-001', name: 'First' }); await db.create({ _id: 'dup-001', name: 'First' });
await expect( await expect(db.create({ _id: 'dup-001', name: 'Second' })).rejects.toThrow();
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 () => { it('should retrieve a document by id', async () => {
await db.create({ _id: 'fetch-001', product: 'Widget', price: 9.99 }); await db.create({ _id: 'fetch-001', product: 'Widget', price: 9.99 });
const doc = await db.getOne<{ product: string; price: number }>( const doc = await db.getOne<{ product: string; price: number }>('fetch-001');
'fetch-001',
);
expect(doc._id).toBe('fetch-001'); expect(doc._id).toBe('fetch-001');
expect(doc.product).toBe('Widget'); expect(doc.product).toBe('Widget');
expect(doc.price).toBe(9.99); expect(doc.price).toBe(9.99);
@@ -87,7 +87,11 @@ describe('FieldAsyncSelect', () => {
const { control, handleSubmit } = useForm({ defaultValues: { vendor: null } }); const { control, handleSubmit } = useForm({ defaultValues: { vendor: null } });
return ( return (
<MantineProvider> <MantineProvider>
<form onSubmit={handleSubmit((data) => { capturedData = data; })}> <form
onSubmit={handleSubmit((data) => {
capturedData = data;
})}
>
<FieldAsyncSelect <FieldAsyncSelect
name="vendor" name="vendor"
control={control} control={control}
@@ -49,11 +49,7 @@ describe('FieldCheckbox', () => {
const { control } = useForm({ defaultValues: { acceptTerms: false } }); const { control } = useForm({ defaultValues: { acceptTerms: false } });
return ( return (
<MantineProvider> <MantineProvider>
<FieldCheckbox <FieldCheckbox name="acceptTerms" control={control} label="I accept the terms and conditions" />
name="acceptTerms"
control={control}
label="I accept the terms and conditions"
/>
</MantineProvider> </MantineProvider>
); );
} }
@@ -69,11 +65,7 @@ describe('FieldCheckbox', () => {
const { control } = useForm({ defaultValues: { acceptTerms: false } }); const { control } = useForm({ defaultValues: { acceptTerms: false } });
return ( return (
<MantineProvider> <MantineProvider>
<FieldCheckbox <FieldCheckbox name="acceptTerms" control={control} label="Accept Terms" />
name="acceptTerms"
control={control}
label="Accept Terms"
/>
</MantineProvider> </MantineProvider>
); );
} }
@@ -114,10 +106,7 @@ describe('FieldCheckbox', () => {
await user.click(screen.getByText('Submit')); await user.click(screen.getByText('Submit'));
await waitFor(() => { await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith( expect(onSubmit).toHaveBeenCalledWith({ acceptTerms: true }, expect.anything());
{ acceptTerms: true },
expect.anything(),
);
}); });
}); });
@@ -70,7 +70,11 @@ describe('FieldLocalSelect', () => {
const { control, handleSubmit } = useForm({ defaultValues: { vendor: null } }); const { control, handleSubmit } = useForm({ defaultValues: { vendor: null } });
return ( return (
<MantineProvider> <MantineProvider>
<form onSubmit={handleSubmit((data) => { capturedData = data; })}> <form
onSubmit={handleSubmit((data) => {
capturedData = data;
})}
>
<FieldLocalSelect <FieldLocalSelect
name="vendor" name="vendor"
control={control} control={control}
@@ -126,8 +130,6 @@ describe('FieldLocalSelect', () => {
expect(screen.getByText('V1 - Vendor 1')).toBeInTheDocument(); expect(screen.getByText('V1 - Vendor 1')).toBeInTheDocument();
}); });
it('filterOption excludes items from dropdown', async () => { it('filterOption excludes items from dropdown', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
@@ -166,7 +168,11 @@ describe('FieldLocalSelect', () => {
const { control, handleSubmit } = useForm({ defaultValues: { vendors: [] } }); const { control, handleSubmit } = useForm({ defaultValues: { vendors: [] } });
return ( return (
<MantineProvider> <MantineProvider>
<form onSubmit={handleSubmit((data) => { capturedData = data; })}> <form
onSubmit={handleSubmit((data) => {
capturedData = data;
})}
>
<FieldLocalSelect <FieldLocalSelect
multiple multiple
name="vendors" name="vendors"
@@ -199,7 +205,11 @@ describe('FieldLocalSelect', () => {
const { control, handleSubmit } = useForm({ defaultValues: { vendors: [VENDORS[0]] } }); const { control, handleSubmit } = useForm({ defaultValues: { vendors: [VENDORS[0]] } });
return ( return (
<MantineProvider> <MantineProvider>
<form onSubmit={handleSubmit((data) => { capturedData = data; })}> <form
onSubmit={handleSubmit((data) => {
capturedData = data;
})}
>
<FieldLocalSelect <FieldLocalSelect
multiple multiple
name="vendors" name="vendors"
@@ -217,7 +227,9 @@ describe('FieldLocalSelect', () => {
const { container } = render(<TestForm />); 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(); expect(clearButton).not.toBeNull();
await user.click(clearButton!); await user.click(clearButton!);
@@ -32,10 +32,7 @@ vi.mock('@repo/core-i18n', () => ({
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const loginSchema = z.object({ const loginSchema = z.object({
username: z username: z.string().min(1, 'Username cannot be empty').min(3, 'Username must be at least 3 characters'),
.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'), email: z.string().email('Please enter a valid email address'),
}); });
@@ -57,12 +54,7 @@ describe('FieldTextInput', () => {
}); });
return ( return (
<MantineProvider> <MantineProvider>
<FieldTextInput <FieldTextInput name="username" control={control} label="Username" placeholder="Enter username" />
name="username"
control={control}
label="Username"
placeholder="Enter username"
/>
</MantineProvider> </MantineProvider>
); );
} }
@@ -143,10 +135,7 @@ describe('FieldTextInput', () => {
await user.click(screen.getByText('Login')); await user.click(screen.getByText('Login'));
await waitFor(() => { await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith( expect(onSubmit).toHaveBeenCalledWith({ username: 'john', email: 'john@example.com' }, expect.anything());
{ username: 'john', email: 'john@example.com' },
expect.anything(),
);
}); });
}); });
@@ -177,10 +166,7 @@ describe('FieldTextInput', () => {
await user.click(screen.getByText('Login')); await user.click(screen.getByText('Login'));
await waitFor(() => { await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith( expect(onSubmit).toHaveBeenCalledWith({ username: 'johndoe', email: 'john@example.com' }, expect.anything());
{ username: 'johndoe', email: 'john@example.com' },
expect.anything(),
);
}); });
}); });
}); });
@@ -41,11 +41,7 @@ interface FormTestWrapperProps {
onSubmit?: (data: Record<string, unknown>) => void; onSubmit?: (data: Record<string, unknown>) => void;
} }
function FormTestWrapper({ function FormTestWrapper({ children, defaultValues = {}, onSubmit = () => {} }: FormTestWrapperProps) {
children,
defaultValues = {},
onSubmit = () => {},
}: FormTestWrapperProps) {
const methods = useForm({ defaultValues }); const methods = useForm({ defaultValues });
return ( return (
@@ -64,10 +60,7 @@ function FormTestWrapper({
// Create a test field component using the HOC // Create a test field component using the HOC
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const TestFieldTextInput = withRHF<React.ComponentProps<typeof TextInput>>( const TestFieldTextInput = withRHF<React.ComponentProps<typeof TextInput>>('TestFieldTextInput', TextInput);
'TestFieldTextInput',
TextInput,
);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Tests // Tests
@@ -114,7 +107,11 @@ describe('withRHF HOC', () => {
const { control, handleSubmit } = useForm({ defaultValues: { email: '' } }); const { control, handleSubmit } = useForm({ defaultValues: { email: '' } });
return ( return (
<MantineProvider> <MantineProvider>
<form onSubmit={handleSubmit((data) => { capturedData = data; })}> <form
onSubmit={handleSubmit((data) => {
capturedData = data;
})}
>
<TestFieldTextInput name="email" control={control} label="Email" /> <TestFieldTextInput name="email" control={control} label="Email" />
<button type="submit">Submit</button> <button type="submit">Submit</button>
</form> </form>
@@ -215,16 +212,12 @@ describe('withRHF HOC', () => {
// The fallback should be the raw JSON string since neither namespace has the key. // 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. // Our mock t() returns defaultValue when key is unknown, which is the raw JSON.
const errorElements = screen.getAllByText((content) => const errorElements = screen.getAllByText((content) => content.includes('validation.unknown_key'));
content.includes('validation.unknown_key'),
);
expect(errorElements.length).toBeGreaterThan(0); expect(errorElements.length).toBeGreaterThan(0);
}); });
it('has the correct displayName for React DevTools', () => { it('has the correct displayName for React DevTools', () => {
expect( expect((TestFieldTextInput as unknown as { displayName: string }).displayName).toBe('TestFieldTextInput');
(TestFieldTextInput as unknown as { displayName: string }).displayName,
).toBe('TestFieldTextInput');
}); });
it('forwards additional Mantine props (placeholder, etc.)', () => { it('forwards additional Mantine props (placeholder, etc.)', () => {
@@ -232,12 +225,7 @@ describe('withRHF HOC', () => {
const { control } = useForm({ defaultValues: { search: '' } }); const { control } = useForm({ defaultValues: { search: '' } });
return ( return (
<MantineProvider> <MantineProvider>
<TestFieldTextInput <TestFieldTextInput name="search" control={control} label="Search" placeholder="Type to search..." />
name="search"
control={control}
label="Search"
placeholder="Type to search..."
/>
</MantineProvider> </MantineProvider>
); );
} }
@@ -210,9 +210,7 @@ function AsyncSelectInner<T extends Record<string, any>>(props: AsyncSelectProps
// Disable Mantine's internal frontend filtering. // Disable Mantine's internal frontend filtering.
// The backend handles the search query, so we always display what the backend returns. // The backend handles the search query, so we always display what the backend returns.
const mantineFilter = filterOption const mantineFilter = filterOption ? ({ options: opts }: any) => opts : undefined;
? ({ options: opts }: any) => opts
: undefined;
// ----- Multi-select mode ----- // ----- Multi-select mode -----
if (multiple) { if (multiple) {
@@ -25,8 +25,8 @@ type ManagedSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'filt
type ManagedMultiSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'filter' | 'name' | 'onSelect'; type ManagedMultiSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'filter' | 'name' | 'onSelect';
/** Props for single-select mode */ /** Props for single-select mode */
export type LocalSelectSingleProps<T extends Record<string, any>> = export type LocalSelectSingleProps<T extends Record<string, any>> = LocalSelectBaseProps<T> &
LocalSelectBaseProps<T> & Omit<SelectProps, ManagedSelectProps> & { Omit<SelectProps, ManagedSelectProps> & {
multiple?: false; multiple?: false;
/** Controlled value — the full object or null */ /** Controlled value — the full object or null */
value?: T | null; value?: T | null;
@@ -35,8 +35,8 @@ export type LocalSelectSingleProps<T extends Record<string, any>> =
}; };
/** Props for multi-select mode */ /** Props for multi-select mode */
export type LocalSelectMultiProps<T extends Record<string, any>> = export type LocalSelectMultiProps<T extends Record<string, any>> = LocalSelectBaseProps<T> &
LocalSelectBaseProps<T> & Omit<MultiSelectProps, ManagedMultiSelectProps> & { Omit<MultiSelectProps, ManagedMultiSelectProps> & {
multiple: true; multiple: true;
/** Controlled value — array of full objects */ /** Controlled value — array of full objects */
value?: T[]; value?: T[];
@@ -45,9 +45,7 @@ export type LocalSelectMultiProps<T extends Record<string, any>> =
}; };
/** Discriminated union — the component narrows based on `multiple` */ /** Discriminated union — the component narrows based on `multiple` */
export type LocalSelectProps<T extends Record<string, any>> = export type LocalSelectProps<T extends Record<string, any>> = LocalSelectSingleProps<T> | LocalSelectMultiProps<T>;
| LocalSelectSingleProps<T>
| LocalSelectMultiProps<T>;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helper: Resolve label for a data item // Helper: Resolve label for a data item
@@ -70,9 +68,7 @@ function resolveLabel<T extends Record<string, any>>(
// Component Implementation // Component Implementation
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function LocalSelectInner<T extends Record<string, any>>( function LocalSelectInner<T extends Record<string, any>>(props: LocalSelectProps<T>) {
props: LocalSelectProps<T>,
) {
const { const {
options, options,
valueKey, valueKey,
@@ -132,10 +128,7 @@ function LocalSelectInner<T extends Record<string, any>>(
// Passthrough filter — we handle filtering ourselves via filterOption in useMemo. // Passthrough filter — we handle filtering ourselves via filterOption in useMemo.
// This prevents Mantine from double-filtering. // This prevents Mantine from double-filtering.
const mantineFilter = filterOption const mantineFilter = filterOption ? ({ options: opts }: { options: ComboboxItem[] }) => opts : undefined;
? ({ options: opts }: { options: ComboboxItem[] }) => opts
: undefined;
// ----- Multi-select mode ----- // ----- Multi-select mode -----
if (multiple) { if (multiple) {
@@ -166,7 +159,7 @@ function LocalSelectInner<T extends Record<string, any>>(
const currentValue = value ? String((value as T)[valueKey]) : null; const currentValue = value ? String((value as T)[valueKey]) : null;
const handleSingleChange = (val: string | 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); (onChange as ((v: T | null) => void) | undefined)?.(obj);
onSelectCallback?.(obj); onSelectCallback?.(obj);
}; };
@@ -117,11 +117,7 @@ export interface LoadOptionsResponse<T> {
* }; * };
* ``` * ```
*/ */
export type LoadOptionsFn<T> = ( export type LoadOptionsFn<T> = (search: string, page: number, prevOptions: T[]) => Promise<LoadOptionsResponse<T>>;
search: string,
page: number,
prevOptions: T[],
) => Promise<LoadOptionsResponse<T>>;
/** /**
* Internal cache entry for the search-keyed options cache. * Internal cache entry for the search-keyed options cache.
@@ -1,10 +1,5 @@
import React from 'react'; import React from 'react';
import { import { useController, type FieldPath, type FieldValues, type UseControllerProps } from 'react-hook-form';
useController,
type FieldPath,
type FieldValues,
type UseControllerProps,
} from 'react-hook-form';
import type { SelectProps, MultiSelectProps } from '@mantine/core'; import type { SelectProps, MultiSelectProps } from '@mantine/core';
import { AsyncSelect } from '../custom/selects/AsyncSelect'; import { AsyncSelect } from '../custom/selects/AsyncSelect';
import type { AsyncSelectBaseProps, LoadOptionsFn } from '../custom/selects/types'; import type { AsyncSelectBaseProps, LoadOptionsFn } from '../custom/selects/types';
@@ -26,8 +21,26 @@ import { useTranslatedError } from '../useTranslatedError';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** Mantine props we manage ourselves */ /** Mantine props we manage ourselves */
type ManagedSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'onBlur' | 'error' | 'filter' | 'name' | 'onSelect'; type ManagedSelectProps =
type ManagedMultiSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'onBlur' | 'error' | 'filter' | 'name' | 'onSelect'; | '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) */ /** Async-specific props (IoC pattern) */
interface AsyncExtraProps<T> { interface AsyncExtraProps<T> {
@@ -69,9 +82,7 @@ export type FieldAsyncSelectProps<
T extends Record<string, any>, T extends Record<string, any>,
TFieldValues extends FieldValues = FieldValues, TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>, TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = > = FieldAsyncSelectSingleProps<T, TFieldValues, TName> | FieldAsyncSelectMultiProps<T, TFieldValues, TName>;
| FieldAsyncSelectSingleProps<T, TFieldValues, TName>
| FieldAsyncSelectMultiProps<T, TFieldValues, TName>;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Component Implementation // Component Implementation
@@ -149,12 +160,7 @@ function FieldAsyncSelectInner<
} }
return ( return (
<AsyncSelect<T> <AsyncSelect<T> {...engineProps} value={field.value ?? null} onChange={handleChange} {...(mantineProps as any)} />
{...engineProps}
value={field.value ?? null}
onChange={handleChange}
{...(mantineProps as any)}
/>
); );
} }
@@ -1,10 +1,5 @@
import React from 'react'; import React from 'react';
import { import { useController, type FieldPath, type FieldValues, type UseControllerProps } from 'react-hook-form';
useController,
type FieldPath,
type FieldValues,
type UseControllerProps,
} from 'react-hook-form';
import type { SelectProps, MultiSelectProps } from '@mantine/core'; import type { SelectProps, MultiSelectProps } from '@mantine/core';
import { LocalSelect } from '../custom/selects/LocalSelect'; import { LocalSelect } from '../custom/selects/LocalSelect';
import type { LocalSelectBaseProps } from '../custom/selects/types'; import type { LocalSelectBaseProps } from '../custom/selects/types';
@@ -28,8 +23,26 @@ import { useTranslatedError } from '../useTranslatedError';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** Mantine props we manage ourselves */ /** Mantine props we manage ourselves */
type ManagedSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'onBlur' | 'error' | 'filter' | 'name' | 'onSelect'; type ManagedSelectProps =
type ManagedMultiSelectProps = 'data' | 'value' | 'defaultValue' | 'onChange' | 'onBlur' | 'error' | 'filter' | 'name' | 'onSelect'; | '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 */ /** Single-select RHF props — stores T | null */
export type FieldLocalSelectSingleProps< export type FieldLocalSelectSingleProps<
@@ -58,9 +71,7 @@ export type FieldLocalSelectProps<
T extends Record<string, any>, T extends Record<string, any>,
TFieldValues extends FieldValues = FieldValues, TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>, TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = > = FieldLocalSelectSingleProps<T, TFieldValues, TName> | FieldLocalSelectMultiProps<T, TFieldValues, TName>;
| FieldLocalSelectSingleProps<T, TFieldValues, TName>
| FieldLocalSelectMultiProps<T, TFieldValues, TName>;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Component Implementation // Component Implementation
@@ -22,17 +22,7 @@ function FieldRichTextEditorComponent<
TFieldValues extends FieldValues = FieldValues, TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>, TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>(props: FieldRichTextEditorProps<TFieldValues, TName>) { >(props: FieldRichTextEditorProps<TFieldValues, TName>) {
const { const { name, control, rules, shouldUnregister, defaultValue, disabled, label, description, withAsterisk } = props;
name,
control,
rules,
shouldUnregister,
defaultValue,
disabled,
label,
description,
withAsterisk,
} = props;
const { const {
field, field,
@@ -71,12 +61,7 @@ function FieldRichTextEditorComponent<
}, [field.value, editor]); }, [field.value, editor]);
return ( return (
<Input.Wrapper <Input.Wrapper label={label} description={description} withAsterisk={withAsterisk} error={translatedError}>
label={label}
description={description}
withAsterisk={withAsterisk}
error={translatedError}
>
<RichTextEditor editor={editor}> <RichTextEditor editor={editor}>
<RichTextEditor.Toolbar sticky stickyOffset={60}> <RichTextEditor.Toolbar sticky stickyOffset={60}>
<RichTextEditor.ControlsGroup> <RichTextEditor.ControlsGroup>
@@ -9,8 +9,6 @@ export interface FieldSegmentedControlProps extends SegmentedControlProps {
// SegmentedControl does NOT have a native `error` prop. // SegmentedControl does NOT have a native `error` prop.
// The HOC wraps it in Input.Wrapper to display validation errors. // The HOC wraps it in Input.Wrapper to display validation errors.
export const FieldSegmentedControl = withRHF<FieldSegmentedControlProps>( export const FieldSegmentedControl = withRHF<FieldSegmentedControlProps>('FieldSegmentedControl', SegmentedControl, {
'FieldSegmentedControl', requiresWrapper: true,
SegmentedControl, });
{ requiresWrapper: true },
);
+3 -12
View File
@@ -1,9 +1,5 @@
import type { ComponentType } from 'react'; import type { ComponentType } from 'react';
import type { import type { FieldPath, FieldValues, UseControllerProps } from 'react-hook-form';
FieldPath,
FieldValues,
UseControllerProps,
} from 'react-hook-form';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Zod i18n JSON payload shape // Zod i18n JSON payload shape
@@ -42,8 +38,7 @@ export type WithRHFProps<
TComponentProps, TComponentProps,
TFieldValues extends FieldValues = FieldValues, TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>, TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = Omit<TComponentProps, ManagedProps> & > = Omit<TComponentProps, ManagedProps> & UseControllerProps<TFieldValues, TName>;
UseControllerProps<TFieldValues, TName>;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Value transform — for components with non-standard value semantics // 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 // Utility: Extract the component's ref type for forwardRef
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export type ExtractRef<T> = T extends ComponentType<infer P> export type ExtractRef<T> = T extends ComponentType<infer P> ? (P extends { ref?: infer R } ? R : never) : never;
? 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 React, { type ComponentType, type Ref } from 'react';
import { import { useController, type FieldPath, type FieldValues, type UseControllerProps } from 'react-hook-form';
useController,
type FieldPath,
type FieldValues,
type UseControllerProps,
} from 'react-hook-form';
import { Input } from '@mantine/core'; import { Input } from '@mantine/core';
import type { WithRHFOptions } from './types'; import type { WithRHFOptions } from './types';
import { useTranslatedError } from './useTranslatedError'; import { useTranslatedError } from './useTranslatedError';
@@ -73,16 +68,7 @@ export function withRHF<TComponentProps extends Record<string, any>>(
TFieldValues extends FieldValues = FieldValues, TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>, TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>(props: Props<TFieldValues, TName>) { >(props: Props<TFieldValues, TName>) {
const { const { name, control, rules, shouldUnregister, defaultValue, disabled, ref, ...mantineProps } = props;
name,
control,
rules,
shouldUnregister,
defaultValue,
disabled,
ref,
...mantineProps
} = props;
const { const {
field, field,
@@ -94,10 +94,13 @@ export function TableFilterDrawer({
setPreviousValues(form.getValues()); setPreviousValues(form.getValues());
// Ensure all fields are explicitly cleared // Ensure all fields are explicitly cleared
const cleared = Object.keys(form.getValues()).reduce((acc, key) => { const cleared = Object.keys(form.getValues()).reduce(
(acc, key) => {
acc[key] = ''; acc[key] = '';
return acc; return acc;
}, {} as Record<string, unknown>); },
{} as Record<string, unknown>,
);
form.reset({ ...cleared, ...(config?.defaultValues || {}) }); form.reset({ ...cleared, ...(config?.defaultValues || {}) });
setHasReset(true); setHasReset(true);
+4 -19
View File
@@ -18,18 +18,10 @@ export interface UseConditionalFieldOptions<TFieldValues extends FieldValues> {
* @param options Configuration object for the conditional field behavior * @param options Configuration object for the conditional field behavior
*/ */
export function useConditionalField<TFieldValues extends FieldValues>( export function useConditionalField<TFieldValues extends FieldValues>(
options: UseConditionalFieldOptions<TFieldValues> options: UseConditionalFieldOptions<TFieldValues>,
) { ) {
// Destructure with default values // Destructure with default values
const { const { condition, name, setValue, unregister, clearErrors, defaultValue, mode = 'unregister' } = options;
condition,
name,
setValue,
unregister,
clearErrors,
defaultValue,
mode = 'unregister'
} = options;
const config = options; const config = options;
@@ -49,7 +41,7 @@ export function useConditionalField<TFieldValues extends FieldValues>(
config.setValue(config.name, targetValue, { config.setValue(config.name, targetValue, {
shouldDirty: true, shouldDirty: true,
shouldTouch: true, shouldTouch: true,
shouldValidate: true shouldValidate: true,
}); });
// 2. Execute the appropriate side-effect strategy based on the active mode // 2. Execute the appropriate side-effect strategy based on the active mode
@@ -64,12 +56,5 @@ export function useConditionalField<TFieldValues extends FieldValues>(
config.clearErrors(config.name); config.clearErrors(config.name);
} }
} }
}, [ }, [condition, name, setValue, unregister, clearErrors, mode]);
condition,
name,
setValue,
unregister,
clearErrors,
mode
]);
} }
@@ -34,7 +34,7 @@ describe('Validator Registry', () => {
const res = schema.safeParse(''); const res = schema.safeParse('');
expect(res.success).toBe(false); expect(res.success).toBe(false);
expect(res.error?.issues[0].message).toBe( 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 schema = compose(z.string(), emailValidator());
const res = schema.safeParse('invalid-email'); const res = schema.safeParse('invalid-email');
expect(res.success).toBe(false); expect(res.success).toBe(false);
expect(res.error?.issues[0].message).toBe( expect(res.error?.issues[0].message).toBe(JSON.stringify({ key: 'validation:invalid_email' }));
JSON.stringify({ key: 'validation:invalid_email' })
);
}); });
}); });
@@ -54,7 +52,7 @@ describe('Validator Registry', () => {
const res = schema.safeParse(5); const res = schema.safeParse(5);
expect(res.success).toBe(false); expect(res.success).toBe(false);
expect(res.error?.issues[0].message).toBe( 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); const res = schema.safeParse(105);
expect(res.success).toBe(false); expect(res.success).toBe(false);
expect(res.error?.issues[0].message).toBe( 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); const res = schema.safeParse(5);
expect(res.success).toBe(false); expect(res.success).toBe(false);
expect(res.error?.issues[0].message).toBe( 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); const res = schema.safeParse(-5);
expect(res.success).toBe(false); expect(res.success).toBe(false);
expect(res.error?.issues[0].message).toBe( 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'); const res = schema.safeParse('abc');
expect(res.success).toBe(false); expect(res.success).toBe(false);
expect(res.error?.issues[0].message).toBe( 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'); const res = schema.safeParse('thisisaverylongusername');
expect(res.success).toBe(false); expect(res.success).toBe(false);
expect(res.error?.issues[0].message).toBe( 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'); const res = schema.safeParse('ab');
expect(res.success).toBe(false); expect(res.success).toBe(false);
expect(res.error?.issues[0].message).toBe( 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'); const res = schema.safeParse('short');
expect(res.success).toBe(false); expect(res.success).toBe(false);
expect(res.error?.issues[0].message).toBe( 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); expect(schema.safeParse('longenough').success).toBe(true);
}); });
@@ -145,9 +143,7 @@ describe('Validator Registry', () => {
const res = schema.safeParse('invalid'); const res = schema.safeParse('invalid');
expect(res.success).toBe(false); expect(res.success).toBe(false);
expect(res.error?.issues[0].message).toBe( expect(res.error?.issues[0].message).toBe(JSON.stringify({ key: 'validation:invalid_phone' }));
JSON.stringify({ key: 'validation:invalid_phone' })
);
}); });
}); });
}); });
@@ -2,10 +2,7 @@ import type { ZodString, ZodNumber, ZodTypeAny } from 'zod';
// ─── UTILITIES ───────────────────────────────────────────────────────────── // ─── UTILITIES ─────────────────────────────────────────────────────────────
export const compose = <T extends ZodTypeAny>( export const compose = <T extends ZodTypeAny>(base: T, ...modifiers: ((schema: any) => any)[]): any => {
base: T,
...modifiers: ((schema: any) => any)[]
): any => {
return modifiers.reduce((acc, curr) => curr(acc), base); return modifiers.reduce((acc, curr) => curr(acc), base);
}; };
@@ -71,20 +68,24 @@ export const rangeLength = (min: number, max: number, field?: string) => (schema
// ─── SECURITY ────────────────────────────────────────────────────────────── // ─── SECURITY ──────────────────────────────────────────────────────────────
export const simplePassword = (min: number = 8) => (schema: ZodString) => { export const simplePassword =
(min: number = 8) =>
(schema: ZodString) => {
return schema.min(min, { return schema.min(min, {
message: JSON.stringify({ key: 'validation:invalid_password_simple', values: { min } }), message: JSON.stringify({ key: 'validation:invalid_password_simple', values: { min } }),
}); });
}; };
export const complexPassword = (min: number = 8) => (schema: ZodString) => { export const complexPassword =
(min: number = 8) =>
(schema: ZodString) => {
return schema return schema
.min(min, { message: JSON.stringify({ key: 'validation:invalid_password_complex' }) }) .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(/[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(/[0-9]/, { message: JSON.stringify({ key: 'validation:invalid_password_complex' }) })
.regex(/[^A-Za-z0-9]/, { message: JSON.stringify({ key: 'validation:invalid_password_complex' }) }); .regex(/[^A-Za-z0-9]/, { message: JSON.stringify({ key: 'validation:invalid_password_complex' }) });
}; };
// ─── TECHNICAL ───────────────────────────────────────────────────────────── // ─── TECHNICAL ─────────────────────────────────────────────────────────────