@weng-lab/genomebrowser-ui 0.1.11 → 0.2.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/.env.local +1 -0
  2. package/dist/TrackSelect/DataGrid/DefaultGroupingCell.d.ts +6 -0
  3. package/dist/TrackSelect/FolderList/Breadcrumb.d.ts +6 -0
  4. package/dist/TrackSelect/FolderList/FolderCard.d.ts +6 -0
  5. package/dist/TrackSelect/FolderList/FolderList.d.ts +6 -0
  6. package/dist/TrackSelect/{Data/humanBiosamples.json.d.ts → Folders/biosamples/data/human.json.d.ts} +1940 -1919
  7. package/dist/TrackSelect/{Data/mouseBiosamples.json.d.ts → Folders/biosamples/data/mouse.json.d.ts} +408 -357
  8. package/dist/TrackSelect/Folders/biosamples/human.d.ts +7 -0
  9. package/dist/TrackSelect/Folders/biosamples/mouse.d.ts +7 -0
  10. package/dist/TrackSelect/Folders/biosamples/shared/AssayToggle.d.ts +14 -0
  11. package/dist/TrackSelect/Folders/biosamples/shared/BiosampleGroupingCell.d.ts +6 -0
  12. package/dist/TrackSelect/Folders/biosamples/shared/BiosampleTreeItem.d.ts +7 -0
  13. package/dist/TrackSelect/Folders/biosamples/shared/columns.d.ts +14 -0
  14. package/dist/TrackSelect/Folders/biosamples/shared/constants.d.ts +19 -0
  15. package/dist/TrackSelect/Folders/biosamples/shared/createFolder.d.ts +24 -0
  16. package/dist/TrackSelect/Folders/biosamples/shared/treeBuilder.d.ts +25 -0
  17. package/dist/TrackSelect/Folders/biosamples/shared/types.d.ts +44 -0
  18. package/dist/TrackSelect/Folders/genes/data/human.json.d.ts +10 -0
  19. package/dist/TrackSelect/Folders/genes/data/mouse.json.d.ts +10 -0
  20. package/dist/TrackSelect/Folders/genes/human.d.ts +7 -0
  21. package/dist/TrackSelect/Folders/genes/mouse.d.ts +7 -0
  22. package/dist/TrackSelect/Folders/genes/shared/columns.d.ts +14 -0
  23. package/dist/TrackSelect/Folders/genes/shared/createFolder.d.ts +12 -0
  24. package/dist/TrackSelect/Folders/genes/shared/treeBuilder.d.ts +13 -0
  25. package/dist/TrackSelect/Folders/genes/shared/types.d.ts +26 -0
  26. package/dist/TrackSelect/Folders/index.d.ts +14 -0
  27. package/dist/TrackSelect/Folders/types.d.ts +76 -0
  28. package/dist/TrackSelect/TrackSelect.d.ts +12 -5
  29. package/dist/TrackSelect/TreeView/CustomTreeItem.d.ts +3 -0
  30. package/dist/TrackSelect/TreeView/TreeViewWrapper.d.ts +1 -1
  31. package/dist/TrackSelect/store.d.ts +1 -2
  32. package/dist/TrackSelect/types.d.ts +24 -62
  33. package/dist/genomebrowser-ui.es.js +1373 -2117
  34. package/dist/genomebrowser-ui.es.js.map +1 -1
  35. package/dist/lib.d.ts +2 -2
  36. package/package.json +3 -3
  37. package/src/TrackSelect/DataGrid/DataGridWrapper.tsx +36 -20
  38. package/src/TrackSelect/DataGrid/DefaultGroupingCell.tsx +64 -0
  39. package/src/TrackSelect/FolderList/Breadcrumb.tsx +38 -0
  40. package/src/TrackSelect/FolderList/FolderCard.tsx +51 -0
  41. package/src/TrackSelect/FolderList/FolderList.tsx +47 -0
  42. package/src/TrackSelect/Folders/NEW.md +929 -0
  43. package/src/TrackSelect/{Data → Folders/biosamples/data}/formatBiosamples.go +2 -2
  44. package/src/TrackSelect/{Data/humanBiosamples.json → Folders/biosamples/data/human.json} +1940 -1919
  45. package/src/TrackSelect/{Data/mouseBiosamples.json → Folders/biosamples/data/mouse.json} +408 -357
  46. package/src/TrackSelect/Folders/biosamples/human.ts +17 -0
  47. package/src/TrackSelect/Folders/biosamples/mouse.ts +17 -0
  48. package/src/TrackSelect/Folders/biosamples/shared/AssayToggle.tsx +65 -0
  49. package/src/TrackSelect/{DataGrid/GroupingCell.tsx → Folders/biosamples/shared/BiosampleGroupingCell.tsx} +7 -5
  50. package/src/TrackSelect/Folders/biosamples/shared/BiosampleTreeItem.tsx +15 -0
  51. package/src/TrackSelect/{DataGrid → Folders/biosamples/shared}/columns.tsx +31 -17
  52. package/src/TrackSelect/Folders/biosamples/shared/constants.tsx +116 -0
  53. package/src/TrackSelect/Folders/biosamples/shared/createFolder.ts +116 -0
  54. package/src/TrackSelect/Folders/biosamples/shared/treeBuilder.ts +227 -0
  55. package/src/TrackSelect/Folders/biosamples/shared/types.ts +48 -0
  56. package/src/TrackSelect/Folders/genes/data/human.json +7 -0
  57. package/src/TrackSelect/Folders/genes/data/mouse.json +7 -0
  58. package/src/TrackSelect/Folders/genes/human.ts +16 -0
  59. package/src/TrackSelect/Folders/genes/mouse.ts +16 -0
  60. package/src/TrackSelect/Folders/genes/shared/columns.tsx +42 -0
  61. package/src/TrackSelect/Folders/genes/shared/createFolder.ts +68 -0
  62. package/src/TrackSelect/Folders/genes/shared/treeBuilder.ts +45 -0
  63. package/src/TrackSelect/Folders/genes/shared/types.ts +29 -0
  64. package/src/TrackSelect/Folders/index.ts +27 -0
  65. package/src/TrackSelect/Folders/types.ts +95 -0
  66. package/src/TrackSelect/TrackSelect.tsx +409 -311
  67. package/src/TrackSelect/TreeView/CustomTreeItem.tsx +217 -0
  68. package/src/TrackSelect/TreeView/TreeViewWrapper.tsx +47 -42
  69. package/src/TrackSelect/store.ts +103 -46
  70. package/src/TrackSelect/types.ts +28 -74
  71. package/src/lib.ts +2 -2
  72. package/test/main.tsx +113 -169
  73. package/.claude/settings.local.json +0 -7
  74. package/dist/TrackSelect/DataGrid/CustomToolbar.d.ts +0 -12
  75. package/dist/TrackSelect/DataGrid/GroupingCell.d.ts +0 -2
  76. package/dist/TrackSelect/DataGrid/columns.d.ts +0 -4
  77. package/dist/TrackSelect/DataGrid/dataGridHelpers.d.ts +0 -49
  78. package/dist/TrackSelect/TreeView/treeViewHelpers.d.ts +0 -49
  79. package/dist/TrackSelect/consts.d.ts +0 -11
  80. package/src/TrackSelect/DataGrid/CustomToolbar.tsx +0 -152
  81. package/src/TrackSelect/DataGrid/dataGridHelpers.tsx +0 -155
  82. package/src/TrackSelect/TreeView/treeViewHelpers.tsx +0 -475
  83. package/src/TrackSelect/consts.ts +0 -92
  84. package/src/TrackSelect/issues.md +0 -404
@@ -1,3 +1,4 @@
1
+ import CloseIcon from "@mui/icons-material/Close";
1
2
  import {
2
3
  Box,
3
4
  Button,
@@ -6,367 +7,464 @@ import {
6
7
  DialogContent,
7
8
  DialogContentText,
8
9
  DialogTitle,
9
- FormControlLabel,
10
+ IconButton,
10
11
  Stack,
11
- Switch,
12
- TextField,
13
12
  } from "@mui/material";
14
- import { GridRowSelectionModel } from "@mui/x-data-grid";
15
13
  import { TreeViewBaseItem } from "@mui/x-tree-view";
16
- import React, { useEffect, useMemo, useRef, useState } from "react";
14
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
15
+ import { Breadcrumb } from "./FolderList/Breadcrumb";
17
16
  import { DataGridWrapper } from "./DataGrid/DataGridWrapper";
18
- import {
19
- flattenIntoRows,
20
- searchTracks,
21
- getTracksData,
22
- } from "./DataGrid/dataGridHelpers";
17
+ import { FolderList } from "./FolderList/FolderList";
18
+ import { FolderDefinition, FolderRuntimeConfig } from "./Folders/types";
19
+ import { createSelectionStore, SelectionStoreInstance } from "./store";
23
20
  import { TreeViewWrapper } from "./TreeView/TreeViewWrapper";
24
- import {
25
- buildSortedAssayTreeView,
26
- buildTreeView,
27
- searchTreeItems,
28
- } from "./TreeView/treeViewHelpers";
29
- import { SelectionStoreInstance } from "./store";
30
- import { ExtendedTreeItemProps, SearchTracksProps } from "./types";
21
+ import { ExtendedTreeItemProps } from "./types";
31
22
 
32
23
  export interface TrackSelectProps {
33
- store: SelectionStoreInstance;
34
- onSubmit?: (trackIds: Set<string>) => void;
24
+ folders: FolderDefinition[];
25
+ onSubmit: (selectedByFolder: Map<string, Set<string>>) => void;
35
26
  onCancel?: () => void;
36
- onReset?: () => void;
27
+ onClear?: () => void;
28
+ maxTracks?: number;
29
+ storageKey?: string;
30
+ /** Initial selection to use when no stored selection exists */
31
+ initialSelection?: Map<string, Set<string>>;
32
+ open: boolean;
33
+ onClose: () => void;
34
+ title?: string;
37
35
  }
38
36
 
37
+ const DEFAULT_MAX_TRACKS = 30;
38
+
39
+ type ViewState = "folder-list" | "folder-detail";
40
+
41
+ const cloneSelectionMap = (selection: Map<string, Set<string>>) => {
42
+ const map = new Map<string, Set<string>>();
43
+ selection.forEach((ids, folderId) => {
44
+ map.set(folderId, new Set(ids));
45
+ });
46
+ return map;
47
+ };
48
+
49
+ const buildRuntimeConfigMap = (folders: FolderDefinition[]) => {
50
+ const map = new Map<string, FolderRuntimeConfig>();
51
+ folders.forEach((folder) => {
52
+ map.set(folder.id, {
53
+ columns: folder.columns,
54
+ groupingModel: folder.groupingModel,
55
+ leafField: folder.leafField,
56
+ });
57
+ });
58
+ return map;
59
+ };
60
+
61
+ const attachFolderId = (
62
+ items: TreeViewBaseItem<ExtendedTreeItemProps>[],
63
+ folderId: string,
64
+ ): TreeViewBaseItem<ExtendedTreeItemProps>[] => {
65
+ return items.map((item) => ({
66
+ ...item,
67
+ folderId,
68
+ children: item.children
69
+ ? attachFolderId(item.children, folderId)
70
+ : undefined,
71
+ }));
72
+ };
73
+
74
+ const DEFAULT_TITLE = "Track Select";
75
+
39
76
  export default function TrackSelect({
40
- store,
77
+ folders,
41
78
  onSubmit,
42
79
  onCancel,
43
- onReset,
80
+ onClear,
81
+ maxTracks,
82
+ storageKey,
83
+ initialSelection,
84
+ open,
85
+ onClose,
86
+ title = DEFAULT_TITLE,
44
87
  }: TrackSelectProps) {
45
88
  const [limitDialogOpen, setLimitDialogOpen] = useState(false);
46
- const [sortedAssay, setSortedAssay] = useState(false);
47
- const [searchQuery, setSearchQuery] = useState("");
48
- const [isSearchResult, setIsSearchResult] = useState(false);
49
- const selectedIds = store((s) => s.selectedIds);
50
- const setSelected = store((s) => s.setSelected);
51
- const clear = store((s) => s.clear);
52
- const MAX_ACTIVE = store((s) => s.maxTracks);
53
- const rows = store((s) => s.rows);
54
- const rowById = store((s) => s.rowById);
55
- const assembly = store((s) => s.assembly);
56
-
57
- // Local working state - changes here don't affect the store until Submit
58
- const [workingIds, setWorkingIds] = useState<Set<string>>(
59
- () => new Set(selectedIds),
89
+ const [clearDialogOpen, setClearDialogOpen] = useState(false);
90
+ const [runtimeConfigByFolder, setRuntimeConfigByFolder] = useState(() =>
91
+ buildRuntimeConfigMap(folders),
60
92
  );
61
93
 
62
- // Get tracks data for search functionality
63
- const tracksData = useMemo(
64
- () => getTracksData(assembly as "GRCh38" | "mm10"),
65
- [assembly],
94
+ // View state: folder list or folder detail
95
+ const [currentView, setCurrentView] = useState<ViewState>(() =>
96
+ folders.length > 1 ? "folder-list" : "folder-detail",
66
97
  );
67
98
 
68
- // Get only real track IDs from working selection (no auto-generated group IDs)
69
- const workingTrackIds = useMemo(() => {
70
- return new Set([...workingIds].filter((id) => rowById.has(id)));
71
- }, [workingIds, rowById]);
99
+ // Create and memoize the selection store
100
+ const folderIds = useMemo(() => folders.map((f) => f.id), [folders]);
101
+ const storeRef = useRef<SelectionStoreInstance | null>(null);
102
+ if (!storeRef.current) {
103
+ storeRef.current = createSelectionStore(
104
+ folderIds,
105
+ storageKey,
106
+ initialSelection,
107
+ );
108
+ }
109
+ const store = storeRef.current;
72
110
 
73
- // Sync workingIds when store's selectedIds changes externally
74
- useEffect(() => {
75
- setWorkingIds(new Set(selectedIds));
76
- }, [selectedIds]);
111
+ // Subscribe to store changes
112
+ const selectedByFolder = store((state) => state.selectedByFolder);
113
+ const activeFolderId = store((state) => state.activeFolderId);
114
+ const setActiveFolder = store((state) => state.setActiveFolder);
115
+ const setSelection = store((state) => state.setSelection);
116
+ const clear = store((state) => state.clear);
77
117
 
78
- const treeItems = useMemo(() => {
79
- return sortedAssay
80
- ? buildSortedAssayTreeView(
81
- Array.from(workingTrackIds),
82
- {
83
- id: "1",
84
- isAssayItem: false,
85
- label: "Biosamples",
86
- icon: "folder",
87
- children: [],
88
- allRowInfo: [],
89
- },
90
- rowById,
91
- )
92
- : buildTreeView(
93
- Array.from(workingTrackIds),
94
- {
95
- id: "1",
96
- isAssayItem: false,
97
- label: "Biosamples",
98
- icon: "folder",
99
- children: [],
100
- allRowInfo: [],
101
- },
102
- rowById,
103
- );
104
- }, [workingTrackIds, sortedAssay, rowById]);
105
-
106
- const [filteredRows, setFilteredRows] = useState(rows);
107
- const [filteredTreeItems, setFilteredTreeItems] = useState([
108
- {
109
- id: "1",
110
- isAssayItem: false,
111
- label: "Biosamples",
112
- icon: "folder",
113
- children: [],
114
- allRowInfo: [],
115
- },
116
- ] as TreeViewBaseItem<ExtendedTreeItemProps>[]);
117
- const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
118
- const searchResultIdsRef = useRef<Set<string>>(new Set());
118
+ // Keep a committed snapshot for cancel functionality
119
+ const [committedSnapshot, setCommittedSnapshot] = useState(() =>
120
+ cloneSelectionMap(selectedByFolder),
121
+ );
119
122
 
120
123
  useEffect(() => {
121
- if (searchQuery === "") {
122
- setFilteredTreeItems(treeItems);
123
- setFilteredRows(rows);
124
- setIsSearchResult(false);
125
- searchResultIdsRef.current = new Set();
126
- } else if (searchResultIdsRef.current.size > 0) {
127
- // When selection changes during search, rebuild tree from selected items that match search
128
- const matchingTrackIds = Array.from(workingTrackIds).filter((id) =>
129
- searchResultIdsRef.current.has(id),
130
- );
131
-
132
- const newTreeItems = sortedAssay
133
- ? buildSortedAssayTreeView(
134
- matchingTrackIds,
135
- {
136
- id: "1",
137
- isAssayItem: false,
138
- label: "Biosamples",
139
- icon: "folder",
140
- children: [],
141
- allRowInfo: [],
142
- },
143
- rowById,
144
- )
145
- : buildTreeView(
146
- matchingTrackIds,
147
- {
148
- id: "1",
149
- isAssayItem: false,
150
- label: "Biosamples",
151
- icon: "folder",
152
- children: [],
153
- allRowInfo: [],
154
- },
155
- rowById,
156
- );
157
-
158
- setFilteredTreeItems(newTreeItems);
124
+ setRuntimeConfigByFolder(buildRuntimeConfigMap(folders));
125
+ // Ensure active folder is valid
126
+ if (!folders.some((folder) => folder.id === activeFolderId)) {
127
+ setActiveFolder(folders[0]?.id ?? "");
159
128
  }
160
- }, [treeItems, searchQuery, workingTrackIds, sortedAssay, rowById, rows]);
129
+ // Update view state if folder count changes
130
+ if (folders.length <= 1) {
131
+ setCurrentView("folder-detail");
132
+ }
133
+ }, [folders, activeFolderId, setActiveFolder]);
161
134
 
162
- const handleToggle = () => {
163
- setSortedAssay(!sortedAssay);
164
- };
135
+ const activeFolder = useMemo(() => {
136
+ return folders.find((folder) => folder.id === activeFolderId) ?? folders[0];
137
+ }, [folders, activeFolderId]);
138
+
139
+ const activeConfig = useMemo(() => {
140
+ if (!activeFolder) return undefined;
141
+ return (
142
+ runtimeConfigByFolder.get(activeFolder.id) ?? {
143
+ columns: activeFolder.columns,
144
+ groupingModel: activeFolder.groupingModel,
145
+ leafField: activeFolder.leafField,
146
+ }
147
+ );
148
+ }, [runtimeConfigByFolder, activeFolder]);
165
149
 
166
- const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
167
- const query = e.target.value;
168
- setSearchQuery(query);
150
+ const selectedIds = useMemo(() => {
151
+ if (!activeFolder) return new Set<string>();
152
+ return new Set(selectedByFolder.get(activeFolder.id) ?? []);
153
+ }, [selectedByFolder, activeFolder]);
169
154
 
170
- // Clear previous timeout
171
- if (searchTimeoutRef.current) {
172
- clearTimeout(searchTimeoutRef.current);
173
- }
155
+ const selectedCount = useMemo(() => {
156
+ let total = 0;
157
+ selectedByFolder.forEach((ids) => {
158
+ total += ids.size;
159
+ });
160
+ return total;
161
+ }, [selectedByFolder]);
174
162
 
175
- // Debounce the search
176
- searchTimeoutRef.current = setTimeout(() => {
177
- if (query === "") {
178
- return; // useEffect handles empty query
179
- }
163
+ const maxTracksLimit = maxTracks ?? DEFAULT_MAX_TRACKS;
164
+
165
+ const rows = useMemo(() => {
166
+ if (!activeFolder) return [];
167
+ return Array.from(activeFolder.rowById.values());
168
+ }, [activeFolder]);
180
169
 
181
- const dataGridSearchProps = {
182
- jsonStructure: "tracks",
183
- query: query,
184
- keyWeightMap: [
185
- "displayname",
186
- "ontology",
187
- "lifeStage",
188
- "sampleType",
189
- "type",
190
- "experimentAccession",
191
- "fileAccession",
192
- ],
193
- tracksData,
194
- };
195
-
196
- const treeSearchProps: SearchTracksProps = {
197
- treeItems: treeItems,
198
- query: query,
199
- keyWeightMap: [
200
- "displayname",
201
- "ontology",
202
- "lifeStage",
203
- "sampleType",
204
- "type",
205
- "experimentAccession",
206
- "fileAccession",
207
- ],
208
- };
209
- const newDataGridRows = searchTracks(dataGridSearchProps)
210
- .map((t) => t.item)
211
- .flatMap(flattenIntoRows);
212
-
213
- // we only want the intersection of filtered tracks displayed on the DataGrid and user-selected tracks to be displayed on the tree
214
- const newDataGridIds = newDataGridRows.map((r) => r.id);
215
- const retIds = searchTreeItems(treeSearchProps).map((r) => r.item.id);
216
- const newTreeIds = retIds.filter((i) => newDataGridIds.includes(i));
217
-
218
- // build new tree from the newTreeIds
219
- const newTreeItems = sortedAssay
220
- ? buildSortedAssayTreeView(
221
- newTreeIds,
222
- {
223
- id: "1",
224
- isAssayItem: false,
225
- label: "Biosamples",
226
- icon: "folder",
227
- children: [],
228
- allRowInfo: [],
229
- },
230
- rowById,
231
- )
232
- : buildTreeView(
233
- newTreeIds,
234
- {
235
- id: "1",
236
- isAssayItem: false,
237
- label: "Biosamples",
238
- icon: "folder",
239
- children: [],
240
- allRowInfo: [],
241
- },
242
- rowById,
243
- );
244
-
245
- // Store search result IDs in ref for use in useEffect
246
- searchResultIdsRef.current = new Set(newDataGridIds);
247
-
248
- setFilteredRows(newDataGridRows);
249
- setIsSearchResult(true);
250
- setFilteredTreeItems(newTreeItems);
251
- }, 300);
170
+ const treeItems = useMemo(() => {
171
+ if (!activeFolder) return [];
172
+ return folders
173
+ .filter((folder) => {
174
+ const selected = selectedByFolder.get(folder.id);
175
+ return selected && selected.size > 0;
176
+ })
177
+ .flatMap((folder) =>
178
+ attachFolderId(
179
+ folder.buildTree(
180
+ Array.from(selectedByFolder.get(folder.id) ?? []),
181
+ folder.rowById,
182
+ ),
183
+ folder.id,
184
+ ),
185
+ );
186
+ }, [folders, selectedByFolder, activeFolder]);
187
+
188
+ const updateActiveFolderConfig = useCallback(
189
+ (partial: Partial<FolderRuntimeConfig>) => {
190
+ if (!activeFolder) return;
191
+ setRuntimeConfigByFolder((prev) => {
192
+ const current = prev.get(activeFolder.id);
193
+ if (!current) return prev;
194
+ const next = new Map(prev);
195
+ next.set(activeFolder.id, { ...current, ...partial });
196
+ return next;
197
+ });
198
+ },
199
+ [activeFolder],
200
+ );
201
+
202
+ // Navigation handlers
203
+ const handleFolderSelect = (folderId: string) => {
204
+ setActiveFolder(folderId);
205
+ setCurrentView("folder-detail");
206
+ };
207
+
208
+ const handleNavigateToRoot = () => {
209
+ setCurrentView("folder-list");
252
210
  };
253
211
 
254
- const handleSelection = (newSelection: GridRowSelectionModel) => {
255
- const allIds: Set<string> =
256
- (newSelection && (newSelection as any).ids) ?? new Set<string>();
212
+ const handleSelectionChange = (ids: Set<string>) => {
213
+ if (!activeFolder) return;
214
+
215
+ // Filter to only include IDs that exist in rowById (exclude auto-generated group IDs)
216
+ const filteredIds = new Set(
217
+ Array.from(ids).filter((id) => activeFolder.rowById.has(id)),
218
+ );
257
219
 
258
- // Count only real track IDs for the limit check
259
- let realTrackCount = 0;
260
- allIds.forEach((id: string) => {
261
- if (rowById.has(id)) {
262
- realTrackCount++;
220
+ // Calculate what the total would be with this change
221
+ let nextTotal = filteredIds.size;
222
+ selectedByFolder.forEach((folderIds, folderId) => {
223
+ if (folderId !== activeFolder.id) {
224
+ nextTotal += folderIds.size;
263
225
  }
264
226
  });
265
227
 
266
- // Block only if the new selection would exceed the limit
267
- if (realTrackCount > MAX_ACTIVE) {
228
+ if (nextTotal > maxTracksLimit) {
268
229
  setLimitDialogOpen(true);
269
230
  return;
270
231
  }
271
232
 
272
- // Update working state (not the store yet)
273
- setWorkingIds(allIds);
233
+ setSelection(activeFolder.id, filteredIds);
234
+ };
235
+
236
+ const handleRemoveTreeItem = (
237
+ item: TreeViewBaseItem<ExtendedTreeItemProps>,
238
+ ) => {
239
+ const folderId = item.folderId;
240
+ if (!folderId || !item.allExpAccessions?.length) {
241
+ return;
242
+ }
243
+
244
+ const current = selectedByFolder.get(folderId) ?? new Set<string>();
245
+ const nextSet = new Set(current);
246
+ item.allExpAccessions.forEach((id) => nextSet.delete(id));
247
+ setSelection(folderId, nextSet);
274
248
  };
275
249
 
276
250
  const handleSubmit = () => {
277
- // Commit working selection to store
278
- setSelected(workingIds);
279
- // Call callback with real track IDs
280
- onSubmit?.(workingTrackIds);
251
+ const committed = cloneSelectionMap(selectedByFolder);
252
+ setCommittedSnapshot(committed);
253
+ onSubmit(committed);
254
+ onClose();
281
255
  };
282
256
 
283
257
  const handleCancel = () => {
284
- // Revert working state to store's committed state
285
- setWorkingIds(new Set(selectedIds));
258
+ // Restore from committed snapshot
259
+ committedSnapshot.forEach((ids, folderId) => {
260
+ setSelection(folderId, ids);
261
+ });
286
262
  onCancel?.();
263
+ onClose();
264
+ };
265
+
266
+ const handleClear = () => {
267
+ setClearDialogOpen(true);
287
268
  };
288
269
 
270
+ const confirmClear = () => {
271
+ setClearDialogOpen(false);
272
+ let newSnapshot: Map<string, Set<string>>;
273
+
274
+ if (currentView === "folder-detail") {
275
+ // Clear only the current folder
276
+ clear(activeFolderId);
277
+ newSnapshot = cloneSelectionMap(selectedByFolder);
278
+ newSnapshot.set(activeFolderId, new Set<string>());
279
+ } else {
280
+ // Clear all folders
281
+ clear();
282
+ newSnapshot = new Map<string, Set<string>>();
283
+ folderIds.forEach((id) => newSnapshot.set(id, new Set<string>()));
284
+ onClear?.();
285
+ }
286
+
287
+ setCommittedSnapshot(newSnapshot);
288
+ onSubmit(newSnapshot);
289
+ };
290
+
291
+ const ToolbarExtras = activeFolder?.ToolbarExtras;
292
+
289
293
  return (
290
- <Box sx={{ flex: 1, pt: 1 }}>
291
- <Box display="flex" justifyContent="space-between" sx={{ mb: 3 }}>
292
- <TextField
293
- id="outlined-suffix-shrink"
294
- label="Search tracks"
295
- variant="outlined"
296
- onChange={handleSearch}
297
- sx={{ width: "400px" }}
298
- />
299
- <FormControlLabel
300
- sx={{ display: "flex", justifyContent: "flex-end", mb: 1 }}
301
- value="Sort by assay"
302
- control={<Switch color="primary" onChange={handleToggle} />}
303
- label="Sort by assay"
304
- labelPlacement="end"
305
- />
306
- </Box>
307
- <Stack direction="row" spacing={2} sx={{ width: "100%" }}>
308
- <Box sx={{ flex: 3, minWidth: 0 }}>
309
- <DataGridWrapper
310
- rows={filteredRows}
311
- label={
312
- isSearchResult
313
- ? `${filteredRows.length} Search Results`
314
- : `${rows.length} Available Tracks`
315
- }
316
- selectedIds={workingIds}
317
- handleSelection={handleSelection}
318
- sortedAssay={sortedAssay}
319
- />
320
- </Box>
321
- <Box sx={{ flex: 2, minWidth: 0 }}>
322
- <TreeViewWrapper
323
- store={store}
324
- items={filteredTreeItems}
325
- trackIds={workingTrackIds}
326
- isSearchResult={isSearchResult}
327
- />
328
- </Box>
329
- </Stack>
330
- <Box
331
- sx={{ display: "flex", justifyContent: "space-between", mt: 2, gap: 2 }}
294
+ <Dialog open={open} onClose={handleCancel} maxWidth="lg" fullWidth>
295
+ <DialogTitle
296
+ sx={{
297
+ bgcolor: "#0c184a",
298
+ color: "white",
299
+ display: "flex",
300
+ justifyContent: "space-between",
301
+ alignItems: "center",
302
+ fontWeight: "bold",
303
+ }}
332
304
  >
333
- <Button
334
- variant="outlined"
335
- color="secondary"
336
- onClick={() => {
337
- if (onReset) {
338
- onReset();
339
- } else {
340
- clear();
341
- setWorkingIds(new Set());
342
- }
343
- }}
305
+ {title}
306
+ <IconButton
307
+ size="large"
308
+ onClick={handleCancel}
309
+ sx={{ color: "white", padding: 0 }}
344
310
  >
345
- Reset
346
- </Button>
347
- <Box sx={{ display: "flex", gap: 2 }}>
348
- <Button variant="outlined" onClick={handleCancel}>
349
- Cancel
350
- </Button>
351
- <Button variant="contained" color="primary" onClick={handleSubmit}>
352
- Submit
353
- </Button>
354
- </Box>
355
- </Box>
356
- <Dialog open={limitDialogOpen} onClose={() => setLimitDialogOpen(false)}>
357
- <DialogTitle>Track Limit Reached</DialogTitle>
358
- <DialogContent>
359
- <DialogContentText>
360
- You can select up to {MAX_ACTIVE} tracks at a time. Please remove a
361
- track before adding another.
362
- </DialogContentText>
363
- </DialogContent>
364
- <DialogActions>
365
- <Button onClick={() => setLimitDialogOpen(false)} autoFocus>
366
- OK
367
- </Button>
368
- </DialogActions>
369
- </Dialog>
370
- </Box>
311
+ <CloseIcon fontSize="large" />
312
+ </IconButton>
313
+ </DialogTitle>
314
+ <DialogContent sx={{ marginTop: "5px" }}>
315
+ {!activeFolder || !activeConfig ? (
316
+ <Box sx={{ p: 2 }}>No folders available.</Box>
317
+ ) : (
318
+ <Box sx={{ flex: 1, pt: 1 }}>
319
+ {/* Toolbar row - breadcrumb on left, extras on right */}
320
+ {(folders.length > 1 ||
321
+ (currentView === "folder-detail" && ToolbarExtras)) && (
322
+ <Box
323
+ display="flex"
324
+ justifyContent="space-between"
325
+ alignItems="center"
326
+ sx={{ mb: 2 }}
327
+ >
328
+ {folders.length > 1 ? (
329
+ <Breadcrumb
330
+ currentFolder={
331
+ currentView === "folder-detail" ? activeFolder : null
332
+ }
333
+ onNavigateToRoot={handleNavigateToRoot}
334
+ />
335
+ ) : (
336
+ <Box />
337
+ )}
338
+ {currentView === "folder-detail" && ToolbarExtras && (
339
+ <ToolbarExtras updateConfig={updateActiveFolderConfig} />
340
+ )}
341
+ </Box>
342
+ )}
343
+
344
+ <Stack direction="row" spacing={2} sx={{ width: "100%" }}>
345
+ {/* Left panel - swaps between FolderList and DataGrid */}
346
+ <Box sx={{ flex: 3, minWidth: 0 }}>
347
+ {currentView === "folder-list" ? (
348
+ <FolderList
349
+ folders={folders}
350
+ onFolderSelect={handleFolderSelect}
351
+ />
352
+ ) : (
353
+ <DataGridWrapper
354
+ rows={rows}
355
+ columns={activeConfig.columns}
356
+ groupingModel={activeConfig.groupingModel}
357
+ leafField={activeConfig.leafField}
358
+ label={`${rows.length} Available ${activeFolder.label}`}
359
+ selectedIds={selectedIds}
360
+ onSelectionChange={handleSelectionChange}
361
+ GroupingCellComponent={activeFolder.GroupingCellComponent}
362
+ />
363
+ )}
364
+ </Box>
365
+ {/* Right panel - always visible */}
366
+ <Box sx={{ flex: 2, minWidth: 0 }}>
367
+ <TreeViewWrapper
368
+ items={treeItems}
369
+ selectedCount={selectedCount}
370
+ onRemove={handleRemoveTreeItem}
371
+ TreeItemComponent={activeFolder.TreeItemComponent}
372
+ />
373
+ </Box>
374
+ </Stack>
375
+ <Box
376
+ sx={{
377
+ display: "flex",
378
+ justifyContent: "space-between",
379
+ mt: 2,
380
+ gap: 2,
381
+ }}
382
+ >
383
+ <Button
384
+ variant="outlined"
385
+ color="secondary"
386
+ onClick={handleClear}
387
+ >
388
+ Clear
389
+ </Button>
390
+ <Box sx={{ display: "flex", gap: 2 }}>
391
+ <Button variant="outlined" onClick={handleCancel}>
392
+ Cancel
393
+ </Button>
394
+ <Button
395
+ variant="contained"
396
+ color="primary"
397
+ onClick={handleSubmit}
398
+ >
399
+ Submit
400
+ </Button>
401
+ </Box>
402
+ </Box>
403
+ <Dialog
404
+ open={limitDialogOpen}
405
+ onClose={() => setLimitDialogOpen(false)}
406
+ >
407
+ <DialogTitle>Track Limit Reached</DialogTitle>
408
+ <DialogContent>
409
+ <DialogContentText>
410
+ You can select up to {maxTracksLimit} tracks at a time. Please
411
+ remove a track before adding another.
412
+ </DialogContentText>
413
+ </DialogContent>
414
+ <DialogActions>
415
+ <Button onClick={() => setLimitDialogOpen(false)} autoFocus>
416
+ OK
417
+ </Button>
418
+ </DialogActions>
419
+ </Dialog>
420
+ <Dialog
421
+ open={clearDialogOpen}
422
+ onClose={() => setClearDialogOpen(false)}
423
+ >
424
+ <DialogTitle
425
+ sx={{
426
+ bgcolor: "#0c184a",
427
+ color: "white",
428
+ fontWeight: "bold",
429
+ }}
430
+ >
431
+ {currentView === "folder-detail"
432
+ ? `Clear ${activeFolder.label}`
433
+ : "Clear All Folders"}
434
+ </DialogTitle>
435
+ <DialogContent sx={{ mt: 2 }}>
436
+ <DialogContentText>
437
+ {currentView === "folder-detail" ? (
438
+ <>
439
+ Are you sure you want to clear the selection for{" "}
440
+ <strong>{activeFolder.label}</strong>?
441
+ </>
442
+ ) : (
443
+ "Are you sure you want to clear all selections?"
444
+ )}
445
+ </DialogContentText>
446
+ </DialogContent>
447
+ <DialogActions sx={{ justifyContent: "center", gap: 2, pb: 2 }}>
448
+ <Button
449
+ variant="contained"
450
+ color="primary"
451
+ onClick={() => setClearDialogOpen(false)}
452
+ autoFocus
453
+ >
454
+ Cancel
455
+ </Button>
456
+ <Button
457
+ variant="outlined"
458
+ color="secondary"
459
+ onClick={confirmClear}
460
+ >
461
+ Clear
462
+ </Button>
463
+ </DialogActions>
464
+ </Dialog>
465
+ </Box>
466
+ )}
467
+ </DialogContent>
468
+ </Dialog>
371
469
  );
372
470
  }