Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 32 additions & 23 deletions components/__tests__/DashboardOverview.test.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,16 @@
// __tests__/components/DashboardOverview.test.tsx

import React from 'react';
import { render, screen } from '@testing-library/react';
import DashboardOverview from '@/components/dashboard/DashboardOverview'; // Adjust path as needed
import { useDashboardData } from '@/hooks/useDashboardData'; // Adjust path to your data hook
import DashboardOverview from '@/components/dashboard/DashboardOverview';
import { useDashboardData } from '@/hooks/useDashboardData';

// Mock the data fetching hook to control loading states
jest.mock('@/hooks/useDashboardData');

describe('Component: Skeleton Loading States for Dashboard', () => {
describe('DashboardOverview', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('renders skeleton UI placeholders when data is loading', () => {
// Force the loading state to true
it('renders skeleton placeholders while loading', () => {
(useDashboardData as jest.Mock).mockReturnValue({
data: null,
isLoading: true,
Expand All @@ -23,36 +19,47 @@ describe('Component: Skeleton Loading States for Dashboard', () => {

render(<DashboardOverview />);

// Assert that skeleton elements are rendered
const skeletons = screen.getAllByTestId('skeleton-loader');
expect(skeletons.length).toBeGreaterThan(0);

// Assert that actual data content is not present
expect(screen.queryByText(/total deliveries/i)).not.toBeInTheDocument();
expect(screen.getAllByTestId('skeleton-loader').length).toBeGreaterThan(0);
expect(screen.queryByText(/dashboard overview/i)).not.toBeInTheDocument();
});

it('removes skeleton loaders and renders actual data when loading completes', () => {
// Simulate a successful data fetch
it('renders metrics, active shipments, and quick actions when data loads', () => {
(useDashboardData as jest.Mock).mockReturnValue({
data: {
totalDeliveries: 1450,
activeDrivers: 32,
metrics: [
{ label: 'Completed', value: '1284' },
{ label: 'Escrowed', value: '$245k' },
],
shipments: [
{
id: 'SC-1048',
route: 'Lagos → Abuja',
status: 'In transit',
eta: '18 min',
},
],
quickActions: ['Create delivery', 'Assign driver'],
},
isLoading: false,
error: null,
});

render(<DashboardOverview />);

// Assert that skeletons are removed
expect(screen.queryByTestId('skeleton-loader')).not.toBeInTheDocument();

// Assert that real data is rendered
expect(
screen.getByRole('heading', { name: /dashboard overview/i })
).toBeInTheDocument();
expect(screen.getByText('1450')).toBeInTheDocument();
expect(screen.getByText('32')).toBeInTheDocument();
expect(screen.getByText(/active shipments/i)).toBeInTheDocument();
expect(screen.getByText(/quick actions/i)).toBeInTheDocument();
expect(screen.getByText('SC-1048')).toBeInTheDocument();
expect(screen.getByText('Create delivery')).toBeInTheDocument();
});

it('renders an error boundary or state if data fetching fails', () => {
it('renders an error state if data fetching fails', () => {
(useDashboardData as jest.Mock).mockReturnValue({
data: null,
isLoading: false,
Expand All @@ -62,6 +69,8 @@ describe('Component: Skeleton Loading States for Dashboard', () => {
render(<DashboardOverview />);

expect(screen.queryByTestId('skeleton-loader')).not.toBeInTheDocument();
expect(screen.getByText(/failed to fetch dashboard metrics/i)).toBeInTheDocument();
expect(
screen.getByText(/failed to fetch dashboard metrics/i)
).toBeInTheDocument();
});
});
});
151 changes: 151 additions & 0 deletions components/dashboard/DashboardOverview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
'use client';

import { useDashboardData } from '@/hooks/useDashboardData';

export default function DashboardOverview() {
const { data, isLoading, error } = useDashboardData();

if (isLoading) {
return (
<section aria-label="Dashboard overview" className="space-y-6">
<div className="flex items-center justify-between">
<div>
<div
data-testid="skeleton-loader"
className="h-7 w-48 animate-pulse rounded bg-slate-200 dark:bg-slate-700"
/>
<div
data-testid="skeleton-loader"
className="mt-2 h-4 w-64 animate-pulse rounded bg-slate-200 dark:bg-slate-700"
/>
</div>
</div>

<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 3 }).map((_, index) => (
<div
key={index}
data-testid="skeleton-loader"
className="h-28 animate-pulse rounded-xl bg-slate-200 dark:bg-slate-700"
/>
))}
</div>
</section>
);
}

if (error) {
return (
<section aria-label="Dashboard overview">
<div
role="alert"
className="rounded-lg border border-red-200 bg-red-50 p-4 text-red-700 dark:border-red-800 dark:bg-red-900/20 dark:text-red-400"
>
Failed to fetch dashboard metrics
</div>
</section>
);
}

if (!data) {
return (
<section aria-label="Dashboard overview">
<p>No dashboard data available.</p>
</section>
);
}

return (
<section aria-label="Dashboard overview" className="space-y-6">
<header className="space-y-2">
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">
Dashboard Overview
</h1>
<p className="text-sm text-slate-500 dark:text-slate-400">
Live logistics performance and shipment activity
</p>
</header>

<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{data.metrics.map((metric) => (
<div
key={metric.label}
className="rounded-xl border border-slate-200 bg-white p-5 dark:border-slate-800 dark:bg-slate-900"
>
<p className="text-sm text-slate-500 dark:text-slate-400">
{metric.label}
</p>
<p className="mt-3 text-2xl font-bold text-slate-900 dark:text-white">
{metric.value}
</p>
</div>
))}
<div className="rounded-xl border border-slate-200 bg-white p-5 dark:border-slate-800 dark:bg-slate-900">
<p className="text-sm text-slate-500 dark:text-slate-400">
Total Deliveries
</p>
<p className="mt-3 text-2xl font-bold text-slate-900 dark:text-white">
{data.totalDeliveries}
</p>
</div>
<div className="rounded-xl border border-slate-200 bg-white p-5 dark:border-slate-800 dark:bg-slate-900">
<p className="text-sm text-slate-500 dark:text-slate-400">
Active Drivers
</p>
<p className="mt-3 text-2xl font-bold text-slate-900 dark:text-white">
{data.activeDrivers}
</p>
</div>
</div>

<div className="grid gap-6 lg:grid-cols-[1.5fr_1fr]">
<div className="rounded-xl border border-slate-200 bg-white p-5 dark:border-slate-800 dark:bg-slate-900">
<h2 className="text-lg font-semibold text-slate-900 dark:text-white">
Active shipments
</h2>
<ul className="mt-4 space-y-3" aria-label="Active shipments list">
{data.shipments.map((shipment) => (
<li
key={shipment.id}
className="flex items-center justify-between rounded-lg bg-slate-50 p-3 dark:bg-slate-800/80"
>
<div>
<p className="font-medium text-slate-900 dark:text-white">
{shipment.id}
</p>
<p className="text-sm text-slate-500 dark:text-slate-400">
{shipment.route}
</p>
</div>
<div className="text-right">
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">
{shipment.status}
</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
{shipment.eta}
</p>
</div>
</li>
))}
</ul>
</div>

<div className="rounded-xl border border-slate-200 bg-white p-5 dark:border-slate-800 dark:bg-slate-900">
<h2 className="text-lg font-semibold text-slate-900 dark:text-white">
Quick actions
</h2>
<ul className="mt-4 space-y-2" aria-label="Quick actions list">
{data.quickActions.map((action) => (
<li
key={action}
className="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700 dark:border-slate-700 dark:text-slate-200"
>
{action}
</li>
))}
</ul>
</div>
</div>
</section>
);
}
26 changes: 22 additions & 4 deletions components/layout/NotificationCenter.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use client';

import { useMemo } from 'react';
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react';
import { useNotifications } from '@/hooks/useNotifications';

Expand Down Expand Up @@ -57,7 +58,7 @@ function NotificationItem({
width: '8px',
height: '8px',
borderRadius: '50%',
background: read ? 'transparent' : typeColors[type] ?? '#6b7280',
background: read ? 'transparent' : (typeColors[type] ?? '#6b7280'),
marginTop: '6px',
flexShrink: 0,
}}
Expand Down Expand Up @@ -102,6 +103,21 @@ export function NotificationCenter() {
const { notifications, unreadCount, isLoading, markAllAsRead } =
useNotifications();

const orderedNotifications = useMemo(
() =>
[...notifications].sort((a, b) => {
const timeA = new Date(a.createdAt).getTime();
const timeB = new Date(b.createdAt).getTime();

if (Number.isNaN(timeA) && Number.isNaN(timeB)) return 0;
if (Number.isNaN(timeA)) return 1;
if (Number.isNaN(timeB)) return -1;

return timeB - timeA;
}),
[notifications]
);

return (
<Popover style={{ position: 'relative' }}>
{/* Bell trigger button */}
Expand Down Expand Up @@ -171,7 +187,9 @@ export function NotificationCenter() {
borderBottom: '1px solid #e5e7eb',
}}
>
<h3 style={{ fontWeight: 700, fontSize: '0.95rem', color: '#111827' }}>
<h3
style={{ fontWeight: 700, fontSize: '0.95rem', color: '#111827' }}
>
Notifications
{unreadCount > 0 && (
<span
Expand Down Expand Up @@ -234,7 +252,7 @@ export function NotificationCenter() {
You&apos;re all caught up!
</p>
)}
{notifications.map((n) => (
{orderedNotifications.map((n) => (
<NotificationItem
key={n.id}
title={n.title}
Expand All @@ -248,4 +266,4 @@ export function NotificationCenter() {
</PopoverPanel>
</Popover>
);
}
}
44 changes: 40 additions & 4 deletions components/layout/__tests__/NotificationCenter.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ describe('NotificationCenter', () => {
it('should render the bell icon button', () => {
render(<NotificationCenter />);
expect(
screen.getByRole('button', { name: /notifications/i }),
screen.getByRole('button', { name: /notifications/i })
).toBeInTheDocument();
});

Expand Down Expand Up @@ -88,15 +88,51 @@ describe('NotificationCenter', () => {
render(<NotificationCenter />);
expect(screen.getByText('Delivery Update')).toBeInTheDocument();
expect(
screen.getByText('Your package is out for delivery.'),
screen.getByText('Your package is out for delivery.')
).toBeInTheDocument();
});

it('prepends newer shipment status updates before older notifications', () => {
mockUseNotifications.mockReturnValue({
...baseReturn,
notifications: [
{
id: 'older',
title: 'Shipment picked up',
message: 'Driver has collected your package.',
type: 'delivery',
read: false,
createdAt: '2026-04-28T09:30:00Z',
},
{
id: 'newer',
title: 'Shipment in transit',
message: 'Package is now moving to the destination.',
type: 'delivery',
read: false,
createdAt: '2026-04-28T10:15:00Z',
},
],
unreadCount: 2,
});

const { container } = render(<NotificationCenter />);

const older = screen.getByText('Shipment picked up');
const newer = screen.getByText('Shipment in transit');

expect(
newer.compareDocumentPosition(older) & Node.DOCUMENT_POSITION_FOLLOWING
).toBeTruthy();
expect(container.textContent).toContain('Shipment in transit');
expect(container.textContent).toContain('Shipment picked up');
});

it('should show "Mark all as read" button when unread count > 0', () => {
mockUseNotifications.mockReturnValue({ ...baseReturn, unreadCount: 3 });
render(<NotificationCenter />);
expect(
screen.getByRole('button', { name: /mark all as read/i }),
screen.getByRole('button', { name: /mark all as read/i })
).toBeInTheDocument();
});

Expand All @@ -112,4 +148,4 @@ describe('NotificationCenter', () => {
render(<NotificationCenter />);
expect(screen.getByText(/loading notifications/i)).toBeInTheDocument();
});
});
});
Loading