diff --git a/__tests__/components/shared/CommandPalette.test.tsx b/__tests__/components/shared/CommandPalette.test.tsx new file mode 100644 index 0000000..961d31e --- /dev/null +++ b/__tests__/components/shared/CommandPalette.test.tsx @@ -0,0 +1,278 @@ +// @ts-nocheck +'use client'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import * as hookModule from '@/hooks/useCommandPalette'; +import * as serviceModule from '@/services/commandPaletteService'; +import CommandPalette from '@/components/ui/CommandPalette'; + +// ── Mock next/navigation ────────────────────────────────────────────────────── +jest.mock('next/navigation', () => ({ + useRouter: () => ({ push: jest.fn() }), +})); + +// ── Mock cmdk ──────────────────────────────────────────────────────────────── +jest.mock('cmdk', () => { + const Dialog = ({ open, children }: { open: boolean; children: React.ReactNode }) => + open ?
{children}
: null; + const Input = ({ placeholder, value, onValueChange, ...rest }: any) => ( + onValueChange?.(e.target.value)} + {...rest} + /> + ); + const List = ({ children }: any) =>
{children}
; + const Empty = ({ children }: any) =>
{children}
; + const Group = ({ children, heading }: any) => ( +
+ {heading} + {children} +
+ ); + const Item = ({ children, onSelect }: any) => ( +
+ {children} +
+ ); + return { Command: { Dialog, Input, List, Empty, Group, Item } }; +}); + +// ── Spy on the hook ─────────────────────────────────────────────────────────── +const mockUseCommandPalette = jest.spyOn(hookModule, 'useCommandPalette'); + +// ── Spy on the service ──────────────────────────────────────────────────────── +const mockFetchDeliveries = jest.spyOn(serviceModule.commandPaletteService, 'fetchDeliveries'); + +// ── Shared mock data (sourced from backend API shape) ───────────────────────── +const mockDeliveries: serviceModule.DeliverySummary[] = [ + { id: 'd-001', title: 'Laptop to Abuja', status: 'In transit' }, + { id: 'd-002', title: 'Phone to Lagos', status: 'Pending' }, + { id: 'd-003', title: 'Books to Kano', status: 'Delivered' }, +]; + +const baseHookValue = { + open: true, + setOpen: jest.fn(), + query: '', + setQuery: jest.fn(), + actionItems: [ + { + id: 'settings', + title: 'Open settings', + description: 'Go to app settings', + path: '/settings', + type: 'static' as const, + }, + { + id: 'faq', + title: 'Jump to FAQ', + description: 'Read support and docs', + path: '/faq', + type: 'static' as const, + }, + ], + deliverySectionItems: mockDeliveries.map((d) => ({ + id: d.id, + title: d.title, + description: d.status ?? 'Delivery record', + path: `/deliveries/${d.id}`, + type: 'delivery' as const, + })), + loading: false, + error: null, + inputRef: { current: null }, + onSelect: jest.fn(), +}; + +// ───────────────────────────────────────────────────────────────────────────── + +describe('CommandPalette — interaction tests', () => { + beforeEach(() => { + mockUseCommandPalette.mockReturnValue({ ...baseHookValue }); + mockFetchDeliveries.mockResolvedValue(mockDeliveries); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + // ── 1. Keyboard trigger: Ctrl+K / Cmd+K opens the palette ────────────────── + it('opens the palette when Ctrl+K is pressed', () => { + // Start closed + mockUseCommandPalette.mockReturnValue({ ...baseHookValue, open: false }); + render(); + + // Palette should not be visible yet + expect(screen.queryByTestId('command-dialog')).not.toBeInTheDocument(); + + // Fire Ctrl+K on the window — the hook's useEffect handles this + fireEvent.keyDown(window, { key: 'k', ctrlKey: true }); + + // Re-render with open:true to simulate hook state update + mockUseCommandPalette.mockReturnValue({ ...baseHookValue, open: true }); + render(); + + expect(screen.getByTestId('command-dialog')).toBeInTheDocument(); + }); + + it('opens the palette when Meta+K (Cmd+K) is pressed', () => { + mockUseCommandPalette.mockReturnValue({ ...baseHookValue, open: false }); + render(); + + fireEvent.keyDown(window, { key: 'k', metaKey: true }); + + mockUseCommandPalette.mockReturnValue({ ...baseHookValue, open: true }); + render(); + + expect(screen.getByTestId('command-dialog')).toBeInTheDocument(); + }); + + // ── 2. Renders input and items when open ──────────────────────────────────── + it('renders the search input when the palette is open', () => { + render(); + expect( + screen.getByPlaceholderText('Search deliveries, settings, FAQ...'), + ).toBeInTheDocument(); + }); + + it('renders all static action items', () => { + render(); + expect(screen.getByText('Open settings')).toBeInTheDocument(); + expect(screen.getByText('Jump to FAQ')).toBeInTheDocument(); + }); + + it('renders delivery section items sourced from the backend API', () => { + render(); + expect(screen.getByText('Laptop to Abuja')).toBeInTheDocument(); + expect(screen.getByText('Phone to Lagos')).toBeInTheDocument(); + expect(screen.getByText('Books to Kano')).toBeInTheDocument(); + }); + + // ── 3. Search input filters matching records ──────────────────────────────── + it('filters delivery items to only those matching the typed query', () => { + // Simulate the hook returning filtered results for query "Laptop" + mockUseCommandPalette.mockReturnValue({ + ...baseHookValue, + query: 'Laptop', + deliverySectionItems: [ + { + id: 'd-001', + title: 'Laptop to Abuja', + description: 'In transit', + path: '/deliveries/d-001', + type: 'delivery', + }, + ], + }); + + render(); + + expect(screen.getByText('Laptop to Abuja')).toBeInTheDocument(); + expect(screen.queryByText('Phone to Lagos')).not.toBeInTheDocument(); + expect(screen.queryByText('Books to Kano')).not.toBeInTheDocument(); + }); + + it('calls setQuery when the user types in the search input', async () => { + const setQuery = jest.fn(); + mockUseCommandPalette.mockReturnValue({ ...baseHookValue, setQuery }); + render(); + + const input = screen.getByPlaceholderText('Search deliveries, settings, FAQ...'); + await userEvent.type(input, 'Lagos'); + + expect(setQuery).toHaveBeenCalled(); + }); + + it('shows no delivery items when query does not match any record', () => { + mockUseCommandPalette.mockReturnValue({ + ...baseHookValue, + query: 'xyznonexistent', + deliverySectionItems: [], + actionItems: [], + }); + + render(); + + expect(screen.queryByText('Laptop to Abuja')).not.toBeInTheDocument(); + expect(screen.queryByText('Phone to Lagos')).not.toBeInTheDocument(); + }); + + // ── 4. Escape closes the palette ─────────────────────────────────────────── + it('calls setOpen(false) when Escape is pressed', () => { + const setOpen = jest.fn(); + mockUseCommandPalette.mockReturnValue({ ...baseHookValue, setOpen }); + const { rerender } = render(); + + fireEvent.keyDown(window, { key: 'Escape' }); + + mockUseCommandPalette.mockReturnValue({ ...baseHookValue, open: false, setOpen }); + rerender(); + + expect(screen.queryByTestId('command-dialog')).not.toBeInTheDocument(); + }); + + it('unmounts the palette dialog when open is set to false', () => { + mockUseCommandPalette.mockReturnValue({ ...baseHookValue, open: false }); + render(); + expect(screen.queryByTestId('command-dialog')).not.toBeInTheDocument(); + }); + + // ── 5. Loading state ──────────────────────────────────────────────────────── + it('shows loading indicator while fetching deliveries from the API', () => { + mockUseCommandPalette.mockReturnValue({ + ...baseHookValue, + loading: true, + deliverySectionItems: [], + }); + + render(); + expect(screen.getByText(/loading deliveries/i)).toBeInTheDocument(); + }); + + // ── 6. Error state ────────────────────────────────────────────────────────── + it('shows error message when the backend API call fails', () => { + mockUseCommandPalette.mockReturnValue({ + ...baseHookValue, + error: 'Unable to load deliveries', + deliverySectionItems: [], + }); + + render(); + expect(screen.getByText('Unable to load deliveries')).toBeInTheDocument(); + }); + + // ── 7. Service integration: data comes from the backend API ───────────────── + it('fetches deliveries from the backend API endpoint via commandPaletteService', async () => { + mockFetchDeliveries.mockResolvedValue(mockDeliveries); + + await serviceModule.commandPaletteService.fetchDeliveries(); + + expect(mockFetchDeliveries).toHaveBeenCalledTimes(1); + const result = await serviceModule.commandPaletteService.fetchDeliveries(); + expect(result).toEqual(mockDeliveries); + }); + + it('service returns correct delivery records matching backend API shape', async () => { + mockFetchDeliveries.mockResolvedValue(mockDeliveries); + + const result = await serviceModule.commandPaletteService.fetchDeliveries(); + + expect(result).toHaveLength(3); + expect(result[0]).toEqual({ id: 'd-001', title: 'Laptop to Abuja', status: 'In transit' }); + expect(result[1]).toEqual({ id: 'd-002', title: 'Phone to Lagos', status: 'Pending' }); + }); + + // ── 8. Item selection navigates to the correct path ──────────────────────── + it('calls onSelect with the correct path when a delivery item is clicked', () => { + const onSelect = jest.fn(); + mockUseCommandPalette.mockReturnValue({ ...baseHookValue, onSelect }); + render(); + + const items = screen.getAllByRole('option'); + fireEvent.click(items[0]); + + expect(onSelect).toHaveBeenCalledTimes(1); + }); +}); \ No newline at end of file diff --git a/components/fleet/EnterpriseDashboard.tsx b/components/fleet/EnterpriseDashboard.tsx new file mode 100644 index 0000000..cd7e87b --- /dev/null +++ b/components/fleet/EnterpriseDashboard.tsx @@ -0,0 +1,250 @@ +'use client'; + +import { useRef } from 'react'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { useFleet } from '@/hooks/useFleet'; +import type { Driver, DriverStatus } from '@/types/fleet'; + +// ── Constants ───────────────────────────────────────────────────────────────── + +const ROW_HEIGHT = 56; // px — fixed height required by react-virtual +const TABLE_HEIGHT = 600; // px — visible viewport for the table body + +const STATUS_LABEL: Record = { + active: 'Active', + on_delivery: 'On Delivery', + idle: 'Idle', + offline: 'Offline', +}; + +const STATUS_BADGE: Record = { + active: 'bg-emerald-100 text-emerald-700', + on_delivery: 'bg-blue-100 text-blue-700', + idle: 'bg-amber-100 text-amber-700', + offline: 'bg-gray-200 text-gray-500', +}; + +// ── Sub-components ──────────────────────────────────────────────────────────── + +function StickyHeader() { + return ( +
+
Driver
+
Vehicle
+
Status
+
Active
+
Completed
+
Rating
+
+ ); +} + +interface VirtualRowProps { + driver: Driver; + style: React.CSSProperties; +} + +function VirtualRow({ driver, style }: VirtualRowProps) { + return ( +
+
+ {driver.name} + {driver.phone} +
+
+ {driver.vehicleType} + {driver.vehiclePlate} +
+
+ + {STATUS_LABEL[driver.status]} + +
+
{driver.activeDeliveries}
+
{driver.completedDeliveries}
+
★ {driver.rating.toFixed(1)}
+
+ ); +} + +function LoadingSkeleton() { + return ( +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+ ))} +
+ ); +} + +function SummaryBar({ + total, + active, + onDelivery, + idle, + offline, +}: { + total: number; + active: number; + onDelivery: number; + idle: number; + offline: number; +}) { + return ( +
+ {[ + { label: 'Total', value: total, color: 'text-gray-900' }, + { label: 'Active', value: active, color: 'text-emerald-600' }, + { label: 'On Delivery', value: onDelivery, color: 'text-blue-600' }, + { label: 'Idle', value: idle, color: 'text-amber-600' }, + { label: 'Offline', value: offline, color: 'text-gray-400' }, + ].map(({ label, value, color }) => ( +
+

{value}

+

{label}

+
+ ))} +
+ ); +} + +// ── Main component ──────────────────────────────────────────────────────────── + +/** + * EnterpriseDashboard + * + * Renders a virtualized fleet table capable of smoothly displaying 1,000+ + * driver rows at 60fps. Only rows visible within the viewport are mounted in + * the DOM — off-screen rows are unmounted, keeping memory and paint cost flat + * regardless of fleet size. + * + * Architecture: Component → Hook (useFleet) → Service (fleetService) + * Data source: backend API via fleetService.getFleet() + */ +export function EnterpriseDashboard() { + const { drivers, summary, isLoading, error, refetch } = useFleet(); + + // The scrollable container ref is passed to useVirtualizer so it knows + // which element's scroll position to observe. + const scrollContainerRef = useRef(null); + + const virtualizer = useVirtualizer({ + count: drivers.length, + getScrollElement: () => scrollContainerRef.current, + estimateSize: () => ROW_HEIGHT, + overscan: 10, // render 10 extra rows above/below viewport for smooth scroll + }); + + const virtualItems = virtualizer.getVirtualItems(); + + return ( +
+ {/* Header */} +
+
+

Enterprise Fleet

+

+ Real-time overview of all drivers and deliveries +

+
+ +
+ + {/* Summary bar — sourced from backend API via useFleet → fleetService */} + {summary && ( + + )} + + {/* Table */} +
+ {/* Sticky header — always visible during vertical scroll */} + + + {/* Scrollable body */} + {isLoading ? ( + + ) : error ? ( +
+ {error} +
+ ) : drivers.length === 0 ? ( +
+ No drivers found in your fleet. +
+ ) : ( +
+ {/* Total height spacer — makes the scrollbar reflect the full list */} +
+ {virtualItems.map((virtualItem) => { + const driver = drivers[virtualItem.index]; + return ( + + ); + })} +
+
+ )} + + {/* Footer row count */} + {!isLoading && !error && drivers.length > 0 && ( +
+ Showing {drivers.length.toLocaleString()} driver + {drivers.length !== 1 ? 's' : ''} · DOM renders{' '} + {virtualItems.length} rows +
+ )} +
+
+ ); +} \ No newline at end of file