diff --git a/Src/WitsmlExplorer.Frontend/components/Constants.tsx b/Src/WitsmlExplorer.Frontend/components/Constants.tsx index cb0a610cd..022f4cbac 100644 --- a/Src/WitsmlExplorer.Frontend/components/Constants.tsx +++ b/Src/WitsmlExplorer.Frontend/components/Constants.tsx @@ -10,12 +10,3 @@ export const DateFormat = { export const MILLIS_IN_SECOND = 1000; export const SECONDS_IN_MINUTE = 60; - -export const STORAGE_THEME_KEY = "selectedTheme"; -export const STORAGE_TIMEZONE_KEY = "selectedTimeZone"; -export const STORAGE_MODE_KEY = "selectedMode"; -export const STORAGE_FILTER_HIDDENOBJECTS_KEY = "hiddenObjects"; -export const STORAGE_MISSING_DATA_AGENT_CHECKS_KEY = "missingDataAgentChecks"; -export const STORAGE_DATETIMEFORMAT_KEY = "selectedDateTimeFormat"; -export const STORAGE_QUERYVIEW_DATA = "queryViewData"; -export const STORAGE_DECIMAL_KEY = "decimalPrefernce"; diff --git a/Src/WitsmlExplorer.Frontend/components/ContentViews/ServerManager.tsx b/Src/WitsmlExplorer.Frontend/components/ContentViews/ServerManager.tsx index f38656e45..a07ad9030 100644 --- a/Src/WitsmlExplorer.Frontend/components/ContentViews/ServerManager.tsx +++ b/Src/WitsmlExplorer.Frontend/components/ContentViews/ServerManager.tsx @@ -20,7 +20,7 @@ import ServerService from "../../services/serverService"; import WellService from "../../services/wellService"; import { Colors } from "../../styles/Colors"; import Icon from "../../styles/Icons"; -import { STORAGE_FILTER_HIDDENOBJECTS_KEY } from "../Constants"; +import { STORAGE_FILTER_HIDDENOBJECTS_KEY, getLocalStorageItem } from "../../tools/localStorageHelpers"; import ServerModal, { showDeleteServerModal } from "../Modals/ServerModal"; import UserCredentialsModal, { UserCredentialsModalProps } from "../Modals/UserCredentialsModal"; @@ -92,8 +92,8 @@ const ServerManager = (): React.ReactElement => { const updateVisibleObjects = (supportedObjects: string[]) => { const updatedVisibility = { ...allVisibleObjects }; - const hiddenItems = localStorage.getItem(STORAGE_FILTER_HIDDENOBJECTS_KEY)?.split(",") || []; - hiddenItems.forEach((objectType) => (updatedVisibility[objectType as ObjectType] = VisibilityStatus.Hidden)); + const hiddenItems = getLocalStorageItem(STORAGE_FILTER_HIDDENOBJECTS_KEY, { defaultValue: [] }); + hiddenItems.forEach((objectType) => (updatedVisibility[objectType] = VisibilityStatus.Hidden)); Object.values(ObjectType) .filter((objectType) => !supportedObjects.map((o) => o.toLowerCase()).includes(objectType.toLowerCase())) .forEach((objectType) => (updatedVisibility[objectType] = VisibilityStatus.Disabled)); diff --git a/Src/WitsmlExplorer.Frontend/components/ContentViews/table/ColumnDef.tsx b/Src/WitsmlExplorer.Frontend/components/ContentViews/table/ColumnDef.tsx index afd93021f..58f708eb2 100644 --- a/Src/WitsmlExplorer.Frontend/components/ContentViews/table/ColumnDef.tsx +++ b/Src/WitsmlExplorer.Frontend/components/ContentViews/table/ColumnDef.tsx @@ -1,12 +1,12 @@ import { Checkbox, IconButton, useTheme } from "@material-ui/core"; import { ColumnDef, Row, SortingFn, Table } from "@tanstack/react-table"; -import { useMemo, useContext } from "react"; +import { useContext, useMemo } from "react"; +import OperationContext from "../../../contexts/operationContext"; +import { DecimalPreference } from "../../../contexts/operationStateReducer"; import Icon from "../../../styles/Icons"; -import { getFromStorage, orderingStorageKey, widthsStorageKey } from "./contentTableStorage"; +import { STORAGE_CONTENTTABLE_ORDER_KEY, STORAGE_CONTENTTABLE_WIDTH_KEY, getLocalStorageItem } from "../../../tools/localStorageHelpers"; import { activeId, calculateColumnWidth, componentSortingFn, expanderId, measureSortingFn, selectId, toggleRow } from "./contentTableUtils"; import { ContentTableColumn, ContentType } from "./tableParts"; -import OperationContext from "../../../contexts/operationContext"; -import { DecimalPreference } from "../../../contexts/operationStateReducer"; declare module "@tanstack/react-table" { // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -24,7 +24,7 @@ export const useColumnDef = (viewId: string, columns: ContentTableColumn[], inse } = useContext(OperationContext); return useMemo(() => { - const savedWidths = getFromStorage(viewId, widthsStorageKey); + const savedWidths = getLocalStorageItem<{ [label: string]: number }>(viewId + STORAGE_CONTENTTABLE_WIDTH_KEY); let columnDef: ColumnDef[] = columns.map((column) => { return { id: column.label, @@ -39,7 +39,7 @@ export const useColumnDef = (viewId: string, columns: ContentTableColumn[], inse }; }); - const savedOrder = getFromStorage(viewId, orderingStorageKey); + const savedOrder = getLocalStorageItem(viewId + STORAGE_CONTENTTABLE_ORDER_KEY); if (savedOrder) { const sortedColumns = savedOrder.flatMap((label) => { const foundColumn = columnDef.find((col) => col.id == label); diff --git a/Src/WitsmlExplorer.Frontend/components/ContentViews/table/ColumnOptionsMenu.tsx b/Src/WitsmlExplorer.Frontend/components/ContentViews/table/ColumnOptionsMenu.tsx index 854662d39..a14137a4e 100644 --- a/Src/WitsmlExplorer.Frontend/components/ContentViews/table/ColumnOptionsMenu.tsx +++ b/Src/WitsmlExplorer.Frontend/components/ContentViews/table/ColumnOptionsMenu.tsx @@ -4,8 +4,9 @@ import { Table } from "@tanstack/react-table"; import { useContext, useState } from "react"; import styled from "styled-components"; import OperationContext from "../../../contexts/operationContext"; +import { useLocalStorageState } from "../../../hooks/useLocalStorageState"; import { Colors } from "../../../styles/Colors"; -import { orderingStorageKey, removeFromStorage, saveToStorage } from "./contentTableStorage"; +import { STORAGE_CONTENTTABLE_ORDER_KEY, removeLocalStorageItem } from "../../../tools/localStorageHelpers"; import { calculateColumnWidth, expanderId, selectId } from "./contentTableUtils"; import { ContentTableColumn, ContentType } from "./tableParts"; @@ -28,6 +29,7 @@ export const ColumnOptionsMenu = (props: { const [draggedOverId, setDraggedOverId] = useState(); const [isMenuOpen, setIsMenuOpen] = useState(false); const [menuAnchor, setMenuAnchor] = useState(null); + const [, saveOrderToStorage] = useLocalStorageState(viewId + STORAGE_CONTENTTABLE_ORDER_KEY); const isCompactMode = useTheme().props.MuiCheckbox?.size === "small"; const drop = (e: React.DragEvent) => { @@ -43,7 +45,7 @@ export const ColumnOptionsMenu = (props: { order.push(draggedId); } table.setColumnOrder(order); - saveToStorage(viewId, orderingStorageKey, order); + if (viewId) saveOrderToStorage(order); } setDraggedId(null); setDraggedOverId(null); @@ -56,7 +58,7 @@ export const ColumnOptionsMenu = (props: { order[index] = order[index - 1]; order[index - 1] = columnId; table.setColumnOrder(order); - saveToStorage(viewId, orderingStorageKey, order); + if (viewId) saveOrderToStorage(order); } }; @@ -67,7 +69,7 @@ export const ColumnOptionsMenu = (props: { order[index] = order[index + 1]; order[index + 1] = columnId; table.setColumnOrder(order); - saveToStorage(viewId, orderingStorageKey, order); + if (viewId) saveOrderToStorage(order); } }; @@ -145,7 +147,7 @@ export const ColumnOptionsMenu = (props: { { table.setColumnOrder([...(checkableRows ? [selectId] : []), ...(expandableRows ? [expanderId] : []), ...columns.map((column) => column.label)]); - removeFromStorage(viewId, orderingStorageKey); + if (viewId) removeLocalStorageItem(viewId + STORAGE_CONTENTTABLE_ORDER_KEY); }} > Reset ordering diff --git a/Src/WitsmlExplorer.Frontend/components/ContentViews/table/contentTableStorage.ts b/Src/WitsmlExplorer.Frontend/components/ContentViews/table/contentTableStorage.ts index 5b2ec7dea..71ae2b613 100644 --- a/Src/WitsmlExplorer.Frontend/components/ContentViews/table/contentTableStorage.ts +++ b/Src/WitsmlExplorer.Frontend/components/ContentViews/table/contentTableStorage.ts @@ -1,63 +1,24 @@ import { Table, VisibilityState } from "@tanstack/react-table"; import { useEffect } from "react"; - -export const widthsStorageKey = "-widths"; -const hiddenStorageKey = "-hidden"; -export const orderingStorageKey = "-ordering"; -type StorageKey = typeof widthsStorageKey | typeof hiddenStorageKey | typeof orderingStorageKey; -type StorageKeyToPreference = { - [widthsStorageKey]: { [label: string]: number }; - [hiddenStorageKey]: string[]; - [orderingStorageKey]: string[]; -}; - -export function saveToStorage(viewId: string | null, storageKey: Key, preference: StorageKeyToPreference[Key]): void { - try { - if (viewId != null) { - localStorage.setItem(viewId + storageKey, JSON.stringify(preference)); - } - } catch { - // disregard unavailable local storage - } -} - -export function getFromStorage(viewId: string | null, storageKey: Key): StorageKeyToPreference[Key] | null { - try { - if (viewId != null) { - return JSON.parse(localStorage.getItem(viewId + storageKey)); - } - } catch { - return null; - } -} - -export function removeFromStorage(viewId: string | null, storageKey: Key): void { - try { - if (viewId != null) { - localStorage.removeItem(viewId + storageKey); - } - } catch { - // disregard unavailable local storage - } -} +import { useLocalStorageState } from "../../../hooks/useLocalStorageState"; +import { STORAGE_CONTENTTABLE_HIDDEN_KEY, STORAGE_CONTENTTABLE_WIDTH_KEY, getLocalStorageItem } from "../../../tools/localStorageHelpers"; export const useStoreWidthsEffect = (viewId: string | null, table: Table) => { + const [, setWidths] = useLocalStorageState<{ [label: string]: number }>(viewId + STORAGE_CONTENTTABLE_WIDTH_KEY); useEffect(() => { - const dispatch = setTimeout(() => { - if (viewId != null) { - const widths = Object.assign({}, ...table.getLeafHeaders().map((header) => ({ [header.id]: header.getSize() }))); - saveToStorage(viewId, widthsStorageKey, widths); - } - }, 400); - return () => clearTimeout(dispatch); + if (viewId) { + const widths = Object.assign({}, ...table.getLeafHeaders().map((header) => ({ [header.id]: header.getSize() }))); + if (viewId) setWidths(widths); + } }, [table.getTotalSize()]); }; export const useStoreVisibilityEffect = (viewId: string | null, columnVisibility: VisibilityState) => { + const [, setVisibility] = useLocalStorageState(viewId + STORAGE_CONTENTTABLE_HIDDEN_KEY); useEffect(() => { - if (viewId != null) { + if (viewId) { const hiddenColumns = Object.entries(columnVisibility).flatMap(([columnId, isVisible]) => (isVisible ? [] : columnId)); - saveToStorage(viewId, hiddenStorageKey, hiddenColumns); + if (viewId) setVisibility(hiddenColumns); } }, [columnVisibility]); }; @@ -66,6 +27,6 @@ export const initializeColumnVisibility = (viewId: string | null) => { if (viewId == null) { return {}; } - const hiddenColumns = getFromStorage(viewId, hiddenStorageKey); + const hiddenColumns = getLocalStorageItem(viewId + STORAGE_CONTENTTABLE_HIDDEN_KEY); return hiddenColumns == null ? {} : Object.assign({}, ...hiddenColumns.map((hiddenColumn) => ({ [hiddenColumn]: false }))); }; diff --git a/Src/WitsmlExplorer.Frontend/components/Modals/MissingDataAgentModal.tsx b/Src/WitsmlExplorer.Frontend/components/Modals/MissingDataAgentModal.tsx index d57db5694..c9175fdc0 100644 --- a/Src/WitsmlExplorer.Frontend/components/Modals/MissingDataAgentModal.tsx +++ b/Src/WitsmlExplorer.Frontend/components/Modals/MissingDataAgentModal.tsx @@ -1,18 +1,19 @@ import { Accordion, Autocomplete, Button, Icon, Typography } from "@equinor/eds-core-react"; import { CloudUpload } from "@material-ui/icons"; -import { useContext, useEffect, useRef, useState } from "react"; +import { useContext, useRef, useState } from "react"; import styled from "styled-components"; import { v4 as uuid } from "uuid"; import OperationContext from "../../contexts/operationContext"; import OperationType from "../../contexts/operationType"; import useExport from "../../hooks/useExport"; +import { useLocalStorageState } from "../../hooks/useLocalStorageState"; import MissingDataJob, { MissingDataCheck } from "../../models/jobs/missingDataJob"; import WellReference from "../../models/jobs/wellReference"; import WellboreReference from "../../models/jobs/wellboreReference"; import { ObjectType } from "../../models/objectType"; import JobService, { JobType } from "../../services/jobService"; import { Colors } from "../../styles/Colors"; -import { STORAGE_MISSING_DATA_AGENT_CHECKS_KEY } from "../Constants"; +import { STORAGE_MISSING_DATA_AGENT_CHECKS_KEY } from "../../tools/localStorageHelpers"; import { StyledAccordionHeader } from "./LogComparisonModal"; import { objectToProperties, selectAllProperties } from "./MissingDataAgentProperties"; import ModalDialog, { ModalContentLayout, ModalWidth } from "./ModalDialog"; @@ -31,21 +32,15 @@ const MissingDataAgentModal = (props: MissingDataAgentModalProps): React.ReactEl dispatchOperation, operationState: { colors } } = useContext(OperationContext); - const [missingDataChecks, setMissingDataChecks] = useState([{ id: uuid() } as MissingDataCheck]); + const [missingDataChecks, setMissingDataChecks] = useLocalStorageState(STORAGE_MISSING_DATA_AGENT_CHECKS_KEY, { + defaultValue: [{ id: uuid() } as MissingDataCheck], + valueVerifier: verifyObjectIsChecks, + storageTransformer: (checks) => checks.map((check) => ({ ...check, id: uuid() })) + }); const [errors, setErrors] = useState([]); const { exportData, exportOptions } = useExport(); const inputFileRef = useRef(null); - useEffect(() => { - const checkString = localStorage.getItem(STORAGE_MISSING_DATA_AGENT_CHECKS_KEY); - const checks = stringToChecks(checkString); - if (checks.length > 0) setMissingDataChecks(checks); - }, []); - - useEffect(() => { - localStorage.setItem(STORAGE_MISSING_DATA_AGENT_CHECKS_KEY, JSON.stringify(missingDataChecks)); - }, [missingDataChecks]); - const stringToChecks = (checkString: string): MissingDataCheck[] => { try { const checksObj = JSON.parse(checkString); @@ -56,19 +51,6 @@ const MissingDataAgentModal = (props: MissingDataAgentModalProps): React.ReactEl } }; - const verifyObjectIsChecks = (obj: any): boolean => { - if (!Array.isArray(obj)) return false; - return obj.every( - (check) => - typeof check === "object" && - (!("objectType" in check) || missingDataObjectOptions.includes(check.objectType)) && - (!("properties" in check) || - ("objectType" in check && - Array.isArray(check.properties) && - check.properties.every((property: any) => typeof property === "string" && objectToProperties[check.objectType].includes(property)))) - ); - }; - const validateChecks = (): boolean => { const updatedErrors = []; @@ -233,6 +215,19 @@ const MissingDataAgentModal = (props: MissingDataAgentModalProps): React.ReactEl ); }; +const verifyObjectIsChecks = (obj: any): boolean => { + if (!Array.isArray(obj)) return false; + return obj.every( + (check) => + typeof check === "object" && + (!("objectType" in check) || missingDataObjectOptions.includes(check.objectType)) && + (!("properties" in check) || + ("objectType" in check && + Array.isArray(check.properties) && + check.properties.every((property: any) => typeof property === "string" && objectToProperties[check.objectType].includes(property)))) + ); +}; + export default MissingDataAgentModal; const CheckLayout = styled.div` diff --git a/Src/WitsmlExplorer.Frontend/components/Modals/SettingsModal.tsx b/Src/WitsmlExplorer.Frontend/components/Modals/SettingsModal.tsx index 0b0e97e17..94cc4c8f8 100644 --- a/Src/WitsmlExplorer.Frontend/components/Modals/SettingsModal.tsx +++ b/Src/WitsmlExplorer.Frontend/components/Modals/SettingsModal.tsx @@ -9,7 +9,7 @@ import { getAccountInfo, msalEnabled, signOut } from "../../msal/MsalAuthProvide import AuthorizationService from "../../services/authorizationService"; import { dark, light } from "../../styles/Colors"; import Icon from "../../styles/Icons"; -import { STORAGE_DATETIMEFORMAT_KEY, STORAGE_DECIMAL_KEY, STORAGE_MODE_KEY, STORAGE_THEME_KEY, STORAGE_TIMEZONE_KEY } from "../Constants"; +import { STORAGE_DATETIMEFORMAT_KEY, STORAGE_DECIMAL_KEY, STORAGE_MODE_KEY, STORAGE_THEME_KEY, STORAGE_TIMEZONE_KEY, setLocalStorageItem } from "../../tools/localStorageHelpers"; import { getOffsetFromTimeZone } from "../DateFormatter"; import { StyledNativeSelect } from "../Select"; import ModalDialog from "./ModalDialog"; @@ -30,7 +30,6 @@ const SettingsModal = (): React.ReactElement => { operationState: { theme, timeZone, colors, dateTimeFormat, decimals }, dispatchOperation } = useContext(OperationContext); - const [checkedDecimalPreference, setCheckedDecimalPreference] = useState(() => { return decimals === DecimalPreference.Raw ? DecimalPreference.Raw : DecimalPreference.Decimal; }); @@ -38,7 +37,7 @@ const SettingsModal = (): React.ReactElement => { const onChangeTheme = (event: any) => { const selectedTheme = event.target.value; - localStorage.setItem(STORAGE_THEME_KEY, selectedTheme); + setLocalStorageItem(STORAGE_THEME_KEY, selectedTheme); dispatchOperation({ type: OperationType.SetTheme, payload: selectedTheme }); }; const onChangeMode = (event: any) => { @@ -48,19 +47,19 @@ const SettingsModal = (): React.ReactElement => { } else { selectedMode = dark; } - localStorage.setItem(STORAGE_MODE_KEY, event.target.value); + setLocalStorageItem<"light" | "dark">(STORAGE_MODE_KEY, event.target.value); dispatchOperation({ type: OperationType.SetMode, payload: selectedMode }); }; const onChangeDateTimeFormat = (event: any) => { const selectedDateTimeFormat = event.target.value; - localStorage.setItem(STORAGE_DATETIMEFORMAT_KEY, selectedDateTimeFormat); + setLocalStorageItem(STORAGE_DATETIMEFORMAT_KEY, selectedDateTimeFormat); dispatchOperation({ type: OperationType.SetDateTimeFormat, payload: selectedDateTimeFormat }); }; const onChangeTimeZone = (event: any) => { const selectedTimeZone = event.target.value; - localStorage.setItem(STORAGE_TIMEZONE_KEY, selectedTimeZone); + setLocalStorageItem(STORAGE_TIMEZONE_KEY, selectedTimeZone); dispatchOperation({ type: OperationType.SetTimeZone, payload: selectedTimeZone }); }; @@ -68,7 +67,7 @@ const SettingsModal = (): React.ReactElement => { const inputDecimals: any = parseInt(event.target.value, 10); if (!isNaN(inputDecimals) && inputDecimals >= 0 && inputDecimals <= 10) { setDecimalError(false); - localStorage.setItem(STORAGE_DECIMAL_KEY, inputDecimals); + setLocalStorageItem(STORAGE_DECIMAL_KEY, inputDecimals); dispatchOperation({ type: OperationType.SetDecimal, payload: inputDecimals }); } else { setDecimalError(true); @@ -78,7 +77,7 @@ const SettingsModal = (): React.ReactElement => { const onChangeDecimalPreference = (event: React.ChangeEvent) => { const selectedValue = event.target.value; if (event.target.value === DecimalPreference.Raw) { - localStorage.setItem(STORAGE_DECIMAL_KEY, DecimalPreference.Raw); + setLocalStorageItem(STORAGE_DECIMAL_KEY, DecimalPreference.Raw); dispatchOperation({ type: OperationType.SetDecimal, payload: DecimalPreference.Raw as DecimalPreference }); } setCheckedDecimalPreference(selectedValue); diff --git a/Src/WitsmlExplorer.Frontend/components/Sidebar/FilterPanel.tsx b/Src/WitsmlExplorer.Frontend/components/Sidebar/FilterPanel.tsx index 1ee7b33ad..2ab4f891a 100644 --- a/Src/WitsmlExplorer.Frontend/components/Sidebar/FilterPanel.tsx +++ b/Src/WitsmlExplorer.Frontend/components/Sidebar/FilterPanel.tsx @@ -8,7 +8,7 @@ import NavigationType from "../../contexts/navigationType"; import OperationContext from "../../contexts/operationContext"; import { ObjectType } from "../../models/objectType"; import { Colors } from "../../styles/Colors"; -import { STORAGE_FILTER_HIDDENOBJECTS_KEY } from "../Constants"; +import { STORAGE_FILTER_HIDDENOBJECTS_KEY, setLocalStorageItem } from "../../tools/localStorageHelpers"; const FilterPanel = (): React.ReactElement => { const { navigationState, dispatchNavigation } = useContext(NavigationContext); @@ -25,12 +25,11 @@ const FilterPanel = (): React.ReactElement => { } else { updatedVisibility[objectType] = VisibilityStatus.Visible; } - localStorage.setItem( + setLocalStorageItem( STORAGE_FILTER_HIDDENOBJECTS_KEY, Object.entries(updatedVisibility) .filter(([, value]) => value == VisibilityStatus.Hidden) - .map(([key]) => key) - .join(",") + .map(([key]) => key as ObjectType) ); updateSelectedFilter({ objectVisibilityStatus: updatedVisibility }); }; diff --git a/Src/WitsmlExplorer.Frontend/contexts/queryContext.tsx b/Src/WitsmlExplorer.Frontend/contexts/queryContext.tsx index 10d32443a..1bc6ea022 100644 --- a/Src/WitsmlExplorer.Frontend/contexts/queryContext.tsx +++ b/Src/WitsmlExplorer.Frontend/contexts/queryContext.tsx @@ -1,7 +1,8 @@ import React, { Dispatch, useEffect } from "react"; import { v4 as uuid } from "uuid"; -import { STORAGE_QUERYVIEW_DATA } from "../components/Constants"; import { QueryTemplatePreset, ReturnElements, StoreFunction, getQueryTemplateWithPreset } from "../components/ContentViews/QueryViewUtils"; +import { useLocalStorageState } from "../hooks/useLocalStorageState"; +import { STORAGE_QUERYVIEW_DATA, getLocalStorageItem } from "../tools/localStorageHelpers"; export interface QueryElement { query: string; @@ -131,7 +132,7 @@ const removeTab = (state: QueryState, action: QueryAction): QueryState => { const getInitialQueryState = (initialQueryState: Partial): QueryState => { if (initialQueryState) return { ...getDefaultQueryState(), ...initialQueryState }; - return retrieveStoredQuery(); + return getLocalStorageItem(STORAGE_QUERYVIEW_DATA, { defaultValue: getDefaultQueryState(), valueVerifier: validateQueryState }); }; export interface QueryContextProviderProps { @@ -141,52 +142,32 @@ export interface QueryContextProviderProps { export function QueryContextProvider({ initialQueryState, children }: QueryContextProviderProps) { const [queryState, dispatchQuery] = React.useReducer(queryReducer, initialQueryState, getInitialQueryState); + const [, setLocalStorageQuery] = useLocalStorageState(STORAGE_QUERYVIEW_DATA, { + storageTransformer: (state) => ({ ...state, queries: state.queries.map((query) => ({ ...query, result: "" })) }) + }); useEffect(() => { - const dispatch = setTimeout(() => { - setStoredQuery(queryState); - }, 500); - return () => clearTimeout(dispatch); + setLocalStorageQuery(queryState); }, [queryState]); return {children}; } -const retrieveStoredQuery = () => { - try { - const storedQuery = localStorage.getItem(STORAGE_QUERYVIEW_DATA); - const queryState = JSON.parse(storedQuery); - validateQueryState(queryState); - return queryState; - } catch { - return getDefaultQueryState(); - } -}; +const validateQueryState = (queryState: QueryState): boolean => { + if (!queryState) return false; -const setStoredQuery = (queryState: QueryState) => { - try { - // As results can be large, we don't store them in local storage - const queryStateWithoutResults = { - ...queryState, - queries: queryState.queries.map((query) => ({ ...query, result: "" })) - }; - localStorage.setItem(STORAGE_QUERYVIEW_DATA, JSON.stringify(queryStateWithoutResults)); - } catch { - /* disregard unavailable local storage */ - } -}; + const hasValidProperty = (obj: any, prop: string, type: string) => prop in obj && typeof obj[prop] === type; -const validateQueryState = (queryState: QueryState) => { - if (!queryState) throw new Error("No query state"); - if (!("queries" in queryState) || !Array.isArray(queryState.queries)) throw new Error("Invalid queries in query state"); - if (!("tabIndex" in queryState) || typeof queryState.tabIndex !== "number") throw new Error("Invalid tabIndex in query state"); - - queryState.queries.forEach((query, index) => { - if (!("query" in query) || typeof query.query !== "string") throw new Error(`Invalid query in query state at index ${index}`); - if (!("result" in query) || typeof query.result !== "string") throw new Error(`Invalid result in query state at index ${index}`); - if (!("storeFunction" in query) || typeof query.storeFunction !== "string") throw new Error(`Invalid storeFunction in query state at index ${index}`); - if (!("returnElements" in query) || typeof query.returnElements !== "string") throw new Error(`Invalid returnElements in query state at index ${index}`); - if (!("optionsIn" in query) || typeof query.optionsIn !== "string") throw new Error(`Invalid optionsIn in query state at index ${index}`); - if (!("tabId" in query) || typeof query.tabId !== "string") throw new Error(`Invalid tabId in query state at index ${index}`); - }); + if (!hasValidProperty(queryState, "queries", "object") || !Array.isArray(queryState.queries)) return false; + if (!hasValidProperty(queryState, "tabIndex", "number")) return false; + + return queryState.queries.every( + (query) => + hasValidProperty(query, "query", "string") && + hasValidProperty(query, "result", "string") && + hasValidProperty(query, "storeFunction", "string") && + hasValidProperty(query, "returnElements", "string") && + hasValidProperty(query, "optionsIn", "string") && + hasValidProperty(query, "tabId", "string") + ); }; diff --git a/Src/WitsmlExplorer.Frontend/hooks/useLocalStorageState.tsx b/Src/WitsmlExplorer.Frontend/hooks/useLocalStorageState.tsx new file mode 100644 index 000000000..b84b84653 --- /dev/null +++ b/Src/WitsmlExplorer.Frontend/hooks/useLocalStorageState.tsx @@ -0,0 +1,31 @@ +import { useEffect, useState } from "react"; +import { StorageOptions, getLocalStorageItem, setLocalStorageItem } from "../tools/localStorageHelpers"; + +/** + * Custom hook that updates localStorage with the current state. + * + * @template T - The type of the state. + * @param {string} key - The key under which the state will be stored in localStorage. + * @param {StorageOptions} [options] - Optional configuration. + * @returns {[T | null, React.Dispatch>]} - The state and a function to update it. + */ +export const useLocalStorageState = (key: string, options?: StorageOptions): [T | null, React.Dispatch>] => { + const { defaultValue, delay = 250 } = options || {}; + const [state, setState] = useState(() => { + if (typeof window !== "undefined" && key) { + return getLocalStorageItem(key, options); + } + return defaultValue || null; + }); + + useEffect(() => { + if (typeof window !== "undefined" && key) { + const dispatch = setTimeout(() => { + setLocalStorageItem(key, state, options); + }, delay); + return () => clearTimeout(dispatch); + } + }, [key, state]); + + return [state, setState]; +}; diff --git a/Src/WitsmlExplorer.Frontend/pages/index.tsx b/Src/WitsmlExplorer.Frontend/pages/index.tsx index 73011872d..3b9881acb 100644 --- a/Src/WitsmlExplorer.Frontend/pages/index.tsx +++ b/Src/WitsmlExplorer.Frontend/pages/index.tsx @@ -5,7 +5,6 @@ import Head from "next/head"; import { SnackbarProvider } from "notistack"; import React, { useEffect } from "react"; import { AssetsLoader } from "../components/AssetsLoader"; -import { STORAGE_DATETIMEFORMAT_KEY, STORAGE_DECIMAL_KEY, STORAGE_MODE_KEY, STORAGE_THEME_KEY, STORAGE_TIMEZONE_KEY } from "../components/Constants"; import ContextMenuPresenter from "../components/ContextMenus/ContextMenuPresenter"; import { ErrorBoundary, ErrorFallback } from "../components/ErrorBoundary"; import GlobalStyles from "../components/GlobalStyles"; @@ -21,15 +20,15 @@ import { initNavigationStateReducer } from "../contexts/navigationStateReducer"; import OperationContext from "../contexts/operationContext"; import { DateTimeFormat, + DecimalPreference, SetDateTimeFormatAction, + SetDecimalAction, SetModeAction, SetThemeAction, SetTimeZoneAction, TimeZone, UserTheme, - initOperationStateReducer, - SetDecimalAction, - DecimalPreference + initOperationStateReducer } from "../contexts/operationStateReducer"; import OperationType from "../contexts/operationType"; import { QueryContextProvider } from "../contexts/queryContext"; @@ -37,6 +36,7 @@ import { enableDarkModeDebug } from "../debugUtils/darkModeDebug"; import { authRequest, msalEnabled, msalInstance } from "../msal/MsalAuthProvider"; import { dark, light } from "../styles/Colors"; import { getTheme } from "../styles/material-eds"; +import { STORAGE_DATETIMEFORMAT_KEY, STORAGE_DECIMAL_KEY, STORAGE_MODE_KEY, STORAGE_THEME_KEY, STORAGE_TIMEZONE_KEY, getLocalStorageItem } from "../tools/localStorageHelpers"; const Home = (): React.ReactElement => { const [operationState, dispatchOperation] = initOperationStateReducer(); @@ -44,27 +44,27 @@ const Home = (): React.ReactElement => { useEffect(() => { if (typeof localStorage != "undefined") { - const localStorageTheme = localStorage.getItem(STORAGE_THEME_KEY) as UserTheme; + const localStorageTheme = getLocalStorageItem(STORAGE_THEME_KEY); if (localStorageTheme) { const action: SetThemeAction = { type: OperationType.SetTheme, payload: localStorageTheme }; dispatchOperation(action); } - const storedTimeZone = localStorage.getItem(STORAGE_TIMEZONE_KEY) as TimeZone; + const storedTimeZone = getLocalStorageItem(STORAGE_TIMEZONE_KEY); if (storedTimeZone) { const action: SetTimeZoneAction = { type: OperationType.SetTimeZone, payload: storedTimeZone }; dispatchOperation(action); } - const storedMode = localStorage.getItem(STORAGE_MODE_KEY) as "light" | "dark"; + const storedMode = getLocalStorageItem<"light" | "dark">(STORAGE_MODE_KEY); if (storedMode) { const action: SetModeAction = { type: OperationType.SetMode, payload: storedMode == "light" ? light : dark }; dispatchOperation(action); } - const storedDateTimeFormat = localStorage.getItem(STORAGE_DATETIMEFORMAT_KEY) as DateTimeFormat; + const storedDateTimeFormat = getLocalStorageItem(STORAGE_DATETIMEFORMAT_KEY) as DateTimeFormat; if (storedDateTimeFormat) { const action: SetDateTimeFormatAction = { type: OperationType.SetDateTimeFormat, payload: storedDateTimeFormat }; dispatchOperation(action); } - const storedDecimals = localStorage.getItem(STORAGE_DECIMAL_KEY) as DecimalPreference; + const storedDecimals = getLocalStorageItem(STORAGE_DECIMAL_KEY) as DecimalPreference; if (storedDecimals) { const action: SetDecimalAction = { type: OperationType.SetDecimal, payload: storedDecimals }; dispatchOperation(action); diff --git a/Src/WitsmlExplorer.Frontend/services/authorizationService.ts b/Src/WitsmlExplorer.Frontend/services/authorizationService.ts index 44777eaca..c149d02ab 100644 --- a/Src/WitsmlExplorer.Frontend/services/authorizationService.ts +++ b/Src/WitsmlExplorer.Frontend/services/authorizationService.ts @@ -3,6 +3,7 @@ import { UpdateServerAction } from "../contexts/modificationActions"; import ModificationType from "../contexts/modificationType"; import { ErrorDetails } from "../models/errorDetails"; import { Server } from "../models/server"; +import { STORAGE_KEEP_SERVER_CREDENTIALS, getLocalStorageItem, removeLocalStorageItem, setLocalStorageItem } from "../tools/localStorageHelpers"; import { ApiClient, throwError } from "./apiClient"; import { AuthorizationClient } from "./authorizationClient"; @@ -87,25 +88,16 @@ class AuthorizationService { } public getKeepLoggedInToServer(serverUrl: string): boolean { - try { - return localStorage.getItem(serverUrl) == "keep"; - } catch { - // ignore unavailable local storage - } - return false; + return getLocalStorageItem(serverUrl + STORAGE_KEEP_SERVER_CREDENTIALS) == "keep"; } // Verify basic credentials for the first time // Basic credentials for this call will be set in header: WitsmlAuth public async verifyCredentials(credentials: BasicServerCredentials, keep: boolean, abortSignal?: AbortSignal): Promise { - try { - if (keep) { - localStorage.setItem(credentials.server.url, "keep"); - } else { - localStorage.removeItem(credentials.server.url); - } - } catch { - // ignore unavailable local storage + if (keep) { + setLocalStorageItem(credentials.server.url + STORAGE_KEEP_SERVER_CREDENTIALS, "keep"); + } else { + removeLocalStorageItem(credentials.server.url + STORAGE_KEEP_SERVER_CREDENTIALS); } const response = await AuthorizationClient.get(`/api/credentials/authorize?keep=` + keep, abortSignal, credentials); if (!response.ok) { diff --git a/Src/WitsmlExplorer.Frontend/tools/localStorageHelpers.tsx b/Src/WitsmlExplorer.Frontend/tools/localStorageHelpers.tsx new file mode 100644 index 000000000..b343439e7 --- /dev/null +++ b/Src/WitsmlExplorer.Frontend/tools/localStorageHelpers.tsx @@ -0,0 +1,74 @@ +export const STORAGE_THEME_KEY = "selectedTheme"; +export const STORAGE_TIMEZONE_KEY = "selectedTimeZone"; +export const STORAGE_MODE_KEY = "selectedMode"; +export const STORAGE_FILTER_HIDDENOBJECTS_KEY = "hiddenObjects"; +export const STORAGE_MISSING_DATA_AGENT_CHECKS_KEY = "missingDataAgentChecks"; +export const STORAGE_DATETIMEFORMAT_KEY = "selectedDateTimeFormat"; +export const STORAGE_QUERYVIEW_DATA = "queryViewData"; +export const STORAGE_DECIMAL_KEY = "decimalPrefernce"; +export const STORAGE_KEEP_SERVER_CREDENTIALS = "-keepCredentials"; +export const STORAGE_CONTENTTABLE_WIDTH_KEY = "-widths"; +export const STORAGE_CONTENTTABLE_HIDDEN_KEY = "-hidden"; +export const STORAGE_CONTENTTABLE_ORDER_KEY = "-ordering"; + +export const getLocalStorageItem = (key: string, options?: StorageOptions): T | null => { + const { defaultValue, valueVerifier } = options || {}; + try { + if (typeof window !== "undefined" && key) { + const item = localStorage.getItem(key); + const parsedItem = item ? JSON.parse(item) : null; + if (valueVerifier) { + return valueVerifier(parsedItem) ? parsedItem : defaultValue || null; + } + return parsedItem || defaultValue || null; + } + } catch (error) { + if (error instanceof SyntaxError) { + console.warn(`Error parsing localStorage item for key “${key}”. Removing the item from localStorage as the type might have changed.`, error); + removeLocalStorageItem(key); + } else { + // disregard unavailable local storage + console.warn(`Error getting localStorage item for key “${key}”:`, error); + } + } + return defaultValue || null; +}; + +export const setLocalStorageItem = (key: string, value: T, options?: StorageOptions): void => { + const { storageTransformer } = options || {}; + try { + if (typeof window !== "undefined" && key) { + const transformedValue = storageTransformer ? storageTransformer(value) : value; + localStorage.setItem(key, JSON.stringify(transformedValue)); + } + } catch (error) { + // disregard unavailable local storage + console.warn(`Error setting localStorage item for key “${key}”:`, error); + } +}; + +export const removeLocalStorageItem = (key: string): void => { + try { + if (typeof window !== "undefined" && key) { + localStorage.removeItem(key); + } + } catch (error) { + // disregard unavailable local storage + console.warn(`Error removing localStorage key “${key}”:`, error); + } +}; + +/** + * Interface for the options object that can be passed to the localStorage helper functions. + * + * @property defaultValue - An optional default value to use when the localStorage item is not found or an error occurs. + * @property delay - An optional delay (in milliseconds) used to debounce the setting of the item. Only used by the useLocalStorageState hook. + * @property valueVerifier - An optional function to verify the value retrieved from localStorage. If this function returns false, defaultValue or null will be used instead. + * @property storageTransformer - An optional function to transform the value before it's stored in localStorage. + */ +export interface StorageOptions { + defaultValue?: T; + delay?: number; + valueVerifier?: (value: T) => boolean; + storageTransformer?: (value: T) => T; +}