diff --git a/features/deliveries/components/ActiveDeliveriesTable.tsx b/features/deliveries/components/ActiveDeliveriesTable.tsx new file mode 100644 index 0000000..12273b1 --- /dev/null +++ b/features/deliveries/components/ActiveDeliveriesTable.tsx @@ -0,0 +1,211 @@ +'use client'; + +import { ChevronLeft, ChevronRight, Package } from 'lucide-react'; +import { useTablePagination, DEFAULT_PAGE_SIZE } from '@/hooks/useTablePagination'; +import type { Delivery } from '@/types/delivery'; + +interface ActiveDeliveriesTableProps { + deliveries: Delivery[]; + isLoading?: boolean; + error?: string | null; + /** Rows per page. Defaults to 10 — pagination kicks in beyond that. */ + pageSize?: number; + onRetry?: () => void; +} + +const STATUS_COLORS: Record = { + PENDING: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200', + ACCEPTED: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200', + IN_TRANSIT: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200', + DELIVERED: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200', + CANCELLED: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200', +}; + +function formatDate(dateString: string): string { + const parsed = new Date(dateString); + if (Number.isNaN(parsed.getTime())) return '--'; + return parsed.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); +} + +/** + * ActiveDeliveriesTable — the customer-facing table view of active shipments. + * + * Rows beyond `pageSize` (10 by default) are paginated client-side so the table + * stays scannable regardless of how many shipments are in flight. + */ +export function ActiveDeliveriesTable({ + deliveries, + isLoading = false, + error = null, + pageSize = DEFAULT_PAGE_SIZE, + onRetry, +}: ActiveDeliveriesTableProps) { + const { + page, + totalPages, + totalItems, + pageItems, + rangeStart, + rangeEnd, + isPaginated, + canPreviousPage, + canNextPage, + nextPage, + previousPage, + goToPage, + } = useTablePagination(deliveries, { pageSize }); + + if (isLoading) { + return ( +
+ Loading deliveries... +
+ ); + } + + if (error) { + return ( +
+

{error}

+ {onRetry && ( + + )} +
+ ); + } + + if (totalItems === 0) { + return ( +
+
+ ); + } + + return ( +
+
+ + + + + + + + + + + + {pageItems.map((delivery) => ( + + + + + + + + ))} + +
+ Tracking Number + + Route + + Status + + Amount + + Created +
+ {delivery.trackingNumber} + + {delivery.origin} to {delivery.destination} + + + {delivery.status} + + + {delivery.amount} {delivery.currency ?? 'XLM'} + + {formatDate(delivery.createdAt)} +
+
+ +
+

+ Showing {rangeStart}-{rangeEnd} of {totalItems} deliveries +

+ + {isPaginated && ( + + )} +
+
+ ); +} diff --git a/features/deliveries/components/__tests__/ActiveDeliveriesTable.test.tsx b/features/deliveries/components/__tests__/ActiveDeliveriesTable.test.tsx new file mode 100644 index 0000000..0d0fc47 --- /dev/null +++ b/features/deliveries/components/__tests__/ActiveDeliveriesTable.test.tsx @@ -0,0 +1,230 @@ +import React from 'react'; +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ActiveDeliveriesTable } from '@/features/deliveries/components'; +import type { Delivery } from '@/types/delivery'; + +/** + * Builds a deterministic set of deliveries. No network, no wallet and no socket + * is involved: the table is a pure presentational component fed by props, which + * keeps these tests free of the flakiness that mocked transports introduce. + */ +function makeDeliveries(count: number): Delivery[] { + return Array.from({ length: count }, (_, index) => { + const n = index + 1; + return { + id: `delivery-${n}`, + trackingNumber: `TRK${String(n).padStart(4, '0')}`, + senderId: 'sender-1', + status: 'IN_TRANSIT', + origin: `Origin ${n}`, + destination: `Destination ${n}`, + escrowStatus: 'LOCKED', + amount: 100 + n, + currency: 'XLM', + createdAt: '2026-01-15T10:00:00.000Z', + updatedAt: '2026-01-15T10:00:00.000Z', + } satisfies Delivery; + }); +} + +/** Data rows only — the header row lives in its own . */ +function getBodyRows(): HTMLElement[] { + const [, ...bodyRows] = screen.getAllByRole('row'); + return bodyRows; +} + +describe('ActiveDeliveriesTable', () => { + describe('rendering', () => { + it('renders a row per delivery with its tracking number, route and status', () => { + render(); + + expect(screen.getByRole('table', { name: 'Active deliveries' })).toBeInTheDocument(); + expect(getBodyRows()).toHaveLength(3); + + const firstRow = getBodyRows()[0]; + expect(within(firstRow).getByText('TRK0001')).toBeInTheDocument(); + expect(within(firstRow).getByText('Origin 1 to Destination 1')).toBeInTheDocument(); + expect(within(firstRow).getByText('IN_TRANSIT')).toBeInTheDocument(); + expect(within(firstRow).getByText('101 XLM')).toBeInTheDocument(); + }); + + it('hides pagination controls when the data fits on one page', () => { + render(); + + expect(getBodyRows()).toHaveLength(10); + expect(screen.queryByRole('navigation', { name: 'Deliveries pagination' })).not.toBeInTheDocument(); + expect(screen.getByTestId('pagination-summary')).toHaveTextContent( + 'Showing 1-10 of 10 deliveries', + ); + }); + }); + + describe('pagination beyond 10 rows', () => { + it('shows only the first 10 rows and exposes the remaining pages', () => { + render(); + + expect(getBodyRows()).toHaveLength(10); + expect(screen.getByText('TRK0001')).toBeInTheDocument(); + expect(screen.queryByText('TRK0011')).not.toBeInTheDocument(); + + const nav = screen.getByRole('navigation', { name: 'Deliveries pagination' }); + expect(within(nav).getByRole('button', { name: 'Page 1' })).toHaveAttribute( + 'aria-current', + 'page', + ); + expect(within(nav).getByRole('button', { name: 'Page 3' })).toBeInTheDocument(); + expect(within(nav).queryByRole('button', { name: 'Page 4' })).not.toBeInTheDocument(); + }); + + it('advances to the next page of shipments', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Next page' })); + + expect(getBodyRows()).toHaveLength(10); + expect(screen.getByText('TRK0011')).toBeInTheDocument(); + expect(screen.queryByText('TRK0001')).not.toBeInTheDocument(); + expect(screen.getByTestId('pagination-summary')).toHaveTextContent( + 'Showing 11-20 of 25 deliveries', + ); + }); + + it('returns to the previous page', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Next page' })); + await user.click(screen.getByRole('button', { name: 'Previous page' })); + + expect(screen.getByText('TRK0001')).toBeInTheDocument(); + expect(screen.getByTestId('pagination-summary')).toHaveTextContent( + 'Showing 1-10 of 25 deliveries', + ); + }); + + it('jumps straight to a page via its numbered control', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Page 3' })); + + expect(getBodyRows()).toHaveLength(5); + expect(screen.getByText('TRK0021')).toBeInTheDocument(); + expect(screen.getByTestId('pagination-summary')).toHaveTextContent( + 'Showing 21-25 of 25 deliveries', + ); + }); + + it('renders a partial final page rather than padding it', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Next page' })); + + expect(getBodyRows()).toHaveLength(1); + expect(screen.getByText('TRK0011')).toBeInTheDocument(); + }); + + it('disables Previous on the first page and Next on the last page', async () => { + const user = userEvent.setup(); + render(); + + expect(screen.getByRole('button', { name: 'Previous page' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Next page' })).toBeEnabled(); + + await user.click(screen.getByRole('button', { name: 'Next page' })); + + expect(screen.getByRole('button', { name: 'Previous page' })).toBeEnabled(); + expect(screen.getByRole('button', { name: 'Next page' })).toBeDisabled(); + }); + + it('respects a custom page size', () => { + render(); + + expect(getBodyRows()).toHaveLength(5); + expect(screen.getByRole('button', { name: 'Page 3' })).toBeInTheDocument(); + }); + + it('falls back to the last valid page when the dataset shrinks below the current page', async () => { + const user = userEvent.setup(); + const { rerender } = render(); + + await user.click(screen.getByRole('button', { name: 'Page 3' })); + expect(screen.getByText('TRK0021')).toBeInTheDocument(); + + rerender(); + + expect(screen.getByRole('button', { name: 'Page 2' })).toHaveAttribute('aria-current', 'page'); + expect(screen.queryByRole('button', { name: 'Page 3' })).not.toBeInTheDocument(); + expect(screen.getByTestId('pagination-summary')).toHaveTextContent( + 'Showing 11-12 of 12 deliveries', + ); + }); + }); + + describe('edge cases', () => { + it('renders the empty state and no table when there are no deliveries', () => { + render(); + + expect(screen.getByText('No active deliveries')).toBeInTheDocument(); + expect(screen.queryByRole('table')).not.toBeInTheDocument(); + expect(screen.queryByRole('navigation', { name: 'Deliveries pagination' })).not.toBeInTheDocument(); + }); + + it('renders the loading state instead of the table', () => { + render(); + + expect(screen.getByRole('status')).toHaveTextContent('Loading deliveries...'); + expect(screen.queryByRole('table')).not.toBeInTheDocument(); + }); + + it('renders the error state and calls onRetry', async () => { + const user = userEvent.setup(); + const onRetry = jest.fn(); + render( + , + ); + + expect(screen.getByRole('alert')).toHaveTextContent('Unable to load deliveries'); + expect(screen.queryByRole('table')).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Try again' })); + expect(onRetry).toHaveBeenCalledTimes(1); + }); + + it('omits the retry button when no retry handler is supplied', () => { + render(); + + expect(screen.getByRole('alert')).toHaveTextContent('Network unreachable'); + expect(screen.queryByRole('button', { name: 'Try again' })).not.toBeInTheDocument(); + }); + + it('prefers the loading state over the error state', () => { + render(); + + expect(screen.getByRole('status')).toBeInTheDocument(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('renders a placeholder for an unparseable creation date', () => { + const [delivery] = makeDeliveries(1); + render(); + + expect(screen.getByText('--')).toBeInTheDocument(); + }); + + it('falls back to XLM when a delivery carries no currency', () => { + const [delivery] = makeDeliveries(1); + const { currency: _currency, ...withoutCurrency } = delivery; + render(); + + expect(screen.getByText('101 XLM')).toBeInTheDocument(); + }); + }); +}); diff --git a/features/deliveries/components/index.ts b/features/deliveries/components/index.ts index 0d537fe..8b47a75 100644 --- a/features/deliveries/components/index.ts +++ b/features/deliveries/components/index.ts @@ -1 +1,2 @@ export { DeliveryFilters } from './DeliveryFilters'; +export { ActiveDeliveriesTable } from './ActiveDeliveriesTable'; diff --git a/hooks/__tests__/useTablePagination.test.ts b/hooks/__tests__/useTablePagination.test.ts new file mode 100644 index 0000000..fbf590c --- /dev/null +++ b/hooks/__tests__/useTablePagination.test.ts @@ -0,0 +1,124 @@ +import { act, renderHook } from '@testing-library/react'; +import { useTablePagination, DEFAULT_PAGE_SIZE } from '@/hooks/useTablePagination'; + +const makeRows = (count: number) => + Array.from({ length: count }, (_, index) => ({ id: `row-${index + 1}` })); + +describe('useTablePagination', () => { + it('defaults to a page size of 10', () => { + const { result } = renderHook(() => useTablePagination(makeRows(25))); + + expect(DEFAULT_PAGE_SIZE).toBe(10); + expect(result.current.pageSize).toBe(10); + expect(result.current.pageItems).toHaveLength(10); + }); + + it('keeps everything on a single page when rows do not exceed the page size', () => { + const { result } = renderHook(() => useTablePagination(makeRows(10))); + + expect(result.current.totalPages).toBe(1); + expect(result.current.isPaginated).toBe(false); + expect(result.current.canNextPage).toBe(false); + expect(result.current.canPreviousPage).toBe(false); + }); + + it('splits rows into pages once the dataset exceeds the page size', () => { + const { result } = renderHook(() => useTablePagination(makeRows(23))); + + expect(result.current.totalPages).toBe(3); + expect(result.current.isPaginated).toBe(true); + expect(result.current.pageItems[0]).toEqual({ id: 'row-1' }); + + act(() => result.current.nextPage()); + expect(result.current.page).toBe(2); + expect(result.current.pageItems[0]).toEqual({ id: 'row-11' }); + expect(result.current.pageItems).toHaveLength(10); + + act(() => result.current.nextPage()); + expect(result.current.page).toBe(3); + expect(result.current.pageItems).toHaveLength(3); + expect(result.current.pageItems[0]).toEqual({ id: 'row-21' }); + }); + + it('reports a human-readable range for the current page', () => { + const { result } = renderHook(() => useTablePagination(makeRows(23))); + + expect(result.current.rangeStart).toBe(1); + expect(result.current.rangeEnd).toBe(10); + + act(() => result.current.goToPage(3)); + expect(result.current.rangeStart).toBe(21); + expect(result.current.rangeEnd).toBe(23); + }); + + it('never advances past the last page', () => { + const { result } = renderHook(() => useTablePagination(makeRows(12))); + + act(() => result.current.nextPage()); + act(() => result.current.nextPage()); + act(() => result.current.nextPage()); + + expect(result.current.page).toBe(2); + expect(result.current.canNextPage).toBe(false); + }); + + it('never moves before the first page', () => { + const { result } = renderHook(() => useTablePagination(makeRows(12))); + + act(() => result.current.previousPage()); + + expect(result.current.page).toBe(1); + expect(result.current.canPreviousPage).toBe(false); + }); + + it('clamps out-of-range page requests', () => { + const { result } = renderHook(() => useTablePagination(makeRows(15))); + + act(() => result.current.goToPage(99)); + expect(result.current.page).toBe(2); + + act(() => result.current.goToPage(-4)); + expect(result.current.page).toBe(1); + }); + + it('falls back to the last valid page when the dataset shrinks', () => { + const { result, rerender } = renderHook(({ rows }) => useTablePagination(rows), { + initialProps: { rows: makeRows(30) }, + }); + + act(() => result.current.goToPage(3)); + expect(result.current.page).toBe(3); + + rerender({ rows: makeRows(12) }); + + expect(result.current.totalPages).toBe(2); + expect(result.current.page).toBe(2); + expect(result.current.pageItems).toHaveLength(2); + }); + + it('handles an empty dataset without dividing by zero', () => { + const { result } = renderHook(() => useTablePagination([])); + + expect(result.current.totalPages).toBe(1); + expect(result.current.totalItems).toBe(0); + expect(result.current.pageItems).toEqual([]); + expect(result.current.rangeStart).toBe(0); + expect(result.current.rangeEnd).toBe(0); + expect(result.current.isPaginated).toBe(false); + }); + + it('honours a custom page size', () => { + const { result } = renderHook(() => useTablePagination(makeRows(9), { pageSize: 4 })); + + expect(result.current.pageSize).toBe(4); + expect(result.current.totalPages).toBe(3); + expect(result.current.pageItems).toHaveLength(4); + }); + + it('ignores a non-positive page size and uses the default', () => { + const { result } = renderHook(() => useTablePagination(makeRows(12), { pageSize: 0 })); + + expect(result.current.pageSize).toBe(DEFAULT_PAGE_SIZE); + expect(result.current.totalPages).toBe(2); + }); +}); diff --git a/hooks/useTablePagination.ts b/hooks/useTablePagination.ts new file mode 100644 index 0000000..3deb688 --- /dev/null +++ b/hooks/useTablePagination.ts @@ -0,0 +1,89 @@ +'use client'; + +import { useCallback, useMemo, useState } from 'react'; + +export const DEFAULT_PAGE_SIZE = 10; + +export interface UseTablePaginationOptions { + /** Rows rendered per page. Defaults to 10. */ + pageSize?: number; +} + +export interface UseTablePaginationResult { + /** 1-based index of the page currently rendered. */ + page: number; + /** Rows rendered per page. */ + pageSize: number; + /** Total number of pages — always at least 1, even for an empty dataset. */ + totalPages: number; + /** Total number of rows across every page. */ + totalItems: number; + /** The slice of rows belonging to the current page. */ + pageItems: T[]; + /** 1-based index of the first row on the current page (0 when empty). */ + rangeStart: number; + /** 1-based index of the last row on the current page (0 when empty). */ + rangeEnd: number; + /** True when pagination controls are meaningful (more than one page). */ + isPaginated: boolean; + canPreviousPage: boolean; + canNextPage: boolean; + nextPage: () => void; + previousPage: () => void; + goToPage: (page: number) => void; +} + +/** + * useTablePagination — client-side pagination for table views. + * + * The hook owns nothing but the current page index; the visible slice is derived + * so a shrinking dataset (a filter being applied, rows being removed) can never + * leave the table stranded on a page that no longer exists. + */ +export function useTablePagination( + items: T[], + { pageSize = DEFAULT_PAGE_SIZE }: UseTablePaginationOptions = {}, +): UseTablePaginationResult { + const [requestedPage, setRequestedPage] = useState(1); + + const safePageSize = pageSize > 0 ? Math.floor(pageSize) : DEFAULT_PAGE_SIZE; + const totalItems = items.length; + const totalPages = Math.max(1, Math.ceil(totalItems / safePageSize)); + + // Derived rather than stored: if `items` shrinks below the requested page we + // fall back to the last valid page instead of rendering an empty table. + const page = Math.min(Math.max(requestedPage, 1), totalPages); + + const pageItems = useMemo(() => { + const start = (page - 1) * safePageSize; + return items.slice(start, start + safePageSize); + }, [items, page, safePageSize]); + + const goToPage = useCallback((target: number) => { + setRequestedPage(Number.isFinite(target) ? Math.max(1, Math.floor(target)) : 1); + }, []); + + const nextPage = useCallback(() => { + setRequestedPage((current) => Math.min(Math.max(current, 1) + 1, totalPages)); + }, [totalPages]); + + const previousPage = useCallback(() => { + setRequestedPage((current) => Math.max(Math.min(current, totalPages) - 1, 1)); + }, [totalPages]); + + return { + page, + pageSize: safePageSize, + totalPages, + totalItems, + pageItems, + rangeStart: totalItems === 0 ? 0 : (page - 1) * safePageSize + 1, + rangeEnd: Math.min(page * safePageSize, totalItems), + isPaginated: totalPages > 1, + canPreviousPage: page > 1, + canNextPage: page < totalPages, + nextPage, + previousPage, + goToPage, + }; +}