Skip to content

Commit 68dba0e

Browse files
committed
feat(web): add list merging functionality (task-25)
- Created ListMerge component with merge strategies (union, intersection, combine) - Integrated merge modal into CatalogueManager with menu item - Implemented merge logic in useCatalogue hook using basic storage operations - Added type guard for list ID filtering to fix TypeScript errors - Fixed pre-existing lint error in hierarchical-layout.ts (duplicated branches) - Removed unused mergeLists method from CatalogueService Merge strategies: - Union: Include all unique entities from all lists - Intersection: Include only entities appearing in ALL selected lists - Combine: Include all entities from all lists, keeping duplicates - Optional deduplication for union/combine strategies
1 parent 707db98 commit 68dba0e

File tree

4 files changed

+410
-7
lines changed

4 files changed

+410
-7
lines changed

apps/web/src/components/catalogue/CatalogueManager.tsx

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
IconDatabase,
3232
IconDownload,
3333
IconEdit,
34+
IconGitMerge,
3435
IconList,
3536
IconPlus,
3637
IconSearch,
@@ -46,6 +47,7 @@ import { CatalogueListComponent } from "@/components/catalogue/CatalogueList";
4647
import { CreateListModal } from "@/components/catalogue/CreateListModal";
4748
import { ExportModal } from "@/components/catalogue/ExportModal";
4849
import { ImportModal } from "@/components/catalogue/ImportModal";
50+
import { ListMerge } from "@/components/catalogue/ListMerge";
4951
import type { ListTemplate } from "@/components/catalogue/ListTemplates";
5052
import { ListTemplates } from "@/components/catalogue/ListTemplates";
5153
import { ShareModal } from "@/components/catalogue/ShareModal";
@@ -73,6 +75,7 @@ export const CatalogueManager = ({ onNavigate, shareData, initialListId }: Catal
7375
generateShareUrl,
7476
importFromShareUrl,
7577
getListStats,
78+
mergeLists,
7679
} = useCatalogueContext();
7780

7881
const navigate = useNavigate();
@@ -85,6 +88,7 @@ export const CatalogueManager = ({ onNavigate, shareData, initialListId }: Catal
8588
const [showShareModal, setShowShareModal] = useState(false);
8689
const [showImportModal, setShowImportModal] = useState(false);
8790
const [showExportModal, setShowExportModal] = useState(false);
91+
const [showMergeModal, setShowMergeModal] = useState(false);
8892
const [shareUrl, setShareUrl] = useState<string>("");
8993
const [searchQuery, setSearchQuery] = useState("");
9094
const [showSystemCatalogues, setShowSystemCatalogues] = useState(false);
@@ -240,6 +244,34 @@ export const CatalogueManager = ({ onNavigate, shareData, initialListId }: Catal
240244
});
241245
};
242246

247+
// Handle merge lists
248+
const handleMergeLists = async (
249+
sourceListIds: string[],
250+
mergeStrategy: 'union' | 'intersection' | 'combine',
251+
newListName: string,
252+
deduplicate: boolean
253+
) => {
254+
const mergedListId = await mergeLists(
255+
sourceListIds,
256+
mergeStrategy,
257+
newListName,
258+
deduplicate
259+
);
260+
261+
setActiveTab('lists');
262+
setShowMergeModal(false);
263+
264+
logger.info("catalogue-ui", "Lists merged successfully", {
265+
sourceListIds,
266+
mergeStrategy,
267+
mergedListId,
268+
newListName,
269+
deduplicate,
270+
});
271+
272+
return mergedListId;
273+
};
274+
243275
// Handle create list from template or custom
244276
const handleCreateList = async (params: {
245277
title: string;
@@ -333,6 +365,12 @@ export const CatalogueManager = ({ onNavigate, shareData, initialListId }: Catal
333365
>
334366
Smart Lists
335367
</Menu.Item>
368+
<Menu.Item
369+
leftSection={<IconGitMerge size={14} />}
370+
onClick={() => setShowMergeModal(true)}
371+
>
372+
Merge Lists
373+
</Menu.Item>
336374
<Menu.Item
337375
leftSection={<IconPlus size={14} />}
338376
onClick={() => setShowCreateModal(true)}
@@ -574,6 +612,21 @@ export const CatalogueManager = ({ onNavigate, shareData, initialListId }: Catal
574612
/>
575613
</Modal>
576614

615+
<Modal
616+
opened={showMergeModal}
617+
onClose={() => setShowMergeModal(false)}
618+
title="Merge Lists"
619+
size="lg"
620+
trapFocus
621+
returnFocus
622+
>
623+
<ListMerge
624+
lists={lists}
625+
onMerge={handleMergeLists}
626+
onClose={() => setShowMergeModal(false)}
627+
/>
628+
</Modal>
629+
577630
<Modal
578631
opened={showShareModal}
579632
onClose={() => setShowShareModal(false)}
Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
/**
2+
* List Merge component
3+
* Allows merging multiple lists with different strategies
4+
*/
5+
6+
import type { CatalogueList } from "@bibgraph/utils";
7+
import {
8+
Badge,
9+
Box,
10+
Button,
11+
Card,
12+
Checkbox,
13+
Group,
14+
Radio,
15+
ScrollArea,
16+
Stack,
17+
Text,
18+
TextInput,
19+
Title,
20+
} from "@mantine/core";
21+
import {
22+
IconCheck,
23+
} from "@tabler/icons-react";
24+
import React, { useState } from "react";
25+
26+
import { ICON_SIZE } from '@/config/style-constants';
27+
28+
export type MergeStrategy = 'union' | 'intersection' | 'combine';
29+
30+
interface ListMergeProps {
31+
lists: CatalogueList[];
32+
onMerge: (
33+
sourceListIds: string[],
34+
mergeStrategy: MergeStrategy,
35+
newListName: string,
36+
deduplicate: boolean
37+
) => Promise<string>;
38+
onClose: () => void;
39+
}
40+
41+
const MERGE_STRATEGIES: {
42+
value: MergeStrategy;
43+
label: string;
44+
description: string;
45+
}[] = [
46+
{
47+
value: 'union',
48+
label: 'Union (All Unique)',
49+
description: 'Include all entities from all lists, removing duplicates',
50+
},
51+
{
52+
value: 'intersection',
53+
label: 'Intersection (Common)',
54+
description: 'Include only entities that appear in ALL selected lists',
55+
},
56+
{
57+
value: 'combine',
58+
label: 'Combine All',
59+
description: 'Include all entities from all lists, keeping duplicates',
60+
},
61+
];
62+
63+
export const ListMerge = ({ lists, onMerge, onClose }: ListMergeProps) => {
64+
const [selectedListIds, setSelectedListIds] = useState<string[]>([]);
65+
const [mergeStrategy, setMergeStrategy] = useState<MergeStrategy>('union');
66+
const [deduplicate, setDeduplicate] = useState(true);
67+
const [newListName, setNewListName] = useState('');
68+
const [isSubmitting, setIsSubmitting] = useState(false);
69+
const [error, setError] = useState<string | null>(null);
70+
71+
const isFormValid = selectedListIds.length >= 2 && newListName.trim().length > 0;
72+
73+
// Type guard to filter lists with IDs
74+
const listHasId = (list: CatalogueList): list is CatalogueList & { id: string } => {
75+
return list.id !== undefined;
76+
};
77+
78+
const handleToggleList = (listId: string) => {
79+
setSelectedListIds((prev) =>
80+
prev.includes(listId)
81+
? prev.filter((id) => id !== listId)
82+
: [...prev, listId]
83+
);
84+
setError(null);
85+
};
86+
87+
const handleSubmit = async () => {
88+
if (!isFormValid) return;
89+
90+
setIsSubmitting(true);
91+
setError(null);
92+
93+
try {
94+
await onMerge(selectedListIds, mergeStrategy, newListName.trim(), deduplicate);
95+
onClose();
96+
} catch (err) {
97+
const errorMessage = err instanceof Error ? err.message : 'Failed to merge lists';
98+
setError(errorMessage);
99+
} finally {
100+
setIsSubmitting(false);
101+
}
102+
};
103+
104+
const sortedLists = [...lists].filter(listHasId).sort((a, b) =>
105+
a.title.localeCompare(b.title)
106+
);
107+
108+
return (
109+
<Stack gap="lg">
110+
{/* Header */}
111+
<Group justify="space-between" align="flex-start">
112+
<div>
113+
<Title order={3}>Merge Lists</Title>
114+
<Text size="sm" c="dimmed">
115+
Combine multiple lists into one
116+
</Text>
117+
</div>
118+
</Group>
119+
120+
{/* Error Alert */}
121+
{error && (
122+
<Box c="red.9" bg="red.0" p="md" style={{ borderRadius: '4px' }}>
123+
<Text size="sm">{error}</Text>
124+
</Box>
125+
)}
126+
127+
{/* List Selection */}
128+
<Box>
129+
<Text fw={500} mb="xs">
130+
Select Lists to Merge (minimum 2)
131+
</Text>
132+
<ScrollArea.Autosize mah={200}>
133+
<Stack gap="xs">
134+
{sortedLists.map((list) => (
135+
<Card
136+
key={list.id}
137+
padding="xs"
138+
radius="sm"
139+
withBorder
140+
onClick={() => handleToggleList(list.id)}
141+
style={{
142+
cursor: 'pointer',
143+
borderColor: selectedListIds.includes(list.id)
144+
? 'var(--mantine-color-blue-5)'
145+
: undefined,
146+
}}
147+
>
148+
<Group justify="space-between">
149+
<Group gap="xs">
150+
<Checkbox
151+
checked={selectedListIds.includes(list.id)}
152+
onChange={() => {}}
153+
onClick={(e) => {
154+
e.stopPropagation();
155+
handleToggleList(list.id);
156+
}}
157+
/>
158+
<Box>
159+
<Text size="sm" fw={500}>
160+
{list.title}
161+
</Text>
162+
{list.description && (
163+
<Text size="xs" c="dimmed">
164+
{list.description}
165+
</Text>
166+
)}
167+
</Box>
168+
</Group>
169+
<Badge size="xs" variant="light">
170+
{list.type}
171+
</Badge>
172+
</Group>
173+
</Card>
174+
))}
175+
</Stack>
176+
</ScrollArea.Autosize>
177+
</Box>
178+
179+
{/* Merge Strategy */}
180+
<Box>
181+
<Text fw={500} mb="xs">Merge Strategy</Text>
182+
<Radio.Group
183+
value={mergeStrategy}
184+
onChange={(value) => setMergeStrategy(value as MergeStrategy)}
185+
>
186+
<Stack gap="sm">
187+
{MERGE_STRATEGIES.map((strategy) => (
188+
<Radio
189+
key={strategy.value}
190+
value={strategy.value}
191+
label={strategy.label}
192+
description={strategy.description}
193+
/>
194+
))}
195+
</Stack>
196+
</Radio.Group>
197+
</Box>
198+
199+
{/* Deduplicate Option */}
200+
<Checkbox
201+
label="Remove duplicate entities"
202+
description="When enabled, entities appearing multiple times will be deduplicated"
203+
checked={deduplicate}
204+
onChange={(e) => setDeduplicate(e.currentTarget.checked)}
205+
disabled={mergeStrategy === 'intersection'} // Intersection already deduplicates
206+
/>
207+
208+
{/* New List Name */}
209+
<TextInput
210+
label="New List Name"
211+
placeholder="Merged List"
212+
value={newListName}
213+
onChange={(e) => setNewListName(e.target.value)}
214+
required
215+
/>
216+
217+
{/* Selection Summary */}
218+
{selectedListIds.length > 0 && (
219+
<Box c="blue" bg="blue.0" p="xs" style={{ borderRadius: '4px' }}>
220+
<Text size="sm">
221+
{selectedListIds.length} list{selectedListIds.length !== 1 ? 's' : ''} selected
222+
</Text>
223+
</Box>
224+
)}
225+
226+
{/* Actions */}
227+
<Group justify="flex-end" gap="xs">
228+
<Button
229+
variant="subtle"
230+
onClick={onClose}
231+
disabled={isSubmitting}
232+
>
233+
Cancel
234+
</Button>
235+
<Button
236+
onClick={handleSubmit}
237+
disabled={!isFormValid || isSubmitting}
238+
loading={isSubmitting}
239+
leftSection={!isSubmitting && <IconCheck size={ICON_SIZE.MD} />}
240+
>
241+
Merge Lists
242+
</Button>
243+
</Group>
244+
</Stack>
245+
);
246+
};

0 commit comments

Comments
 (0)