Skip to content

Commit e7903b6

Browse files
feat(datasets): implement google sheets deep linking and dataset copy/paste (#298) 657b799
1 parent bd0576a commit e7903b6

File tree

4 files changed

+204
-26
lines changed

4 files changed

+204
-26
lines changed

src/App.js

Lines changed: 176 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,13 @@ import {
1818
saveDatasetAsJson,
1919
saveToIndexedDB,
2020
} from "./localStorage";
21-
import { exportToGoogleSheet, requestSheetsToken } from "./GoogleSheets";
21+
import {
22+
exportToGoogleSheet,
23+
requestSheetsToken,
24+
extractSpreadsheetId,
25+
importFromGoogleSheet,
26+
getApiType,
27+
} from "./GoogleSheets";
2228
import _ from "lodash";
2329
import { getQueryStringValue, setQueryStringValue } from "./queryString";
2430
import "./global.css";
@@ -177,6 +183,16 @@ class App extends React.Component {
177183
this.updateMapAndAssociatedData();
178184
});
179185

186+
const pendingSheetId = getQueryStringValue("sheetId");
187+
if (pendingSheetId) {
188+
// Remove the sheetId from the URL to prevent reload loops
189+
const url = new URL(window.location.href);
190+
url.searchParams.delete("sheetId");
191+
window.history.replaceState({}, document.title, url.toString());
192+
193+
this.loadSharedSheet(pendingSheetId);
194+
}
195+
180196
// Initialize the Broadcast Channel
181197
this.syncChannel = new BroadcastChannel("app_playback_sync");
182198
this.syncChannel.onmessage = this.handleSyncMessage;
@@ -203,6 +219,50 @@ class App extends React.Component {
203219
}
204220
};
205221

222+
loadSharedSheet = async (sheetId) => {
223+
this.setState({ pendingSheetId: null });
224+
try {
225+
toast.info("Loading Google Sheet...");
226+
const token = await requestSheetsToken();
227+
const logs = await importFromGoogleSheet(sheetId, token);
228+
229+
const logsWithTimestamp = logs.map((logEntry) => ({
230+
...logEntry,
231+
timestamp: logEntry.timestamp || new Date().toISOString(),
232+
}));
233+
234+
await uploadCloudLogs(logsWithTimestamp, "_clipboard");
235+
236+
toast.success("Dataset from Google Sheet loaded. You can now Paste into any dataset.", { autoClose: false });
237+
} catch (error) {
238+
log(`Error loading shared sheet: ${error.message}`, error);
239+
240+
// If the browser blocks the popup (often happens on page load without user gesture)
241+
if (error.message && error.message.includes("Failed to open login popup window")) {
242+
toast.warning(
243+
<div>
244+
Popup blocked by browser.
245+
<br />
246+
<br />
247+
<button
248+
className="toggle-button toggle-button-active"
249+
onClick={() => {
250+
toast.dismiss();
251+
this.loadSharedSheet(sheetId);
252+
}}
253+
style={{ padding: "4px 8px", fontSize: "14px" }}
254+
>
255+
Load Google Sheet
256+
</button>
257+
</div>,
258+
{ autoClose: false, closeOnClick: false }
259+
);
260+
} else {
261+
toast.error(`Failed to load shared sheet: ${error.message}`);
262+
}
263+
}
264+
};
265+
206266
updateToggleState(newValue, toggleName, jsonPaths) {
207267
this.setState((prevState) => {
208268
const newToggleOptions = {
@@ -587,12 +647,16 @@ class App extends React.Component {
587647
try {
588648
const token = await requestSheetsToken();
589649
const sheetUrl = await exportToGoogleSheet(index, token);
650+
const spreadsheetId = extractSpreadsheetId(sheetUrl);
651+
const deepLink = `${window.location.origin}${window.location.pathname}?sheetId=${spreadsheetId}`;
590652
toast.success(
591653
<span>
592654
Exported to{" "}
593655
<a href={sheetUrl} target="_blank" rel="noopener noreferrer">
594656
Google Sheet
595657
</a>
658+
<br />
659+
Shareable Fleet Debugger URL <a href={deepLink}>{deepLink}</a>
596660
</span>,
597661
{ autoClose: false }
598662
);
@@ -602,6 +666,71 @@ class App extends React.Component {
602666
}
603667
};
604668

669+
const handleCopyClick = async (e) => {
670+
e.stopPropagation();
671+
log(`Copy dataset ${index} initiated`);
672+
this.setState({ activeMenuIndex: null });
673+
674+
try {
675+
const data = await getUploadedData(index);
676+
if (!data || !data.rawLogs || data.rawLogs.length === 0) {
677+
toast.warning("Dataset is empty, nothing to copy.");
678+
return;
679+
}
680+
681+
let logsToCopy = data.rawLogs;
682+
const { logTypes } = this.state.filters;
683+
const totalFilterCount = Object.keys(logTypes).length;
684+
const activeFilterCount = Object.values(logTypes).filter(Boolean).length;
685+
686+
if (activeFilterCount !== totalFilterCount) {
687+
if (window.confirm("Do you want to copy only the currently filtered log types?")) {
688+
logsToCopy = data.rawLogs.filter((logEntry) => {
689+
const apiType = getApiType(logEntry);
690+
return logTypes[apiType] !== false;
691+
});
692+
}
693+
}
694+
695+
const dataToSave = { ...data, rawLogs: logsToCopy };
696+
await saveToIndexedDB(dataToSave, "_clipboard");
697+
toast.success(`Dataset ${index + 1} copied to clipboard!`);
698+
} catch (error) {
699+
log(`Error copying dataset: ${error.message}`, error);
700+
toast.error(`Error copying dataset: ${error.message}`);
701+
}
702+
};
703+
704+
const handlePasteClick = async (e) => {
705+
e.stopPropagation();
706+
log(`Paste to dataset ${index} initiated`);
707+
this.setState({ activeMenuIndex: null });
708+
709+
try {
710+
const clipboardData = await getUploadedData("_clipboard");
711+
if (!clipboardData || !clipboardData.rawLogs) {
712+
toast.warning("Clipboard is empty.");
713+
return;
714+
}
715+
await saveToIndexedDB(clipboardData, index);
716+
toast.success(`Pasted into Dataset ${index + 1}!`);
717+
718+
this.setState(
719+
(prevState) => {
720+
const newUploadedDatasets = [...prevState.uploadedDatasets];
721+
newUploadedDatasets[index] = "Uploaded";
722+
return { uploadedDatasets: newUploadedDatasets };
723+
},
724+
() => {
725+
this.switchDataset(index);
726+
}
727+
);
728+
} catch (error) {
729+
log(`Error pasting dataset: ${error.message}`, error);
730+
toast.error(`Error pasting dataset: ${error.message}`);
731+
}
732+
};
733+
605734
const handlePruneClick = async (e) => {
606735
e.stopPropagation();
607736
log(`Prune initiated for dataset ${index}`);
@@ -680,6 +809,25 @@ class App extends React.Component {
680809
}
681810

682811
try {
812+
if (result.pasteClipboard) {
813+
const clipboardData = await getUploadedData("_clipboard");
814+
if (!clipboardData || !clipboardData.rawLogs) {
815+
toast.warning("Clipboard is empty.");
816+
return;
817+
}
818+
await saveToIndexedDB(clipboardData, index);
819+
toast.success(`Pasted into Dataset ${index + 1}!`);
820+
this.setState(
821+
(prevState) => {
822+
const newUploadedDatasets = [...prevState.uploadedDatasets];
823+
newUploadedDatasets[index] = "Uploaded";
824+
return { uploadedDatasets: newUploadedDatasets };
825+
},
826+
() => this.switchDataset(index)
827+
);
828+
return;
829+
}
830+
683831
if (result.file) {
684832
const uploadEvent = { target: { files: [result.file] } };
685833
await this.handleFileUpload(uploadEvent, index);
@@ -706,7 +854,9 @@ class App extends React.Component {
706854
newUploadedDatasets[index] = "Uploaded";
707855
return { uploadedDatasets: newUploadedDatasets };
708856
},
709-
() => this.switchDataset(index)
857+
() => {
858+
this.switchDataset(index);
859+
}
710860
);
711861
}
712862
} catch (error) {
@@ -748,29 +898,34 @@ class App extends React.Component {
748898
isActive ? "dataset-button-active" : isUploaded ? "dataset-button-uploaded" : "dataset-button-empty"
749899
}`}
750900
>
751-
{isUploaded ? `Dataset ${index + 1}` : `Select Dataset ${index + 1}`}
752-
901+
{isUploaded ? `Dataset ${index + 1}` : `Select Data ${index + 1}`}
753902
{isUploaded && isActive && (
754903
<span className="dataset-button-actions" onClick={toggleMenu}>
755904
756-
{isMenuOpen && (
757-
<div className="dataset-button-menu">
758-
<div className="dataset-button-menu-item export" onClick={handleSaveClick}>
759-
Export File
760-
</div>
761-
<div className="dataset-button-menu-item export" onClick={handleGoogleSheetExport}>
762-
Export GSheet
763-
</div>
764-
<div className="dataset-button-menu-item prune" onClick={handlePruneClick}>
765-
Prune
766-
</div>
767-
<div className="dataset-button-menu-item delete" onClick={handleDeleteClick}>
768-
Delete
769-
</div>
770-
</div>
771-
)}
772905
</span>
773906
)}
907+
{isMenuOpen && (
908+
<div className="dataset-button-menu" style={{ top: "100%", right: 0 }}>
909+
<div className="dataset-button-menu-item paste" onClick={handlePasteClick}>
910+
Paste Dataset
911+
</div>
912+
<div className="dataset-button-menu-item copy" onClick={handleCopyClick}>
913+
Copy Dataset
914+
</div>
915+
<div className="dataset-button-menu-item export" onClick={handleSaveClick}>
916+
Export File
917+
</div>
918+
<div className="dataset-button-menu-item export" onClick={handleGoogleSheetExport}>
919+
Export GSheet
920+
</div>
921+
<div className="dataset-button-menu-item prune" onClick={handlePruneClick}>
922+
Prune
923+
</div>
924+
<div className="dataset-button-menu-item delete" onClick={handleDeleteClick}>
925+
Delete
926+
</div>
927+
</div>
928+
)}
774929
</button>
775930
</div>
776931
);
@@ -858,6 +1013,7 @@ class App extends React.Component {
8581013
onLogsReceived: handleCloudLogsReceived,
8591014
onExtraLogsReceived: handleExtraLogsReceived,
8601015
onFileUpload: handleFileUpload,
1016+
onPasteClipboard: () => cleanupAndResolve({ pasteClipboard: true }),
8611017
hasExtraDataSource: HAS_EXTRA_DATA_SOURCE,
8621018
});
8631019
dialogRoot.render(datasetLoadingComponent);

src/DatasetLoading.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { isTokenValid, fetchLogsWithToken, useCloudLoggingLogin, buildQueryFilte
88
import DatasetSideLoading from "./DatasetSideLoading";
99
import { GOOGLE_CLIENT_ID } from "./constants";
1010

11-
const CloudLoggingFormComponent = ({ onLogsReceived, onFileUpload }) => {
11+
const CloudLoggingFormComponent = ({ onLogsReceived, onFileUpload, onPasteClipboard }) => {
1212
const getStoredValue = (key, defaultValue = "") => localStorage.getItem(`datasetLoading_${key}`) || defaultValue;
1313

1414
const [fetching, setFetching] = useState(false);
@@ -162,7 +162,12 @@ const CloudLoggingFormComponent = ({ onLogsReceived, onFileUpload }) => {
162162
<progress className="progress-bar" />
163163
</div>
164164
)}
165-
<DatasetSideLoading onLogsReceived={onLogsReceived} onFileUpload={onFileUpload} setLocalError={setLocalError}>
165+
<DatasetSideLoading
166+
onLogsReceived={onLogsReceived}
167+
onFileUpload={onFileUpload}
168+
setLocalError={setLocalError}
169+
onPasteClipboard={onPasteClipboard}
170+
>
166171
<button type="button" onClick={handleFetch} disabled={fetching} className="fetch-logs-button">
167172
{fetching ? "Fetching..." : isTokenValid() ? "Fetch Logs" : "Sign in and Fetch Logs"}
168173
</button>

src/DatasetSideLoading.js

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
// src/DatasetSideLoading.js
2-
import { useState } from "react";
2+
import { useState, useEffect } from "react";
33
import { toast } from "react-toastify";
44
import { useSheetsLogin, isSheetsTokenValid, getSheetsToken, importFromGoogleSheet } from "./GoogleSheets";
5+
import { getUploadedData } from "./localStorage";
56
import { log } from "./Utils";
67

78
/**
@@ -14,10 +15,21 @@ import { log } from "./Utils";
1415
* @param {Function} props.setLocalError Callback to set error messages in the parent.
1516
* @param {React.ReactNode} props.children The primary fetch button(s) to show alongside sideload buttons.
1617
*/
17-
export const DatasetSideLoading = ({ onLogsReceived, onFileUpload, setLocalError, children }) => {
18+
export const DatasetSideLoading = ({ onLogsReceived, onFileUpload, setLocalError, onPasteClipboard, children }) => {
1819
const [sheetFormVisible, setSheetFormVisible] = useState(false);
1920
const [sheetUrl, setSheetUrl] = useState(localStorage.getItem("datasetLoading_sheetUrl") || "");
2021
const [sheetLoading, setSheetLoading] = useState(false);
22+
const [hasClipboard, setHasClipboard] = useState(false);
23+
24+
useEffect(() => {
25+
getUploadedData("_clipboard")
26+
.then((data) => {
27+
if (data && data.rawLogs && data.rawLogs.length > 0) {
28+
setHasClipboard(true);
29+
}
30+
})
31+
.catch(() => {});
32+
}, []);
2133

2234
const handleSheetImport = (token) => {
2335
setSheetLoading(true);
@@ -69,6 +81,11 @@ export const DatasetSideLoading = ({ onLogsReceived, onFileUpload, setLocalError
6981
<>
7082
<div className="cloud-logging-buttons">
7183
{children}
84+
{onPasteClipboard && hasClipboard && (
85+
<button type="button" onClick={onPasteClipboard} className="sideload-logs-button">
86+
Paste Dataset
87+
</button>
88+
)}
7289
<button type="button" onClick={() => setSheetFormVisible(!sheetFormVisible)} className="sideload-logs-button">
7390
Load Google Sheet
7491
</button>

src/GoogleSheets.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ const API_TYPE_REGEX_MAP = [
2525
{ name: "updateTask", regex: /updateTask/i },
2626
];
2727

28-
function getApiType(logEntry) {
28+
export function getApiType(logEntry) {
2929
const typeField = logEntry["@type"] || logEntry.jsonpayload?.["@type"] || "";
3030
for (const { name, regex } of API_TYPE_REGEX_MAP) {
3131
if (regex.test(typeField)) return name;
@@ -266,7 +266,7 @@ export async function exportToGoogleSheet(index, token) {
266266

267267
// --- Import ---
268268

269-
function extractSpreadsheetId(input) {
269+
export function extractSpreadsheetId(input) {
270270
const trimmed = input.trim();
271271
const urlMatch = trimmed.match(/\/spreadsheets\/d\/([a-zA-Z0-9-_]+)/);
272272
if (urlMatch) return urlMatch[1];

0 commit comments

Comments
 (0)