From 6c22ee003dd62bb2cd0e8e15f2a794f98cd986b5 Mon Sep 17 00:00:00 2001
From: Nick Fitzpatrick <nick@metabase.com>
Date: Mon, 1 May 2023 15:37:10 -0300
Subject: [PATCH] Adding beforeUnload hook to Status Listing (#30372)

* Adding beforeUnload hook to Status Listing

* Adding callMockEvent function, updating tests

* running prettier

* linting
---
 .../src/metabase-types/store/mocks/upload.ts  |  4 +-
 frontend/src/metabase/redux/uploads.ts        |  6 +-
 .../StatusListing/StatusListing.tsx           | 30 ++++-----
 .../StatusListing/StatusListing.unit.spec.tsx | 67 +++++++++++++++----
 frontend/test/__support__/events.ts           | 25 +++++++
 5 files changed, 99 insertions(+), 33 deletions(-)
 create mode 100644 frontend/test/__support__/events.ts

diff --git a/frontend/src/metabase-types/store/mocks/upload.ts b/frontend/src/metabase-types/store/mocks/upload.ts
index 360e8651d03..3995612b49d 100644
--- a/frontend/src/metabase-types/store/mocks/upload.ts
+++ b/frontend/src/metabase-types/store/mocks/upload.ts
@@ -1,7 +1,7 @@
 import { FileUpload } from "../upload";
 
-export const createMockUploadState = () => {
-  return {};
+export const createMockUploadState = (uploads = {}) => {
+  return { ...uploads };
 };
 
 export const createMockUpload = (props?: Partial<FileUpload>): FileUpload => {
diff --git a/frontend/src/metabase/redux/uploads.ts b/frontend/src/metabase/redux/uploads.ts
index b5f1e00c36a..ad7b36dbf7d 100644
--- a/frontend/src/metabase/redux/uploads.ts
+++ b/frontend/src/metabase/redux/uploads.ts
@@ -34,8 +34,10 @@ const uploadEnd = createAction(UPLOAD_FILE_TO_COLLECTION_END);
 const uploadError = createAction(UPLOAD_FILE_TO_COLLECTION_ERROR);
 const clearUpload = createAction(UPLOAD_FILE_TO_COLLECTION_CLEAR);
 
-export const getAllUploads = (state: State) =>
-  Object.keys(state.upload).map(key => state.upload[key]);
+export const getAllUploads = (state: State) => Object.values(state.upload);
+
+export const hasActiveUploads = (state: State) =>
+  getAllUploads(state).some(upload => upload.status === "in-progress");
 
 export const uploadFile = createThunkAction(
   UPLOAD_FILE_TO_COLLECTION,
diff --git a/frontend/src/metabase/status/components/StatusListing/StatusListing.tsx b/frontend/src/metabase/status/components/StatusListing/StatusListing.tsx
index fcd104a8c63..0677fe9c84a 100644
--- a/frontend/src/metabase/status/components/StatusListing/StatusListing.tsx
+++ b/frontend/src/metabase/status/components/StatusListing/StatusListing.tsx
@@ -1,27 +1,27 @@
 import React from "react";
-import { connect } from "react-redux";
+import { t } from "ttag";
+import { useBeforeUnload } from "react-use";
+
+import { useSelector } from "metabase/lib/redux";
 
 import { getUserIsAdmin, getUser } from "metabase/selectors/user";
-import type { State } from "metabase-types/store";
+import { hasActiveUploads } from "metabase/redux/uploads";
 
 import DatabaseStatus from "../../containers/DatabaseStatus";
 import FileUploadStatus from "../FileUploadStatus";
 import { StatusListingRoot } from "./StatusListing.styled";
 
-const mapStateToProps = (state: State) => ({
-  isAdmin: getUserIsAdmin(state),
-  isLoggedIn: !!getUser(state),
-});
+const StatusListingView = () => {
+  const isLoggedIn = !!useSelector(getUser);
+  const isAdmin = useSelector(getUserIsAdmin);
+
+  const uploadInProgress = useSelector(hasActiveUploads);
 
-export interface StatusListingProps {
-  isAdmin: boolean;
-  isLoggedIn: boolean;
-}
+  useBeforeUnload(
+    uploadInProgress,
+    t`CSV Upload in progress. Are you sure you want to leave?`,
+  );
 
-export const StatusListingView = ({
-  isAdmin,
-  isLoggedIn,
-}: StatusListingProps) => {
   if (!isLoggedIn) {
     return null;
   }
@@ -34,4 +34,4 @@ export const StatusListingView = ({
   );
 };
 
-export default connect(mapStateToProps)(StatusListingView);
+export default StatusListingView;
diff --git a/frontend/src/metabase/status/components/StatusListing/StatusListing.unit.spec.tsx b/frontend/src/metabase/status/components/StatusListing/StatusListing.unit.spec.tsx
index 400efd02aa3..1c8952d6502 100644
--- a/frontend/src/metabase/status/components/StatusListing/StatusListing.unit.spec.tsx
+++ b/frontend/src/metabase/status/components/StatusListing/StatusListing.unit.spec.tsx
@@ -1,36 +1,75 @@
 import React from "react";
 import { renderWithProviders, screen } from "__support__/ui";
 import { setupCollectionsEndpoints } from "__support__/server-mocks";
-import { createMockCollection } from "metabase-types/api/mocks";
+import { callMockEvent } from "__support__/events";
+import { createMockCollection, createMockUser } from "metabase-types/api/mocks";
 
-import {
-  StatusListingView as StatusListing,
-  StatusListingProps,
-} from "./StatusListing";
+import { createMockState, createMockUpload } from "metabase-types/store/mocks";
+import { FileUploadState } from "metabase-types/store/upload";
+import StatusListing from "./StatusListing";
 
 const DatabaseStatusMock = () => <div>DatabaseStatus</div>;
 
 jest.mock("../../containers/DatabaseStatus", () => DatabaseStatusMock);
 
-const setup = (options?: Partial<StatusListingProps>) => {
-  setupCollectionsEndpoints([createMockCollection()]);
+interface setupProps {
+  isAdmin?: boolean;
+  upload?: FileUploadState;
+}
 
-  return renderWithProviders(<StatusListing isAdmin isLoggedIn {...options} />);
+const setup = ({ isAdmin = false, upload = {} }: setupProps = {}) => {
+  setupCollectionsEndpoints([createMockCollection({})]);
+
+  return renderWithProviders(<StatusListing />, {
+    storeInitialState: createMockState({
+      currentUser: createMockUser({
+        is_superuser: isAdmin,
+      }),
+      upload,
+    }),
+  });
 };
 
 describe("StatusListing", () => {
-  it("should render database statuses for admins", () => {
-    setup({
-      isAdmin: true,
-      isLoggedIn: true,
-    });
+  afterEach(() => {
+    jest.resetAllMocks();
+  });
 
+  it("should render database statuses for admins", () => {
+    setup({ isAdmin: true });
     expect(screen.getByText("DatabaseStatus")).toBeInTheDocument();
   });
 
   it("should not render database statuses for non-admins", () => {
-    renderWithProviders(<StatusListing isAdmin={false} isLoggedIn />);
+    setup();
+    expect(screen.queryByText("DatabaseStatus")).not.toBeInTheDocument();
+  });
 
+  it("should not render if no one is logged in", () => {
+    setupCollectionsEndpoints([createMockCollection({})]);
+    renderWithProviders(<StatusListing />);
     expect(screen.queryByText("DatabaseStatus")).not.toBeInTheDocument();
   });
+
+  it("should give an alert if a user navigates away from the page during an upload", () => {
+    const mockEventListener = jest.spyOn(window, "addEventListener");
+
+    const mockUpload = createMockUpload();
+    setup({ isAdmin: true, upload: { [mockUpload.id]: mockUpload } });
+
+    const mockEvent = callMockEvent(mockEventListener, "beforeunload");
+    expect(mockEvent.returnValue).toEqual(
+      "CSV Upload in progress. Are you sure you want to leave?",
+    );
+    expect(mockEvent.preventDefault).toHaveBeenCalled();
+  });
+
+  it("should not give an alert if a user navigates away from the page while no uploads are in progress", () => {
+    const mockEventListener = jest.spyOn(window, "addEventListener");
+
+    setup({ isAdmin: true });
+
+    const mockEvent = callMockEvent(mockEventListener, "beforeunload");
+    expect(mockEvent.returnValue).toBeUndefined();
+  });
 });
diff --git a/frontend/test/__support__/events.ts b/frontend/test/__support__/events.ts
new file mode 100644
index 00000000000..ca11c6a395c
--- /dev/null
+++ b/frontend/test/__support__/events.ts
@@ -0,0 +1,25 @@
+type MockEvent = {
+  preventDefault: jest.Mock;
+  returnValue?: string;
+};
+
+type CallMockEventType = (
+  mockEventListener: jest.SpyInstance,
+  eventName: string,
+) => MockEvent;
+
+// calls event handler in the mockEventListener that matches the eventName
+// and uses the mockEvent to hold the callback's return value
+export const callMockEvent: CallMockEventType = (
+  mockEventListener: jest.SpyInstance,
+  eventName: string,
+) => {
+  const mockEvent = {
+    preventDefault: jest.fn(),
+  };
+
+  mockEventListener.mock.calls
+    .filter(([event]) => eventName === event)
+    .forEach(([_, callback]) => callback(mockEvent));
+  return mockEvent;
+};
-- 
GitLab