diff --git a/components/search/GlobalSearch.tsx b/components/search/GlobalSearch.tsx new file mode 100644 index 0000000..bf822a4 --- /dev/null +++ b/components/search/GlobalSearch.tsx @@ -0,0 +1,105 @@ +'use client'; + +import { Search, X } from 'lucide-react'; +import { useGlobalSearch } from '@/hooks/useGlobalSearch'; +import type { SearchResult } from '@/services/globalSearchService'; + +interface GlobalSearchProps { + /** Called with the selected result — typically routes to `result.href`. */ + onSelect?: (result: SearchResult) => void; +} + +/** + * GlobalSearch — unified search across deliveries, drivers and transactions. + * + * Results arrive as one flat list and are rendered under a heading per category, + * always in the canonical order, with empty categories omitted entirely. + */ +export function GlobalSearch({ onSelect }: GlobalSearchProps) { + const { query, setQuery, groups, totalResults, isLoading, error, isEmpty, clear } = + useGlobalSearch(); + + return ( +
+
+
+ +
+ {isLoading && ( +

+ Searching... +

+ )} + + {!isLoading && error && ( +

+ {error} +

+ )} + + {!isLoading && !error && isEmpty && ( +

+ No results found for "{query.trim()}". +

+ )} + + {!isLoading && !error && totalResults > 0 && ( +
+

+ {totalResults} result{totalResults === 1 ? '' : 's'} found +

+ {groups.map((group) => ( +
+

+ {group.label} +

+
    + {group.results.map((result) => ( +
  • + +
  • + ))} +
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/components/search/__tests__/GlobalSearch.test.tsx b/components/search/__tests__/GlobalSearch.test.tsx new file mode 100644 index 0000000..d410e31 --- /dev/null +++ b/components/search/__tests__/GlobalSearch.test.tsx @@ -0,0 +1,208 @@ +import React from 'react'; +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { GlobalSearch } from '@/components/search/GlobalSearch'; +import { globalSearchService, type SearchResult } from '@/services/globalSearchService'; + +jest.mock('@/services/globalSearchService', () => { + const actual = jest.requireActual('@/services/globalSearchService'); + return { + ...actual, + globalSearchService: { search: jest.fn() }, + }; +}); + +const mockSearch = globalSearchService.search as jest.Mock; + +const MIXED_RESULTS: SearchResult[] = [ + { id: 't1', category: 'transactions', title: '0xabc123', subtitle: '250 XLM', href: '/transactions/t1' }, + { id: 'd1', category: 'deliveries', title: 'TRK001', subtitle: 'In transit', href: '/deliveries/d1' }, + { id: 'v1', category: 'drivers', title: 'Ada Obi', subtitle: 'Lagos', href: '/fleet/drivers/v1' }, + { id: 'd2', category: 'deliveries', title: 'TRK002', subtitle: 'Pending', href: '/deliveries/d2' }, +]; + +describe('GlobalSearch', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockSearch.mockResolvedValue([]); + }); + + it('renders the search field and nothing else before a query is typed', () => { + render(); + + expect(screen.getByRole('searchbox', { name: 'Global search' })).toBeInTheDocument(); + expect(screen.queryByRole('heading')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Clear search' })).not.toBeInTheDocument(); + }); + + describe('categorisation', () => { + it('groups results under Deliveries, Drivers and Transactions headings in order', async () => { + const user = userEvent.setup(); + mockSearch.mockResolvedValue(MIXED_RESULTS); + render(); + + await user.type(screen.getByRole('searchbox', { name: 'Global search' }), 'a'); + + await waitFor(() => expect(screen.getAllByRole('heading')).toHaveLength(3)); + expect(screen.getAllByRole('heading').map((heading) => heading.textContent)).toEqual([ + 'Deliveries', + 'Drivers', + 'Transactions', + ]); + }); + + it('places each result under its own category', async () => { + const user = userEvent.setup(); + mockSearch.mockResolvedValue(MIXED_RESULTS); + render(); + + await user.type(screen.getByRole('searchbox', { name: 'Global search' }), 'a'); + + const deliveries = await screen.findByRole('region', { name: 'Deliveries' }); + expect(within(deliveries).getAllByRole('listitem')).toHaveLength(2); + expect(within(deliveries).getByText('TRK001')).toBeInTheDocument(); + expect(within(deliveries).getByText('TRK002')).toBeInTheDocument(); + + const drivers = screen.getByRole('region', { name: 'Drivers' }); + expect(within(drivers).getAllByRole('listitem')).toHaveLength(1); + expect(within(drivers).getByText('Ada Obi')).toBeInTheDocument(); + + const transactions = screen.getByRole('region', { name: 'Transactions' }); + expect(within(transactions).getByText('0xabc123')).toBeInTheDocument(); + expect(within(transactions).queryByText('TRK001')).not.toBeInTheDocument(); + }); + + it('renders the subtitle alongside each result title', async () => { + const user = userEvent.setup(); + mockSearch.mockResolvedValue(MIXED_RESULTS); + render(); + + await user.type(screen.getByRole('searchbox', { name: 'Global search' }), 'a'); + + expect(await screen.findByText('In transit')).toBeInTheDocument(); + expect(screen.getByText('250 XLM')).toBeInTheDocument(); + }); + + it('omits a heading for a category with no matches', async () => { + const user = userEvent.setup(); + mockSearch.mockResolvedValue([MIXED_RESULTS[1]]); + render(); + + await user.type(screen.getByRole('searchbox', { name: 'Global search' }), 'trk'); + + expect(await screen.findByRole('heading', { name: 'Deliveries' })).toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: 'Drivers' })).not.toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: 'Transactions' })).not.toBeInTheDocument(); + }); + + it('re-categorises when a follow-up query returns a different mix', async () => { + const user = userEvent.setup(); + mockSearch.mockResolvedValueOnce(MIXED_RESULTS); + render(); + + const input = screen.getByRole('searchbox', { name: 'Global search' }); + await user.type(input, 'a'); + await waitFor(() => expect(screen.getAllByRole('heading')).toHaveLength(3)); + + mockSearch.mockResolvedValueOnce([MIXED_RESULTS[2]]); + await user.type(input, 'd'); + + await waitFor(() => expect(screen.getAllByRole('heading')).toHaveLength(1)); + expect(screen.getByRole('heading', { name: 'Drivers' })).toBeInTheDocument(); + expect(screen.queryByText('TRK001')).not.toBeInTheDocument(); + }); + }); + + describe('selection', () => { + it('reports the selected result to the caller', async () => { + const user = userEvent.setup(); + const onSelect = jest.fn(); + mockSearch.mockResolvedValue(MIXED_RESULTS); + render(); + + await user.type(screen.getByRole('searchbox', { name: 'Global search' }), 'a'); + await user.click(await screen.findByRole('button', { name: /Ada Obi/ })); + + expect(onSelect).toHaveBeenCalledTimes(1); + expect(onSelect).toHaveBeenCalledWith(MIXED_RESULTS[2]); + }); + + it('does not throw when no onSelect handler is supplied', async () => { + const user = userEvent.setup(); + mockSearch.mockResolvedValue(MIXED_RESULTS); + render(); + + await user.type(screen.getByRole('searchbox', { name: 'Global search' }), 'a'); + const button = await screen.findByRole('button', { name: /Ada Obi/ }); + + await expect(user.click(button)).resolves.not.toThrow(); + }); + }); + + describe('states', () => { + it('shows a loading message while the search is in flight', async () => { + const user = userEvent.setup(); + let resolveSearch: (value: SearchResult[]) => void = () => {}; + mockSearch.mockReturnValue( + new Promise((resolve) => { + resolveSearch = resolve; + }), + ); + render(); + + await user.type(screen.getByRole('searchbox', { name: 'Global search' }), 'a'); + + expect(await screen.findByText('Searching...')).toBeInTheDocument(); + + resolveSearch(MIXED_RESULTS); + await waitFor(() => expect(screen.queryByText('Searching...')).not.toBeInTheDocument()); + }); + + it('shows an empty state when nothing matches', async () => { + const user = userEvent.setup(); + mockSearch.mockResolvedValue([]); + render(); + + await user.type(screen.getByRole('searchbox', { name: 'Global search' }), 'zzz'); + + expect(await screen.findByText('No results found for "zzz".')).toBeInTheDocument(); + expect(screen.queryByRole('heading')).not.toBeInTheDocument(); + }); + + it('shows an error state when the search service fails', async () => { + const user = userEvent.setup(); + mockSearch.mockRejectedValue(new Error('Search service unavailable')); + render(); + + await user.type(screen.getByRole('searchbox', { name: 'Global search' }), 'a'); + + expect(await screen.findByRole('alert')).toHaveTextContent('Search service unavailable'); + expect(screen.queryByRole('heading')).not.toBeInTheDocument(); + }); + + it('clears the query and results via the clear button', async () => { + const user = userEvent.setup(); + mockSearch.mockResolvedValue(MIXED_RESULTS); + render(); + + const input = screen.getByRole('searchbox', { name: 'Global search' }); + await user.type(input, 'a'); + await screen.findByRole('heading', { name: 'Deliveries' }); + + await user.click(screen.getByRole('button', { name: 'Clear search' })); + + expect(input).toHaveValue(''); + await waitFor(() => expect(screen.queryByRole('heading')).not.toBeInTheDocument()); + }); + + it('does not query the service for whitespace-only input', async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByRole('searchbox', { name: 'Global search' }), ' '); + + await waitFor(() => expect(mockSearch).not.toHaveBeenCalled()); + expect(screen.queryByText(/No results found/)).not.toBeInTheDocument(); + }); + }); +}); diff --git a/hooks/__tests__/useGlobalSearch.test.ts b/hooks/__tests__/useGlobalSearch.test.ts new file mode 100644 index 0000000..d39953e --- /dev/null +++ b/hooks/__tests__/useGlobalSearch.test.ts @@ -0,0 +1,220 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { useGlobalSearch } from '@/hooks/useGlobalSearch'; +import { globalSearchService, type SearchResult } from '@/services/globalSearchService'; + +jest.mock('@/services/globalSearchService', () => { + const actual = jest.requireActual('@/services/globalSearchService'); + return { + ...actual, + globalSearchService: { search: jest.fn() }, + }; +}); + +const mockSearch = globalSearchService.search as jest.Mock; + +const result = (overrides: Partial & Pick): SearchResult => ({ + title: overrides.id, + href: `/${overrides.category}/${overrides.id}`, + ...overrides, +}); + +const MIXED_RESULTS: SearchResult[] = [ + result({ id: 't1', category: 'transactions', title: '0xabc' }), + result({ id: 'd1', category: 'deliveries', title: 'TRK001' }), + result({ id: 'v1', category: 'drivers', title: 'Ada Obi' }), + result({ id: 'd2', category: 'deliveries', title: 'TRK002' }), +]; + +describe('useGlobalSearch', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockSearch.mockResolvedValue([]); + }); + + it('starts idle without hitting the search service', () => { + const { result: hook } = renderHook(() => useGlobalSearch()); + + expect(hook.current.query).toBe(''); + expect(hook.current.groups).toEqual([]); + expect(hook.current.totalResults).toBe(0); + expect(hook.current.isLoading).toBe(false); + expect(hook.current.isEmpty).toBe(false); + expect(mockSearch).not.toHaveBeenCalled(); + }); + + it('groups results into Deliveries, Drivers and Transactions in canonical order', async () => { + mockSearch.mockResolvedValue(MIXED_RESULTS); + const { result: hook } = renderHook(() => useGlobalSearch()); + + act(() => hook.current.setQuery('a')); + + await waitFor(() => expect(hook.current.groups).toHaveLength(3)); + + expect(hook.current.groups.map((group) => group.category)).toEqual([ + 'deliveries', + 'drivers', + 'transactions', + ]); + expect(hook.current.groups.map((group) => group.label)).toEqual([ + 'Deliveries', + 'Drivers', + 'Transactions', + ]); + expect(hook.current.groups[0].results.map((entry) => entry.id)).toEqual(['d1', 'd2']); + expect(hook.current.totalResults).toBe(4); + }); + + it('omits categories with no results', async () => { + mockSearch.mockResolvedValue([result({ id: 'v1', category: 'drivers', title: 'Ada Obi' })]); + const { result: hook } = renderHook(() => useGlobalSearch()); + + act(() => hook.current.setQuery('ada')); + + await waitFor(() => expect(hook.current.groups).toHaveLength(1)); + expect(hook.current.groups[0].category).toBe('drivers'); + }); + + it('exposes a loading state while the search is in flight', async () => { + let resolveSearch: (value: SearchResult[]) => void = () => {}; + mockSearch.mockReturnValue( + new Promise((resolve) => { + resolveSearch = resolve; + }), + ); + const { result: hook } = renderHook(() => useGlobalSearch()); + + act(() => hook.current.setQuery('trk')); + await waitFor(() => expect(hook.current.isLoading).toBe(true)); + + await act(async () => { + resolveSearch([]); + }); + + expect(hook.current.isLoading).toBe(false); + }); + + it('reports an empty state only after a search has actually run', async () => { + mockSearch.mockResolvedValue([]); + const { result: hook } = renderHook(() => useGlobalSearch()); + + act(() => hook.current.setQuery('nothing-matches')); + + await waitFor(() => expect(hook.current.isEmpty).toBe(true)); + expect(hook.current.groups).toEqual([]); + expect(hook.current.error).toBeNull(); + }); + + it('does not search for a whitespace-only query', async () => { + const { result: hook } = renderHook(() => useGlobalSearch()); + + act(() => hook.current.setQuery(' ')); + + await waitFor(() => expect(hook.current.isLoading).toBe(false)); + expect(mockSearch).not.toHaveBeenCalled(); + expect(hook.current.isEmpty).toBe(false); + }); + + it('surfaces a service failure and drops stale results', async () => { + mockSearch.mockResolvedValueOnce(MIXED_RESULTS); + const { result: hook } = renderHook(() => useGlobalSearch()); + + act(() => hook.current.setQuery('a')); + await waitFor(() => expect(hook.current.totalResults).toBe(4)); + + mockSearch.mockRejectedValueOnce(new Error('Search service unavailable')); + act(() => hook.current.setQuery('ab')); + + await waitFor(() => expect(hook.current.error).toBe('Search service unavailable')); + expect(hook.current.groups).toEqual([]); + expect(hook.current.isEmpty).toBe(false); + }); + + it('falls back to a generic message for a non-Error rejection', async () => { + mockSearch.mockRejectedValue('boom'); + const { result: hook } = renderHook(() => useGlobalSearch()); + + act(() => hook.current.setQuery('a')); + + await waitFor(() => expect(hook.current.error).toBe('Unable to complete the search')); + }); + + it('clears the error once a later query succeeds', async () => { + mockSearch.mockRejectedValueOnce(new Error('Search service unavailable')); + const { result: hook } = renderHook(() => useGlobalSearch()); + + act(() => hook.current.setQuery('a')); + await waitFor(() => expect(hook.current.error).toBe('Search service unavailable')); + + mockSearch.mockResolvedValueOnce([result({ id: 'd1', category: 'deliveries', title: 'TRK001' })]); + act(() => hook.current.setQuery('trk')); + + await waitFor(() => expect(hook.current.totalResults).toBe(1)); + expect(hook.current.error).toBeNull(); + }); + + it('aborts the previous request when the query changes', async () => { + mockSearch.mockResolvedValue([]); + const { result: hook } = renderHook(() => useGlobalSearch()); + + act(() => hook.current.setQuery('a')); + await waitFor(() => expect(mockSearch).toHaveBeenCalledTimes(1)); + + const firstSignal = mockSearch.mock.calls[0][1] as AbortSignal; + act(() => hook.current.setQuery('ab')); + + await waitFor(() => expect(firstSignal.aborted).toBe(true)); + expect(mockSearch).toHaveBeenLastCalledWith('ab', expect.any(AbortSignal)); + }); + + it('ignores a stale response that resolves after a newer query', async () => { + let resolveFirst: (value: SearchResult[]) => void = () => {}; + mockSearch.mockReturnValueOnce( + new Promise((resolve) => { + resolveFirst = resolve; + }), + ); + mockSearch.mockResolvedValueOnce([result({ id: 'd2', category: 'deliveries', title: 'TRK002' })]); + + const { result: hook } = renderHook(() => useGlobalSearch()); + + act(() => hook.current.setQuery('a')); + act(() => hook.current.setQuery('ab')); + + await waitFor(() => expect(hook.current.totalResults).toBe(1)); + + await act(async () => { + resolveFirst([result({ id: 'd1', category: 'deliveries', title: 'STALE' })]); + }); + + expect(hook.current.results.map((entry) => entry.id)).toEqual(['d2']); + }); + + it('resets everything when the query is emptied', async () => { + mockSearch.mockResolvedValue(MIXED_RESULTS); + const { result: hook } = renderHook(() => useGlobalSearch()); + + act(() => hook.current.setQuery('a')); + await waitFor(() => expect(hook.current.totalResults).toBe(4)); + + act(() => hook.current.setQuery('')); + + await waitFor(() => expect(hook.current.totalResults).toBe(0)); + expect(hook.current.groups).toEqual([]); + expect(hook.current.isEmpty).toBe(false); + }); + + it('clears the query and results on demand', async () => { + mockSearch.mockResolvedValue(MIXED_RESULTS); + const { result: hook } = renderHook(() => useGlobalSearch()); + + act(() => hook.current.setQuery('a')); + await waitFor(() => expect(hook.current.totalResults).toBe(4)); + + act(() => hook.current.clear()); + + expect(hook.current.query).toBe(''); + expect(hook.current.groups).toEqual([]); + expect(hook.current.isLoading).toBe(false); + expect(hook.current.error).toBeNull(); + }); +}); diff --git a/hooks/useGlobalSearch.ts b/hooks/useGlobalSearch.ts new file mode 100644 index 0000000..f984793 --- /dev/null +++ b/hooks/useGlobalSearch.ts @@ -0,0 +1,114 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + globalSearchService, + SEARCH_CATEGORIES, + SEARCH_CATEGORY_LABELS, + type SearchCategory, + type SearchResult, +} from '@/services/globalSearchService'; + +export interface SearchResultGroup { + category: SearchCategory; + label: string; + results: SearchResult[]; +} + +export interface UseGlobalSearchResult { + query: string; + setQuery: (query: string) => void; + /** Non-empty categories, always in the canonical Deliveries/Drivers/Transactions order. */ + groups: SearchResultGroup[]; + /** Flat result list, useful for keyboard navigation and counters. */ + results: SearchResult[]; + totalResults: number; + isLoading: boolean; + error: string | null; + /** True once a search has run for a non-empty query and returned nothing. */ + isEmpty: boolean; + clear: () => void; +} + +/** The outcome of the most recently completed search, tagged with its query. */ +interface SearchSnapshot { + query: string; + results: SearchResult[]; + error: string | null; +} + +const EMPTY_SNAPSHOT: SearchSnapshot = { query: '', results: [], error: null }; + +/** + * useGlobalSearch — runs the unified search and groups the results by category. + * + * The snapshot is tagged with the query that produced it, so every derived value + * — loading, results, grouping — follows from whether that tag still matches the + * current query. Results from a superseded query can therefore never be shown, + * and in-flight requests are aborted as soon as the query moves on. + */ +export function useGlobalSearch(): UseGlobalSearchResult { + const [query, setQuery] = useState(''); + const [snapshot, setSnapshot] = useState(EMPTY_SNAPSHOT); + + const trimmedQuery = query.trim(); + + useEffect(() => { + if (!trimmedQuery) return; + + const controller = new AbortController(); + + globalSearchService + .search(trimmedQuery, controller.signal) + .then((results) => { + if (controller.signal.aborted) return; + setSnapshot({ query: trimmedQuery, results, error: null }); + }) + .catch((searchError: unknown) => { + if (controller.signal.aborted) return; + setSnapshot({ + query: trimmedQuery, + results: [], + error: + searchError instanceof Error ? searchError.message : 'Unable to complete the search', + }); + }); + + return () => controller.abort(); + }, [trimmedQuery]); + + // The snapshot only counts while it still describes the query on screen. + const isSettled = trimmedQuery !== '' && snapshot.query === trimmedQuery; + const results = useMemo( + () => (isSettled ? snapshot.results : []), + [isSettled, snapshot.results], + ); + const error = isSettled ? snapshot.error : null; + + const groups = useMemo( + () => + SEARCH_CATEGORIES.map((category) => ({ + category, + label: SEARCH_CATEGORY_LABELS[category], + results: results.filter((result) => result.category === category), + })).filter((group) => group.results.length > 0), + [results], + ); + + const clear = useCallback(() => { + setQuery(''); + setSnapshot(EMPTY_SNAPSHOT); + }, []); + + return { + query, + setQuery, + groups, + results, + totalResults: results.length, + isLoading: trimmedQuery !== '' && !isSettled, + error, + isEmpty: isSettled && error === null && results.length === 0, + clear, + }; +} diff --git a/services/__tests__/globalSearchService.test.ts b/services/__tests__/globalSearchService.test.ts new file mode 100644 index 0000000..d65f14f --- /dev/null +++ b/services/__tests__/globalSearchService.test.ts @@ -0,0 +1,144 @@ +import api from '@/lib/api'; +import { globalSearchService, SEARCH_CATEGORIES } from '@/services/globalSearchService'; + +jest.mock('@/lib/api', () => ({ + __esModule: true, + default: { get: jest.fn() }, +})); + +const mockGet = api.get as jest.Mock; + +describe('globalSearchService.search', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('exposes the three unified search categories in canonical order', () => { + expect(SEARCH_CATEGORIES).toEqual(['deliveries', 'drivers', 'transactions']); + }); + + it('requests the search endpoint with the trimmed query', async () => { + mockGet.mockResolvedValue({ data: { results: [] } }); + + await globalSearchService.search(' TRK001 '); + + expect(mockGet).toHaveBeenCalledWith('/search', { + params: { q: 'TRK001' }, + signal: undefined, + }); + }); + + it('short-circuits an empty query without calling the API', async () => { + await expect(globalSearchService.search(' ')).resolves.toEqual([]); + + expect(mockGet).not.toHaveBeenCalled(); + }); + + it('normalises results from a wrapped response', async () => { + mockGet.mockResolvedValue({ + data: { + results: [ + { id: 'd1', category: 'deliveries', title: 'TRK001', subtitle: 'In transit' }, + { id: 'v1', category: 'drivers', title: 'Ada Obi' }, + ], + }, + }); + + await expect(globalSearchService.search('a')).resolves.toEqual([ + { + id: 'd1', + category: 'deliveries', + title: 'TRK001', + subtitle: 'In transit', + href: '/deliveries/d1', + }, + { + id: 'v1', + category: 'drivers', + title: 'Ada Obi', + subtitle: undefined, + href: '/fleet/drivers/v1', + }, + ]); + }); + + it('accepts a bare array response', async () => { + mockGet.mockResolvedValue({ + data: [{ id: 't1', category: 'transactions', title: '0xabc' }], + }); + + const results = await globalSearchService.search('0x'); + + expect(results).toHaveLength(1); + expect(results[0].href).toBe('/transactions/t1'); + }); + + it('keeps an explicit href supplied by the API', async () => { + mockGet.mockResolvedValue({ + data: { results: [{ id: 'd1', category: 'deliveries', title: 'TRK001', href: '/custom/d1' }] }, + }); + + const [result] = await globalSearchService.search('trk'); + + expect(result.href).toBe('/custom/d1'); + }); + + it('falls back to the id when a record carries no title', async () => { + mockGet.mockResolvedValue({ + data: { results: [{ id: 'd1', category: 'deliveries' }] }, + }); + + const [result] = await globalSearchService.search('d'); + + expect(result.title).toBe('d1'); + }); + + it('drops malformed records instead of rendering broken rows', async () => { + mockGet.mockResolvedValue({ + data: { + results: [ + null, + 'not-an-object', + { category: 'deliveries', title: 'missing id' }, + { id: 'x1', category: 'unknown-category', title: 'bad category' }, + { id: 'd1', category: 'deliveries', title: 'TRK001' }, + ], + }, + }); + + const results = await globalSearchService.search('a'); + + expect(results).toHaveLength(1); + expect(results[0].id).toBe('d1'); + }); + + it('returns an empty list when the payload has no results field', async () => { + mockGet.mockResolvedValue({ data: {} }); + + await expect(globalSearchService.search('a')).resolves.toEqual([]); + }); + + it('returns an empty list when results is not an array', async () => { + mockGet.mockResolvedValue({ data: { results: 'nope' } }); + + await expect(globalSearchService.search('a')).resolves.toEqual([]); + }); + + it('propagates transport errors to the caller', async () => { + mockGet.mockRejectedValue(new Error('Network Error')); + + await expect(globalSearchService.search('a')).rejects.toThrow('Network Error'); + }); + + it('forwards an abort signal to the transport', async () => { + mockGet.mockResolvedValue({ data: { results: [] } }); + const controller = new AbortController(); + + await globalSearchService.search('a', controller.signal); + + expect(mockGet).toHaveBeenCalledWith('/search', { + params: { q: 'a' }, + signal: controller.signal, + }); + }); +}); diff --git a/services/globalSearchService.ts b/services/globalSearchService.ts new file mode 100644 index 0000000..8e3c241 --- /dev/null +++ b/services/globalSearchService.ts @@ -0,0 +1,87 @@ +import api from '@/lib/api'; + +/** The categories the unified search groups its results into. */ +export type SearchCategory = 'deliveries' | 'drivers' | 'transactions'; + +export const SEARCH_CATEGORIES: SearchCategory[] = ['deliveries', 'drivers', 'transactions']; + +export const SEARCH_CATEGORY_LABELS: Record = { + deliveries: 'Deliveries', + drivers: 'Drivers', + transactions: 'Transactions', +}; + +export interface SearchResult { + id: string; + category: SearchCategory; + /** Primary line — a tracking number, a driver name, a transaction hash. */ + title: string; + /** Secondary line — status, region, amount. */ + subtitle?: string; + /** In-app route the result navigates to when selected. */ + href: string; +} + +interface RawSearchResponse { + results?: unknown; +} + +const ROUTE_BY_CATEGORY: Record = { + deliveries: '/deliveries', + drivers: '/fleet/drivers', + transactions: '/transactions', +}; + +function isSearchCategory(value: unknown): value is SearchCategory { + return typeof value === 'string' && (SEARCH_CATEGORIES as string[]).includes(value); +} + +/** + * Normalises one raw record from the search endpoint. + * Records without an id or an unrecognised category are dropped rather than + * rendered as a broken row. + */ +function toSearchResult(raw: unknown): SearchResult | null { + if (typeof raw !== 'object' || raw === null) return null; + const record = raw as Record; + + const id = typeof record.id === 'string' ? record.id : null; + if (!id) return null; + if (!isSearchCategory(record.category)) return null; + + const category = record.category; + + return { + id, + category, + title: typeof record.title === 'string' && record.title ? record.title : id, + subtitle: typeof record.subtitle === 'string' ? record.subtitle : undefined, + href: + typeof record.href === 'string' && record.href + ? record.href + : `${ROUTE_BY_CATEGORY[category]}/${id}`, + }; +} + +/** + * globalSearchService — talks to the unified search endpoint that spans + * deliveries, drivers and transactions. + */ +export const globalSearchService = { + async search(query: string, signal?: AbortSignal): Promise { + const trimmed = query.trim(); + if (!trimmed) return []; + + const { data } = await api.get('/search', { + params: { q: trimmed }, + signal, + }); + + const rawResults = Array.isArray(data) ? data : ((data as RawSearchResponse)?.results ?? []); + if (!Array.isArray(rawResults)) return []; + + return rawResults + .map(toSearchResult) + .filter((result): result is SearchResult => result !== null); + }, +};