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