From b96b0d3e9bb12c27fa884d433948e420adbf9576 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Wed, 26 Nov 2025 15:33:14 +0100 Subject: [PATCH 01/19] feat --- src/components/Toolbar/Toolbar.react.js | 34 ++-- src/components/Toolbar/Toolbar.scss | 19 ++- .../Data/Browser/BrowserToolbar.react.js | 6 + .../Data/Browser/DataBrowser.react.js | 155 ++++++++++++++++-- src/dashboard/Data/Browser/Databrowser.scss | 23 +++ 5 files changed, 213 insertions(+), 24 deletions(-) diff --git a/src/components/Toolbar/Toolbar.react.js b/src/components/Toolbar/Toolbar.react.js index e5e6642534..fb41e49d80 100644 --- a/src/components/Toolbar/Toolbar.react.js +++ b/src/components/Toolbar/Toolbar.react.js @@ -158,19 +158,33 @@ const Toolbar = props => { {props.classwiseCloudFunctions && props.classwiseCloudFunctions[`${props.appId}${props.appName}`] && props.classwiseCloudFunctions[`${props.appId}${props.appName}`][props.className] && ( - + )} - + + )} ); diff --git a/src/components/Toolbar/Toolbar.scss b/src/components/Toolbar/Toolbar.scss index 88f7261171..d40ba99f27 100644 --- a/src/components/Toolbar/Toolbar.scss +++ b/src/components/Toolbar/Toolbar.scss @@ -126,10 +126,16 @@ body:global(.expanded) { background-color: white; } -.btn { +.panelButtons { position: absolute; right: 5px; bottom: 5px; + display: flex; + gap: 5px; +} + +.btn { + position: relative; border: none; padding: 6px 3px; width: 110px; @@ -152,4 +158,15 @@ body:global(.expanded) { fill: #ffffff; } } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + + &:hover { + svg { + fill: #797592; + } + } + } } diff --git a/src/dashboard/Data/Browser/BrowserToolbar.react.js b/src/dashboard/Data/Browser/BrowserToolbar.react.js index 9a322592d8..c0fb533c1f 100644 --- a/src/dashboard/Data/Browser/BrowserToolbar.react.js +++ b/src/dashboard/Data/Browser/BrowserToolbar.react.js @@ -80,6 +80,9 @@ const BrowserToolbar = ({ togglePanel, isPanelVisible, + addPanel, + removePanel, + panelCount, classwiseCloudFunctions, appId, appName, @@ -282,6 +285,9 @@ const BrowserToolbar = ({ selectedData={selectedData} togglePanel={togglePanel} isPanelVisible={isPanelVisible} + addPanel={addPanel} + removePanel={removePanel} + panelCount={panelCount} classwiseCloudFunctions={classwiseCloudFunctions} appId={appId} appName={appName} diff --git a/src/dashboard/Data/Browser/DataBrowser.react.js b/src/dashboard/Data/Browser/DataBrowser.react.js index 8311bf1d14..e8bab692c0 100644 --- a/src/dashboard/Data/Browser/DataBrowser.react.js +++ b/src/dashboard/Data/Browser/DataBrowser.react.js @@ -117,6 +117,9 @@ export default class DataBrowser extends React.Component { autoLoadFirstRow: storedAutoLoadFirstRow, prefetchCache: {}, selectionHistory: [], + displayedObjectIds: [], // Array of object IDs currently displayed in the panel + panelCount: 1, // Number of panels to display + multiPanelData: {}, // Object mapping objectId to panel data }; this.handleResizeDiv = this.handleResizeDiv.bind(this); @@ -141,6 +144,8 @@ export default class DataBrowser extends React.Component { this.toggleScrollToTop = this.toggleScrollToTop.bind(this); this.toggleAutoLoadFirstRow = this.toggleAutoLoadFirstRow.bind(this); this.handleCellClick = this.handleCellClick.bind(this); + this.addPanel = this.addPanel.bind(this); + this.removePanel = this.removePanel.bind(this); this.saveOrderTimeout = null; this.aggregationPanelRef = React.createRef(); } @@ -260,6 +265,20 @@ export default class DataBrowser extends React.Component { this.aggregationPanelRef.current.scrollTop = 0; } } + + // Store the fetched panel data in multiPanelData when it changes + if ( + this.props.AggregationPanelData !== prevProps.AggregationPanelData && + this.state.selectedObjectId && + Object.keys(this.props.AggregationPanelData).length > 0 + ) { + this.setState(prev => ({ + multiPanelData: { + ...prev.multiPanelData, + [this.state.selectedObjectId]: this.props.AggregationPanelData + } + })); + } } handleResizeStart() { @@ -720,7 +739,28 @@ export default class DataBrowser extends React.Component { if (history.length > 3) { history.shift(); } - return { selectedObjectId, selectionHistory: history }; + + // Check if the new object is already displayed in the panel + let newDisplayedObjectIds = [...prevState.displayedObjectIds]; + if (prevState.panelCount > 1 && selectedObjectId) { + // If the selected object is not in the displayed list, update all displayed objects + if (!newDisplayedObjectIds.includes(selectedObjectId)) { + // Shift to the next batch of objects + const currentIndex = this.props.data?.findIndex(obj => obj.id === selectedObjectId); + if (currentIndex !== -1) { + newDisplayedObjectIds = []; + for (let i = 0; i < prevState.panelCount && currentIndex + i < this.props.data.length; i++) { + newDisplayedObjectIds.push(this.props.data[currentIndex + i].id); + } + } + } + } + + return { + selectedObjectId, + selectionHistory: history, + displayedObjectIds: newDisplayedObjectIds + }; }, () => this.handlePrefetch() ); @@ -760,6 +800,57 @@ export default class DataBrowser extends React.Component { }); } + addPanel() { + const currentIndex = this.props.data?.findIndex(obj => obj.id === this.state.selectedObjectId); + const newPanelCount = this.state.panelCount + 1; + const newDisplayedObjectIds = []; + + if (currentIndex !== -1 && currentIndex !== undefined) { + // First, ensure current object data is in multiPanelData + const currentObjectData = { ...this.state.multiPanelData }; + if (this.state.selectedObjectId && !currentObjectData[this.state.selectedObjectId] && + Object.keys(this.props.AggregationPanelData).length > 0) { + currentObjectData[this.state.selectedObjectId] = this.props.AggregationPanelData; + } + + for (let i = 0; i < newPanelCount && currentIndex + i < this.props.data.length; i++) { + const objectId = this.props.data[currentIndex + i].id; + newDisplayedObjectIds.push(objectId); + // Fetch data for this object if not already in multiPanelData + if (!currentObjectData[objectId]) { + setTimeout(() => { + this.handleCallCloudFunction( + objectId, + this.props.className, + this.props.app.applicationId + ); + }, i * 100); // Stagger requests slightly + } + } + + this.setState({ + panelCount: newPanelCount, + displayedObjectIds: newDisplayedObjectIds, + multiPanelData: currentObjectData + }); + } + } + + removePanel() { + this.setState(prevState => { + if (prevState.panelCount <= 1) { + return {}; + } + const newPanelCount = prevState.panelCount - 1; + // Remove the last displayed object + const newDisplayedObjectIds = prevState.displayedObjectIds.slice(0, -1); + return { + panelCount: newPanelCount, + displayedObjectIds: newDisplayedObjectIds + }; + }); + } + getPrefetchSettings() { const config = this.props.classwiseCloudFunctions?.[ @@ -923,6 +1014,13 @@ export default class DataBrowser extends React.Component { ) { this.props.setAggregationPanelData(cached.data); this.props.setLoadingInfoPanel(false); + // Also store in multiPanelData for multi-panel display + this.setState(prev => ({ + multiPanelData: { + ...prev.multiPanelData, + [objectId]: cached.data + } + })); } else { if (cached) { this.setState(prev => { @@ -1089,18 +1187,46 @@ export default class DataBrowser extends React.Component { className={styles.aggregationPanelContainer} ref={this.aggregationPanelRef} > - + {this.state.panelCount > 1 ? ( +
+ {this.state.displayedObjectIds.map((objectId, index) => { + const panelData = this.state.multiPanelData[objectId] || {}; + const isLoading = objectId === this.state.selectedObjectId && this.props.isLoadingCloudFunction; + return ( +
+ + {index < this.state.displayedObjectIds.length - 1 && ( +
+ )} +
+ ); + })} +
+ ) : ( + + )}
)} @@ -1133,6 +1259,9 @@ export default class DataBrowser extends React.Component { allClassesSchema={this.state.allClassesSchema} togglePanel={this.togglePanelVisibility} isPanelVisible={this.state.isPanelVisible} + addPanel={this.addPanel} + removePanel={this.removePanel} + panelCount={this.state.panelCount} appId={this.props.app.applicationId} appName={this.props.appName} scrollToTop={this.state.scrollToTop} diff --git a/src/dashboard/Data/Browser/Databrowser.scss b/src/dashboard/Data/Browser/Databrowser.scss index 5b90920add..09733dcea5 100644 --- a/src/dashboard/Data/Browser/Databrowser.scss +++ b/src/dashboard/Data/Browser/Databrowser.scss @@ -23,3 +23,26 @@ overflow: auto; background-color: #fefafb; } + +.multiPanelWrapper { + display: flex; + flex-direction: row; + height: 100%; + overflow-x: auto; +} + +.panelColumn { + flex: 1; + min-width: 0; + display: flex; + flex-direction: row; + overflow-y: auto; +} + +.panelSeparator { + width: 1px; + background-color: #e3e3ea; + align-self: stretch; + margin: 0; + flex-shrink: 0; +} From 5fc148635025f3b5c748c2b3319121d3e1933db7 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Wed, 26 Nov 2025 15:36:33 +0100 Subject: [PATCH 02/19] fix loading 2nd --- .../Data/Browser/DataBrowser.react.js | 91 +++++++++++++++---- src/dashboard/Data/Browser/Databrowser.scss | 7 +- 2 files changed, 73 insertions(+), 25 deletions(-) diff --git a/src/dashboard/Data/Browser/DataBrowser.react.js b/src/dashboard/Data/Browser/DataBrowser.react.js index e8bab692c0..32f19079ab 100644 --- a/src/dashboard/Data/Browser/DataBrowser.react.js +++ b/src/dashboard/Data/Browser/DataBrowser.react.js @@ -750,7 +750,14 @@ export default class DataBrowser extends React.Component { if (currentIndex !== -1) { newDisplayedObjectIds = []; for (let i = 0; i < prevState.panelCount && currentIndex + i < this.props.data.length; i++) { - newDisplayedObjectIds.push(this.props.data[currentIndex + i].id); + const objectId = this.props.data[currentIndex + i].id; + newDisplayedObjectIds.push(objectId); + // Fetch data for objects not already in multiPanelData + if (!prevState.multiPanelData[objectId]) { + setTimeout(() => { + this.fetchDataForMultiPanel(objectId); + }, i * 100); + } } } } @@ -800,6 +807,52 @@ export default class DataBrowser extends React.Component { }); } + fetchDataForMultiPanel(objectId) { + // Fetch data for a specific object and store it in multiPanelData + const { className, app } = this.props; + const { prefetchCache } = this.state; + const { prefetchStale } = this.getPrefetchSettings(); + + const cached = prefetchCache[objectId]; + if (cached && (!prefetchStale || (Date.now() - cached.timestamp) / 1000 < prefetchStale)) { + // Use cached data + this.setState(prev => ({ + multiPanelData: { + ...prev.multiPanelData, + [objectId]: cached.data + } + })); + } else { + // Fetch fresh data + const cloudCodeFunction = + this.props.classwiseCloudFunctions?.[ + `${app.applicationId}${this.props.appName}` + ]?.[className]?.[0]?.cloudCodeFunction; + + if (!cloudCodeFunction) { + return; + } + + const params = { + object: Parse.Object.extend(className) + .createWithoutData(objectId) + .toPointer(), + }; + const options = { useMasterKey: true }; + + Parse.Cloud.run(cloudCodeFunction, params, options).then(result => { + this.setState(prev => ({ + multiPanelData: { + ...prev.multiPanelData, + [objectId]: result + } + })); + }).catch(error => { + console.error(`Failed to fetch panel data for ${objectId}:`, error); + }); + } + } + addPanel() { const currentIndex = this.props.data?.findIndex(obj => obj.id === this.state.selectedObjectId); const newPanelCount = this.state.panelCount + 1; @@ -819,11 +872,7 @@ export default class DataBrowser extends React.Component { // Fetch data for this object if not already in multiPanelData if (!currentObjectData[objectId]) { setTimeout(() => { - this.handleCallCloudFunction( - objectId, - this.props.className, - this.props.app.applicationId - ); + this.fetchDataForMultiPanel(objectId); }, i * 100); // Stagger requests slightly } } @@ -1193,23 +1242,25 @@ export default class DataBrowser extends React.Component { const panelData = this.state.multiPanelData[objectId] || {}; const isLoading = objectId === this.state.selectedObjectId && this.props.isLoadingCloudFunction; return ( -
- + +
+ +
{index < this.state.displayedObjectIds.length - 1 && (
)} -
+
); })}
diff --git a/src/dashboard/Data/Browser/Databrowser.scss b/src/dashboard/Data/Browser/Databrowser.scss index 09733dcea5..0e70fb4bd2 100644 --- a/src/dashboard/Data/Browser/Databrowser.scss +++ b/src/dashboard/Data/Browser/Databrowser.scss @@ -28,21 +28,18 @@ display: flex; flex-direction: row; height: 100%; - overflow-x: auto; + width: 100%; } .panelColumn { flex: 1; min-width: 0; - display: flex; - flex-direction: row; overflow-y: auto; + overflow-x: hidden; } .panelSeparator { width: 1px; background-color: #e3e3ea; - align-self: stretch; - margin: 0; flex-shrink: 0; } From 54ccdf1cfb9d4a3ca71969399ff1be92c4ad4a00 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Wed, 26 Nov 2025 15:44:04 +0100 Subject: [PATCH 03/19] fix prefetch columns --- .../Data/Browser/DataBrowser.react.js | 74 ++++++++++++++++--- 1 file changed, 62 insertions(+), 12 deletions(-) diff --git a/src/dashboard/Data/Browser/DataBrowser.react.js b/src/dashboard/Data/Browser/DataBrowser.react.js index 32f19079ab..d80ba5a2fd 100644 --- a/src/dashboard/Data/Browser/DataBrowser.react.js +++ b/src/dashboard/Data/Browser/DataBrowser.react.js @@ -120,6 +120,7 @@ export default class DataBrowser extends React.Component { displayedObjectIds: [], // Array of object IDs currently displayed in the panel panelCount: 1, // Number of panels to display multiPanelData: {}, // Object mapping objectId to panel data + _objectsToFetch: [], // Temporary field for async fetch handling }; this.handleResizeDiv = this.handleResizeDiv.bind(this); @@ -742,6 +743,9 @@ export default class DataBrowser extends React.Component { // Check if the new object is already displayed in the panel let newDisplayedObjectIds = [...prevState.displayedObjectIds]; + let newMultiPanelData = { ...prevState.multiPanelData }; + const objectsToFetch = []; + if (prevState.panelCount > 1 && selectedObjectId) { // If the selected object is not in the displayed list, update all displayed objects if (!newDisplayedObjectIds.includes(selectedObjectId)) { @@ -749,14 +753,23 @@ export default class DataBrowser extends React.Component { const currentIndex = this.props.data?.findIndex(obj => obj.id === selectedObjectId); if (currentIndex !== -1) { newDisplayedObjectIds = []; + const { prefetchCache } = prevState; + const { prefetchStale } = this.getPrefetchSettings(); + for (let i = 0; i < prevState.panelCount && currentIndex + i < this.props.data.length; i++) { const objectId = this.props.data[currentIndex + i].id; newDisplayedObjectIds.push(objectId); - // Fetch data for objects not already in multiPanelData - if (!prevState.multiPanelData[objectId]) { - setTimeout(() => { - this.fetchDataForMultiPanel(objectId); - }, i * 100); + + // Check if data is already available + if (!newMultiPanelData[objectId]) { + const cached = prefetchCache[objectId]; + if (cached && (!prefetchStale || (Date.now() - cached.timestamp) / 1000 < prefetchStale)) { + // Use cached data immediately + newMultiPanelData[objectId] = cached.data; + } else { + // Mark for fetching + objectsToFetch.push({ objectId, delay: i * 100 }); + } } } } @@ -766,10 +779,24 @@ export default class DataBrowser extends React.Component { return { selectedObjectId, selectionHistory: history, - displayedObjectIds: newDisplayedObjectIds + displayedObjectIds: newDisplayedObjectIds, + multiPanelData: newMultiPanelData, + _objectsToFetch: objectsToFetch // Temporary field to handle after setState }; }, - () => this.handlePrefetch() + () => { + // Fetch any objects that weren't in cache + if (this.state._objectsToFetch && this.state._objectsToFetch.length > 0) { + this.state._objectsToFetch.forEach(({ objectId, delay }) => { + setTimeout(() => { + this.fetchDataForMultiPanel(objectId); + }, delay); + }); + // Clean up temporary field + this.setState({ _objectsToFetch: [] }); + } + this.handlePrefetch(); + } ); } } @@ -808,7 +835,7 @@ export default class DataBrowser extends React.Component { } fetchDataForMultiPanel(objectId) { - // Fetch data for a specific object and store it in multiPanelData + // Fetch data for a specific object and store it in both prefetchCache and multiPanelData const { className, app } = this.props; const { prefetchCache } = this.state; const { prefetchStale } = this.getPrefetchSettings(); @@ -841,7 +868,12 @@ export default class DataBrowser extends React.Component { const options = { useMasterKey: true }; Parse.Cloud.run(cloudCodeFunction, params, options).then(result => { + // Store in both prefetchCache and multiPanelData this.setState(prev => ({ + prefetchCache: { + ...prev.prefetchCache, + [objectId]: { data: result, timestamp: Date.now() } + }, multiPanelData: { ...prev.multiPanelData, [objectId]: result @@ -866,22 +898,40 @@ export default class DataBrowser extends React.Component { currentObjectData[this.state.selectedObjectId] = this.props.AggregationPanelData; } + const { prefetchCache } = this.state; + const { prefetchStale } = this.getPrefetchSettings(); + const objectsToFetch = []; + for (let i = 0; i < newPanelCount && currentIndex + i < this.props.data.length; i++) { const objectId = this.props.data[currentIndex + i].id; newDisplayedObjectIds.push(objectId); - // Fetch data for this object if not already in multiPanelData + + // Check if data is already available if (!currentObjectData[objectId]) { - setTimeout(() => { - this.fetchDataForMultiPanel(objectId); - }, i * 100); // Stagger requests slightly + const cached = prefetchCache[objectId]; + if (cached && (!prefetchStale || (Date.now() - cached.timestamp) / 1000 < prefetchStale)) { + // Use cached data immediately + currentObjectData[objectId] = cached.data; + } else { + // Mark for fetching + objectsToFetch.push(objectId); + } } } + // Update state with all available data this.setState({ panelCount: newPanelCount, displayedObjectIds: newDisplayedObjectIds, multiPanelData: currentObjectData }); + + // Fetch missing data asynchronously + objectsToFetch.forEach((objectId, i) => { + setTimeout(() => { + this.fetchDataForMultiPanel(objectId); + }, i * 100); + }); } } From 566529ef88665f2997b8cb64b15deb70b56e5fa3 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Wed, 26 Nov 2025 15:50:31 +0100 Subject: [PATCH 04/19] add scroll sync --- .../Data/Browser/BrowserToolbar.react.js | 21 +++++ .../Data/Browser/DataBrowser.react.js | 91 ++++++++++++++----- 2 files changed, 87 insertions(+), 25 deletions(-) diff --git a/src/dashboard/Data/Browser/BrowserToolbar.react.js b/src/dashboard/Data/Browser/BrowserToolbar.react.js index c0fb533c1f..d3aab0cc88 100644 --- a/src/dashboard/Data/Browser/BrowserToolbar.react.js +++ b/src/dashboard/Data/Browser/BrowserToolbar.react.js @@ -90,6 +90,8 @@ const BrowserToolbar = ({ toggleScrollToTop, autoLoadFirstRow, toggleAutoLoadFirstRow, + syncPanelScroll, + toggleSyncPanelScroll, }) => { const selectionLength = Object.keys(selection).length; const isPendingEditCloneRows = editCloneRows && editCloneRows.length > 0; @@ -415,6 +417,25 @@ const BrowserToolbar = ({ toggleAutoLoadFirstRow(); }} /> + + {syncPanelScroll && ( + + )} + Scroll multiple panels + + } + onClick={() => { + toggleSyncPanelScroll(); + }} + />
diff --git a/src/dashboard/Data/Browser/DataBrowser.react.js b/src/dashboard/Data/Browser/DataBrowser.react.js index d80ba5a2fd..917b0d3ae0 100644 --- a/src/dashboard/Data/Browser/DataBrowser.react.js +++ b/src/dashboard/Data/Browser/DataBrowser.react.js @@ -23,6 +23,7 @@ const BROWSER_SHOW_ROW_NUMBER = 'browserShowRowNumber'; const AGGREGATION_PANEL_VISIBLE = 'aggregationPanelVisible'; const BROWSER_SCROLL_TO_TOP = 'browserScrollToTop'; const AGGREGATION_PANEL_AUTO_LOAD_FIRST_ROW = 'aggregationPanelAutoLoadFirstRow'; +const AGGREGATION_PANEL_SYNC_SCROLL = 'aggregationPanelSyncScroll'; function formatValueForCopy(value, type) { if (value === undefined) { @@ -89,6 +90,8 @@ export default class DataBrowser extends React.Component { window.localStorage?.getItem(BROWSER_SCROLL_TO_TOP) !== 'false'; const storedAutoLoadFirstRow = window.localStorage?.getItem(AGGREGATION_PANEL_AUTO_LOAD_FIRST_ROW) === 'true'; + const storedSyncPanelScroll = + window.localStorage?.getItem(AGGREGATION_PANEL_SYNC_SCROLL) !== 'false'; const hasAggregation = props.classwiseCloudFunctions?.[ `${props.app.applicationId}${props.appName}` @@ -115,6 +118,7 @@ export default class DataBrowser extends React.Component { showRowNumber: storedRowNumber, scrollToTop: storedScrollToTop, autoLoadFirstRow: storedAutoLoadFirstRow, + syncPanelScroll: storedSyncPanelScroll, prefetchCache: {}, selectionHistory: [], displayedObjectIds: [], // Array of object IDs currently displayed in the panel @@ -144,11 +148,14 @@ export default class DataBrowser extends React.Component { this.setShowRowNumber = this.setShowRowNumber.bind(this); this.toggleScrollToTop = this.toggleScrollToTop.bind(this); this.toggleAutoLoadFirstRow = this.toggleAutoLoadFirstRow.bind(this); + this.toggleSyncPanelScroll = this.toggleSyncPanelScroll.bind(this); this.handleCellClick = this.handleCellClick.bind(this); this.addPanel = this.addPanel.bind(this); this.removePanel = this.removePanel.bind(this); + this.handlePanelScroll = this.handlePanelScroll.bind(this); this.saveOrderTimeout = null; this.aggregationPanelRef = React.createRef(); + this.panelColumnRefs = []; } componentWillReceiveProps(props) { @@ -834,6 +841,28 @@ export default class DataBrowser extends React.Component { }); } + toggleSyncPanelScroll() { + this.setState(prevState => { + const newSyncPanelScroll = !prevState.syncPanelScroll; + window.localStorage?.setItem(AGGREGATION_PANEL_SYNC_SCROLL, newSyncPanelScroll); + return { syncPanelScroll: newSyncPanelScroll }; + }); + } + + handlePanelScroll(event, index) { + if (!this.state.syncPanelScroll || this.state.panelCount <= 1) { + return; + } + + // Sync scroll position to all other panel columns + const scrollTop = event.target.scrollTop; + this.panelColumnRefs.forEach((ref, i) => { + if (i !== index && ref && ref.current) { + ref.current.scrollTop = scrollTop; + } + }); + } + fetchDataForMultiPanel(objectId) { // Fetch data for a specific object and store it in both prefetchCache and multiPanelData const { className, app } = this.props; @@ -1288,31 +1317,41 @@ export default class DataBrowser extends React.Component { > {this.state.panelCount > 1 ? (
- {this.state.displayedObjectIds.map((objectId, index) => { - const panelData = this.state.multiPanelData[objectId] || {}; - const isLoading = objectId === this.state.selectedObjectId && this.props.isLoadingCloudFunction; - return ( - -
- -
- {index < this.state.displayedObjectIds.length - 1 && ( -
- )} - - ); - })} + {(() => { + // Initialize refs array if needed + if (this.panelColumnRefs.length !== this.state.displayedObjectIds.length) { + this.panelColumnRefs = this.state.displayedObjectIds.map(() => React.createRef()); + } + return this.state.displayedObjectIds.map((objectId, index) => { + const panelData = this.state.multiPanelData[objectId] || {}; + const isLoading = objectId === this.state.selectedObjectId && this.props.isLoadingCloudFunction; + return ( + +
this.handlePanelScroll(e, index)} + > + +
+ {index < this.state.displayedObjectIds.length - 1 && ( +
+ )} + + ); + }); + })()}
) : ( From a297dfba9f66a173004ce1e7d8849478c30d2cbc Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Wed, 26 Nov 2025 15:55:03 +0100 Subject: [PATCH 05/19] scroll --- .../Data/Browser/DataBrowser.react.js | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/dashboard/Data/Browser/DataBrowser.react.js b/src/dashboard/Data/Browser/DataBrowser.react.js index 917b0d3ae0..168e5434bd 100644 --- a/src/dashboard/Data/Browser/DataBrowser.react.js +++ b/src/dashboard/Data/Browser/DataBrowser.react.js @@ -153,9 +153,11 @@ export default class DataBrowser extends React.Component { this.addPanel = this.addPanel.bind(this); this.removePanel = this.removePanel.bind(this); this.handlePanelScroll = this.handlePanelScroll.bind(this); + this.handleWrapperWheel = this.handleWrapperWheel.bind(this); this.saveOrderTimeout = null; this.aggregationPanelRef = React.createRef(); this.panelColumnRefs = []; + this.multiPanelWrapperRef = React.createRef(); } componentWillReceiveProps(props) { @@ -863,6 +865,23 @@ export default class DataBrowser extends React.Component { }); } + handleWrapperWheel(event) { + if (!this.state.syncPanelScroll || this.state.panelCount <= 1) { + return; + } + + // Prevent default scrolling + event.preventDefault(); + + // Apply scroll to all columns + const delta = event.deltaY; + this.panelColumnRefs.forEach((ref) => { + if (ref && ref.current) { + ref.current.scrollTop += delta; + } + }); + } + fetchDataForMultiPanel(objectId) { // Fetch data for a specific object and store it in both prefetchCache and multiPanelData const { className, app } = this.props; @@ -1316,7 +1335,11 @@ export default class DataBrowser extends React.Component { ref={this.aggregationPanelRef} > {this.state.panelCount > 1 ? ( -
+
{(() => { // Initialize refs array if needed if (this.panelColumnRefs.length !== this.state.displayedObjectIds.length) { From 6a8e9f5a2517b3a0a0cb8a844c8b371d51fab9c3 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Wed, 26 Nov 2025 16:00:09 +0100 Subject: [PATCH 06/19] error log fix --- .../Data/Browser/DataBrowser.react.js | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/dashboard/Data/Browser/DataBrowser.react.js b/src/dashboard/Data/Browser/DataBrowser.react.js index 168e5434bd..24fe353d8a 100644 --- a/src/dashboard/Data/Browser/DataBrowser.react.js +++ b/src/dashboard/Data/Browser/DataBrowser.react.js @@ -219,11 +219,19 @@ export default class DataBrowser extends React.Component { componentDidMount() { document.body.addEventListener('keydown', this.handleKey); window.addEventListener('resize', this.updateMaxWidth); + // Add wheel event listener for multi-panel scrolling + if (this.multiPanelWrapperRef.current && this.state.panelCount > 1 && this.state.syncPanelScroll) { + this.multiPanelWrapperRef.current.addEventListener('wheel', this.handleWrapperWheel, { passive: false }); + } } componentWillUnmount() { document.body.removeEventListener('keydown', this.handleKey); window.removeEventListener('resize', this.updateMaxWidth); + // Remove wheel event listener + if (this.multiPanelWrapperRef.current) { + this.multiPanelWrapperRef.current.removeEventListener('wheel', this.handleWrapperWheel); + } } componentDidUpdate(prevProps, prevState) { @@ -289,6 +297,20 @@ export default class DataBrowser extends React.Component { } })); } + + // Manage wheel event listener based on state changes + const prevNeedsListener = prevState.panelCount > 1 && prevState.syncPanelScroll; + const nowNeedsListener = this.state.panelCount > 1 && this.state.syncPanelScroll; + + if (prevNeedsListener !== nowNeedsListener && this.multiPanelWrapperRef.current) { + if (nowNeedsListener) { + // Add listener + this.multiPanelWrapperRef.current.addEventListener('wheel', this.handleWrapperWheel, { passive: false }); + } else { + // Remove listener + this.multiPanelWrapperRef.current.removeEventListener('wheel', this.handleWrapperWheel); + } + } } handleResizeStart() { @@ -1338,7 +1360,6 @@ export default class DataBrowser extends React.Component {
{(() => { // Initialize refs array if needed From 84e3d8a4024ef8a2ff00254bc3a2dec9459d280f Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Wed, 26 Nov 2025 16:09:27 +0100 Subject: [PATCH 07/19] scrollbar width --- src/dashboard/Data/Browser/Databrowser.scss | 26 +++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/dashboard/Data/Browser/Databrowser.scss b/src/dashboard/Data/Browser/Databrowser.scss index 0e70fb4bd2..f6014c5e1e 100644 --- a/src/dashboard/Data/Browser/Databrowser.scss +++ b/src/dashboard/Data/Browser/Databrowser.scss @@ -36,6 +36,32 @@ min-width: 0; overflow-y: auto; overflow-x: hidden; + + /* Custom narrow scrollbar for webkit browsers */ + &::-webkit-scrollbar { + width: 4px; + } + + &::-webkit-scrollbar:hover { + width: 4px; + } + + &::-webkit-scrollbar-track { + background: #f0f0f0; + } + + &::-webkit-scrollbar-thumb { + background: #c0c0c0; + border-radius: 3px; + } + + &::-webkit-scrollbar-thumb:hover { + background: #a0a0a0; + } + + /* Firefox scrollbar support */ + scrollbar-width: thin; + scrollbar-color: #c0c0c0 #f0f0f0; } .panelSeparator { From 81f83204d07cd7cc60f751129084a9ec8c7b5524 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Wed, 26 Nov 2025 16:13:20 +0100 Subject: [PATCH 08/19] scrollbar style --- src/dashboard/Data/Browser/Databrowser.scss | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/dashboard/Data/Browser/Databrowser.scss b/src/dashboard/Data/Browser/Databrowser.scss index f6014c5e1e..c78024fd2e 100644 --- a/src/dashboard/Data/Browser/Databrowser.scss +++ b/src/dashboard/Data/Browser/Databrowser.scss @@ -47,21 +47,21 @@ } &::-webkit-scrollbar-track { - background: #f0f0f0; + background: transparent; } &::-webkit-scrollbar-thumb { - background: #c0c0c0; + background: rgba(0, 0, 0, 0.2); border-radius: 3px; } &::-webkit-scrollbar-thumb:hover { - background: #a0a0a0; + background: rgba(0, 0, 0, 0.4); } /* Firefox scrollbar support */ scrollbar-width: thin; - scrollbar-color: #c0c0c0 #f0f0f0; + scrollbar-color: rgba(0, 0, 0, 0.2) transparent; } .panelSeparator { From f558fe5b6dba189d4cfc9bbeb845ac713e2b11ab Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Wed, 26 Nov 2025 16:15:25 +0100 Subject: [PATCH 09/19] fix nav back --- .../Data/Browser/DataBrowser.react.js | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/dashboard/Data/Browser/DataBrowser.react.js b/src/dashboard/Data/Browser/DataBrowser.react.js index 24fe353d8a..aced8ae676 100644 --- a/src/dashboard/Data/Browser/DataBrowser.react.js +++ b/src/dashboard/Data/Browser/DataBrowser.react.js @@ -787,8 +787,22 @@ export default class DataBrowser extends React.Component { const { prefetchCache } = prevState; const { prefetchStale } = this.getPrefetchSettings(); - for (let i = 0; i < prevState.panelCount && currentIndex + i < this.props.data.length; i++) { - const objectId = this.props.data[currentIndex + i].id; + // Determine if navigating up or down + const oldFirstIndex = this.props.data?.findIndex(obj => obj.id === prevState.displayedObjectIds[0]); + const navigatingUp = currentIndex < oldFirstIndex; + + // Calculate the starting index for the new batch + let startIndex; + if (navigatingUp) { + // When navigating up, position the new object at the END of the batch + startIndex = Math.max(0, currentIndex - prevState.panelCount + 1); + } else { + // When navigating down, position the new object at the START of the batch + startIndex = currentIndex; + } + + for (let i = 0; i < prevState.panelCount && startIndex + i < this.props.data.length; i++) { + const objectId = this.props.data[startIndex + i].id; newDisplayedObjectIds.push(objectId); // Check if data is already available From b28933c429dd1e9769d78b6b84b3b1c19417b1b2 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Wed, 26 Nov 2025 16:20:40 +0100 Subject: [PATCH 10/19] fix button width --- src/components/Toolbar/Toolbar.scss | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Toolbar/Toolbar.scss b/src/components/Toolbar/Toolbar.scss index d40ba99f27..5453305442 100644 --- a/src/components/Toolbar/Toolbar.scss +++ b/src/components/Toolbar/Toolbar.scss @@ -137,14 +137,14 @@ body:global(.expanded) { .btn { position: relative; border: none; - padding: 6px 3px; - width: 110px; + padding: 6px 8px; background: none; color: white; display: flex; align-items: center; gap: 5px; cursor: pointer; + white-space: nowrap; svg { From b9c891e3012d97d7be3dde2e6f2df47b985129ef Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Wed, 26 Nov 2025 16:26:16 +0100 Subject: [PATCH 11/19] menu rename --- src/dashboard/Data/Browser/BrowserToolbar.react.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dashboard/Data/Browser/BrowserToolbar.react.js b/src/dashboard/Data/Browser/BrowserToolbar.react.js index d3aab0cc88..964dabdb73 100644 --- a/src/dashboard/Data/Browser/BrowserToolbar.react.js +++ b/src/dashboard/Data/Browser/BrowserToolbar.react.js @@ -429,7 +429,7 @@ const BrowserToolbar = ({ className="menuCheck" /> )} - Scroll multiple panels + Sync panel scrolling } onClick={() => { From f9eac4bc32d1a8ee00817457c8a42d65a031a66b Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Wed, 26 Nov 2025 20:28:09 +0100 Subject: [PATCH 12/19] batch navigation --- .../Data/Browser/BrowserToolbar.react.js | 21 +++ .../Data/Browser/DataBrowser.react.js | 121 +++++++++++++----- 2 files changed, 110 insertions(+), 32 deletions(-) diff --git a/src/dashboard/Data/Browser/BrowserToolbar.react.js b/src/dashboard/Data/Browser/BrowserToolbar.react.js index 964dabdb73..0dfc2b06a5 100644 --- a/src/dashboard/Data/Browser/BrowserToolbar.react.js +++ b/src/dashboard/Data/Browser/BrowserToolbar.react.js @@ -92,6 +92,8 @@ const BrowserToolbar = ({ toggleAutoLoadFirstRow, syncPanelScroll, toggleSyncPanelScroll, + batchNavigate, + toggleBatchNavigate, }) => { const selectionLength = Object.keys(selection).length; const isPendingEditCloneRows = editCloneRows && editCloneRows.length > 0; @@ -436,6 +438,25 @@ const BrowserToolbar = ({ toggleSyncPanelScroll(); }} /> + + {batchNavigate && ( + + )} + Batch-navigate panels + + } + onClick={() => { + toggleBatchNavigate(); + }} + />
diff --git a/src/dashboard/Data/Browser/DataBrowser.react.js b/src/dashboard/Data/Browser/DataBrowser.react.js index aced8ae676..30914d515e 100644 --- a/src/dashboard/Data/Browser/DataBrowser.react.js +++ b/src/dashboard/Data/Browser/DataBrowser.react.js @@ -24,6 +24,7 @@ const AGGREGATION_PANEL_VISIBLE = 'aggregationPanelVisible'; const BROWSER_SCROLL_TO_TOP = 'browserScrollToTop'; const AGGREGATION_PANEL_AUTO_LOAD_FIRST_ROW = 'aggregationPanelAutoLoadFirstRow'; const AGGREGATION_PANEL_SYNC_SCROLL = 'aggregationPanelSyncScroll'; +const AGGREGATION_PANEL_BATCH_NAVIGATE = 'aggregationPanelBatchNavigate'; function formatValueForCopy(value, type) { if (value === undefined) { @@ -92,6 +93,8 @@ export default class DataBrowser extends React.Component { window.localStorage?.getItem(AGGREGATION_PANEL_AUTO_LOAD_FIRST_ROW) === 'true'; const storedSyncPanelScroll = window.localStorage?.getItem(AGGREGATION_PANEL_SYNC_SCROLL) !== 'false'; + const storedBatchNavigate = + window.localStorage?.getItem(AGGREGATION_PANEL_BATCH_NAVIGATE) !== 'false'; const hasAggregation = props.classwiseCloudFunctions?.[ `${props.app.applicationId}${props.appName}` @@ -119,6 +122,7 @@ export default class DataBrowser extends React.Component { scrollToTop: storedScrollToTop, autoLoadFirstRow: storedAutoLoadFirstRow, syncPanelScroll: storedSyncPanelScroll, + batchNavigate: storedBatchNavigate, prefetchCache: {}, selectionHistory: [], displayedObjectIds: [], // Array of object IDs currently displayed in the panel @@ -149,6 +153,7 @@ export default class DataBrowser extends React.Component { this.toggleScrollToTop = this.toggleScrollToTop.bind(this); this.toggleAutoLoadFirstRow = this.toggleAutoLoadFirstRow.bind(this); this.toggleSyncPanelScroll = this.toggleSyncPanelScroll.bind(this); + this.toggleBatchNavigate = this.toggleBatchNavigate.bind(this); this.handleCellClick = this.handleCellClick.bind(this); this.addPanel = this.addPanel.bind(this); this.removePanel = this.removePanel.bind(this); @@ -621,7 +626,9 @@ export default class DataBrowser extends React.Component { // Up - standalone (move to the previous row) // or with ctrl/meta (excel style - move to the first row) const prevObjectID = this.state.selectedObjectId; - const newRow = e.ctrlKey || e.metaKey ? 0 : Math.max(this.state.current.row - 1, 0); + // Calculate step size based on batch navigation mode + const stepSize = this.state.panelCount > 1 && this.state.batchNavigate ? this.state.panelCount : 1; + const newRow = e.ctrlKey || e.metaKey ? 0 : Math.max(this.state.current.row - stepSize, 0); this.setState({ current: { row: newRow, @@ -664,10 +671,12 @@ export default class DataBrowser extends React.Component { // Down - standalone (move to the next row) // or with ctrl/meta (excel style - move to the last row) const prevObjectID = this.state.selectedObjectId; + // Calculate step size based on batch navigation mode + const stepSizeDown = this.state.panelCount > 1 && this.state.batchNavigate ? this.state.panelCount : 1; const newRow = e.ctrlKey || e.metaKey ? this.props.data.length - 1 - : Math.min(this.state.current.row + 1, this.props.data.length - 1); + : Math.min(this.state.current.row + stepSizeDown, this.props.data.length - 1); this.setState({ current: { row: newRow, @@ -778,42 +787,80 @@ export default class DataBrowser extends React.Component { const objectsToFetch = []; if (prevState.panelCount > 1 && selectedObjectId) { - // If the selected object is not in the displayed list, update all displayed objects - if (!newDisplayedObjectIds.includes(selectedObjectId)) { - // Shift to the next batch of objects - const currentIndex = this.props.data?.findIndex(obj => obj.id === selectedObjectId); - if (currentIndex !== -1) { - newDisplayedObjectIds = []; - const { prefetchCache } = prevState; - const { prefetchStale } = this.getPrefetchSettings(); - - // Determine if navigating up or down - const oldFirstIndex = this.props.data?.findIndex(obj => obj.id === prevState.displayedObjectIds[0]); - const navigatingUp = currentIndex < oldFirstIndex; - - // Calculate the starting index for the new batch - let startIndex; - if (navigatingUp) { - // When navigating up, position the new object at the END of the batch - startIndex = Math.max(0, currentIndex - prevState.panelCount + 1); - } else { - // When navigating down, position the new object at the START of the batch - startIndex = currentIndex; - } + // Check if batch navigation is enabled + if (prevState.batchNavigate) { + // If the selected object is not in the displayed list, update all displayed objects + if (!newDisplayedObjectIds.includes(selectedObjectId)) { + // Shift to the next batch of objects + const currentIndex = this.props.data?.findIndex(obj => obj.id === selectedObjectId); + if (currentIndex !== -1) { + newDisplayedObjectIds = []; + const { prefetchCache } = prevState; + const { prefetchStale } = this.getPrefetchSettings(); + + // Determine if navigating up or down + const oldFirstIndex = this.props.data?.findIndex(obj => obj.id === prevState.displayedObjectIds[0]); + const navigatingUp = currentIndex < oldFirstIndex; + + // Calculate the starting index for the new batch + let startIndex; + if (navigatingUp) { + // When navigating up, position the new object at the END of the batch + startIndex = Math.max(0, currentIndex - prevState.panelCount + 1); + } else { + // When navigating down, position the new object at the START of the batch + startIndex = currentIndex; + } - for (let i = 0; i < prevState.panelCount && startIndex + i < this.props.data.length; i++) { - const objectId = this.props.data[startIndex + i].id; - newDisplayedObjectIds.push(objectId); + for (let i = 0; i < prevState.panelCount && startIndex + i < this.props.data.length; i++) { + const objectId = this.props.data[startIndex + i].id; + newDisplayedObjectIds.push(objectId); + + // Check if data is already available + if (!newMultiPanelData[objectId]) { + const cached = prefetchCache[objectId]; + if (cached && (!prefetchStale || (Date.now() - cached.timestamp) / 1000 < prefetchStale)) { + // Use cached data immediately + newMultiPanelData[objectId] = cached.data; + } else { + // Mark for fetching + objectsToFetch.push({ objectId, delay: i * 100 }); + } + } + } + } + } + } else { + // Individual navigation mode: slide the window one object at a time + if (!newDisplayedObjectIds.includes(selectedObjectId)) { + const currentIndex = this.props.data?.findIndex(obj => obj.id === selectedObjectId); + if (currentIndex !== -1) { + const { prefetchCache } = prevState; + const { prefetchStale } = this.getPrefetchSettings(); + + // Determine if navigating up or down + const oldFirstIndex = this.props.data?.findIndex(obj => obj.id === prevState.displayedObjectIds[0]); + const navigatingUp = currentIndex < oldFirstIndex; + + if (navigatingUp) { + // Remove the last object and add the new object at the beginning + newDisplayedObjectIds.pop(); + newDisplayedObjectIds.unshift(selectedObjectId); + } else { + // Remove the first object and add the new object at the end + newDisplayedObjectIds.shift(); + newDisplayedObjectIds.push(selectedObjectId); + } - // Check if data is already available - if (!newMultiPanelData[objectId]) { - const cached = prefetchCache[objectId]; + // Check if data is already available for the new object + if (!newMultiPanelData[selectedObjectId]) { + const cached = prefetchCache[selectedObjectId]; if (cached && (!prefetchStale || (Date.now() - cached.timestamp) / 1000 < prefetchStale)) { // Use cached data immediately - newMultiPanelData[objectId] = cached.data; + newMultiPanelData[selectedObjectId] = cached.data; } else { // Mark for fetching - objectsToFetch.push({ objectId, delay: i * 100 }); + objectsToFetch.push({ objectId: selectedObjectId, delay: 0 }); } } } @@ -887,6 +934,14 @@ export default class DataBrowser extends React.Component { }); } + toggleBatchNavigate() { + this.setState(prevState => { + const newBatchNavigate = !prevState.batchNavigate; + window.localStorage?.setItem(AGGREGATION_PANEL_BATCH_NAVIGATE, newBatchNavigate); + return { batchNavigate: newBatchNavigate }; + }); + } + handlePanelScroll(event, index) { if (!this.state.syncPanelScroll || this.state.panelCount <= 1) { return; @@ -1468,6 +1523,8 @@ export default class DataBrowser extends React.Component { toggleAutoLoadFirstRow={this.toggleAutoLoadFirstRow} syncPanelScroll={this.state.syncPanelScroll} toggleSyncPanelScroll={this.toggleSyncPanelScroll} + batchNavigate={this.state.batchNavigate} + toggleBatchNavigate={this.toggleBatchNavigate} {...other} /> From 0fa75ce910e5d2d77bb90afee7262a0a3a48e9fb Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Wed, 26 Nov 2025 20:32:13 +0100 Subject: [PATCH 13/19] simplify batch nav --- .../Data/Browser/DataBrowser.react.js | 98 ++++++------------- 1 file changed, 30 insertions(+), 68 deletions(-) diff --git a/src/dashboard/Data/Browser/DataBrowser.react.js b/src/dashboard/Data/Browser/DataBrowser.react.js index 30914d515e..7b2fbcc71c 100644 --- a/src/dashboard/Data/Browser/DataBrowser.react.js +++ b/src/dashboard/Data/Browser/DataBrowser.react.js @@ -787,80 +787,42 @@ export default class DataBrowser extends React.Component { const objectsToFetch = []; if (prevState.panelCount > 1 && selectedObjectId) { - // Check if batch navigation is enabled - if (prevState.batchNavigate) { - // If the selected object is not in the displayed list, update all displayed objects - if (!newDisplayedObjectIds.includes(selectedObjectId)) { - // Shift to the next batch of objects - const currentIndex = this.props.data?.findIndex(obj => obj.id === selectedObjectId); - if (currentIndex !== -1) { - newDisplayedObjectIds = []; - const { prefetchCache } = prevState; - const { prefetchStale } = this.getPrefetchSettings(); - - // Determine if navigating up or down - const oldFirstIndex = this.props.data?.findIndex(obj => obj.id === prevState.displayedObjectIds[0]); - const navigatingUp = currentIndex < oldFirstIndex; - - // Calculate the starting index for the new batch - let startIndex; - if (navigatingUp) { - // When navigating up, position the new object at the END of the batch - startIndex = Math.max(0, currentIndex - prevState.panelCount + 1); - } else { - // When navigating down, position the new object at the START of the batch - startIndex = currentIndex; - } - - for (let i = 0; i < prevState.panelCount && startIndex + i < this.props.data.length; i++) { - const objectId = this.props.data[startIndex + i].id; - newDisplayedObjectIds.push(objectId); - - // Check if data is already available - if (!newMultiPanelData[objectId]) { - const cached = prefetchCache[objectId]; - if (cached && (!prefetchStale || (Date.now() - cached.timestamp) / 1000 < prefetchStale)) { - // Use cached data immediately - newMultiPanelData[objectId] = cached.data; - } else { - // Mark for fetching - objectsToFetch.push({ objectId, delay: i * 100 }); - } - } - } + // If the selected object is not in the displayed list, update displayed objects + if (!newDisplayedObjectIds.includes(selectedObjectId)) { + const currentIndex = this.props.data?.findIndex(obj => obj.id === selectedObjectId); + if (currentIndex !== -1) { + const { prefetchCache } = prevState; + const { prefetchStale } = this.getPrefetchSettings(); + + // Determine if navigating up or down + const oldFirstIndex = this.props.data?.findIndex(obj => obj.id === prevState.displayedObjectIds[0]); + const navigatingUp = currentIndex < oldFirstIndex; + + // Calculate the starting index for the new batch + let startIndex; + if (navigatingUp) { + // When navigating up, position the new object at the END of the batch + startIndex = Math.max(0, currentIndex - prevState.panelCount + 1); + } else { + // When navigating down, position the new object at the START of the batch + startIndex = currentIndex; } - } - } else { - // Individual navigation mode: slide the window one object at a time - if (!newDisplayedObjectIds.includes(selectedObjectId)) { - const currentIndex = this.props.data?.findIndex(obj => obj.id === selectedObjectId); - if (currentIndex !== -1) { - const { prefetchCache } = prevState; - const { prefetchStale } = this.getPrefetchSettings(); - - // Determine if navigating up or down - const oldFirstIndex = this.props.data?.findIndex(obj => obj.id === prevState.displayedObjectIds[0]); - const navigatingUp = currentIndex < oldFirstIndex; - - if (navigatingUp) { - // Remove the last object and add the new object at the beginning - newDisplayedObjectIds.pop(); - newDisplayedObjectIds.unshift(selectedObjectId); - } else { - // Remove the first object and add the new object at the end - newDisplayedObjectIds.shift(); - newDisplayedObjectIds.push(selectedObjectId); - } - // Check if data is already available for the new object - if (!newMultiPanelData[selectedObjectId]) { - const cached = prefetchCache[selectedObjectId]; + // Build the new batch of displayed objects + newDisplayedObjectIds = []; + for (let i = 0; i < prevState.panelCount && startIndex + i < this.props.data.length; i++) { + const objectId = this.props.data[startIndex + i].id; + newDisplayedObjectIds.push(objectId); + + // Check if data is already available + if (!newMultiPanelData[objectId]) { + const cached = prefetchCache[objectId]; if (cached && (!prefetchStale || (Date.now() - cached.timestamp) / 1000 < prefetchStale)) { // Use cached data immediately - newMultiPanelData[selectedObjectId] = cached.data; + newMultiPanelData[objectId] = cached.data; } else { // Mark for fetching - objectsToFetch.push({ objectId: selectedObjectId, delay: 0 }); + objectsToFetch.push({ objectId, delay: i * 100 }); } } } From 7f5355c4f8b96b111e23c15e3b239ab9d21b6b0e Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Wed, 26 Nov 2025 20:46:34 +0100 Subject: [PATCH 14/19] fix pre-fetching --- README.md | 2 +- .../Data/Browser/DataBrowser.react.js | 31 ++++++++++++++++--- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 08e4d7419e..d30bbb24ec 100644 --- a/README.md +++ b/README.md @@ -1378,7 +1378,7 @@ To reduce the time for info panel data to appear, data can be prefetched. | Parameter | Type | Optional | Default | Example | Description | |--------------------------------|---------|----------|---------|---------|-----------------------------------------------------------------------------------------------------------------------------------| -| `infoPanel[*].prefetchObjects` | Number | yes | `0` | `2` | Number of next rows to prefetch when browsing sequential rows. For example, `2` means the next 2 rows will be fetched in advance. | +| `infoPanel[*].prefetchObjects` | Number | yes | `0` | `2` | Number of navigation steps to prefetch ahead when browsing sequential rows. For example, `2` means data for the next 2 navigation steps will be fetched in advance. When using multi-panel mode with batch navigation enabled, each navigation step corresponds to a full batch of panels, so the total number of prefetched objects will be `prefetchObjects × panelCount`. | | `infoPanel[*].prefetchStale` | Number | yes | `0` | `10` | Duration in seconds after which prefetched data is discarded as stale. | | `infoPanel[*].prefetchImage` | Boolean | yes | `true` | `false` | Whether to prefetch image content when prefetching objects. Only applies when `prefetchObjects` is enabled. | | `infoPanel[*].prefetchVideo` | Boolean | yes | `true` | `false` | Whether to prefetch video content when prefetching objects. Only applies when `prefetchObjects` is enabled. | diff --git a/src/dashboard/Data/Browser/DataBrowser.react.js b/src/dashboard/Data/Browser/DataBrowser.react.js index 7b2fbcc71c..51ee8dc5a3 100644 --- a/src/dashboard/Data/Browser/DataBrowser.react.js +++ b/src/dashboard/Data/Browser/DataBrowser.react.js @@ -1089,15 +1089,36 @@ export default class DataBrowser extends React.Component { return; } const [a, b, c] = history.slice(-3); - if (a + 1 === b && b + 1 === c) { + // Detect step size from the last two selections + const stepAB = b - a; + const stepBC = c - b; + // Check if we have a consistent navigation pattern (same step size) + if (stepAB === stepBC && stepAB > 0) { + // Prefetch ahead based on the detected step size + const stepSize = stepAB; + const panelCount = this.state.panelCount; + + // When in multi-panel mode, prefetch all objects in the upcoming batches for ( let i = 1; - i <= prefetchObjects && c + i < this.props.data.length; + i <= prefetchObjects && c + (i * stepSize) < this.props.data.length; i++ ) { - const objId = this.props.data[c + i].id; - if (!cache[objId]) { - this.prefetchObject(objId); + // For each step ahead, prefetch the main object + const mainObjId = this.props.data[c + (i * stepSize)].id; + if (!Object.prototype.hasOwnProperty.call(cache, mainObjId)) { + this.prefetchObject(mainObjId); + } + + // If in multi-panel mode, also prefetch the other objects that would be displayed in the batch + if (panelCount > 1) { + const batchStartIndex = c + (i * stepSize); + for (let j = 1; j < panelCount && batchStartIndex + j < this.props.data.length; j++) { + const batchObjId = this.props.data[batchStartIndex + j].id; + if (!Object.prototype.hasOwnProperty.call(cache, batchObjId)) { + this.prefetchObject(batchObjId); + } + } } } } From c68007dca41afb3f7c1866e1ca298ca9389af2f6 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Wed, 26 Nov 2025 21:10:27 +0100 Subject: [PATCH 15/19] add panel checkbox --- .../Data/Browser/BrowserToolbar.react.js | 21 +++++++++++++ .../Data/Browser/DataBrowser.react.js | 30 +++++++++++++++++++ src/dashboard/Data/Browser/Databrowser.scss | 20 ++++++++++++- 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/src/dashboard/Data/Browser/BrowserToolbar.react.js b/src/dashboard/Data/Browser/BrowserToolbar.react.js index 0dfc2b06a5..f57018c7fb 100644 --- a/src/dashboard/Data/Browser/BrowserToolbar.react.js +++ b/src/dashboard/Data/Browser/BrowserToolbar.react.js @@ -94,6 +94,8 @@ const BrowserToolbar = ({ toggleSyncPanelScroll, batchNavigate, toggleBatchNavigate, + showPanelCheckbox, + toggleShowPanelCheckbox, }) => { const selectionLength = Object.keys(selection).length; const isPendingEditCloneRows = editCloneRows && editCloneRows.length > 0; @@ -457,6 +459,25 @@ const BrowserToolbar = ({ toggleBatchNavigate(); }} /> + + {showPanelCheckbox && ( + + )} + Show panel selection + + } + onClick={() => { + toggleShowPanelCheckbox(); + }} + />
diff --git a/src/dashboard/Data/Browser/DataBrowser.react.js b/src/dashboard/Data/Browser/DataBrowser.react.js index 51ee8dc5a3..3cea720906 100644 --- a/src/dashboard/Data/Browser/DataBrowser.react.js +++ b/src/dashboard/Data/Browser/DataBrowser.react.js @@ -25,6 +25,7 @@ const BROWSER_SCROLL_TO_TOP = 'browserScrollToTop'; const AGGREGATION_PANEL_AUTO_LOAD_FIRST_ROW = 'aggregationPanelAutoLoadFirstRow'; const AGGREGATION_PANEL_SYNC_SCROLL = 'aggregationPanelSyncScroll'; const AGGREGATION_PANEL_BATCH_NAVIGATE = 'aggregationPanelBatchNavigate'; +const AGGREGATION_PANEL_SHOW_CHECKBOX = 'aggregationPanelShowCheckbox'; function formatValueForCopy(value, type) { if (value === undefined) { @@ -95,6 +96,8 @@ export default class DataBrowser extends React.Component { window.localStorage?.getItem(AGGREGATION_PANEL_SYNC_SCROLL) !== 'false'; const storedBatchNavigate = window.localStorage?.getItem(AGGREGATION_PANEL_BATCH_NAVIGATE) !== 'false'; + const storedShowPanelCheckbox = + window.localStorage?.getItem(AGGREGATION_PANEL_SHOW_CHECKBOX) !== 'false'; const hasAggregation = props.classwiseCloudFunctions?.[ `${props.app.applicationId}${props.appName}` @@ -123,6 +126,7 @@ export default class DataBrowser extends React.Component { autoLoadFirstRow: storedAutoLoadFirstRow, syncPanelScroll: storedSyncPanelScroll, batchNavigate: storedBatchNavigate, + showPanelCheckbox: storedShowPanelCheckbox, prefetchCache: {}, selectionHistory: [], displayedObjectIds: [], // Array of object IDs currently displayed in the panel @@ -154,6 +158,7 @@ export default class DataBrowser extends React.Component { this.toggleAutoLoadFirstRow = this.toggleAutoLoadFirstRow.bind(this); this.toggleSyncPanelScroll = this.toggleSyncPanelScroll.bind(this); this.toggleBatchNavigate = this.toggleBatchNavigate.bind(this); + this.toggleShowPanelCheckbox = this.toggleShowPanelCheckbox.bind(this); this.handleCellClick = this.handleCellClick.bind(this); this.addPanel = this.addPanel.bind(this); this.removePanel = this.removePanel.bind(this); @@ -904,6 +909,14 @@ export default class DataBrowser extends React.Component { }); } + toggleShowPanelCheckbox() { + this.setState(prevState => { + const newShowPanelCheckbox = !prevState.showPanelCheckbox; + window.localStorage?.setItem(AGGREGATION_PANEL_SHOW_CHECKBOX, String(newShowPanelCheckbox)); + return { showPanelCheckbox: newShowPanelCheckbox }; + }); + } + handlePanelScroll(event, index) { if (!this.state.syncPanelScroll || this.state.panelCount <= 1) { return; @@ -1421,6 +1434,7 @@ export default class DataBrowser extends React.Component { return this.state.displayedObjectIds.map((objectId, index) => { const panelData = this.state.multiPanelData[objectId] || {}; const isLoading = objectId === this.state.selectedObjectId && this.props.isLoadingCloudFunction; + const isRowSelected = this.props.selection[objectId]; return (
this.handlePanelScroll(e, index)} > + {this.state.showPanelCheckbox && ( +
+ { + this.props.selectRow(objectId, !isRowSelected); + }} + onMouseDown={(e) => { + e.preventDefault(); + }} + /> +
+ )} diff --git a/src/dashboard/Data/Browser/Databrowser.scss b/src/dashboard/Data/Browser/Databrowser.scss index c78024fd2e..352a8903d8 100644 --- a/src/dashboard/Data/Browser/Databrowser.scss +++ b/src/dashboard/Data/Browser/Databrowser.scss @@ -66,6 +66,24 @@ .panelSeparator { width: 1px; - background-color: #e3e3ea; + background-color: white; flex-shrink: 0; } + +.panelHeader { + position: sticky; + top: 0; + z-index: 10; + background-color: white; + padding: 8px; + border-bottom: 1px solid #e3e3ea; + display: flex; + align-items: center; + justify-content: center; + + input[type="checkbox"] { + cursor: pointer; + width: 16px; + height: 16px; + } +} From ce06f08e48df9e15deb7bd6f7ff6f8aa4b15d2e9 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Wed, 26 Nov 2025 21:34:18 +0100 Subject: [PATCH 16/19] fix wrong selection upward navigation --- src/dashboard/Data/Browser/DataBrowser.react.js | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/src/dashboard/Data/Browser/DataBrowser.react.js b/src/dashboard/Data/Browser/DataBrowser.react.js index 3cea720906..068262f942 100644 --- a/src/dashboard/Data/Browser/DataBrowser.react.js +++ b/src/dashboard/Data/Browser/DataBrowser.react.js @@ -799,19 +799,9 @@ export default class DataBrowser extends React.Component { const { prefetchCache } = prevState; const { prefetchStale } = this.getPrefetchSettings(); - // Determine if navigating up or down - const oldFirstIndex = this.props.data?.findIndex(obj => obj.id === prevState.displayedObjectIds[0]); - const navigatingUp = currentIndex < oldFirstIndex; - // Calculate the starting index for the new batch - let startIndex; - if (navigatingUp) { - // When navigating up, position the new object at the END of the batch - startIndex = Math.max(0, currentIndex - prevState.panelCount + 1); - } else { - // When navigating down, position the new object at the START of the batch - startIndex = currentIndex; - } + // Always position the selected object at the START of the batch for consistency + const startIndex = currentIndex; // Build the new batch of displayed objects newDisplayedObjectIds = []; From a5772aa90498c7d7631c9c3a01f0bdd421122b0f Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Wed, 26 Nov 2025 21:45:09 +0100 Subject: [PATCH 17/19] header style and clickable --- src/dashboard/Data/Browser/DataBrowser.react.js | 17 ++++++++++------- src/dashboard/Data/Browser/Databrowser.scss | 7 ++++--- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/dashboard/Data/Browser/DataBrowser.react.js b/src/dashboard/Data/Browser/DataBrowser.react.js index 068262f942..d658159094 100644 --- a/src/dashboard/Data/Browser/DataBrowser.react.js +++ b/src/dashboard/Data/Browser/DataBrowser.react.js @@ -1433,16 +1433,19 @@ export default class DataBrowser extends React.Component { onScroll={(e) => this.handlePanelScroll(e, index)} > {this.state.showPanelCheckbox && ( -
+
{ + this.props.selectRow(objectId, !isRowSelected); + }} + onMouseDown={(e) => { + e.preventDefault(); + }} + > { - this.props.selectRow(objectId, !isRowSelected); - }} - onMouseDown={(e) => { - e.preventDefault(); - }} + readOnly />
)} diff --git a/src/dashboard/Data/Browser/Databrowser.scss b/src/dashboard/Data/Browser/Databrowser.scss index 352a8903d8..dc3d1e3e2e 100644 --- a/src/dashboard/Data/Browser/Databrowser.scss +++ b/src/dashboard/Data/Browser/Databrowser.scss @@ -74,15 +74,16 @@ position: sticky; top: 0; z-index: 10; - background-color: white; - padding: 8px; + background-color: #67637a; + padding: 8px 8px 5px 8px; border-bottom: 1px solid #e3e3ea; display: flex; align-items: center; justify-content: center; + cursor: pointer; input[type="checkbox"] { - cursor: pointer; + pointer-events: none; width: 16px; height: 16px; } From 708626d24dee354ec4baa5effe0b688d7ba1dd4f Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Wed, 26 Nov 2025 22:01:53 +0100 Subject: [PATCH 18/19] style --- src/components/Toolbar/Toolbar.react.js | 10 ++++++---- src/dashboard/Data/Browser/Databrowser.scss | 8 ++++---- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/components/Toolbar/Toolbar.react.js b/src/components/Toolbar/Toolbar.react.js index fb41e49d80..9193d96692 100644 --- a/src/components/Toolbar/Toolbar.react.js +++ b/src/components/Toolbar/Toolbar.react.js @@ -161,14 +161,16 @@ const Toolbar = props => {
{props.isPanelVisible && ( <> + {props.panelCount > 1 && ( + + )} - )}