Skip to content

Commit fa2b91c

Browse files
committed
fix(web): resolve 404 relationship errors and improve entity display UX
- Fix 404 errors in relationship queries by correctly parsing filter strings into objects for getSources, getInstitutions, getTopics, getPublishers - Hide null, undefined, empty strings, and empty arrays from entity display - Hide internal API URLs (works_api_url, cited_by_api_url, etc.) - Add field label humanization utility for user-friendly field names - Improve history fallback to show "Work W1234567..." instead of "works: W..." - Apply humanized labels across EntityDataDisplay component
1 parent 1594c23 commit fa2b91c

File tree

4 files changed

+251
-10
lines changed

4 files changed

+251
-10
lines changed

apps/web/src/components/EntityDataDisplay.tsx

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import { Link } from "@tanstack/react-router";
3636

3737
import { ICON_SIZE } from "@/config/style-constants";
3838
import { useVersionComparison } from "@/hooks/use-version-comparison";
39+
import { humanizeFieldName } from "@/utils/field-labels";
3940
import { convertOpenAlexToInternalLink, isOpenAlexId } from "@/utils/openalex-link-conversion";
4041

4142
/** Section priority for consistent ordering */
@@ -65,8 +66,9 @@ const SECTION_ICONS: Record<string, import("react").ReactNode> = {
6566
// ============================================================================
6667

6768
const renderPrimitiveValue = (value: unknown): import("react").ReactNode => {
69+
// Don't render null/undefined - these fields will be filtered out
6870
if (value === null || value === undefined) {
69-
return <Text c="dimmed" fs="italic" size="sm">null</Text>;
71+
return null;
7072
}
7173

7274
if (typeof value === "boolean") {
@@ -161,6 +163,29 @@ interface SectionData {
161163
icon: import("react").ReactNode;
162164
}
163165

166+
/**
167+
* Check if a value should be displayed (not null, undefined, empty string, or empty array)
168+
*/
169+
const isDisplayableValue = (value: unknown): boolean => {
170+
if (value === null || value === undefined) return false;
171+
if (typeof value === "string" && value.trim() === "") return false;
172+
if (Array.isArray(value) && value.length === 0) return false;
173+
return true;
174+
};
175+
176+
/**
177+
* Fields that should be hidden from display (internal API fields, URLs, etc.)
178+
*/
179+
const HIDDEN_FIELDS = new Set([
180+
"works_api_url",
181+
"cited_by_api_url",
182+
"updated_date",
183+
"created_date",
184+
"ngrams_url",
185+
"abstract_inverted_index",
186+
"indexed_in",
187+
]);
188+
164189
const groupFields = (data: Record<string, unknown>): SectionData[] => {
165190
const groups: Record<string, Record<string, unknown>> = {
166191
"Identifiers": {},
@@ -175,11 +200,16 @@ const groupFields = (data: Record<string, unknown>): SectionData[] => {
175200
const identifierKeys = ["id", "ids", "doi", "orcid", "issn", "ror", "mag", "openalex_id", "pmid", "pmcid"];
176201
const metricKeys = ["cited_by_count", "works_count", "h_index", "i10_index", "counts_by_year", "summary_stats", "fwci", "citation_normalized_percentile", "cited_by_percentile_year"];
177202
const relationshipKeys = ["authorships", "institutions", "concepts", "topics", "keywords", "grants", "sustainable_development_goals", "mesh", "affiliations", "last_known_institutions", "primary_location", "locations", "best_oa_location", "alternate_host_venues", "x_concepts"];
178-
const dateKeys = ["created_date", "updated_date", "publication_date", "publication_year"];
203+
const dateKeys = ["publication_date", "publication_year"];
179204
const geoKeys = ["country_code", "countries_distinct_count", "geo", "latitude", "longitude"];
180205
const basicKeys = ["display_name", "title", "type", "description", "homepage_url", "image_url", "thumbnail_url", "is_oa", "oa_status", "has_fulltext"];
181206

182207
Object.entries(data).forEach(([key, value]) => {
208+
// Skip hidden fields and non-displayable values
209+
if (HIDDEN_FIELDS.has(key) || !isDisplayableValue(value)) {
210+
return;
211+
}
212+
183213
const lowerKey = key.toLowerCase();
184214
if (identifierKeys.some(k => lowerKey.includes(k))) {
185215
groups["Identifiers"][key] = value;
@@ -220,10 +250,10 @@ const renderValueContent = (value: unknown): import("react").ReactNode => {
220250
return renderPrimitiveValue(value);
221251
}
222252

223-
// Arrays
253+
// Arrays - don't render empty arrays
224254
if (Array.isArray(value)) {
225255
if (value.length === 0) {
226-
return <Text c="dimmed" fs="italic" size="sm">[ ]</Text>;
256+
return null;
227257
}
228258

229259
// Primitive arrays - inline badges
@@ -336,7 +366,7 @@ export const EntityDataDisplay = ({ data, title }: EntityDataDisplayProps) => {
336366
{section.fields.map((field) => (
337367
<Box key={field.key}>
338368
<Text size="sm" fw={600} c="blue.7" mb="xs">
339-
{field.key.replaceAll('_', ' ').replaceAll(/\b\w/g, l => l.toUpperCase())}
369+
{humanizeFieldName(field.key)}
340370
</Text>
341371
{renderValueContent(field.value)}
342372
</Box>

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

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,25 @@ export const HistoryCard = ({ entry, onClose, formatDate }: HistoryCardProps) =>
4343
enabled: !isSpecialId && !titleFromNotes,
4444
});
4545

46+
// Format entity type for display (e.g., "works" -> "Work", "authors" -> "Author")
47+
const formatEntityType = (entityType: string): string => {
48+
const singular = entityType.endsWith("s") ? entityType.slice(0, -1) : entityType;
49+
return singular.charAt(0).toUpperCase() + singular.slice(1);
50+
};
51+
52+
// Format entity ID for display (shortened if it's a long ID)
53+
const formatEntityId = (entityId: string): string => {
54+
// Extract just the ID part (e.g., "W123456789" from URL or keep as-is)
55+
const idMatch = entityId.match(/([A-Z]\d+)$/);
56+
if (idMatch) {
57+
const id = idMatch[1];
58+
// Show first letter + first few digits for readability
59+
return id.length > 8 ? `${id.substring(0, 8)}...` : id;
60+
}
61+
// For other IDs, truncate if too long
62+
return entityId.length > 15 ? `${entityId.substring(0, 15)}...` : entityId;
63+
};
64+
4665
// Determine the title to display
4766
let title: string;
4867
if (titleFromNotes) {
@@ -54,7 +73,8 @@ export const HistoryCard = ({ entry, onClose, formatDate }: HistoryCardProps) =>
5473
} else if (isLoading) {
5574
title = "Loading...";
5675
} else {
57-
title = `${entry.entityType}: ${entry.entityId}`;
76+
// Improved fallback: "Work W1234567..." instead of "works: W123456789012345"
77+
title = `${formatEntityType(entry.entityType)} ${formatEntityId(entry.entityId)}`;
5878
}
5979

6080
// Compute link URL - for special IDs, try to extract from notes; otherwise use entity path

apps/web/src/hooks/use-entity-relationship-queries.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,20 @@ export const useEntityRelationshipQueries = (entityId: string | undefined, entit
367367
};
368368
};
369369

370+
/**
371+
* Parse a filter string like "key:value" into an object { key: value }
372+
* Used to convert filter strings for API functions that expect filter objects
373+
* @param filterString - Filter string in format "key:value"
374+
* @returns Object with the key-value pair
375+
*/
376+
const parseFilterStringToObject = (filterString: string): Record<string, string> => {
377+
const colonIndex = filterString.indexOf(':');
378+
if (colonIndex === -1) return {};
379+
const key = filterString.substring(0, colonIndex);
380+
const value = filterString.substring(colonIndex + 1);
381+
return { [key]: value };
382+
};
383+
370384
/**
371385
* Execute a single relationship query using the OpenAlex API or embedded data extraction
372386
* @param entityId
@@ -382,6 +396,8 @@ const executeRelationshipQuery = async (entityId: string, entityType: EntityType
382396
const pageSize = customPageSize ?? config.pageSize ?? DEFAULT_PAGE_SIZE;
383397

384398
// Choose the appropriate API function based on target type
399+
// Note: Some API functions accept `filter` (string), others accept `filters` (object)
400+
// For functions expecting `filters` object, we parse the filter string into an object
385401
let response;
386402
switch (config.targetType) {
387403
case 'works':
@@ -402,31 +418,31 @@ const executeRelationshipQuery = async (entityId: string, entityType: EntityType
402418
break;
403419
case 'sources':
404420
response = await getSources({
405-
filters: { id: filter }, // getSources uses 'filters' object, not 'filter' string
421+
filters: parseFilterStringToObject(filter),
406422
per_page: pageSize,
407423
page,
408424
...(config.select && { select: config.select }),
409425
});
410426
break;
411427
case 'institutions':
412428
response = await getInstitutions({
413-
filters: { id: filter }, // getInstitutions uses 'filters' object, not 'filter' string
429+
filters: parseFilterStringToObject(filter),
414430
per_page: pageSize,
415431
page,
416432
...(config.select && { select: config.select }),
417433
});
418434
break;
419435
case 'topics':
420436
response = await getTopics({
421-
filters: { id: filter },
437+
filters: parseFilterStringToObject(filter),
422438
per_page: pageSize,
423439
page,
424440
...(config.select && { select: config.select }),
425441
});
426442
break;
427443
case 'publishers':
428444
response = await getPublishers({
429-
filters: { id: filter },
445+
filters: parseFilterStringToObject(filter),
430446
per_page: pageSize,
431447
page,
432448
...(config.select && { select: config.select }),

apps/web/src/utils/field-labels.ts

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
/**
2+
* Field Label Humanization Utility
3+
*
4+
* Provides human-readable labels for OpenAlex API field names.
5+
* This centralizes the mapping to ensure consistent labeling across the UI.
6+
*/
7+
8+
/**
9+
* Mapping of API field names to human-readable labels.
10+
* Keys are lowercase to enable case-insensitive matching.
11+
*/
12+
const FIELD_LABELS: Record<string, string> = {
13+
// Identifiers
14+
id: "ID",
15+
ids: "External IDs",
16+
doi: "DOI",
17+
orcid: "ORCID",
18+
ror: "ROR ID",
19+
mag: "MAG ID",
20+
openalex_id: "OpenAlex ID",
21+
pmid: "PubMed ID",
22+
pmcid: "PMC ID",
23+
issn: "ISSN",
24+
issn_l: "ISSN-L",
25+
wikidata: "Wikidata",
26+
wikipedia: "Wikipedia",
27+
28+
// Basic Information
29+
display_name: "Name",
30+
display_name_alternatives: "Name Alternatives",
31+
title: "Title",
32+
type: "Type",
33+
type_crossref: "Type (Crossref)",
34+
description: "Description",
35+
homepage_url: "Homepage",
36+
image_url: "Image URL",
37+
thumbnail_url: "Thumbnail",
38+
39+
// Metrics
40+
cited_by_count: "Times Cited",
41+
works_count: "Works Count",
42+
h_index: "H-Index",
43+
i10_index: "i10-Index",
44+
"2yr_mean_citedness": "2-Year Mean Citedness",
45+
"2yr_cited_by_count": "2-Year Citation Count",
46+
"2yr_works_count": "2-Year Works Count",
47+
"2yr_i10_index": "2-Year i10-Index",
48+
"2yr_h_index": "2-Year H-Index",
49+
fwci: "Field-Weighted Citation Impact",
50+
citation_normalized_percentile: "Citation Percentile",
51+
cited_by_percentile_year: "Citation Percentile (Year)",
52+
summary_stats: "Summary Statistics",
53+
counts_by_year: "Counts By Year",
54+
55+
// Open Access
56+
is_oa: "Open Access",
57+
oa_status: "OA Status",
58+
oa_url: "OA URL",
59+
has_fulltext: "Has Full Text",
60+
is_in_doaj: "In DOAJ",
61+
apc_list: "APC List Price",
62+
apc_paid: "APC Paid",
63+
64+
// Dates
65+
publication_date: "Publication Date",
66+
publication_year: "Publication Year",
67+
68+
// Geographic
69+
country_code: "Country Code",
70+
countries_distinct_count: "Countries",
71+
geo: "Geographic Data",
72+
latitude: "Latitude",
73+
longitude: "Longitude",
74+
city: "City",
75+
region: "Region",
76+
77+
// Authorship & Affiliations
78+
authorships: "Authors",
79+
institutions: "Institutions",
80+
affiliations: "Affiliations",
81+
last_known_institutions: "Last Known Institutions",
82+
last_known_institution: "Last Known Institution",
83+
corresponding_author_ids: "Corresponding Authors",
84+
corresponding_institution_ids: "Corresponding Institutions",
85+
86+
// Content & Classification
87+
concepts: "Concepts",
88+
topics: "Topics",
89+
keywords: "Keywords",
90+
mesh: "MeSH Terms",
91+
sustainable_development_goals: "UN SDGs",
92+
grants: "Grants",
93+
referenced_works: "References",
94+
related_works: "Related Works",
95+
cited_by_api_url: "Cited By",
96+
97+
// Locations
98+
primary_location: "Primary Location",
99+
locations: "Locations",
100+
best_oa_location: "Best OA Location",
101+
alternate_host_venues: "Alternate Venues",
102+
host_venue: "Host Venue",
103+
104+
// Source-specific
105+
publisher: "Publisher",
106+
host_organization: "Host Organization",
107+
host_organization_name: "Host Organization Name",
108+
host_organization_lineage: "Host Organization Lineage",
109+
lineage: "Lineage",
110+
associated_institutions: "Associated Institutions",
111+
112+
// Work-specific
113+
abstract_inverted_index: "Abstract",
114+
biblio: "Bibliographic Info",
115+
language: "Language",
116+
license: "License",
117+
version: "Version",
118+
is_retracted: "Retracted",
119+
is_paratext: "Is Paratext",
120+
121+
// Other
122+
x_concepts: "Concepts (Legacy)",
123+
relevance_score: "Relevance Score",
124+
works: "Works",
125+
};
126+
127+
/**
128+
* Convert an API field name to a human-readable label.
129+
* Uses the predefined mapping or falls back to automatic formatting.
130+
*
131+
* @param key - The API field name (e.g., "2yr_mean_citedness", "cited_by_count")
132+
* @returns Human-readable label (e.g., "2-Year Mean Citedness", "Times Cited")
133+
*/
134+
export function humanizeFieldName(key: string): string {
135+
// Check for exact match (case-insensitive)
136+
const lowerKey = key.toLowerCase();
137+
if (lowerKey in FIELD_LABELS) {
138+
return FIELD_LABELS[lowerKey];
139+
}
140+
141+
// Fallback: convert snake_case to Title Case with special handling
142+
return key
143+
// Handle year prefix patterns like "2yr_" -> "2-Year "
144+
.replace(/^(\d+)yr_/i, "$1-Year ")
145+
// Replace underscores with spaces
146+
.replaceAll("_", " ")
147+
// Capitalize first letter of each word
148+
.replace(/\b\w/g, (l) => l.toUpperCase())
149+
// Handle common abbreviations that should stay uppercase
150+
.replace(/\bId\b/g, "ID")
151+
.replace(/\bDoi\b/g, "DOI")
152+
.replace(/\bOrcid\b/g, "ORCID")
153+
.replace(/\bRor\b/g, "ROR")
154+
.replace(/\bOa\b/g, "OA")
155+
.replace(/\bApi\b/g, "API")
156+
.replace(/\bUrl\b/g, "URL");
157+
}
158+
159+
/**
160+
* Get label for a field, with optional custom overrides.
161+
*
162+
* @param key - The API field name
163+
* @param customLabels - Optional custom label overrides
164+
* @returns Human-readable label
165+
*/
166+
export function getFieldLabel(
167+
key: string,
168+
customLabels?: Record<string, string>
169+
): string {
170+
// Check custom labels first
171+
if (customLabels && key in customLabels) {
172+
return customLabels[key];
173+
}
174+
return humanizeFieldName(key);
175+
}

0 commit comments

Comments
 (0)