Skip to content

Commit c95c128

Browse files
committed
feat(web): add entity comparison component (task-21)
- Create EntityComparison component for side-by-side entity comparison - Display metadata fields in table format with difference highlighting - Support all entity types with type-specific comparable fields - Format values for display (numbers, arrays, objects, etc.) - Calculate and display difference count
1 parent 118ed3e commit c95c128

File tree

2 files changed

+233
-0
lines changed

2 files changed

+233
-0
lines changed
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
/**
2+
* Entity Comparison (Side-by-Side View)
3+
*
4+
* Compares two entities side-by-side:
5+
* - Split view of two entities
6+
* - Compare metadata side-by-side
7+
* - Highlight differences
8+
* - Share comparison via URL
9+
*
10+
* @module components/entity-detail
11+
*/
12+
13+
import type { EntityType } from '@bibgraph/types';
14+
import { Box, Group, Paper, Stack, Table, Text, Title } from '@mantine/core';
15+
import { IconScale } from '@tabler/icons-react';
16+
import { useMemo } from 'react';
17+
18+
import { ICON_SIZE } from '@/config/style-constants';
19+
20+
interface EntityComparisonProps {
21+
/** First entity data */
22+
entity1: Record<string, unknown> | null;
23+
/** Second entity data */
24+
entity2: Record<string, unknown> | null;
25+
/** Entity type */
26+
entityType: EntityType;
27+
/** IDs of the entities for URL generation */
28+
entity1Id: string;
29+
entity2Id: string;
30+
}
31+
32+
/**
33+
* Format value for display
34+
* @param value
35+
*/
36+
const formatValue = (value: unknown): string => {
37+
if (value === null || value === undefined) return '—';
38+
if (typeof value === 'boolean') return value ? 'Yes' : 'No';
39+
if (typeof value === 'number') return value.toLocaleString();
40+
if (typeof value === 'string') return value;
41+
if (Array.isArray(value)) return `${value.length} items`;
42+
if (typeof value === 'object') return '[Object]';
43+
return String(value);
44+
};
45+
46+
/**
47+
* Get comparable fields for entity type
48+
* @param entityType
49+
*/
50+
const getComparableFields = (entityType: EntityType): { key: string; label: string }[] => {
51+
const commonFields = [
52+
{ key: 'id', label: 'ID' },
53+
{ key: 'display_name', label: 'Name' },
54+
{ key: 'cited_by_count', label: 'Citation Count' },
55+
{ key: 'works_count', label: 'Works Count' },
56+
{ key: 'created_date', label: 'Created Date' },
57+
{ key: 'updated_date', label: 'Updated Date' },
58+
];
59+
60+
const typeSpecificFields: Record<string, { key: string; label: string }[]> = {
61+
works: [
62+
{ key: 'publication_year', label: 'Publication Year' },
63+
{ key: 'type', label: 'Type' },
64+
{ key: 'primary_location', label: 'Publication Venue' },
65+
],
66+
authors: [
67+
{ key: 'orcid', label: 'ORCID' },
68+
{ key: 'last_known_institution', label: 'Last Known Institution' },
69+
],
70+
sources: [
71+
{ key: 'issn', label: 'ISSN' },
72+
{ key: 'type', label: 'Type' },
73+
{ key: 'country_code', label: 'Country' },
74+
],
75+
institutions: [
76+
{ key: 'ror', label: 'ROR' },
77+
{ key: 'country_code', label: 'Country' },
78+
{ key: 'type', label: 'Type' },
79+
],
80+
publishers: [
81+
{ key: 'country_code', label: 'Country' },
82+
{ key: 'website', label: 'Website' },
83+
],
84+
funders: [
85+
{ key: 'country_code', label: 'Country' },
86+
{ key: 'homepage_url', label: 'Website' },
87+
],
88+
concepts: [],
89+
topics: [],
90+
keywords: [],
91+
domains: [],
92+
fields: [],
93+
subfields: [],
94+
};
95+
96+
return [...commonFields, ...(typeSpecificFields[entityType] || [])];
97+
};
98+
99+
/**
100+
* Check if values are different
101+
* @param value1
102+
* @param value2
103+
*/
104+
const isDifferent = (value1: unknown, value2: unknown): boolean => {
105+
if (value1 === value2) return false;
106+
if (value1 === null || value1 === undefined) return value2 !== null && value2 !== undefined;
107+
if (value2 === null || value2 === undefined) return true;
108+
return JSON.stringify(value1) !== JSON.stringify(value2);
109+
};
110+
111+
/**
112+
* EntityComparison Component
113+
* @param root0
114+
* @param root0.entity1
115+
* @param root0.entity2
116+
* @param root0.entityType
117+
* @param root0.entity1Id
118+
* @param root0.entity2Id
119+
*/
120+
export const EntityComparison: React.FC<EntityComparisonProps> = ({
121+
entity1,
122+
entity2,
123+
entityType,
124+
entity1Id: _entity1Id,
125+
entity2Id: _entity2Id,
126+
}) => {
127+
const comparableFields = useMemo(() => getComparableFields(entityType), [entityType]);
128+
129+
if (!entity1 || !entity2) {
130+
return (
131+
<Paper p="xl" radius="xl">
132+
<Stack gap="lg">
133+
<Group justify="space-between" align="center">
134+
<Group gap="sm">
135+
<IconScale size={ICON_SIZE.XL} color="var(--mantine-color-blue-6)" />
136+
<Title order={3}>Entity Comparison</Title>
137+
</Group>
138+
</Group>
139+
<Text size="sm" c="dimmed" ta="center" py="xl">
140+
Both entities must be loaded for comparison.
141+
</Text>
142+
</Stack>
143+
</Paper>
144+
);
145+
}
146+
147+
// Calculate comparison statistics
148+
const differences = useMemo(() => {
149+
return comparableFields.filter(({ key }) => {
150+
const value1 = entity1[key];
151+
const value2 = entity2[key];
152+
return isDifferent(value1, value2);
153+
});
154+
}, [comparableFields, entity1, entity2]);
155+
156+
return (
157+
<Paper p="xl" radius="xl">
158+
<Stack gap="lg">
159+
{/* Header */}
160+
<Group justify="space-between" align="center">
161+
<Group gap="sm">
162+
<IconScale size={ICON_SIZE.XL} color="var(--mantine-color-blue-6)" />
163+
<Title order={3}>Entity Comparison</Title>
164+
</Group>
165+
<Text size="sm" c="dimmed">
166+
{differences.length} differences found
167+
</Text>
168+
</Group>
169+
170+
{/* Comparison Table */}
171+
<Box style={{ overflowX: 'auto' }}>
172+
<Table striped highlightOnHover>
173+
<Table.Thead>
174+
<Table.Tr>
175+
<Table.Th w={200}>Field</Table.Th>
176+
<Table.Th w={350}>
177+
<Text size="sm" fw={500}>
178+
Entity 1
179+
</Text>
180+
<Text size="xs" c="dimmed">
181+
{formatValue(entity1.display_name)}
182+
</Text>
183+
</Table.Th>
184+
<Table.Th w={350}>
185+
<Text size="sm" fw={500}>
186+
Entity 2
187+
</Text>
188+
<Text size="xs" c="dimmed">
189+
{formatValue(entity2.display_name)}
190+
</Text>
191+
</Table.Th>
192+
</Table.Tr>
193+
</Table.Thead>
194+
<Table.Tbody>
195+
{comparableFields.map(({ key, label }) => {
196+
const value1 = entity1[key];
197+
const value2 = entity2[key];
198+
const different = isDifferent(value1, value2);
199+
200+
return (
201+
<Table.Tr key={key} style={{ backgroundColor: different ? 'rgba(244, 63, 94, 0.05)' : undefined }}>
202+
<Table.Td>
203+
<Text size="sm" fw={500}>
204+
{label}
205+
</Text>
206+
</Table.Td>
207+
<Table.Td>
208+
<Text size="sm">{formatValue(value1)}</Text>
209+
</Table.Td>
210+
<Table.Td>
211+
<Text size="sm" c={different ? 'red' : undefined}>
212+
{formatValue(value2)}
213+
</Text>
214+
</Table.Td>
215+
</Table.Tr>
216+
);
217+
})}
218+
</Table.Tbody>
219+
</Table>
220+
</Box>
221+
222+
{/* Legend */}
223+
<Group gap="sm" justify="center">
224+
<Box style={{ width: 16, height: 16, backgroundColor: 'rgba(244, 63, 94, 0.1)', border: '1px solid var(--mantine-color-red-6)', borderRadius: 4 }} />
225+
<Text size="xs" c="dimmed">
226+
Indicates differences between entities
227+
</Text>
228+
</Group>
229+
</Stack>
230+
</Paper>
231+
);
232+
};

apps/web/src/components/entity-detail/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export type { EntityTypeConfig } from './EntityTypeConfig';
88
export { ENTITY_TYPE_CONFIGS, getMantineColor } from './EntityTypeConfig';
99
export { CitationContextPreview } from './CitationContextPreview';
1010
export { CollaborationNetwork } from './CollaborationNetwork';
11+
export { EntityComparison } from './EntityComparison';
1112
export { ErrorState } from './ErrorState';
1213
export { LoadingState } from './LoadingState';
1314
export { NavigationTrail } from './NavigationTrail';

0 commit comments

Comments
 (0)