Skip to content

Commit e5881a7

Browse files
committed
feat(web): add view modes, filters, and performance feedback to search
- Add Table/Card/List view toggle with icon buttons - Display result count with filtered count indication - Show search duration (e.g., "0.32s") with tooltip showing full ms - Add entity type filter chips with color coding and selection state - Add "Clear filters" button when filters are active - Implement responsive card and list views with proper styling - Use toEntityType helper for type-safe color mapping (no type assertions) Improves search UX by giving users multiple view options and filtering capabilities while providing feedback on search performance.
1 parent b1fd735 commit e5881a7

File tree

1 file changed

+250
-26
lines changed

1 file changed

+250
-26
lines changed

apps/web/src/routes/search.lazy.tsx

Lines changed: 250 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { cachedOpenAlex } from "@bibgraph/client";
22
import type { AutocompleteResult } from "@bibgraph/types";
33
import { ENTITY_METADATA, toEntityType } from "@bibgraph/types";
4-
import { convertToRelativeUrl, ErrorRecovery,SearchEmptyState } from "@bibgraph/ui";
4+
import { convertToRelativeUrl, ErrorRecovery, SearchEmptyState } from "@bibgraph/ui";
55
import { formatLargeNumber, logger } from "@bibgraph/utils";
66
import {
77
Anchor,
@@ -10,18 +10,25 @@ import {
1010
Card,
1111
Container,
1212
Group,
13+
Paper,
14+
SegmentedControl,
15+
SimpleGrid,
1316
Stack,
1417
Table,
1518
Text,
1619
Title,
20+
Tooltip,
1721
} from "@mantine/core";
1822
import { notifications } from "@mantine/notifications";
1923
import {
2024
IconBookmark,
2125
IconBookmarkOff,
26+
IconLayoutGrid,
27+
IconList,
28+
IconTable,
2229
} from "@tabler/icons-react";
2330
import { useQuery, useQueryClient } from "@tanstack/react-query";
24-
import { createLazyFileRoute,useSearch } from "@tanstack/react-router";
31+
import { createLazyFileRoute, useSearch } from "@tanstack/react-router";
2532
import { useEffect, useMemo, useState } from "react";
2633

2734
import { BORDER_STYLE_GRAY_3, ICON_SIZE, SEARCH, TIME_MS } from '@/config/style-constants';
@@ -35,6 +42,20 @@ interface SearchFilters {
3542
query: string;
3643
}
3744

45+
type ViewMode = "table" | "card" | "list";
46+
47+
// Calculate entity type breakdown from results
48+
const getEntityTypeBreakdown = (results: AutocompleteResult[]) => {
49+
const breakdown = results.reduce((acc, result) => {
50+
acc[result.entity_type] = (acc[result.entity_type] || 0) + 1;
51+
return acc;
52+
}, {} as Record<string, number>);
53+
54+
return Object.entries(breakdown)
55+
.map(([type, count]) => ({ type, count }))
56+
.sort((a, b) => b.count - a.count);
57+
};
58+
3859
// Real OpenAlex API autocomplete function - searches across all entity types
3960
const searchAllEntities = async (
4061
filters: SearchFilters,
@@ -135,6 +156,16 @@ const SearchPage = () => {
135156
const searchParams = useSearch({ from: "/search" });
136157
const queryClient = useQueryClient();
137158

159+
// View mode state
160+
const [viewMode, setViewMode] = useState<ViewMode>("table");
161+
162+
// Entity type filter state
163+
const [selectedTypes, setSelectedTypes] = useState<string[]>([]);
164+
165+
// Search duration tracking
166+
const [searchStartTime, setSearchStartTime] = useState<number>(0);
167+
const [searchDuration, setSearchDuration] = useState<number>(0);
168+
138169
// Derive initial search filters from URL parameters using useMemo
139170
const initialSearchFilters = useMemo<SearchFilters>(() => {
140171
const qParam = searchParams.q;
@@ -185,6 +216,7 @@ const SearchPage = () => {
185216
});
186217

187218
const handleSearch = async (filters: SearchFilters) => {
219+
setSearchStartTime(Date.now());
188220
setSearchFilters(filters);
189221
// Auto-tracking in useUserInteractions will handle page visit recording
190222
};
@@ -250,11 +282,26 @@ const SearchPage = () => {
250282
if (searchResults && searchResults.length > 0 && retryCount > 0) {
251283
setRetryCount(0);
252284
}
253-
}, [searchResults, retryCount]);
285+
if (searchResults && searchStartTime > 0) {
286+
setSearchDuration(Date.now() - searchStartTime);
287+
}
288+
}, [searchResults, retryCount, searchStartTime]);
254289

255290
const hasResults = searchResults && searchResults.length > 0;
256291
const hasQuery = Boolean(searchFilters.query.trim());
257292

293+
// Filter results by selected entity types
294+
const filteredResults = useMemo(() => {
295+
if (!searchResults) return [];
296+
if (selectedTypes.length === 0) return searchResults;
297+
return searchResults.filter(result => selectedTypes.includes(result.entity_type));
298+
}, [searchResults, selectedTypes]);
299+
300+
// Calculate entity type breakdown
301+
const entityTypeBreakdown = useMemo(() => {
302+
return searchResults ? getEntityTypeBreakdown(searchResults) : [];
303+
}, [searchResults]);
304+
258305
const renderSearchResults = () => {
259306
if (isLoading) return renderLoadingState();
260307
if (error) return renderErrorState(
@@ -267,15 +314,105 @@ const SearchPage = () => {
267314
);
268315
if (!hasResults) return renderNoResultsState(searchFilters.query, handleQuickSearch);
269316

317+
const displayResults = selectedTypes.length > 0 ? filteredResults : searchResults;
318+
270319
return (
271320
<Stack>
272-
<Group justify="space-between" align="center">
273-
<Text size="sm" c="dimmed">
274-
Found {searchResults.length} results for &quot;
275-
{searchFilters.query}&quot;
276-
</Text>
321+
{/* Enhanced Results Header */}
322+
<Stack gap="md">
323+
{/* Primary stats row */}
324+
<Group justify="space-between" align="center" wrap="nowrap">
325+
<Group gap="md" align="center">
326+
<Text size="sm" fw={500}>
327+
{displayResults.length} {displayResults.length === 1 ? 'result' : 'results'}
328+
{selectedTypes.length > 0 && ` (filtered from ${searchResults.length})`}
329+
</Text>
330+
{searchDuration > 0 && (
331+
<Tooltip label={`${searchDuration}ms from OpenAlex API`}>
332+
<Text size="xs" c="dimmed" style={{ cursor: 'help' }}>
333+
{(searchDuration / 1000).toFixed(2)}s
334+
</Text>
335+
</Tooltip>
336+
)}
337+
</Group>
338+
339+
{/* View mode toggle */}
340+
<SegmentedControl
341+
value={viewMode}
342+
onChange={(value) => setViewMode(value as ViewMode)}
343+
data={[
344+
{
345+
value: 'table',
346+
label: (
347+
<Tooltip label="Table view"><IconTable size={ICON_SIZE.SM} /></Tooltip>
348+
)
349+
},
350+
{
351+
value: 'card',
352+
label: (
353+
<Tooltip label="Card view"><IconLayoutGrid size={ICON_SIZE.SM} /></Tooltip>
354+
)
355+
},
356+
{
357+
value: 'list',
358+
label: (
359+
<Tooltip label="List view"><IconList size={ICON_SIZE.SM} /></Tooltip>
360+
)
361+
},
362+
]}
363+
size="xs"
364+
/>
365+
</Group>
366+
367+
{/* Entity type breakdown and filter chips */}
368+
{entityTypeBreakdown.length > 0 && (
369+
<Stack gap="xs">
370+
<Group gap="xs" wrap="wrap">
371+
<Text size="xs" c="dimmed">Filter by type:</Text>
372+
{entityTypeBreakdown.map(({ type, count }) => {
373+
const isSelected = selectedTypes.includes(type);
374+
// Get color safely using toEntityType helper, defaulting to gray for unknown types
375+
const pluralForm = toEntityType(type);
376+
const color = pluralForm && pluralForm in ENTITY_METADATA
377+
? ENTITY_METADATA[pluralForm].color
378+
: "gray";
379+
return (
380+
<Badge
381+
key={type}
382+
size="sm"
383+
color={color}
384+
variant={isSelected ? "filled" : "light"}
385+
leftSection={isSelected ? "✓ " : undefined}
386+
style={{ cursor: 'pointer', userSelect: 'none' }}
387+
onClick={() => {
388+
setSelectedTypes(prev =>
389+
prev.includes(type)
390+
? prev.filter(t => t !== type)
391+
: [...prev, type]
392+
);
393+
}}
394+
>
395+
{type} ({count})
396+
</Badge>
397+
);
398+
})}
399+
{selectedTypes.length > 0 && (
400+
<Button
401+
size="xs"
402+
variant="subtle"
403+
onClick={() => setSelectedTypes([])}
404+
>
405+
Clear filters
406+
</Button>
407+
)}
408+
</Group>
409+
</Stack>
410+
)}
411+
</Stack>
277412

278-
{hasQuery && (
413+
{/* Bookmark button */}
414+
{hasQuery && (
415+
<Group justify="end">
279416
<Button
280417
variant="light"
281418
color={userInteractions.isBookmarked ? "yellow" : "gray"}
@@ -337,23 +474,22 @@ const SearchPage = () => {
337474
>
338475
{userInteractions.isBookmarked ? "Bookmarked" : "Bookmark Search"}
339476
</Button>
340-
)}
341-
</Group>
342-
343-
{/* Using Mantine Table directly due to TanStack Table hook compatibility
344-
issues with lazy-loaded routes. BaseTable's useReactTable/useVirtualizer
345-
hooks cause "Invalid hook call" errors in this lazy route context. */}
346-
<Table striped highlightOnHover withTableBorder>
347-
<Table.Thead>
348-
<Table.Tr>
349-
<Table.Th w={100}>Type</Table.Th>
350-
<Table.Th>Name</Table.Th>
351-
<Table.Th w={100}>Citations</Table.Th>
352-
<Table.Th w={100}>Works</Table.Th>
353-
</Table.Tr>
354-
</Table.Thead>
355-
<Table.Tbody>
356-
{searchResults.map((result) => {
477+
</Group>
478+
)}
479+
480+
{/* Results display based on view mode */}
481+
{viewMode === "table" && (
482+
<Table striped highlightOnHover withTableBorder>
483+
<Table.Thead>
484+
<Table.Tr>
485+
<Table.Th w={100}>Type</Table.Th>
486+
<Table.Th>Name</Table.Th>
487+
<Table.Th w={100}>Citations</Table.Th>
488+
<Table.Th w={100}>Works</Table.Th>
489+
</Table.Tr>
490+
</Table.Thead>
491+
<Table.Tbody>
492+
{displayResults.map((result) => {
357493
const entityUrl = convertToRelativeUrl(result.id);
358494
return (
359495
<Table.Tr key={result.id}>
@@ -398,6 +534,94 @@ const SearchPage = () => {
398534
})}
399535
</Table.Tbody>
400536
</Table>
537+
)}
538+
539+
{viewMode === "card" && (
540+
<SimpleGrid cols={{ base: 1, xs: 2, sm: 2, md: 3, lg: 4 }} spacing="md">
541+
{displayResults.map((result) => {
542+
const entityUrl = convertToRelativeUrl(result.id);
543+
return (
544+
<Card
545+
key={result.id}
546+
shadow="sm"
547+
p="md"
548+
withBorder
549+
style={{ cursor: 'pointer' }}
550+
component={entityUrl ? "a" : "div"}
551+
href={entityUrl}
552+
>
553+
<Stack gap="xs">
554+
<Group justify="space-between" align="start">
555+
<Badge size="sm" color={getEntityTypeColor(result.entity_type)} variant="light">
556+
{result.entity_type}
557+
</Badge>
558+
<Text size="xs" c="dimmed">
559+
{result.cited_by_count ? `${formatLargeNumber(result.cited_by_count)} citations` : '—'}
560+
</Text>
561+
</Group>
562+
<Text size="sm" fw={500} lineClamp={2}>
563+
{result.display_name}
564+
</Text>
565+
{result.hint && (
566+
<Text size="xs" c="dimmed" lineClamp={1}>
567+
{result.hint}
568+
</Text>
569+
)}
570+
</Stack>
571+
</Card>
572+
);
573+
})}
574+
</SimpleGrid>
575+
)}
576+
577+
{viewMode === "list" && (
578+
<Stack gap="xs">
579+
{displayResults.map((result) => {
580+
const entityUrl = convertToRelativeUrl(result.id);
581+
return (
582+
<Paper
583+
key={result.id}
584+
withBorder
585+
p="sm"
586+
radius="md"
587+
style={{ cursor: 'pointer' }}
588+
component={entityUrl ? "a" : "div"}
589+
href={entityUrl}
590+
>
591+
<Group justify="space-between" align="center">
592+
<Group gap="sm" align="start" style={{ flex: 1 }}>
593+
<Badge size="xs" color={getEntityTypeColor(result.entity_type)} variant="light">
594+
{result.entity_type}
595+
</Badge>
596+
<Stack gap={0} style={{ flex: 1 }}>
597+
<Text size="sm" fw={500}>
598+
{result.display_name}
599+
</Text>
600+
{result.hint && (
601+
<Text size="xs" c="dimmed" lineClamp={1}>
602+
{result.hint}
603+
</Text>
604+
)}
605+
</Stack>
606+
</Group>
607+
<Group gap="md">
608+
{result.cited_by_count && (
609+
<Text size="xs" c="dimmed" ta="right">
610+
{formatLargeNumber(result.cited_by_count)} citations
611+
</Text>
612+
)}
613+
{result.works_count && (
614+
<Text size="xs" c="dimmed" ta="right">
615+
{formatLargeNumber(result.works_count)} works
616+
</Text>
617+
)}
618+
</Group>
619+
</Group>
620+
</Paper>
621+
);
622+
})}
623+
</Stack>
624+
)}
401625
</Stack>
402626
);
403627
};

0 commit comments

Comments
 (0)