Repository: ethyca/fidesops (language: python)

Issue:
Admin UI: Persist Redux store to localStorage
Update Admin UI Redux Store from storing data in internal memory to localStorage. This would allow a better overall user experience when a user refreshes a web page via F5 or clicking the UI button to reload a given page.




Discussion / hints:

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7eeee73b8..6c595609c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -40,6 +40,7 @@ The types of changes are:
 * Add consent request api [#1387](https://github.com/ethyca/fidesops/pull/1387)
 * Add authenticated route to get consent preferences [#1402](https://github.com/ethyca/fidesops/pull/1402)
 * Access and erasure support for Braze [#1248](https://github.com/ethyca/fidesops/pull/1248)
+* Admin UI: Persist Redux store to localStorage [#1401](https://github.com/ethyca/fidesops/pull/1409)
 
 ### Removed
 
diff --git a/clients/ops/admin-ui/package-lock.json b/clients/ops/admin-ui/package-lock.json
index 68b2a7af1..0acf039d0 100644
--- a/clients/ops/admin-ui/package-lock.json
+++ b/clients/ops/admin-ui/package-lock.json
@@ -29,6 +29,7 @@
         "react-dom": "^17.0.2",
         "react-feature-flags": "^1.0.0",
         "react-redux": "^7.2.6",
+        "redux-persist": "^6.0.0",
         "whatwg-fetch": "^3.6.2",
         "yup": "^0.32.11"
       },
@@ -6078,9 +6079,9 @@
       }
     },
     "node_modules/caniuse-lite": {
-      "version": "1.0.30001406",
-      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001406.tgz",
-      "integrity": "sha512-bWTlaXUy/rq0BBtYShc/jArYfBPjEV95euvZ8JVtO43oQExEN/WquoqpufFjNu4kSpi5cy5kMbNvzztWDfv1Jg==",
+      "version": "1.0.30001412",
+      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001412.tgz",
+      "integrity": "sha512-+TeEIee1gS5bYOiuf+PS/kp2mrXic37Hl66VY6EAfxasIk5fELTktK2oOezYed12H8w7jt3s512PpulQidPjwA==",
       "funding": [
         {
           "type": "opencollective",
@@ -11112,6 +11113,14 @@
         "@babel/runtime": "^7.9.2"
       }
     },
+    "node_modules/redux-persist": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/redux-persist/-/redux-persist-6.0.0.tgz",
+      "integrity": "sha512-71LLMbUq2r02ng2We9S215LtPu3fY0KgaGE0k8WRgl6RkqxtGfl7HUozz1Dftwsb0D/5mZ8dwAaPbtnzfvbEwQ==",
+      "peerDependencies": {
+        "redux": ">4.0.0"
+      }
+    },
     "node_modules/redux-thunk": {
       "version": "2.4.1",
       "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.4.1.tgz",
@@ -17197,9 +17206,9 @@
       "dev": true
     },
     "caniuse-lite": {
-      "version": "1.0.30001406",
-      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001406.tgz",
-      "integrity": "sha512-bWTlaXUy/rq0BBtYShc/jArYfBPjEV95euvZ8JVtO43oQExEN/WquoqpufFjNu4kSpi5cy5kMbNvzztWDfv1Jg=="
+      "version": "1.0.30001412",
+      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001412.tgz",
+      "integrity": "sha512-+TeEIee1gS5bYOiuf+PS/kp2mrXic37Hl66VY6EAfxasIk5fELTktK2oOezYed12H8w7jt3s512PpulQidPjwA=="
     },
     "chalk": {
       "version": "4.1.2",
@@ -20816,6 +20825,12 @@
         "@babel/runtime": "^7.9.2"
       }
     },
+    "redux-persist": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/redux-persist/-/redux-persist-6.0.0.tgz",
+      "integrity": "sha512-71LLMbUq2r02ng2We9S215LtPu3fY0KgaGE0k8WRgl6RkqxtGfl7HUozz1Dftwsb0D/5mZ8dwAaPbtnzfvbEwQ==",
+      "requires": {}
+    },
     "redux-thunk": {
       "version": "2.4.1",
       "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.4.1.tgz",
diff --git a/clients/ops/admin-ui/package.json b/clients/ops/admin-ui/package.json
index 6dbff223f..0bc9bf0f4 100644
--- a/clients/ops/admin-ui/package.json
+++ b/clients/ops/admin-ui/package.json
@@ -43,6 +43,7 @@
     "react-dom": "^17.0.2",
     "react-feature-flags": "^1.0.0",
     "react-redux": "^7.2.6",
+    "redux-persist": "^6.0.0",
     "whatwg-fetch": "^3.6.2",
     "yup": "^0.32.11"
   },
diff --git a/clients/ops/admin-ui/src/app/store.ts b/clients/ops/admin-ui/src/app/store.ts
index 5a28cd747..181e704ec 100644
--- a/clients/ops/admin-ui/src/app/store.ts
+++ b/clients/ops/admin-ui/src/app/store.ts
@@ -1,13 +1,24 @@
-import { configureStore, StateFromReducersMapObject } from "@reduxjs/toolkit";
+import {
+  AnyAction,
+  combineReducers,
+  configureStore,
+  StateFromReducersMapObject,
+} from "@reduxjs/toolkit";
 import { setupListeners } from "@reduxjs/toolkit/query/react";
-
-import { STORED_CREDENTIALS_KEY } from "../constants";
 import {
-  authApi,
-  AuthState,
-  credentialStorage,
-  reducer as authReducer,
-} from "../features/auth";
+  FLUSH,
+  PAUSE,
+  PERSIST,
+  persistReducer,
+  persistStore,
+  PURGE,
+  REGISTER,
+  REHYDRATE,
+} from "redux-persist";
+import createWebStorage from "redux-persist/lib/storage/createWebStorage";
+
+import { STORAGE_ROOT_KEY } from "../constants";
+import { authApi, AuthState, reducer as authReducer } from "../features/auth";
 import {
   connectionTypeApi,
   reducer as connectionTypeReducer,
@@ -25,27 +36,83 @@ import {
   userApi,
 } from "../features/user-management";
 
-const reducer = {
-  [privacyRequestApi.reducerPath]: privacyRequestApi.reducer,
-  subjectRequests: privacyRequestsReducer,
-  [userApi.reducerPath]: userApi.reducer,
+/**
+ * To prevent the "redux-perist failed to create sync storage. falling back to noop storage"
+ * console message within Next.js, the following snippet is required.
+ * {@https://mightycoders.xyz/redux-persist-failed-to-create-sync-storage-falling-back-to-noop-storage}
+ */
+const createNoopStorage = () => ({
+  getItem() {
+    return Promise.resolve(null);
+  },
+  setItem(_key: any, value: any) {
+    return Promise.resolve(value);
+  },
+  removeItem() {
+    return Promise.resolve();
+  },
+});
+
+const storage =
+  typeof window !== "undefined"
+    ? createWebStorage("local")
+    : createNoopStorage();
+
+const reducerMap = {
   [authApi.reducerPath]: authApi.reducer,
-  userManagement: userManagementReducer,
-  [datastoreConnectionApi.reducerPath]: datastoreConnectionApi.reducer,
-  datastoreConnections: datastoreConnectionReducer,
   auth: authReducer,
   [connectionTypeApi.reducerPath]: connectionTypeApi.reducer,
   connectionType: connectionTypeReducer,
+  [datastoreConnectionApi.reducerPath]: datastoreConnectionApi.reducer,
+  datastoreConnections: datastoreConnectionReducer,
+  [privacyRequestApi.reducerPath]: privacyRequestApi.reducer,
+  subjectRequests: privacyRequestsReducer,
+  [userApi.reducerPath]: userApi.reducer,
+  userManagement: userManagementReducer,
 };
 
-export type RootState = StateFromReducersMapObject<typeof reducer>;
+const allReducers = combineReducers(reducerMap);
+
+const rootReducer = (state: any, action: AnyAction) => {
+  let newState = { ...state };
+  if (action.type === "auth/logout") {
+    storage.removeItem(STORAGE_ROOT_KEY);
+    newState = undefined;
+  }
+  return allReducers(newState, action);
+};
+
+const persistConfig = {
+  key: "root",
+  storage,
+  /*
+    NOTE: It is also strongly recommended to blacklist any api(s) that you have configured with RTK Query.
+    If the api slice reducer is not blacklisted, the api cache will be automatically persisted 
+    and restored which could leave you with phantom subscriptions from components that do not exist any more.
+    (https://redux-toolkit.js.org/usage/usage-guide#use-with-redux-persist)
+  */
+  blacklist: [
+    authApi.reducerPath,
+    connectionTypeApi.reducerPath,
+    datastoreConnectionApi.reducerPath,
+    privacyRequestApi.reducerPath,
+    userApi.reducerPath,
+  ],
+};
+
+const persistedReducer = persistReducer(persistConfig, rootReducer);
+
+export type RootState = StateFromReducersMapObject<typeof reducerMap>;
 
 export const makeStore = (preloadedState?: Partial<RootState>) =>
   configureStore({
-    reducer,
+    reducer: persistedReducer,
     middleware: (getDefaultMiddleware) =>
-      getDefaultMiddleware().concat(
-        credentialStorage.middleware,
+      getDefaultMiddleware({
+        serializableCheck: {
+          ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
+        },
+      }).concat(
         privacyRequestApi.middleware,
         userApi.middleware,
         authApi.middleware,
@@ -58,7 +125,7 @@ export const makeStore = (preloadedState?: Partial<RootState>) =>
 
 let storedAuthState: AuthState | undefined;
 if (typeof window !== "undefined" && "localStorage" in window) {
-  const storedAuthStateString = localStorage.getItem(STORED_CREDENTIALS_KEY);
+  const storedAuthStateString = localStorage.getItem(STORAGE_ROOT_KEY);
   if (storedAuthStateString) {
     try {
       storedAuthState = JSON.parse(storedAuthStateString);
@@ -74,6 +141,8 @@ const store = makeStore({
   auth: storedAuthState,
 });
 
+export const persistor = persistStore(store);
+
 setupListeners(store.dispatch);
 
 export default store;
diff --git a/clients/ops/admin-ui/src/constants.ts b/clients/ops/admin-ui/src/constants.ts
index 1c2104598..baff53885 100644
--- a/clients/ops/admin-ui/src/constants.ts
+++ b/clients/ops/admin-ui/src/constants.ts
@@ -6,7 +6,10 @@ const API_URL = process.env.NEXT_PUBLIC_FIDESOPS_API
   : "";
 export const BASE_URL = API_URL + BASE_API_URN;
 
-export const STORED_CREDENTIALS_KEY = "auth.fidesops-admin-ui";
+/**
+ * Redux-persist storage root key
+ */
+export const STORAGE_ROOT_KEY = "persist:root";
 
 export const USER_PRIVILEGES: UserPrivileges[] = [
   {
diff --git a/clients/ops/admin-ui/src/features/auth/auth.slice.ts b/clients/ops/admin-ui/src/features/auth/auth.slice.ts
index 06f968998..aba06d253 100644
--- a/clients/ops/admin-ui/src/features/auth/auth.slice.ts
+++ b/clients/ops/admin-ui/src/features/auth/auth.slice.ts
@@ -1,12 +1,8 @@
-import {
-  createListenerMiddleware,
-  createSlice,
-  PayloadAction,
-} from "@reduxjs/toolkit";
+import { createSlice, PayloadAction } from "@reduxjs/toolkit";
 import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
 
 import type { RootState } from "../../app/store";
-import { BASE_URL, STORED_CREDENTIALS_KEY } from "../../constants";
+import { BASE_URL } from "../../constants";
 import { addCommonHeaders } from "../common/CommonHeaders";
 import { utf8ToB64 } from "../common/utils";
 import { User } from "../user-management/types";
@@ -56,27 +52,6 @@ export const selectToken = (state: RootState) => selectAuth(state).token;
 
 export const { login, logout } = authSlice.actions;
 
-export const credentialStorage = createListenerMiddleware();
-credentialStorage.startListening({
-  actionCreator: login,
-  effect: (action, { getState }) => {
-    if (window && window.localStorage) {
-      localStorage.setItem(
-        STORED_CREDENTIALS_KEY,
-        JSON.stringify(selectAuth(getState() as RootState))
-      );
-    }
-  },
-});
-credentialStorage.startListening({
-  actionCreator: logout,
-  effect: () => {
-    if (window && window.localStorage) {
-      localStorage.removeItem(STORED_CREDENTIALS_KEY);
-    }
-  },
-});
-
 // Auth API
 export const authApi = createApi({
   reducerPath: "authApi",
diff --git a/clients/ops/admin-ui/src/features/datastore-connections/add-connection/AddConnection.tsx b/clients/ops/admin-ui/src/features/datastore-connections/add-connection/AddConnection.tsx
index ee28a34b7..577d092d7 100644
--- a/clients/ops/admin-ui/src/features/datastore-connections/add-connection/AddConnection.tsx
+++ b/clients/ops/admin-ui/src/features/datastore-connections/add-connection/AddConnection.tsx
@@ -18,35 +18,11 @@ import { AddConnectionStep } from "./types";
 const AddConnection: React.FC = () => {
   const dispatch = useDispatch();
   const router = useRouter();
-  const { connectorType, key, step: currentStep } = router.query;
+  const { connectorType, step: currentStep } = router.query;
 
   const { connectionOption, step } = useAppSelector(selectConnectionTypeState);
 
-  /**
-   * NOTE: If the user reloads the web page via F5, the react redux store state is lost.
-   * By default its persisted in internal memory. As a result, a runtime exception occurs
-   * which impedes the page rendering.
-   *
-   * @example
-   * The above error occurred in the <AddConnection> component
-   *
-   * For now, a temporary solution is to redirect the user
-   * to the "Choose your connection" step. This allows a better overall user experience.
-   * A permanent solution will be to persist the react redux store state to either local storage
-   * or session storage. Once completed, this method can be deleted.
-   */
-  const reload = useCallback(() => {
-    if (
-      key &&
-      currentStep &&
-      (currentStep as unknown as number) !== step?.stepId
-    ) {
-      window.location.href = STEPS[1].href;
-    }
-  }, [currentStep, key, step?.stepId]);
-
   useEffect(() => {
-    reload();
     if (connectorType) {
       dispatch(setConnectionOption(JSON.parse(connectorType as string)));
     }
@@ -55,19 +31,7 @@ const AddConnection: React.FC = () => {
       dispatch(setStep(item || STEPS[1]));
     }
     return () => {};
-  }, [connectorType, currentStep, dispatch, reload, router.query.step]);
-
-  const getComponent = useCallback(() => {
-    switch (step.stepId) {
-      case 1:
-        return <ChooseConnection />;
-      case 2:
-      case 3:
-        return <ConfigureConnector />;
-      default:
-        return <ChooseConnection />;
-    }
-  }, [step.stepId]);
+  }, [connectorType, currentStep, dispatch, router.query.step]);
 
   const getLabel = useCallback(
     (s: AddConnectionStep): string => {
@@ -108,7 +72,17 @@ const AddConnection: React.FC = () => {
           {!connectionOption && <Text>{getLabel(step)}</Text>}
         </Box>
       </Heading>
-      {getComponent()}
+      {(() => {
+        switch (step.stepId) {
+          case 1:
+            return <ChooseConnection />;
+          case 2:
+          case 3:
+            return <ConfigureConnector />;
+          default:
+            return <ChooseConnection />;
+        }
+      })()}
     </>
   );
 };
diff --git a/clients/ops/admin-ui/src/pages/_app.tsx b/clients/ops/admin-ui/src/pages/_app.tsx
index 188390a36..4fe31232f 100644
--- a/clients/ops/admin-ui/src/pages/_app.tsx
+++ b/clients/ops/admin-ui/src/pages/_app.tsx
@@ -9,8 +9,9 @@ import React from "react";
 // @ts-ignore
 import { FlagsProvider } from "react-feature-flags";
 import { Provider } from "react-redux";
+import { PersistGate } from "redux-persist/integration/react";
 
-import store from "../app/store";
+import store, { persistor } from "../app/store";
 import flags from "../flags.json";
 import theme from "../theme";
 
@@ -29,9 +30,11 @@ const MyApp = ({ Component, pageProps }: AppProps) => (
   <FlagsProvider value={flags}>
     <SafeHydrate>
       <Provider store={store}>
-        <ChakraProvider theme={theme}>
-          <Component {...pageProps} />
-        </ChakraProvider>
+        <PersistGate loading={null} persistor={persistor}>
+          <ChakraProvider theme={theme}>
+            <Component {...pageProps} />
+          </ChakraProvider>
+        </PersistGate>
       </Provider>
     </SafeHydrate>
   </FlagsProvider>


Test patch:
diff --git a/clients/ops/admin-ui/src/features/auth/__tests__/auth.slice.test.ts b/clients/ops/admin-ui/src/features/auth/__tests__/auth.slice.test.ts
deleted file mode 100644
index f90d25eac..000000000
--- a/clients/ops/admin-ui/src/features/auth/__tests__/auth.slice.test.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-import { makeStore } from "../../../app/store";
-import { STORED_CREDENTIALS_KEY } from "../../../constants";
-import { login, logout } from "../auth.slice";
-
-describe("Auth", () => {
-  it("should persist auth state to localStorage on login", () => {
-    jest.spyOn(Object.getPrototypeOf(window.localStorage), "setItem");
-    const store = makeStore();
-
-    store.dispatch(
-      login({
-        user_data: {
-          username: "Test",
-        },
-        token_data: {
-          access_token: "test-access-token",
-        },
-      })
-    );
-
-    expect(window.localStorage.setItem).toHaveBeenCalledWith(
-      STORED_CREDENTIALS_KEY,
-      JSON.stringify({
-        token: "test-access-token",
-        user: {
-          username: "Test",
-        },
-      })
-    );
-  });
-
-  it("should remove auth state from localStorage on logout", () => {
-    jest.spyOn(Object.getPrototypeOf(window.localStorage), "removeItem");
-    const store = makeStore();
-
-    store.dispatch(logout());
-
-    expect(window.localStorage.removeItem).toHaveBeenCalledWith(
-      STORED_CREDENTIALS_KEY
-    );
-  });
-});