From ef4dd69a8d27d05a340da584512cb29414cef258 Mon Sep 17 00:00:00 2001
From: Nemanja Glumac <31325167+nemanjaglumac@users.noreply.github.com>
Date: Wed, 23 Oct 2024 00:00:53 +0200
Subject: [PATCH] Add `use-temp-storage` hook (#48987)

* Add `use-temp-storage` hook

* Remove try/catch

* Add reducer

* Add tests

* Use RTK to create a new slice and reducer
---
 frontend/src/metabase-types/store/app.ts      | 14 ++++
 .../src/metabase-types/store/mocks/app.ts     |  1 +
 .../common/hooks/use-temp-storage/index.ts    |  1 +
 .../use-temp-storage/use-temp-storage.ts      | 26 +++++++
 .../use-temp-storage.unit.spec.tsx            | 76 +++++++++++++++++++
 frontend/src/metabase/redux/app.ts            | 32 +++++++-
 6 files changed, 148 insertions(+), 2 deletions(-)
 create mode 100644 frontend/src/metabase/common/hooks/use-temp-storage/index.ts
 create mode 100644 frontend/src/metabase/common/hooks/use-temp-storage/use-temp-storage.ts
 create mode 100644 frontend/src/metabase/common/hooks/use-temp-storage/use-temp-storage.unit.spec.tsx

diff --git a/frontend/src/metabase-types/store/app.ts b/frontend/src/metabase-types/store/app.ts
index ca24d79665f..0e267770fc7 100644
--- a/frontend/src/metabase-types/store/app.ts
+++ b/frontend/src/metabase-types/store/app.ts
@@ -14,9 +14,23 @@ export interface AppBreadCrumbs {
   show: boolean;
 }
 
+/**
+ * Storage for non-critical, ephemeral user preferences.
+ * Think of it as a sessionStorage alternative implemented in Redux.
+ * Only specific key/value pairs can be stored here,
+ * and then later used with the `use-temp-storage` hook.
+ */
+// eslint-disable-next-line @typescript-eslint/ban-types
+export type TempStorage = {};
+
+export type TempStorageKey = keyof TempStorage;
+export type TempStorageValue<Key extends TempStorageKey = TempStorageKey> =
+  TempStorage[Key];
+
 export interface AppState {
   errorPage: AppErrorDescriptor | null;
   isNavbarOpen: boolean;
   isDndAvailable: boolean;
   isErrorDiagnosticsOpen: boolean;
+  tempStorage: TempStorage;
 }
diff --git a/frontend/src/metabase-types/store/mocks/app.ts b/frontend/src/metabase-types/store/mocks/app.ts
index adf4cb6ed34..6bc1f50f756 100644
--- a/frontend/src/metabase-types/store/mocks/app.ts
+++ b/frontend/src/metabase-types/store/mocks/app.ts
@@ -5,5 +5,6 @@ export const createMockAppState = (opts?: Partial<AppState>): AppState => ({
   errorPage: null,
   isDndAvailable: false,
   isErrorDiagnosticsOpen: false,
+  tempStorage: {},
   ...opts,
 });
diff --git a/frontend/src/metabase/common/hooks/use-temp-storage/index.ts b/frontend/src/metabase/common/hooks/use-temp-storage/index.ts
new file mode 100644
index 00000000000..7958d81a251
--- /dev/null
+++ b/frontend/src/metabase/common/hooks/use-temp-storage/index.ts
@@ -0,0 +1 @@
+export * from "./use-temp-storage";
diff --git a/frontend/src/metabase/common/hooks/use-temp-storage/use-temp-storage.ts b/frontend/src/metabase/common/hooks/use-temp-storage/use-temp-storage.ts
new file mode 100644
index 00000000000..cda0045e876
--- /dev/null
+++ b/frontend/src/metabase/common/hooks/use-temp-storage/use-temp-storage.ts
@@ -0,0 +1,26 @@
+import { useCallback } from "react";
+
+import { useDispatch, useSelector } from "metabase/lib/redux";
+import { setTempSetting } from "metabase/redux/app";
+import type {
+  State,
+  TempStorageKey,
+  TempStorageValue,
+} from "metabase-types/store";
+
+export const useTempStorage = <Key extends TempStorageKey>(
+  key: Key,
+): [TempStorageValue<Key>, (newValue: TempStorageValue<Key>) => void] => {
+  const dispatch = useDispatch();
+
+  const value = useSelector((state: State) => state.app.tempStorage[key]);
+
+  const setValue = useCallback(
+    (newValue: TempStorageValue<Key>) => {
+      dispatch(setTempSetting({ key, value: newValue }));
+    },
+    [dispatch, key],
+  );
+
+  return [value, setValue];
+};
diff --git a/frontend/src/metabase/common/hooks/use-temp-storage/use-temp-storage.unit.spec.tsx b/frontend/src/metabase/common/hooks/use-temp-storage/use-temp-storage.unit.spec.tsx
new file mode 100644
index 00000000000..83105bb8636
--- /dev/null
+++ b/frontend/src/metabase/common/hooks/use-temp-storage/use-temp-storage.unit.spec.tsx
@@ -0,0 +1,76 @@
+import userEvent from "@testing-library/user-event";
+
+import { renderWithProviders, screen } from "__support__/ui";
+import type {
+  TempStorage,
+  TempStorageKey,
+  TempStorageValue,
+} from "metabase-types/store";
+import {
+  createMockAppState,
+  createMockState,
+} from "metabase-types/store/mocks";
+
+import { useTempStorage } from "./use-temp-storage";
+
+const TestComponent = ({
+  entry,
+  newValue,
+}: {
+  entry: TempStorageKey;
+  newValue?: TempStorageValue;
+}) => {
+  const [value, setValue] = useTempStorage(entry);
+
+  return (
+    <div>
+      {/* @ts-expect-error - The hook still doesn't accept any k/v pair */}
+      <button onClick={() => setValue(newValue)} />
+      <div data-testid="result">{`Value is: ${value}`}</div>
+    </div>
+  );
+};
+
+type SetupProps = {
+  tempStorage: TempStorage;
+  entry: TempStorageKey;
+  newValue?: TempStorageValue;
+};
+
+const setup = ({ tempStorage = {}, entry, newValue }: SetupProps) => {
+  const initialState = createMockState({
+    app: createMockAppState({ tempStorage }),
+  });
+
+  renderWithProviders(<TestComponent entry={entry} newValue={newValue} />, {
+    storeInitialState: initialState,
+  });
+};
+
+describe("useTempStorage hook", () => {
+  it("should return undefined for uninitialized key", () => {
+    const tempStorage = {
+      animal: undefined,
+    };
+    // @ts-expect-error - The hook still doesn't accept any k/v pair
+    setup({ tempStorage, entry: "animal" });
+
+    expect(screen.getByTestId("result")).toHaveTextContent(
+      "Value is: undefined",
+    );
+  });
+
+  it("should read and set the value", async () => {
+    const tempStorage = {
+      animal: "dog",
+    };
+
+    // @ts-expect-error - The hook still doesn't accept any k/v pair
+    setup({ tempStorage, entry: "animal", newValue: "cat" });
+
+    expect(screen.getByTestId("result")).toHaveTextContent("Value is: dog");
+
+    await userEvent.click(screen.getByRole("button"));
+    expect(screen.getByTestId("result")).toHaveTextContent("Value is: cat");
+  });
+});
diff --git a/frontend/src/metabase/redux/app.ts b/frontend/src/metabase/redux/app.ts
index 420bd3cd5ad..135c48d74ee 100644
--- a/frontend/src/metabase/redux/app.ts
+++ b/frontend/src/metabase/redux/app.ts
@@ -1,4 +1,8 @@
-import { createAction } from "@reduxjs/toolkit";
+import {
+  type PayloadAction,
+  createAction,
+  createSlice,
+} from "@reduxjs/toolkit";
 import { LOCATION_CHANGE, push } from "react-router-redux";
 
 import {
@@ -7,7 +11,12 @@ import {
   shouldOpenInBlankWindow,
 } from "metabase/lib/dom";
 import { combineReducers, handleActions } from "metabase/lib/redux";
-import type { Dispatch } from "metabase-types/store";
+import type {
+  Dispatch,
+  TempStorage,
+  TempStorageKey,
+  TempStorageValue,
+} from "metabase-types/store";
 
 interface LocationChangeAction {
   type: string; // "@@router/LOCATION_CHANGE"
@@ -107,6 +116,24 @@ const isErrorDiagnosticsOpen = handleActions(
   false,
 );
 
+const tempStorageSlice = createSlice({
+  name: "tempStorage",
+  initialState: {} as TempStorage,
+  reducers: {
+    setTempSetting: (
+      state,
+      action: PayloadAction<{
+        key: TempStorageKey;
+        value: TempStorageValue<TempStorageKey>;
+      }>,
+    ) => {
+      state[action.payload.key] = action.payload.value;
+    },
+  },
+});
+
+export const { setTempSetting } = tempStorageSlice.actions;
+
 // eslint-disable-next-line import/no-default-export -- deprecated usage
 export default combineReducers({
   errorPage,
@@ -118,4 +145,5 @@ export default combineReducers({
     return true;
   },
   isErrorDiagnosticsOpen,
+  tempStorage: tempStorageSlice.reducer,
 });
-- 
GitLab