+ onValueChange={(tab) => {
+ removeItemLocalStorage("osColumns");
osData.onSet(
(s) => ({
...s,
@@ -94,8 +96,8 @@ export const Dashboard = () => {
search: "",
}),
true,
- )
- }
+ );
+ }}
>
diff --git a/react-app/src/hooks/UseMediaQuery.test.tsx b/react-app/src/hooks/UseMediaQuery.test.tsx
index 69b727361..152a12b5a 100644
--- a/react-app/src/hooks/UseMediaQuery.test.tsx
+++ b/react-app/src/hooks/UseMediaQuery.test.tsx
@@ -5,7 +5,7 @@ import { useMediaQuery } from "./useMediaQuery";
describe("UseMediaQuery", () => {
// https://vitest.dev/api/vi.html#vi-stubglobal
it("returns false if viewport doesn't match media query", () => {
- globalThis.window.matchMedia = vi.fn().mockImplementation(() => ({
+ global.window.matchMedia = vi.fn().mockImplementation(() => ({
matches: false,
addEventListener: () => {},
removeEventListener: () => {},
diff --git a/react-app/src/hooks/useLocalStorage.test.tsx b/react-app/src/hooks/useLocalStorage.test.tsx
new file mode 100644
index 000000000..f2dd6cfb8
--- /dev/null
+++ b/react-app/src/hooks/useLocalStorage.test.tsx
@@ -0,0 +1,38 @@
+import { renderHook } from "@testing-library/react";
+import { describe, it, expect, afterEach } from "vitest";
+import { useLocalStorage, removeItemLocalStorage } from "./useLocalStorage";
+import { Storage } from "@/utils/test-helpers";
+
+describe("UseLocalStorage", () => {
+ global.lobalStorage = new Storage();
+
+ afterEach(() => {
+ global.lobalStorage.clear();
+ });
+ it("sets local storage with key osQuery to null", () => {
+ const { result } = renderHook(() => useLocalStorage("osQuery", null));
+ expect(global.localStorage.getItem("osQuery")).toBe(null);
+ expect(result.current[0]).toEqual(null);
+ });
+
+ it("sets local storage with key osColumns to hidden columns", () => {
+ const { result } = renderHook(() => useLocalStorage("osColumns", ["test"]));
+ expect(global.localStorage.getItem("osColumns")).toBe(JSON.stringify(["test"]));
+ expect(result.current[0]).toEqual(["test"]);
+ });
+
+ it("holds values on rerender", () => {
+ const { result, rerender } = renderHook(() => useLocalStorage("osColumns", ["test"]));
+ expect(result.current[0]).toEqual(["test"]);
+ rerender();
+ expect(result.current[0]).toEqual(["test"]);
+ });
+
+ it("clears storage after removeItemLocalStorage is called", () => {
+ const { result } = renderHook(() => useLocalStorage("osColumns", ["test"]));
+ expect(global.localStorage.getItem("osColumns")).toBe(JSON.stringify(["test"]));
+ expect(result.current[0]).toEqual(["test"]);
+ removeItemLocalStorage();
+ expect(global.localStorage.getItem("osColumns")).toBe(null);
+ });
+});
diff --git a/react-app/src/hooks/useLocalStorage.ts b/react-app/src/hooks/useLocalStorage.ts
new file mode 100644
index 000000000..ea62abfb2
--- /dev/null
+++ b/react-app/src/hooks/useLocalStorage.ts
@@ -0,0 +1,45 @@
+import { useState, useEffect } from "react";
+
+interface GenericInitialValue {
+ [index: number]: unknown;
+}
+type keyType = "osQuery" | "osColumns";
+
+export const removeItemLocalStorage = (key?: keyType) => {
+ if (key) window.localStorage.removeItem(key);
+ else {
+ window.localStorage.removeItem("osQuery");
+ window.localStorage.removeItem("osColumns");
+ }
+};
+
+export const useLocalStorage = (key: keyType, initialValue: GenericInitialValue) => {
+ const [storedValue, setStoredValue] = useState(() => {
+ if (typeof window === "undefined") return initialValue;
+ try {
+ const item = window.localStorage.getItem(key);
+ return item ? JSON.parse(item) : initialValue;
+ } catch (e) {
+ console.log("Error while getting local storage: ", e);
+ }
+ });
+
+ useEffect(() => {
+ const updateLocalStorage = () => {
+ if (typeof window !== "undefined") {
+ if (storedValue === null) {
+ localStorage.removeItem(key);
+ } else {
+ localStorage.setItem(key, JSON.stringify(storedValue));
+ }
+ }
+ };
+ try {
+ updateLocalStorage();
+ } catch (e) {
+ console.log("Error setting local storage", e);
+ }
+ }, [key, storedValue]);
+
+ return [storedValue, setStoredValue];
+};
diff --git a/react-app/src/hooks/useParams.ts b/react-app/src/hooks/useParams.ts
index 87be452e1..11fa2942f 100644
--- a/react-app/src/hooks/useParams.ts
+++ b/react-app/src/hooks/useParams.ts
@@ -1,6 +1,8 @@
import LZ from "lz-string";
import { useMemo } from "react";
import { useSearchParams } from "react-router";
+import { useLocalStorage } from "./useLocalStorage";
+
/**
* useLzQuery syncs a url query parameter with a given state.
* LZ is a library which can compresses JSON into a uri string
@@ -8,21 +10,27 @@ import { useSearchParams } from "react-router";
*/
export const useLzUrl = (props: { key: string; initValue?: T }) => {
const [params, setParams] = useSearchParams();
+ const [query, setQuery] = useLocalStorage("osQuery", null);
const queryString = params.get(props.key) || "";
const state: T = useMemo(() => {
- if (!queryString) return props.initValue;
+ if (!queryString) {
+ if (query) return JSON.parse(query);
+ return props.initValue;
+ }
const decompress = LZ.decompressFromEncodedURIComponent(queryString);
if (!decompress) return props.initValue;
try {
+ setQuery(decompress);
return JSON.parse(decompress);
} catch {
return props.initValue;
}
- }, [queryString]);
+ // adding props.initValue causes this to loop
+ }, [queryString, query, setQuery]);
const onSet = (arg: (arg: T) => T | T, shouldIsolate?: boolean) => {
const val = (() => {
diff --git a/react-app/src/utils/test-helpers/index.tsx b/react-app/src/utils/test-helpers/index.tsx
index 52e9b37d5..f9df9b6cf 100644
--- a/react-app/src/utils/test-helpers/index.tsx
+++ b/react-app/src/utils/test-helpers/index.tsx
@@ -1,5 +1,6 @@
export * from "./dashboard";
export * from "./render";
+export * from "./mockStorage";
export * from "./renderForm";
export * from "./skipCleanup";
export * from "./uploadFiles";
diff --git a/react-app/src/utils/test-helpers/mockStorage.ts b/react-app/src/utils/test-helpers/mockStorage.ts
new file mode 100644
index 000000000..a6d412af3
--- /dev/null
+++ b/react-app/src/utils/test-helpers/mockStorage.ts
@@ -0,0 +1,66 @@
+// adapted from https://github.com/mswjs/local-storage-polyfill/blob/main/src/index.ts
+
+export const STORAGE_MAP_SYMBOL = Symbol("map");
+
+export class Storage {
+ private [STORAGE_MAP_SYMBOL]: Map;
+
+ constructor() {
+ this[STORAGE_MAP_SYMBOL] = new Map();
+ }
+
+ /**
+ * Returns the number of key/value pairs.
+ */
+ get length(): number {
+ return this[STORAGE_MAP_SYMBOL].size;
+ }
+
+ /**
+ * Returns the current value associated with the given key, or null if the given key does not exist.
+ */
+ public getItem(key: Key): string | null {
+ console.log(`getting item for key: ${key} value: ${this[STORAGE_MAP_SYMBOL].get(key) || null}`);
+ return this[STORAGE_MAP_SYMBOL].get(key) || null;
+ }
+
+ /**
+ * Returns the name of the nth key, or null if n is greater than or equal to the number of key/value pairs.
+ */
+ public key(index: number): string | null {
+ console.log(`looking for key index: ${index}`);
+ const keys = Array.from(this[STORAGE_MAP_SYMBOL].keys());
+ return keys[index] || null;
+ }
+
+ /**
+ * Sets the value of the pair identified by key to value, creating a new key/value pair if none existed for key previously.
+ *
+ * Unlike the browser implementation, does not throw when the value cannot be set
+ * because no such policy can be configured in Node.js. Does not emit the storage
+ * event on Window because there's no window.
+ */
+ public setItem(key: Key, value: string): void {
+ console.log(`setting ${key}: ${value}`);
+ this[STORAGE_MAP_SYMBOL].set(key, value);
+ }
+
+ /**
+ * Removes the key/value pair with the given key, if a key/value pair with the given key exists.
+ *
+ * Does not dispatch the storage event on Window.
+ */
+ public removeItem(key: Key): void {
+ console.log(`removing item for key: ${key}`);
+ this[STORAGE_MAP_SYMBOL].delete(key);
+ }
+
+ /**
+ * Removes all key/value pairs, if there are any.
+ *
+ * Does not dispatch the storage event on Window.
+ */
+ public clear(): void {
+ this[STORAGE_MAP_SYMBOL].clear();
+ }
+}