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