Skip to content

Commit b23db52

Browse files
committed
fix(web): improve UX with data formatting, history tracking, and filter UI
- Add context-aware number formatting to display years without commas - Expand HIDDEN_FIELDS to exclude internal API fields from display - Add displayName parameter to history tracking for proper titles - Add debounce to prevent duplicate history entries - Add runtime cleanup filter for corrupted history entries - Refactor RelationshipTypeFilter from 22 checkboxes to categorized Accordion + Chips UI with preset buttons - Add RELATIONSHIP_TYPE_CATEGORIES and FILTER_PRESETS configuration - Convert function declarations to arrow functions per linting rules - Use String#slice() instead of deprecated String#substring()
1 parent e715cff commit b23db52

File tree

11 files changed

+512
-153
lines changed

11 files changed

+512
-153
lines changed

apps/web/src/components/EntityDataDisplay.tsx

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import { Link } from "@tanstack/react-router";
3737
import { ICON_SIZE } from "@/config/style-constants";
3838
import { useVersionComparison } from "@/hooks/use-version-comparison";
3939
import { humanizeFieldName } from "@/utils/field-labels";
40+
import { formatNumber } from "@/utils/format-number";
4041
import { convertOpenAlexToInternalLink, isOpenAlexId } from "@/utils/openalex-link-conversion";
4142

4243
/** Section priority for consistent ordering */
@@ -65,7 +66,7 @@ const SECTION_ICONS: Record<string, import("react").ReactNode> = {
6566
// Value Rendering
6667
// ============================================================================
6768

68-
const renderPrimitiveValue = (value: unknown): import("react").ReactNode => {
69+
const renderPrimitiveValue = (value: unknown, fieldName?: string): import("react").ReactNode => {
6970
// Don't render null/undefined - these fields will be filtered out
7071
if (value === null || value === undefined) {
7172
return null;
@@ -87,7 +88,7 @@ const renderPrimitiveValue = (value: unknown): import("react").ReactNode => {
8788
if (typeof value === "number") {
8889
return (
8990
<Code variant="light" color="blue" ff="monospace" fw={600}>
90-
{value.toLocaleString()}
91+
{formatNumber(value, fieldName)}
9192
</Code>
9293
);
9394
}
@@ -174,16 +175,31 @@ const isDisplayableValue = (value: unknown): boolean => {
174175
};
175176

176177
/**
177-
* Fields that should be hidden from display (internal API fields, URLs, etc.)
178+
* Fields that should be hidden from display (internal API fields, URLs, debug fields, etc.)
178179
*/
179180
const HIDDEN_FIELDS = new Set([
181+
// API URLs (not useful for display)
180182
"works_api_url",
181183
"cited_by_api_url",
184+
"ngrams_url",
185+
"sources_api_url",
186+
// Internal dates (use publication_date instead)
182187
"updated_date",
183188
"created_date",
184-
"ngrams_url",
189+
// Technical data structures
185190
"abstract_inverted_index",
186191
"indexed_in",
192+
// Internal processing fields (from OpenAlex API)
193+
"block_key",
194+
"parsed_longest_name",
195+
"suffix",
196+
"nickname",
197+
"given_name",
198+
"family_name",
199+
"middle_name",
200+
// Other internal fields
201+
"relevance_score",
202+
"filter_key",
187203
]);
188204

189205
const groupFields = (data: Record<string, unknown>): SectionData[] => {
@@ -243,11 +259,11 @@ const groupFields = (data: Record<string, unknown>): SectionData[] => {
243259
// Value Content Renderer
244260
// ============================================================================
245261

246-
const renderValueContent = (value: unknown): import("react").ReactNode => {
262+
const renderValueContent = (value: unknown, fieldName?: string): import("react").ReactNode => {
247263
// Primitives
248264
if (value === null || value === undefined || typeof value === "boolean" ||
249265
typeof value === "number" || typeof value === "string") {
250-
return renderPrimitiveValue(value);
266+
return renderPrimitiveValue(value, fieldName);
251267
}
252268

253269
// Arrays - don't render empty arrays
@@ -279,7 +295,7 @@ const renderValueContent = (value: unknown): import("react").ReactNode => {
279295
{index + 1}
280296
</Badge>
281297
<Box style={{ flex: 1, minWidth: 0 }}>
282-
{renderValueContent(item)}
298+
{renderValueContent(item, fieldName)}
283299
</Box>
284300
</Group>
285301
</Paper>
@@ -300,10 +316,10 @@ const renderValueContent = (value: unknown): import("react").ReactNode => {
300316
{entries.map(([key, val]) => (
301317
<Box key={key}>
302318
<Text size="xs" fw={600} c="dimmed" mb="xs">
303-
{key}
319+
{humanizeFieldName(key)}
304320
</Text>
305321
<Box ml="sm">
306-
{renderValueContent(val)}
322+
{renderValueContent(val, key)}
307323
</Box>
308324
</Box>
309325
))}

apps/web/src/components/entity-detail/EntityDetailLayout.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ export const EntityDetailLayout = ({
5353
entityId,
5454
entityType,
5555
autoTrackVisits: true,
56+
displayName,
5657
});
5758

5859
// Initialize query bookmarking hook for query-specific bookmarking

apps/web/src/components/layout/HistoryCard.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,10 @@ export const HistoryCard = ({ entry, onClose, formatDate }: HistoryCardProps) =>
5656
if (idMatch) {
5757
const id = idMatch[1];
5858
// Show first letter + first few digits for readability
59-
return id.length > 8 ? `${id.substring(0, 8)}...` : id;
59+
return id.length > 8 ? `${id.slice(0, 8)}...` : id;
6060
}
6161
// For other IDs, truncate if too long
62-
return entityId.length > 15 ? `${entityId.substring(0, 15)}...` : entityId;
62+
return entityId.length > 15 ? `${entityId.slice(0, 15)}...` : entityId;
6363
};
6464

6565
// Determine the title to display
Lines changed: 98 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
/**
22
* Component tests for RelationshipTypeFilter
3+
* Tests the categorized accordion + chip UI with preset buttons
34
*/
45

56
import { RelationType } from '@bibgraph/types';
67
import { MantineProvider } from '@mantine/core';
7-
import { cleanup,render, screen } from '@testing-library/react';
8+
import { cleanup, render, screen } from '@testing-library/react';
89
import { userEvent } from '@testing-library/user-event';
9-
import { afterEach,beforeEach, describe, expect, it, vi } from 'vitest';
10+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
1011

1112
import { RelationshipTypeFilter } from './RelationshipTypeFilter';
1213

@@ -49,115 +50,110 @@ describe('RelationshipTypeFilter', () => {
4950
expect(screen.getByText('Filter by Relationship Type')).toBeInTheDocument();
5051
});
5152

52-
it('should render checkboxes for all relationship types', () => {
53+
it('should render preset buttons', () => {
5354
renderComponent();
5455

55-
// Verify all unique RelationType values have checkboxes
56-
const allTypes = getUniqueTypes();
57-
allTypes.forEach((type) => {
58-
const checkbox = screen.getByTestId(`filter-checkbox-${type}`);
59-
expect(checkbox).toBeInTheDocument();
60-
});
56+
expect(screen.getByTestId('preset-all')).toBeInTheDocument();
57+
expect(screen.getByTestId('preset-core')).toBeInTheDocument();
58+
expect(screen.getByTestId('preset-citations')).toBeInTheDocument();
6159
});
6260

63-
it('should show all checkboxes as checked when selectedTypes is empty', () => {
64-
renderComponent([]);
61+
it('should call onChange with empty array when All preset is clicked', async () => {
62+
const user = userEvent.setup();
63+
const selectedTypes = [RelationType.AUTHORSHIP];
64+
renderComponent(selectedTypes);
6565

66-
const allTypes = getUniqueTypes();
67-
allTypes.forEach((type) => {
68-
// Mantine passes data-testid to the wrapper, find input inside
69-
const checkboxWrapper = screen.getByTestId(`filter-checkbox-${type}`);
70-
const input = checkboxWrapper.closest('.mantine-Checkbox-root')?.querySelector('input[type="checkbox"]') as HTMLInputElement;
71-
expect(input).toBeChecked();
72-
});
73-
});
66+
const allPreset = screen.getByTestId('preset-all');
67+
await user.click(allPreset);
7468

75-
it('should only check selected types when selectedTypes is not empty', () => {
76-
const selectedTypes = [RelationType.AUTHORSHIP, RelationType.REFERENCE];
77-
renderComponent(selectedTypes);
69+
expect(mockOnChange).toHaveBeenCalledWith([]);
70+
});
7871

79-
const authorshipCheckbox = screen.getByTestId(`filter-checkbox-${RelationType.AUTHORSHIP}`);
80-
const referenceCheckbox = screen.getByTestId(`filter-checkbox-${RelationType.REFERENCE}`);
81-
const publicationCheckbox = screen.getByTestId(`filter-checkbox-${RelationType.PUBLICATION}`);
72+
it('should call onChange with core types when Core Only preset is clicked', async () => {
73+
const user = userEvent.setup();
74+
renderComponent([]);
8275

83-
const authorshipInput = authorshipCheckbox.closest('.mantine-Checkbox-root')?.querySelector('input[type="checkbox"]') as HTMLInputElement;
84-
const referenceInput = referenceCheckbox.closest('.mantine-Checkbox-root')?.querySelector('input[type="checkbox"]') as HTMLInputElement;
85-
const publicationInput = publicationCheckbox.closest('.mantine-Checkbox-root')?.querySelector('input[type="checkbox"]') as HTMLInputElement;
76+
const corePreset = screen.getByTestId('preset-core');
77+
await user.click(corePreset);
8678

87-
expect(authorshipInput).toBeChecked();
88-
expect(referenceInput).toBeChecked();
89-
expect(publicationInput).not.toBeChecked();
79+
expect(mockOnChange).toHaveBeenCalledWith([
80+
RelationType.AUTHORSHIP,
81+
RelationType.AFFILIATION,
82+
RelationType.PUBLICATION,
83+
RelationType.REFERENCE,
84+
RelationType.TOPIC,
85+
]);
9086
});
9187

92-
it('should call onChange when checkbox is toggled', async () => {
88+
it('should call onChange with REFERENCE when Citations preset is clicked', async () => {
9389
const user = userEvent.setup();
9490
renderComponent([]);
9591

96-
const authorshipCheckbox = screen.getByTestId(`filter-checkbox-${RelationType.AUTHORSHIP}`);
97-
await user.click(authorshipCheckbox);
92+
const citationsPreset = screen.getByTestId('preset-citations');
93+
await user.click(citationsPreset);
9894

99-
// When empty array (all selected), clicking should select only that one type
100-
expect(mockOnChange).toHaveBeenCalledWith([RelationType.AUTHORSHIP]);
95+
expect(mockOnChange).toHaveBeenCalledWith([RelationType.REFERENCE]);
10196
});
10297

103-
it('should add type when checkbox is clicked on unselected type', async () => {
104-
const user = userEvent.setup();
105-
const selectedTypes = [RelationType.AUTHORSHIP];
106-
renderComponent(selectedTypes);
98+
it('should render chips for relationship types within accordion', () => {
99+
renderComponent();
107100

108-
const referenceCheckbox = screen.getByTestId(`filter-checkbox-${RelationType.REFERENCE}`);
109-
await user.click(referenceCheckbox);
101+
// Core category should be open by default
102+
expect(screen.getByTestId(`filter-chip-${RelationType.AUTHORSHIP}`)).toBeInTheDocument();
103+
expect(screen.getByTestId(`filter-chip-${RelationType.REFERENCE}`)).toBeInTheDocument();
104+
});
110105

111-
expect(mockOnChange).toHaveBeenCalledWith([RelationType.AUTHORSHIP, RelationType.REFERENCE]);
106+
it('should render chips for core category types when empty selection (all shown)', () => {
107+
renderComponent([]);
108+
109+
// Chips should be rendered in core category (open by default)
110+
expect(screen.getByTestId(`filter-chip-${RelationType.AUTHORSHIP}`)).toBeInTheDocument();
111+
expect(screen.getByTestId(`filter-chip-${RelationType.REFERENCE}`)).toBeInTheDocument();
112112
});
113113

114-
it('should remove type when checkbox is clicked on selected type', async () => {
115-
const user = userEvent.setup();
114+
it('should render chips in core category for specific selection', () => {
116115
const selectedTypes = [RelationType.AUTHORSHIP, RelationType.REFERENCE];
117116
renderComponent(selectedTypes);
118117

119-
const authorshipCheckbox = screen.getByTestId(`filter-checkbox-${RelationType.AUTHORSHIP}`);
120-
await user.click(authorshipCheckbox);
121-
122-
expect(mockOnChange).toHaveBeenCalledWith([RelationType.REFERENCE]);
118+
// All core category chips should be present
119+
expect(screen.getByTestId(`filter-chip-${RelationType.AUTHORSHIP}`)).toBeInTheDocument();
120+
expect(screen.getByTestId(`filter-chip-${RelationType.REFERENCE}`)).toBeInTheDocument();
121+
expect(screen.getByTestId(`filter-chip-${RelationType.PUBLICATION}`)).toBeInTheDocument();
123122
});
124123

125-
it('should clear all selections when Clear All is clicked', async () => {
124+
it('should call onChange when chip is toggled from "all" state', async () => {
126125
const user = userEvent.setup();
127-
const selectedTypes = [RelationType.AUTHORSHIP, RelationType.REFERENCE];
128-
renderComponent(selectedTypes);
126+
renderComponent([]);
129127

130-
const clearButton = screen.getByTestId('clear-all-button');
131-
await user.click(clearButton);
128+
const authorshipChip = screen.getByTestId(`filter-chip-${RelationType.AUTHORSHIP}`);
129+
await user.click(authorshipChip);
132130

133-
expect(mockOnChange).toHaveBeenCalledWith([]);
131+
// When empty array (all selected), clicking should exclude that type
132+
const allTypes = getUniqueTypes();
133+
const expectedTypes = allTypes.filter(t => t !== RelationType.AUTHORSHIP);
134+
expect(mockOnChange).toHaveBeenCalledWith(expectedTypes);
134135
});
135136

136-
it('should select all types when Select All is clicked', async () => {
137+
it('should add type when chip is clicked on unselected type', async () => {
137138
const user = userEvent.setup();
138139
const selectedTypes = [RelationType.AUTHORSHIP];
139140
renderComponent(selectedTypes);
140141

141-
const selectAllButton = screen.getByTestId('select-all-button');
142-
await user.click(selectAllButton);
142+
const referenceChip = screen.getByTestId(`filter-chip-${RelationType.REFERENCE}`);
143+
await user.click(referenceChip);
143144

144-
const allTypes = getUniqueTypes();
145-
expect(mockOnChange).toHaveBeenCalledWith(allTypes);
145+
expect(mockOnChange).toHaveBeenCalledWith([RelationType.AUTHORSHIP, RelationType.REFERENCE]);
146146
});
147147

148-
it('should disable Clear All button when no types selected', () => {
149-
renderComponent([]);
150-
151-
const clearButton = screen.getByTestId('clear-all-button');
152-
expect(clearButton).toBeDisabled();
153-
});
148+
it('should remove type when chip is clicked on selected type', async () => {
149+
const user = userEvent.setup();
150+
const selectedTypes = [RelationType.AUTHORSHIP, RelationType.REFERENCE];
151+
renderComponent(selectedTypes);
154152

155-
it('should disable Select All button when all types selected', () => {
156-
const allTypes = getUniqueTypes();
157-
renderComponent(allTypes);
153+
const authorshipChip = screen.getByTestId(`filter-chip-${RelationType.AUTHORSHIP}`);
154+
await user.click(authorshipChip);
158155

159-
const selectAllButton = screen.getByTestId('select-all-button');
160-
expect(selectAllButton).toBeDisabled();
156+
expect(mockOnChange).toHaveBeenCalledWith([RelationType.REFERENCE]);
161157
});
162158

163159
it('should use custom title when provided', () => {
@@ -173,4 +169,35 @@ describe('RelationshipTypeFilter', () => {
173169

174170
expect(screen.getByText('Custom Filter Title')).toBeInTheDocument();
175171
});
172+
173+
it('should show selection count badge when types are selected', () => {
174+
const selectedTypes = [RelationType.AUTHORSHIP, RelationType.REFERENCE];
175+
renderComponent(selectedTypes);
176+
177+
expect(screen.getByText('2 selected')).toBeInTheDocument();
178+
});
179+
180+
it('should not show selection count when showing all (empty array)', () => {
181+
renderComponent([]);
182+
183+
expect(screen.queryByText(/\d+ selected/)).not.toBeInTheDocument();
184+
});
185+
186+
it('should highlight active preset button via class when All is active', () => {
187+
renderComponent([]);
188+
189+
// When selectedTypes is empty, "All" preset should be active
190+
// Just verify the button exists and is clickable
191+
const allPreset = screen.getByTestId('preset-all');
192+
expect(allPreset).toBeInTheDocument();
193+
});
194+
195+
it('should render category toggle buttons', () => {
196+
renderComponent();
197+
198+
// Core category should be open by default - look for its toggle
199+
const coreToggle = screen.getByTestId('category-toggle-core');
200+
expect(coreToggle).toBeInTheDocument();
201+
expect(coreToggle).toHaveTextContent('Deselect All');
202+
});
176203
});

0 commit comments

Comments
 (0)