Skip to content

Commit 879ba4d

Browse files
committed
fix(web): additional UX improvements for data display and history
- Hide empty fields in nested objects (e.g., empty ORCID values) - Add 'longest_name' to hidden internal fields - Decode HTML entities in displayed text (e.g., & -> &) - Hide empty relationship sections (0 of 0 entries) - Deduplicate concepts by ID in relationship display - Preserve decimal precision for score/share/percentile fields - Prevent history URL/title mismatches with entity ID validation - Prioritize fetched display names over stored titles in history
1 parent b23db52 commit 879ba4d

File tree

7 files changed

+105
-29
lines changed

7 files changed

+105
-29
lines changed

apps/web/src/components/EntityDataDisplay.tsx

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,23 @@ import { humanizeFieldName } from "@/utils/field-labels";
4040
import { formatNumber } from "@/utils/format-number";
4141
import { convertOpenAlexToInternalLink, isOpenAlexId } from "@/utils/openalex-link-conversion";
4242

43+
/**
44+
* Decode HTML entities in text
45+
* Handles common entities like &, <, >, ", etc.
46+
*/
47+
const decodeHtmlEntities = (text: string): string => {
48+
const entities: Record<string, string> = {
49+
"&amp;": "&",
50+
"&lt;": "<",
51+
"&gt;": ">",
52+
"&quot;": '"',
53+
"&#39;": "'",
54+
"&apos;": "'",
55+
"&nbsp;": " ",
56+
};
57+
return text.replaceAll(/&(?:amp|lt|gt|quot|apos|nbsp|#39);/g, (match) => entities[match] ?? match);
58+
};
59+
4360
/** Section priority for consistent ordering */
4461
const SECTION_PRIORITY: Record<string, number> = {
4562
Identifiers: 1,
@@ -148,7 +165,7 @@ const renderPrimitiveValue = (value: unknown, fieldName?: string): import("react
148165
);
149166
}
150167

151-
return <Text size="sm">{value}</Text>;
168+
return <Text size="sm">{decodeHtmlEntities(value)}</Text>;
152169
}
153170

154171
return <Text c="dimmed" fs="italic" size="sm">{String(value)}</Text>;
@@ -200,6 +217,7 @@ const HIDDEN_FIELDS = new Set([
200217
// Other internal fields
201218
"relevance_score",
202219
"filter_key",
220+
"longest_name",
203221
]);
204222

205223
const groupFields = (data: Record<string, unknown>): SectionData[] => {
@@ -306,9 +324,10 @@ const renderValueContent = (value: unknown, fieldName?: string): import("react")
306324

307325
// Objects - key-value pairs
308326
if (typeof value === "object") {
309-
const entries = Object.entries(value as Record<string, unknown>);
327+
const entries = Object.entries(value as Record<string, unknown>)
328+
.filter(([, val]) => isDisplayableValue(val));
310329
if (entries.length === 0) {
311-
return <Text c="dimmed" fs="italic" size="sm">{"{ }"}</Text>;
330+
return null;
312331
}
313332

314333
return (

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

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,12 @@ export const HistoryCard = ({ entry, onClose, formatDate }: HistoryCardProps) =>
3636
// Try to extract title from notes first
3737
const titleFromNotes = entry.notes?.match(/Title: ([^\n]+)/)?.[1];
3838

39-
// Fetch display name from API if not a special ID and no title in notes
39+
// Always try to fetch display name from API for non-special IDs
40+
// This ensures fresh titles even if stale ones were stored in notes
4041
const { displayName, isLoading } = useEntityDisplayName({
4142
entityId: entry.entityId,
4243
entityType: entry.entityType as EntityType,
43-
enabled: !isSpecialId && !titleFromNotes,
44+
enabled: !isSpecialId,
4445
});
4546

4647
// Format entity type for display (e.g., "works" -> "Work", "authors" -> "Author")
@@ -63,13 +64,17 @@ export const HistoryCard = ({ entry, onClose, formatDate }: HistoryCardProps) =>
6364
};
6465

6566
// Determine the title to display
67+
// Priority: fetched displayName > stored titleFromNotes > fallback
68+
// This ensures fresh titles even if stale ones were stored
6669
let title: string;
67-
if (titleFromNotes) {
68-
title = titleFromNotes;
69-
} else if (isSpecialId) {
70+
if (isSpecialId) {
7071
title = entry.entityId.startsWith("search-") ? `Search: ${entry.entityId.replace("search-", "").split("-")[0]}` : `List: ${entry.entityId.replace("list-", "")}`;
7172
} else if (displayName) {
73+
// Prefer freshly fetched display name
7274
title = displayName;
75+
} else if (titleFromNotes) {
76+
// Fall back to stored title from notes
77+
title = titleFromNotes;
7378
} else if (isLoading) {
7479
title = "Loading...";
7580
} else {

apps/web/src/components/relationship/IncomingRelationships.tsx

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -160,15 +160,17 @@ export const IncomingRelationships: React.FC<IncomingRelationshipsProps> = ({
160160
title="Filter Incoming Relationships"
161161
/>
162162

163-
{incoming.map((section) => (
164-
<RelationshipSection
165-
key={section.id}
166-
section={section}
167-
onPageChange={hasApiData ? (page) => goToPage(section.id, page) : undefined}
168-
onPageSizeChange={hasApiData ? (size) => setPageSize(section.id, size) : undefined}
169-
isLoading={hasApiData ? isLoadingMore(section.id) : false}
170-
/>
171-
))}
163+
{incoming
164+
.filter((section) => section.totalCount > 0)
165+
.map((section) => (
166+
<RelationshipSection
167+
key={section.id}
168+
section={section}
169+
onPageChange={hasApiData ? (page) => goToPage(section.id, page) : undefined}
170+
onPageSizeChange={hasApiData ? (size) => setPageSize(section.id, size) : undefined}
171+
isLoading={hasApiData ? isLoadingMore(section.id) : false}
172+
/>
173+
))}
172174
</Stack>
173175
);
174176
};

apps/web/src/components/relationship/OutgoingRelationships.tsx

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -160,15 +160,17 @@ export const OutgoingRelationships: React.FC<OutgoingRelationshipsProps> = ({
160160
title="Filter Outgoing Relationships"
161161
/>
162162

163-
{outgoing.map((section) => (
164-
<RelationshipSection
165-
key={section.id}
166-
section={section}
167-
onPageChange={hasApiData ? (page) => goToPage(section.id, page) : undefined}
168-
onPageSizeChange={hasApiData ? (size) => setPageSize(section.id, size) : undefined}
169-
isLoading={hasApiData ? isLoadingMore(section.id) : false}
170-
/>
171-
))}
163+
{outgoing
164+
.filter((section) => section.totalCount > 0)
165+
.map((section) => (
166+
<RelationshipSection
167+
key={section.id}
168+
section={section}
169+
onPageChange={hasApiData ? (page) => goToPage(section.id, page) : undefined}
170+
onPageSizeChange={hasApiData ? (size) => setPageSize(section.id, size) : undefined}
171+
isLoading={hasApiData ? isLoadingMore(section.id) : false}
172+
/>
173+
))}
172174
</Stack>
173175
);
174176
};

apps/web/src/hooks/use-entity-relationships-from-data.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -468,8 +468,15 @@ const extractWorkRelationships = (data: Record<string, unknown>, workId: string,
468468
}> | undefined;
469469

470470
if (concepts && concepts.length > 0) {
471+
// Deduplicate concepts by ID (OpenAlex API may return duplicates)
472+
const seenConceptIds = new Set<string>();
471473
const conceptItems: RelationshipItem[] = concepts
472-
.filter(concept => concept.id && concept.display_name)
474+
.filter(concept => {
475+
if (!concept.id || !concept.display_name) return false;
476+
if (seenConceptIds.has(concept.id)) return false;
477+
seenConceptIds.add(concept.id);
478+
return true;
479+
})
473480
.map(concept => {
474481
const conceptId = safeStringId(concept.id);
475482
const conceptName = safeStringId(concept.display_name);

apps/web/src/hooks/use-user-interactions.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,16 @@ export const useUserInteractions = (options: UseUserInteractionsOptions = {}): U
243243
}
244244
}, [entityId, entityType, searchQuery, url]);
245245

246+
// Track which entityId a displayName was loaded for to prevent mismatches
247+
const displayNameEntityRef = useRef<string | undefined>(undefined);
248+
249+
// Update displayName entity reference when displayName changes
250+
useEffect(() => {
251+
if (displayName && entityId) {
252+
displayNameEntityRef.current = entityId;
253+
}
254+
}, [displayName, entityId]);
255+
246256
// Auto-track page visits when enabled
247257
useEffect(() => {
248258
if (autoTrackVisits && entityId && entityType) {
@@ -259,12 +269,16 @@ export const useUserInteractions = (options: UseUserInteractionsOptions = {}): U
259269

260270
const currentUrl = location.pathname + serializeSearch(location.search);
261271

272+
// Only pass displayName if it matches the current entityId
273+
// This prevents race conditions where a stale displayName is stored
274+
const safeDisplayName = displayNameEntityRef.current === entityId ? displayName : undefined;
275+
262276
await catalogueService.addToHistory({
263277
entityType: entityType as EntityType,
264278
entityId: entityId,
265279
url: currentUrl,
266-
// Pass display name for proper history titles
267-
title: displayName,
280+
// Pass display name only if it's verified to match this entity
281+
title: safeDisplayName,
268282
});
269283

270284
// Record for debounce

apps/web/src/utils/format-number.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,16 @@ const MAX_REASONABLE_YEAR = 2100;
1616
*/
1717
const YEAR_FIELD_PATTERNS = ["year", "publication_year"] as const;
1818

19+
/**
20+
* Field names that contain decimal scores/shares requiring precision
21+
*/
22+
const DECIMAL_PRECISION_FIELD_PATTERNS = ["score", "share", "percentile", "fwci"] as const;
23+
24+
/**
25+
* Maximum decimal places to show for score/share fields
26+
*/
27+
const DECIMAL_PRECISION_DIGITS = 4;
28+
1929
/**
2030
* Check if a field name indicates a year value
2131
* @param fieldName - The field name to check
@@ -25,6 +35,15 @@ const isYearField = (fieldName: string): boolean => {
2535
return YEAR_FIELD_PATTERNS.some((pattern) => lowerName.includes(pattern));
2636
};
2737

38+
/**
39+
* Check if a field name indicates a score/share value requiring decimal precision
40+
* @param fieldName - The field name to check
41+
*/
42+
const isDecimalPrecisionField = (fieldName: string): boolean => {
43+
const lowerName = fieldName.toLowerCase();
44+
return DECIMAL_PRECISION_FIELD_PATTERNS.some((pattern) => lowerName.includes(pattern));
45+
};
46+
2847
/**
2948
* Check if a number is in a reasonable year range
3049
* @param value - The numeric value to check
@@ -62,6 +81,14 @@ export const formatNumber = (value: number, fieldName?: string): string => {
6281
return value.toString();
6382
}
6483

84+
// For score/share/percentile fields, preserve decimal precision
85+
if (fieldName && isDecimalPrecisionField(fieldName) && !Number.isInteger(value)) {
86+
return value.toLocaleString(undefined, {
87+
minimumFractionDigits: 0,
88+
maximumFractionDigits: DECIMAL_PRECISION_DIGITS,
89+
});
90+
}
91+
6592
// Default: use locale formatting with thousands separator
6693
return value.toLocaleString();
6794
};

0 commit comments

Comments
 (0)