diff --git a/src/components/Drawer/Drawer.tsx b/src/components/Drawer/Drawer.tsx index 894e423d55..bc30a29f8f 100644 --- a/src/components/Drawer/Drawer.tsx +++ b/src/components/Drawer/Drawer.tsx @@ -3,8 +3,10 @@ import React from 'react'; import {Xmark} from '@gravity-ui/icons'; import {DrawerItem, Drawer as GravityDrawer} from '@gravity-ui/navigation'; import {ActionTooltip, Button, Flex, Icon, Text} from '@gravity-ui/uikit'; +import {debounce} from 'lodash'; import {cn} from '../../utils/cn'; +import {useSetting} from '../../utils/hooks/useSetting'; import {isNumeric} from '../../utils/utils'; import {CopyLinkButton} from '../CopyLinkButton/CopyLinkButton'; import {Portal} from '../Portal/Portal'; @@ -16,6 +18,7 @@ import './Drawer.scss'; const DEFAULT_DRAWER_WIDTH_PERCENTS = 60; const DEFAULT_DRAWER_WIDTH = 600; const DRAWER_WIDTH_KEY = 'drawer-width'; +const SAVE_DEBOUNCE_MS = 200; const b = cn('ydb-drawer'); type DrawerEvent = MouseEvent & { @@ -49,9 +52,9 @@ const DrawerPaneContentWrapper = ({ isPercentageWidth, hideVeil = true, }: DrawerPaneContentWrapperProps) => { - const [drawerWidth, setDrawerWidth] = React.useState(() => { - const savedWidth = localStorage.getItem(storageKey); - return isNumeric(savedWidth) ? Number(savedWidth) : defaultWidth; + const [savedWidthString, setSavedWidthString] = useSetting(storageKey); + const [drawerWidth, setDrawerWidth] = React.useState(() => { + return isNumeric(savedWidthString) ? Number(savedWidthString) : defaultWidth; }); const drawerRef = React.useRef(null); @@ -91,14 +94,24 @@ const DrawerPaneContentWrapper = ({ }; }, [isVisible, onClose, detectClickOutside]); + const saveWidthDebounced = React.useMemo(() => { + return debounce((value: string) => setSavedWidthString(value), SAVE_DEBOUNCE_MS); + }, [setSavedWidthString]); + + React.useEffect(() => { + return () => { + saveWidthDebounced.cancel(); + }; + }, [saveWidthDebounced]); + const handleResizeDrawer = (width: number) => { if (isPercentageWidth && containerWidth > 0) { const percentageWidth = Math.round((width / containerWidth) * 100); setDrawerWidth(percentageWidth); - localStorage.setItem(storageKey, percentageWidth.toString()); + saveWidthDebounced(percentageWidth.toString()); } else { setDrawerWidth(width); - localStorage.setItem(storageKey, width.toString()); + saveWidthDebounced(width.toString()); } }; diff --git a/src/components/SplitPane/SplitPane.tsx b/src/components/SplitPane/SplitPane.tsx index 9e49a8231e..a7e678a3eb 100644 --- a/src/components/SplitPane/SplitPane.tsx +++ b/src/components/SplitPane/SplitPane.tsx @@ -1,9 +1,11 @@ import React from 'react'; +import {debounce} from 'lodash'; import type {SplitProps} from 'react-split'; import SplitPaneLib from 'react-split'; import {cn} from '../../utils/cn'; +import {useSetting} from '../../utils/hooks/useSetting'; import './SplitPane.scss'; @@ -25,22 +27,44 @@ interface SplitPaneProps { const minSizeDefaultInner = [0, 100]; const sizesDefaultInner = [50, 50]; +const SAVE_DEBOUNCE_MS = 200; function SplitPane(props: SplitPaneProps) { const [innerSizes, setInnerSizes] = React.useState(); + const { + collapsedSizes, + triggerCollapse, + triggerExpand, + defaultSizes: defaultSizesProp, + initialSizes, + } = props; + const [savedSizesString, setSavedSizesString] = useSetting( + props.defaultSizePaneKey, + ); + + const saveSizesStringDebounced = React.useMemo(() => { + return debounce((value: string) => setSavedSizesString(value), SAVE_DEBOUNCE_MS); + }, [setSavedSizesString]); + + React.useEffect(() => { + return () => { + saveSizesStringDebounced.cancel(); + }; + }, [saveSizesStringDebounced]); - const getDefaultSizePane = () => { - const {defaultSizePaneKey, defaultSizes = sizesDefaultInner, initialSizes} = props; + const defaultSizePane = React.useMemo(() => { if (initialSizes) { return initialSizes; } - const sizes = localStorage.getItem(defaultSizePaneKey)?.split(',').map(Number); - return sizes || defaultSizes; - }; - const setDefaultSizePane = (sizes: number[]) => { - const {defaultSizePaneKey} = props; - localStorage.setItem(defaultSizePaneKey, sizes.join(',')); - }; + const sizes = savedSizesString?.split(',').map(Number); + return sizes || defaultSizesProp || sizesDefaultInner; + }, [defaultSizesProp, initialSizes, savedSizesString]); + const setDefaultSizePane = React.useCallback( + (sizes: number[]) => { + saveSizesStringDebounced(sizes.join(',')); + }, + [saveSizesStringDebounced], + ); const onDragHandler = (sizes: number[]) => { const {onSplitDragAdditional} = props; if (onSplitDragAdditional) { @@ -58,27 +82,25 @@ function SplitPane(props: SplitPaneProps) { }; React.useEffect(() => { - const {collapsedSizes, triggerCollapse} = props; if (triggerCollapse) { const newSizes = collapsedSizes || minSizeDefaultInner; setDefaultSizePane(newSizes); setInnerSizes(newSizes); } - }, [props.triggerCollapse]); + }, [collapsedSizes, triggerCollapse, setDefaultSizePane]); React.useEffect(() => { - const {triggerExpand, defaultSizes} = props; - const newSizes = defaultSizes || sizesDefaultInner; + const newSizes = defaultSizesProp || sizesDefaultInner; if (triggerExpand) { setDefaultSizePane(newSizes); setInnerSizes(newSizes); } - }, [props.triggerExpand]); + }, [defaultSizesProp, triggerExpand, setDefaultSizePane]); return ( - {children} + {children} ); diff --git a/src/containers/App/SettingsBootstrap.tsx b/src/containers/App/SettingsBootstrap.tsx new file mode 100644 index 0000000000..223305f436 --- /dev/null +++ b/src/containers/App/SettingsBootstrap.tsx @@ -0,0 +1,81 @@ +import React from 'react'; + +import {skipToken} from '@reduxjs/toolkit/query'; + +import {LoaderWrapper} from '../../components/LoaderWrapper/LoaderWrapper'; +import {selectMetaUser} from '../../store/reducers/authentication/authentication'; +import {settingsApi} from '../../store/reducers/settings/api'; +import {DEFAULT_USER_SETTINGS} from '../../store/reducers/settings/constants'; +import {uiFactory} from '../../uiFactory/uiFactory'; +import { + DEFAULT_CLUSTER_TAB_KEY, + DEFAULT_IS_QUERY_RESULT_COLLAPSED, + DEFAULT_IS_TENANT_COMMON_INFO_COLLAPSED, + DEFAULT_IS_TENANT_SUMMARY_COLLAPSED, + DEFAULT_SIZE_RESULT_PANE_KEY, + DEFAULT_SIZE_TENANT_KEY, + DEFAULT_SIZE_TENANT_SUMMARY_KEY, +} from '../../utils/constants'; +import {useTypedSelector} from '../../utils/hooks/useTypedSelector'; + +const DRAWER_WIDTH_KEY = 'drawer-width'; + +/** + * Preloaded keys for "UI layout" state (pane sizes, collapsed flags, etc.) that are stored in the same + * settings backend but are not part of `DEFAULT_USER_SETTINGS`. + * + * If a new UI layout key should be preloaded to prevent UI "pop-in" in remote-settings mode, add it here. + */ +const PRELOADED_UI_LAYOUT_KEYS = [ + DEFAULT_CLUSTER_TAB_KEY, + DEFAULT_SIZE_RESULT_PANE_KEY, + DEFAULT_SIZE_TENANT_SUMMARY_KEY, + DEFAULT_SIZE_TENANT_KEY, + DEFAULT_IS_TENANT_SUMMARY_COLLAPSED, + DEFAULT_IS_TENANT_COMMON_INFO_COLLAPSED, + DEFAULT_IS_QUERY_RESULT_COLLAPSED, + DRAWER_WIDTH_KEY, +] as const; + +interface SettingsBootstrapProps { + children: React.ReactNode; +} + +export function SettingsBootstrap({children}: SettingsBootstrapProps) { + const fallbackUser = useTypedSelector(selectMetaUser); + const userFromFactory = uiFactory.settingsBackend?.getUserId?.(); + const endpointFromFactory = uiFactory.settingsBackend?.getEndpoint?.(); + const remoteAvailable = Boolean( + (endpointFromFactory && userFromFactory && window.api?.settingsService) || + window.api?.metaSettings, + ); + + const user = userFromFactory ?? fallbackUser; + + const settingsKeysToPreload = React.useMemo(() => { + const keys = new Set(Object.keys(DEFAULT_USER_SETTINGS)); + + PRELOADED_UI_LAYOUT_KEYS.forEach((key) => keys.add(key)); + + return Array.from(keys); + }, []); + + const params = React.useMemo(() => { + if (user && remoteAvailable) { + return {user, name: settingsKeysToPreload}; + } + return skipToken; + }, [remoteAvailable, user, settingsKeysToPreload]); + + const {isLoading} = settingsApi.useGetSettingsQuery(params); + + if (!remoteAvailable) { + return children; + } + + return ( + + {children} + + ); +} diff --git a/src/containers/Cluster/Cluster.tsx b/src/containers/Cluster/Cluster.tsx index 5b3aa40b21..98aa0e5750 100644 --- a/src/containers/Cluster/Cluster.tsx +++ b/src/containers/Cluster/Cluster.tsx @@ -30,8 +30,10 @@ import type {AdditionalClusterProps, AdditionalTenantsProps} from '../../types/a import {EFlag} from '../../types/api/enums'; import {uiFactory} from '../../uiFactory/uiFactory'; import {cn} from '../../utils/cn'; +import {DEFAULT_CLUSTER_TAB_KEY} from '../../utils/constants'; import {useAutoRefreshInterval, useTypedDispatch, useTypedSelector} from '../../utils/hooks'; import {useIsViewerUser} from '../../utils/hooks/useIsUserAllowedToMakeChanges'; +import {useSetting} from '../../utils/hooks/useSetting'; import {useAppTitle} from '../App/AppTitleContext'; import {Configs} from '../Configs/Configs'; import {Nodes} from '../Nodes/Nodes'; @@ -68,7 +70,7 @@ export function Cluster({additionalClusterProps, additionalTenantsProps}: Cluste const dispatch = useTypedDispatch(); - const activeTabId = useClusterTab(); + const {activeTabId, onClusterTabClick} = useClusterTab(); const [{clusterName, backend}] = useQueryParams({ clusterName: StringParam, @@ -182,9 +184,8 @@ export function Cluster({additionalClusterProps, additionalTenantsProps}: Cluste view="primary" as="tab" to={path} - onClick={() => { - dispatch(updateDefaultClusterTab(id)); - }} + data-cluster-tab-id={id} + onClick={onClusterTabClick} > {title} @@ -304,7 +305,15 @@ export function Cluster({additionalClusterProps, additionalTenantsProps}: Cluste function useClusterTab() { const dispatch = useTypedDispatch(); - const defaultTab = useTypedSelector((state) => state.cluster.defaultClusterTab); + const defaultTabFromStore = useTypedSelector((state) => state.cluster.defaultClusterTab); + const [savedDefaultTab, setSavedDefaultTab] = useSetting( + DEFAULT_CLUSTER_TAB_KEY, + INITIAL_DEFAULT_CLUSTER_TAB, + ); + const savedDefaultTabValidated = React.useMemo(() => { + return isClusterTab(savedDefaultTab) ? savedDefaultTab : undefined; + }, [savedDefaultTab]); + const defaultTab = savedDefaultTabValidated ?? defaultTabFromStore; const shouldShowNetworkTable = useShouldShowClusterNetworkTable(); const shouldShowEventsTab = useShouldShowEventsTab(); @@ -327,11 +336,27 @@ function useClusterTab() { activeTab = defaultTab; } - React.useEffect(() => { - if (activeTab !== defaultTab) { - dispatch(updateDefaultClusterTab(activeTab)); - } - }, [activeTab, defaultTab, dispatch]); + const onClusterTabClick = React.useCallback( + (event: React.MouseEvent) => { + if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey) { + return; + } + + const rawTabId = event.currentTarget.getAttribute('data-cluster-tab-id'); + if (!rawTabId || !isClusterTab(rawTabId)) { + return; + } + + if (rawTabId !== defaultTabFromStore) { + dispatch(updateDefaultClusterTab(rawTabId)); + } + + if (rawTabId !== savedDefaultTabValidated) { + setSavedDefaultTab(rawTabId); + } + }, + [defaultTabFromStore, dispatch, savedDefaultTabValidated, setSavedDefaultTab], + ); - return activeTab; + return {activeTabId: activeTab, onClusterTabClick} as const; } diff --git a/src/containers/Tenant/ObjectSummary/ObjectSummary.tsx b/src/containers/Tenant/ObjectSummary/ObjectSummary.tsx index c1e4c854d2..098a3d0e66 100644 --- a/src/containers/Tenant/ObjectSummary/ObjectSummary.tsx +++ b/src/containers/Tenant/ObjectSummary/ObjectSummary.tsx @@ -32,6 +32,7 @@ import { formatSecondsToHours, } from '../../../utils/dataFormatters/dataFormatters'; import {useTypedDispatch, useTypedSelector} from '../../../utils/hooks'; +import {useSetting} from '../../../utils/hooks/useSetting'; import {prepareSystemViewType} from '../../../utils/schema'; import {EntityTitle} from '../EntityTitle/EntityTitle'; import {SchemaViewer} from '../Schema/SchemaViewer/SchemaViewer'; @@ -57,16 +58,6 @@ import {isDomain, transformPath} from './transformPath'; import './ObjectSummary.scss'; -const getTenantCommonInfoState = () => { - const collapsed = Boolean(localStorage.getItem(DEFAULT_IS_TENANT_COMMON_INFO_COLLAPSED)); - - return { - triggerExpand: false, - triggerCollapse: false, - collapsed, - }; -}; - interface ObjectSummaryProps { onCollapseSummary: VoidFunction; onExpandSummary: VoidFunction; @@ -82,10 +73,18 @@ export function ObjectSummary({ const dispatch = useTypedDispatch(); const {handleSchemaChange} = useTenantQueryParams(); + const [isCommonInfoCollapsed, setIsCommonInfoCollapsed] = useSetting( + DEFAULT_IS_TENANT_COMMON_INFO_COLLAPSED, + false, + ); const [commonInfoVisibilityState, dispatchCommonInfoVisibilityState] = React.useReducer( paneVisibilityToggleReducerCreator(DEFAULT_IS_TENANT_COMMON_INFO_COLLAPSED), undefined, - getTenantCommonInfoState, + () => ({ + triggerExpand: false, + triggerCollapse: false, + collapsed: Boolean(isCommonInfoCollapsed), + }), ); const {summaryTab = TENANT_SUMMARY_TABS_IDS.overview} = useTypedSelector( @@ -395,13 +394,16 @@ export function ObjectSummary({ }; const onCollapseInfoHandler = () => { + setIsCommonInfoCollapsed(true); dispatchCommonInfoVisibilityState(PaneVisibilityActionTypes.triggerCollapse); }; const onExpandInfoHandler = () => { + setIsCommonInfoCollapsed(false); dispatchCommonInfoVisibilityState(PaneVisibilityActionTypes.triggerExpand); }; const onSplitStartDragAdditional = () => { + setIsCommonInfoCollapsed(false); dispatchCommonInfoVisibilityState(PaneVisibilityActionTypes.clear); }; diff --git a/src/containers/Tenant/Tenant.tsx b/src/containers/Tenant/Tenant.tsx index 4269b2ce8b..f6345e908f 100644 --- a/src/containers/Tenant/Tenant.tsx +++ b/src/containers/Tenant/Tenant.tsx @@ -14,6 +14,7 @@ import {uiFactory} from '../../uiFactory/uiFactory'; import {cn} from '../../utils/cn'; import {DEFAULT_IS_TENANT_SUMMARY_COLLAPSED, DEFAULT_SIZE_TENANT_KEY} from '../../utils/constants'; import {useTypedDispatch, useTypedSelector} from '../../utils/hooks'; +import {useSetting} from '../../utils/hooks/useSetting'; import {isAccessError} from '../../utils/response'; import {useAppTitle} from '../App/AppTitleContext'; @@ -32,26 +33,24 @@ import './Tenant.scss'; const b = cn('tenant-page'); -const getTenantSummaryState = () => { - const collapsed = Boolean(localStorage.getItem(DEFAULT_IS_TENANT_SUMMARY_COLLAPSED)); - - return { - triggerExpand: false, - triggerCollapse: false, - collapsed, - }; -}; - interface TenantProps { additionalTenantProps?: AdditionalTenantsProps; } // eslint-disable-next-line complexity export function Tenant({additionalTenantProps}: TenantProps) { + const [isSummaryCollapsed, setIsSummaryCollapsed] = useSetting( + DEFAULT_IS_TENANT_SUMMARY_COLLAPSED, + false, + ); const [summaryVisibilityState, dispatchSummaryVisibilityAction] = React.useReducer( paneVisibilityToggleReducerCreator(DEFAULT_IS_TENANT_SUMMARY_COLLAPSED), undefined, - getTenantSummaryState, + () => ({ + triggerExpand: false, + triggerCollapse: false, + collapsed: Boolean(isSummaryCollapsed), + }), ); const {database, schema} = useTenantQueryParams(); @@ -108,13 +107,16 @@ export function Tenant({additionalTenantProps}: TenantProps) { const errorProps = showBlockingError ? uiFactory.clusterOrDatabaseAccessError : undefined; const onCollapseSummaryHandler = () => { + setIsSummaryCollapsed(true); dispatchSummaryVisibilityAction(PaneVisibilityActionTypes.triggerCollapse); }; const onExpandSummaryHandler = () => { + setIsSummaryCollapsed(false); dispatchSummaryVisibilityAction(PaneVisibilityActionTypes.triggerExpand); }; const onSplitStartDragAdditional = () => { + setIsSummaryCollapsed(false); dispatchSummaryVisibilityAction(PaneVisibilityActionTypes.clear); }; diff --git a/src/containers/Tenant/utils/paneVisibilityToggleHelpers.tsx b/src/containers/Tenant/utils/paneVisibilityToggleHelpers.tsx index 0b8ebb67be..66a5758255 100644 --- a/src/containers/Tenant/utils/paneVisibilityToggleHelpers.tsx +++ b/src/containers/Tenant/utils/paneVisibilityToggleHelpers.tsx @@ -20,22 +20,13 @@ export enum PaneVisibilityActionTypes { clear = 'clear', } -const setInitialIsPaneCollapsed = (key: string) => { - localStorage.setItem(key, 'true'); -}; - -const deleteInitialIsPaneCollapsed = (key: string) => { - localStorage.removeItem(key); -}; - -export function paneVisibilityToggleReducerCreator(isPaneCollapsedKey: string) { +export function paneVisibilityToggleReducerCreator() { return function paneVisibilityToggleReducer( state: InitialPaneState, action: PaneVisibilityActionTypes, ) { switch (action) { case PaneVisibilityActionTypes.triggerCollapse: { - setInitialIsPaneCollapsed(isPaneCollapsedKey); return { ...state, triggerCollapse: true, @@ -44,7 +35,6 @@ export function paneVisibilityToggleReducerCreator(isPaneCollapsedKey: string) { }; } case PaneVisibilityActionTypes.triggerExpand: { - deleteInitialIsPaneCollapsed(isPaneCollapsedKey); return { ...state, triggerCollapse: false, @@ -53,7 +43,6 @@ export function paneVisibilityToggleReducerCreator(isPaneCollapsedKey: string) { }; } case PaneVisibilityActionTypes.clear: { - deleteInitialIsPaneCollapsed(isPaneCollapsedKey); return { triggerCollapse: false, triggerExpand: false, diff --git a/src/services/api/index.ts b/src/services/api/index.ts index 5fb1718c77..d2a2ae9003 100644 --- a/src/services/api/index.ts +++ b/src/services/api/index.ts @@ -27,6 +27,7 @@ interface YdbEmbeddedAPIProps { // this setting allows to use schema object path relative to database in api requests useRelativePath: undefined | boolean; useMetaSettings: undefined | boolean; + metaSettingsBaseUrl?: string; csrfTokenGetter: undefined | (() => string | undefined); defaults: undefined | AxiosRequestConfig; } @@ -44,6 +45,7 @@ export class YdbEmbeddedAPI { meta?: MetaAPI; metaSettings?: MetaSettingsAPI; + settingsService?: MetaSettingsAPI; codeAssist?: CodeAssistAPI; constructor({ @@ -55,6 +57,7 @@ export class YdbEmbeddedAPI { defaults = {}, useRelativePath = false, useMetaSettings = false, + metaSettingsBaseUrl, }: YdbEmbeddedAPIProps) { const axiosParams: AxiosWrapperOptions = {config: {withCredentials, ...defaults}}; const baseApiParams = {singleClusterMode, proxyMeta, useRelativePath}; @@ -66,6 +69,10 @@ export class YdbEmbeddedAPI { if (useMetaSettings) { this.metaSettings = new MetaSettingsAPI(axiosParams, baseApiParams); } + if (metaSettingsBaseUrl) { + this.settingsService = new MetaSettingsAPI(axiosParams, baseApiParams); + this.settingsService.setBaseUrlOverride(metaSettingsBaseUrl); + } if (webVersion || codeAssistBackend) { this.codeAssist = new CodeAssistAPI(axiosParams, baseApiParams); @@ -85,6 +92,7 @@ export class YdbEmbeddedAPI { this.auth.setCSRFToken(token); this.meta?.setCSRFToken(token); this.metaSettings?.setCSRFToken(token); + this.settingsService?.setCSRFToken(token); this.codeAssist?.setCSRFToken(token); this.operation.setCSRFToken(token); this.pdisk.setCSRFToken(token); diff --git a/src/services/api/metaSettings.ts b/src/services/api/metaSettings.ts index a5642913e9..6bacff6bff 100644 --- a/src/services/api/metaSettings.ts +++ b/src/services/api/metaSettings.ts @@ -14,10 +14,28 @@ interface PendingRequest { reject: (error: unknown) => void; } +function joinBaseUrlAndPath(baseUrl: string, path: string) { + const normalizedBaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl; + const normalizedPath = path.startsWith('/') ? path : `/${path}`; + return `${normalizedBaseUrl}${normalizedPath}`; +} + export class MetaSettingsAPI extends BaseMetaAPI { private batchTimeout: NodeJS.Timeout | undefined = undefined; private currentUser: string | undefined = undefined; private requestQueue: Map | undefined = undefined; + private baseUrlOverride: string | undefined; + + setBaseUrlOverride(baseUrlOverride: string | undefined) { + this.baseUrlOverride = baseUrlOverride; + } + + getPath(path: string, clusterName?: string) { + if (this.baseUrlOverride) { + return joinBaseUrlAndPath(this.baseUrlOverride, path); + } + return super.getPath(path, clusterName); + } getSingleSetting({ name, @@ -84,7 +102,7 @@ export class MetaSettingsAPI extends BaseMetaAPI { }); } else { pendingRequests.forEach((request) => { - request.resolve({name, user, value: undefined}); + request.resolve({value: undefined}); }); } }); diff --git a/src/store/configureStore.ts b/src/store/configureStore.ts index 67332538ec..31e8068c1a 100644 --- a/src/store/configureStore.ts +++ b/src/store/configureStore.ts @@ -5,12 +5,13 @@ import {createBrowserHistory} from 'history'; import {listenForHistoryChange} from 'redux-location-state'; import {YdbEmbeddedAPI} from '../services/api'; +import {uiFactory} from '../uiFactory/uiFactory'; import {parseJson} from '../utils/utils'; import {getUrlData} from './getUrlData'; import rootReducer from './reducers'; import {api as storeApi} from './reducers/api'; -import {syncUserSettingsFromLS} from './reducers/settings/settings'; +import {preloadUserSettingsFromLS, syncUserSettingsFromLS} from './reducers/settings/settings'; import getLocationMiddleware from './state-url-mapping'; export let backend: string | undefined, @@ -19,10 +20,10 @@ export let backend: string | undefined, environment: string | undefined; function _configureStore< - S = any, + S = unknown, A extends Action = UnknownAction, P = S, - M extends Middleware<{}, S, Dispatch> = any, + M extends Middleware<{}, S, Dispatch> = Middleware<{}, S, Dispatch>, >(aRootReducer: Reducer, history: History, preloadedState: P, middleware: M[]) { const {locationMiddleware, reducersWithLocation} = getLocationMiddleware(history, aRootReducer); @@ -44,6 +45,10 @@ function _configureStore< }); syncUserSettingsFromLS(store); + const userIdFromFactory = uiFactory.settingsBackend?.getUserId?.(); + if (!userIdFromFactory) { + preloadUserSettingsFromLS(store); + } return store; } @@ -70,6 +75,7 @@ export function configureStore({ csrfTokenGetter: undefined, useRelativePath: false, useMetaSettings: false, + metaSettingsBaseUrl: uiFactory.settingsBackend?.getEndpoint?.(), defaults: undefined, }), } = {}) { diff --git a/src/store/reducers/cluster/cluster.ts b/src/store/reducers/cluster/cluster.ts index 84d0abdc4e..bad14ef439 100644 --- a/src/store/reducers/cluster/cluster.ts +++ b/src/store/reducers/cluster/cluster.ts @@ -7,7 +7,7 @@ import {clusterTabsIds, isClusterTab} from '../../../containers/Cluster/utils'; import {isClusterInfoV2} from '../../../types/api/cluster'; import type {TClusterInfo} from '../../../types/api/cluster'; import type {TTabletStateInfo} from '../../../types/api/tablet'; -import {CLUSTER_DEFAULT_TITLE, DEFAULT_CLUSTER_TAB_KEY} from '../../../utils/constants'; +import {CLUSTER_DEFAULT_TITLE} from '../../../utils/constants'; import {useClusterNameFromQuery} from '../../../utils/hooks/useDatabaseFromQuery'; import {useIsViewerUser} from '../../../utils/hooks/useIsUserAllowedToMakeChanges'; import {isQueryErrorResponse} from '../../../utils/query'; @@ -26,17 +26,8 @@ import { export const INITIAL_DEFAULT_CLUSTER_TAB = clusterTabsIds.tenants; -const defaultClusterTabLS = localStorage.getItem(DEFAULT_CLUSTER_TAB_KEY); - -let defaultClusterTab: ClusterTab; -if (isClusterTab(defaultClusterTabLS)) { - defaultClusterTab = defaultClusterTabLS; -} else { - defaultClusterTab = INITIAL_DEFAULT_CLUSTER_TAB; -} - const initialState: ClusterState = { - defaultClusterTab, + defaultClusterTab: INITIAL_DEFAULT_CLUSTER_TAB, }; const clusterSlice = createSlice({ name: 'cluster', @@ -51,7 +42,6 @@ const clusterSlice = createSlice({ export function updateDefaultClusterTab(tab: string) { return (dispatch: Dispatch) => { if (isClusterTab(tab)) { - localStorage.setItem(DEFAULT_CLUSTER_TAB_KEY, tab); dispatch(clusterSlice.actions.setDefaultClusterTab(tab)); } }; diff --git a/src/store/reducers/settings/api.ts b/src/store/reducers/settings/api.ts index e42bf09b64..e11b33ad33 100644 --- a/src/store/reducers/settings/api.ts +++ b/src/store/reducers/settings/api.ts @@ -1,36 +1,176 @@ import type { GetSettingsParams, GetSingleSettingParams, + SetSettingResponse, SetSingleSettingParams, + Setting, } from '../../../types/api/settings'; +import {uiFactory} from '../../../uiFactory/uiFactory'; import {serializeReduxError} from '../../../utils/errors/serializeReduxError'; import type {AppDispatch} from '../../defaultStore'; import {api} from '../api'; import {SETTINGS_OPTIONS} from './constants'; -import {parseSettingValue, stringifySettingValue} from './utils'; +import {handleOptimisticSettingWrite, handleRemoteSettingResult} from './effects'; +import {shouldSyncSettingToLS, stringifySettingValue} from './utils'; + +const REMOTE_WRITE_DEBOUNCE_MS = 200; + +async function delayMs(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function withRetries(fn: () => Promise, attempts: number, delay: number) { + let lastError: unknown; + for (let attempt = 0; attempt < attempts; attempt++) { + try { + return await fn(); + } catch (error) { + lastError = error; + if (attempt < attempts - 1) { + await delayMs(delay); + } + } + } + throw lastError; +} + +type DebounceEntry = { + timeoutId: number | undefined; + latestRequest: () => Promise; + pending: Array<{resolve: (value: T) => void; reject: (error: unknown) => void}>; + inFlight: boolean; +}; + +const debouncedRemoteWrites = new Map>(); + +async function flushDebouncedRemoteWrite(key: string) { + const entry = debouncedRemoteWrites.get(key); + if (!entry || entry.inFlight) { + return; + } + + entry.inFlight = true; + entry.timeoutId = undefined; + + const pending = entry.pending as Array<{ + resolve: (value: unknown) => void; + reject: (error: unknown) => void; + }>; + entry.pending = []; + + try { + const result = await entry.latestRequest(); + pending.forEach((p) => p.resolve(result)); + } catch (error) { + pending.forEach((p) => p.reject(error)); + } finally { + entry.inFlight = false; + + if (entry.pending.length > 0) { + entry.timeoutId = window.setTimeout(() => { + flushDebouncedRemoteWrite(key); + }, REMOTE_WRITE_DEBOUNCE_MS); + } else { + debouncedRemoteWrites.delete(key); + } + } +} + +function scheduleDebouncedRemoteWrite(key: string, request: () => Promise) { + const existing = debouncedRemoteWrites.get(key) as DebounceEntry | undefined; + if (existing) { + if (existing.timeoutId !== undefined) { + clearTimeout(existing.timeoutId); + } + existing.latestRequest = request; + return new Promise((resolve, reject) => { + existing.pending.push({resolve, reject}); + if (!existing.inFlight) { + existing.timeoutId = window.setTimeout(() => { + flushDebouncedRemoteWrite(key); + }, REMOTE_WRITE_DEBOUNCE_MS); + } + }); + } + + return new Promise((resolve, reject) => { + const entry: DebounceEntry = { + timeoutId: undefined, + latestRequest: request, + pending: [{resolve, reject}], + inFlight: false, + }; + debouncedRemoteWrites.set(key, entry as DebounceEntry); + entry.timeoutId = window.setTimeout(() => { + flushDebouncedRemoteWrite(key); + }, REMOTE_WRITE_DEBOUNCE_MS); + }); +} + +function resolveRemoteSettingsClientAndUser(user: string) { + const userFromFactory = uiFactory.settingsBackend?.getUserId?.(); + const endpointFromFactory = uiFactory.settingsBackend?.getEndpoint?.(); + + if (endpointFromFactory && userFromFactory && window.api?.settingsService) { + return { + client: window.api.settingsService, + user: userFromFactory, + clientKey: 'service', + } as const; + } + + if (window.api?.metaSettings) { + return {client: window.api.metaSettings, user, clientKey: 'meta'} as const; + } + + return undefined; +} export const settingsApi = api.injectEndpoints({ endpoints: (builder) => ({ - getSingleSetting: builder.query({ + getSingleSetting: builder.query({ queryFn: async ({name, user}) => { try { - if (!window.api?.metaSettings) { + const resolved = resolveRemoteSettingsClientAndUser(user); + if (!resolved) { throw new Error('MetaSettings API is not available'); } - const data = await window.api.metaSettings.getSingleSetting({ + const data = await resolved.client.getSingleSetting({ name, - user, + user: resolved.user, // Directly access options here to avoid them in cache key preventBatching: SETTINGS_OPTIONS[name]?.preventBatching, }); - return {data: parseSettingValue(data?.value)}; + return {data}; } catch (error) { return {error: serializeReduxError(error)}; } }, + async onQueryStarted(args, {dispatch, queryFulfilled}) { + const {name, user} = args; + if (!name) { + return; + } + + try { + const {data} = await queryFulfilled; + + handleRemoteSettingResult({ + user: resolveRemoteSettingsClientAndUser(user)?.user ?? user, + name, + remoteValue: data?.value, + dispatch, + migrateToRemote: (params) => { + dispatch(settingsApi.endpoints.setSingleSetting.initiate(params)); + }, + }); + } catch { + // ignore + } + }, }), setSingleSetting: builder.mutation({ queryFn: async ({ @@ -39,17 +179,31 @@ export const settingsApi = api.injectEndpoints({ value, }: Omit & {value: unknown}) => { try { - if (!window.api?.metaSettings) { + const resolved = resolveRemoteSettingsClientAndUser(user); + if (!resolved) { throw new Error('MetaSettings API is not available'); } - const data = await window.api.metaSettings.setSingleSetting({ - name, - user, - value: stringifySettingValue(value), - }); + const debounceKey = `${resolved.clientKey}:${resolved.user}:${name}`; + + const data = await scheduleDebouncedRemoteWrite( + debounceKey, + () => + withRetries( + () => + resolved.client.setSingleSetting({ + name, + user: resolved.user, + value: stringifySettingValue(value), + }), + 3, + 200, + ), + ); - if (data.status !== 'SUCCESS') { + const status = data?.status; + + if (status && status !== 'SUCCESS') { throw new Error('Cannot set setting - status is not SUCCESS'); } @@ -59,26 +213,29 @@ export const settingsApi = api.injectEndpoints({ } }, async onQueryStarted(args, {dispatch, queryFulfilled}) { - const {name, user, value} = args; + const {name, value} = args; + const shouldSyncToLS = shouldSyncSettingToLS(name); - // Optimistically update existing cache entry - const patchResult = dispatch( - settingsApi.util.updateQueryData('getSingleSetting', {name, user}, () => value), - ); - try { - await queryFulfilled; - } catch { - patchResult.undo(); - } + await handleOptimisticSettingWrite({ + name, + value, + dispatch, + metaSettingsAvailable: Boolean( + window.api?.metaSettings || window.api?.settingsService, + ), + shouldSyncToLS, + awaitRemote: queryFulfilled, + }); }, }), - getSettings: builder.query({ + getSettings: builder.query, GetSettingsParams>({ queryFn: async ({name, user}: GetSettingsParams, baseApi) => { try { - if (!window.api?.metaSettings) { + const resolved = resolveRemoteSettingsClientAndUser(user); + if (!resolved) { throw new Error('MetaSettings API is not available'); } - const data = await window.api.metaSettings.getSettings({name, user}); + const data = await resolved.client.getSettings({name, user: resolved.user}); const patches: Promise[] = []; const dispatch = baseApi.dispatch as AppDispatch; @@ -89,19 +246,29 @@ export const settingsApi = api.injectEndpoints({ const cacheEntryParams: GetSingleSettingParams = { name: settingName, - user, + user: resolved.user, }; - const newSettingValue = parseSettingValue(settingData?.value); + const remoteValue = settingData?.value; const patch = dispatch( settingsApi.util.upsertQueryData( 'getSingleSetting', cacheEntryParams, - newSettingValue, + settingData, ), ); patches.push(patch); + + handleRemoteSettingResult({ + user: resolved.user, + name: settingName, + remoteValue, + dispatch, + migrateToRemote: (params) => { + dispatch(settingsApi.endpoints.setSingleSetting.initiate(params)); + }, + }); }); // Wait for all patches for proper loading state diff --git a/src/store/reducers/settings/effects.ts b/src/store/reducers/settings/effects.ts new file mode 100644 index 0000000000..f88f5dc6e3 --- /dev/null +++ b/src/store/reducers/settings/effects.ts @@ -0,0 +1,70 @@ +import {isNil} from 'lodash'; + +import type {SettingValue} from '../../../types/api/settings'; + +import {setSettingValueInStore} from './settings'; +import { + parseSettingValue, + readSettingValueFromLS, + setSettingValueToLS, + shouldSyncSettingToLS, +} from './utils'; + +export type MigrateToRemoteFn = (args: {user: string; name: string; value: unknown}) => void; + +export function handleRemoteSettingResult(args: { + user: string; + name: string; + remoteValue: SettingValue | undefined; + dispatch: (action: unknown) => void; + migrateToRemote: MigrateToRemoteFn; +}) { + const {user, name, remoteValue, dispatch, migrateToRemote} = args; + + const shouldUseLS = shouldSyncSettingToLS(name); + + if (isNil(remoteValue)) { + if (!shouldUseLS) { + return; + } + const localValue = readSettingValueFromLS(name); + if (!isNil(localValue)) { + migrateToRemote({user, name, value: localValue}); + } + return; + } + + const parsedValue = parseSettingValue(remoteValue); + dispatch(setSettingValueInStore({name, value: parsedValue})); + + if (shouldUseLS) { + setSettingValueToLS(name, parsedValue); + } +} + +export async function handleOptimisticSettingWrite(args: { + name: string; + value: unknown; + dispatch: (action: unknown) => void; + metaSettingsAvailable: boolean; + shouldSyncToLS: boolean; + awaitRemote: Promise; +}) { + const {name, value, dispatch, metaSettingsAvailable, shouldSyncToLS, awaitRemote} = args; + + dispatch(setSettingValueInStore({name, value})); + + if (shouldSyncToLS) { + setSettingValueToLS(name, value); + } + + if (!metaSettingsAvailable) { + return; + } + + try { + await awaitRemote; + } catch { + // keep local state; remote write is best-effort + } +} diff --git a/src/store/reducers/settings/settings.ts b/src/store/reducers/settings/settings.ts index de60ee52ee..3e4cd68fa9 100644 --- a/src/store/reducers/settings/settings.ts +++ b/src/store/reducers/settings/settings.ts @@ -2,20 +2,21 @@ import type {Store} from '@reduxjs/toolkit'; import {createSelector, createSlice} from '@reduxjs/toolkit'; import {isNil} from 'lodash'; -import {settingsManager} from '../../../services/settings'; import {parseJson} from '../../../utils/utils'; import type {AppDispatch, RootState} from '../../defaultStore'; import {DEFAULT_USER_SETTINGS} from './constants'; import type {SettingsState} from './types'; -import {getSettingDefault, readSettingValueFromLS, setSettingValueToLS} from './utils'; - -const userSettings = settingsManager.extractSettingsFromLS(DEFAULT_USER_SETTINGS); -const systemSettings = window.systemSettings || {}; +import { + getSettingDefault, + readSettingValueFromLS, + setSettingValueToLS, + shouldSyncSettingToLS, +} from './utils'; export const initialState: SettingsState = { - userSettings, - systemSettings, + userSettings: {}, + systemSettings: window.systemSettings || {}, }; const settingsSlice = createSlice({ @@ -23,11 +24,19 @@ const settingsSlice = createSlice({ initialState, reducers: (create) => ({ setSettingValue: create.reducer<{name: string; value: unknown}>((state, action) => { - state.userSettings[action.payload.name] = action.payload.value; + return { + ...state, + userSettings: { + ...state.userSettings, + [action.payload.name]: action.payload.value, + }, + }; }), }), }); +export const setSettingValueInStore = settingsSlice.actions.setSettingValue; + /** * Reads LS value or use default when store value undefined * @@ -48,6 +57,10 @@ export const getSettingValue = createSelector( } const defaultValue = getSettingDefault(name); + if (!shouldSyncSettingToLS(name)) { + return defaultValue; + } + const savedValue = readSettingValueFromLS(name); return savedValue ?? defaultValue; @@ -58,7 +71,9 @@ export const setSettingValue = (name: string | undefined, value: unknown) => { return (dispatch: AppDispatch) => { if (name) { dispatch(settingsSlice.actions.setSettingValue({name, value})); - setSettingValueToLS(name, value); + if (shouldSyncSettingToLS(name)) { + setSettingValueToLS(name, value); + } } }; }; @@ -85,4 +100,23 @@ export function syncUserSettingsFromLS(store: Store) { }); } +export function preloadUserSettingsFromLS(store: Store) { + if (typeof window === 'undefined') { + return; + } + + Object.keys(DEFAULT_USER_SETTINGS).forEach((name) => { + if (!shouldSyncSettingToLS(name)) { + return; + } + + const savedValue = readSettingValueFromLS(name); + if (isNil(savedValue)) { + return; + } + + store.dispatch(settingsSlice.actions.setSettingValue({name, value: savedValue})); + }); +} + export default settingsSlice.reducer; diff --git a/src/store/reducers/settings/useSetting.ts b/src/store/reducers/settings/useSetting.ts index 72402ae386..b2e220aea0 100644 --- a/src/store/reducers/settings/useSetting.ts +++ b/src/store/reducers/settings/useSetting.ts @@ -1,14 +1,14 @@ import React from 'react'; import {skipToken} from '@reduxjs/toolkit/query'; -import {isNil} from 'lodash'; -import {useSetting as useLSSetting} from '../../../utils/hooks'; +import {uiFactory} from '../../../uiFactory/uiFactory'; +import {useTypedDispatch} from '../../../utils/hooks/useTypedDispatch'; import {useTypedSelector} from '../../../utils/hooks/useTypedSelector'; import {selectMetaUser} from '../authentication/authentication'; import {settingsApi} from './api'; -import {getSettingDefault} from './utils'; +import {getSettingValue, setSettingValue} from './settings'; type SaveSettingValue = (value: T | undefined) => void; @@ -17,51 +17,40 @@ export function useSetting(name?: string): { saveValue: SaveSettingValue; isLoading: boolean; } { - const user = useTypedSelector(selectMetaUser); + const fallbackUser = useTypedSelector(selectMetaUser); + const userFromFactory = uiFactory.settingsBackend?.getUserId?.(); + const endpointFromFactory = uiFactory.settingsBackend?.getEndpoint?.(); + const remoteAvailable = Boolean( + (endpointFromFactory && userFromFactory && window.api?.settingsService) || + window.api?.metaSettings, + ); + const user = userFromFactory ?? fallbackUser; + const dispatch = useTypedDispatch(); const params = React.useMemo(() => { - if (user && name && window.api?.metaSettings) { + if (user && name && remoteAvailable) { return {user, name}; } return skipToken; - }, [user, name]); - - const {currentData: settingFromMeta, isLoading} = settingsApi.useGetSingleSettingQuery(params); + }, [remoteAvailable, user, name]); + const {isLoading} = settingsApi.useGetSingleSettingQuery(params); const [setMetaSetting] = settingsApi.useSetSingleSettingMutation(); - - const [settingFromLS, saveSettingToLS] = useLSSetting(name); - - const settingValue = React.useMemo(() => { - if (!name) { - return undefined; - } - const defaultValue = getSettingDefault(name); - - let value: unknown; - - if (window.api?.metaSettings) { - value = settingFromMeta; - } else { - value = settingFromLS; - } - return value ?? defaultValue; - }, [name, settingFromMeta, settingFromLS]); + const settingValue = useTypedSelector((state) => getSettingValue(state, name)) as T | undefined; const saveValue = React.useCallback>( (value) => { if (!name) { return; } - if (window.api?.metaSettings && user) { + if (remoteAvailable && user) { setMetaSetting({user, name, value}); + return; } - if (isNil(window.api?.metaSettings)) { - saveSettingToLS(value); - } + dispatch(setSettingValue(name, value)); }, - [user, name, setMetaSetting, saveSettingToLS], + [dispatch, remoteAvailable, user, name, setMetaSetting], ); - return {value: settingValue as T | undefined, saveValue, isLoading} as const; + return {value: settingValue, saveValue, isLoading} as const; } diff --git a/src/store/reducers/settings/utils.ts b/src/store/reducers/settings/utils.ts index 8113e445db..afde2f47d3 100644 --- a/src/store/reducers/settings/utils.ts +++ b/src/store/reducers/settings/utils.ts @@ -2,10 +2,16 @@ import type {SettingValue} from '../../../types/api/settings'; import {parseJson} from '../../../utils/utils'; import type {SettingKey} from './constants'; -import {DEFAULT_USER_SETTINGS} from './constants'; +import {DEFAULT_USER_SETTINGS, SETTINGS_OPTIONS} from './constants'; export function stringifySettingValue(value?: unknown): string { - return typeof value === 'string' ? value : JSON.stringify(value); + if (typeof value === 'string') { + return value; + } + if (value === undefined) { + return 'undefined'; + } + return JSON.stringify(value); } export function parseSettingValue(value?: SettingValue) { try { @@ -40,3 +46,7 @@ export function setSettingValueToLS(name: string | undefined, value: unknown): v export function getSettingDefault(name: string) { return DEFAULT_USER_SETTINGS[name as SettingKey]; } + +export function shouldSyncSettingToLS(name: string) { + return !SETTINGS_OPTIONS[name]?.preventSyncWithLS; +} diff --git a/src/types/api/settings.ts b/src/types/api/settings.ts index 6de550c4c5..d2d11f83d5 100644 --- a/src/types/api/settings.ts +++ b/src/types/api/settings.ts @@ -1,14 +1,17 @@ -export interface SetSettingResponse { - ready: boolean; - status: 'SUCCESS'; -} +export type SetSettingResponse = {ready?: boolean; status?: string} | undefined; + export type GetSettingResponse = Record; export interface Setting { - user: string; - name: string; + /** + * JSON string representation of the stored value. + */ value?: SettingValue; } -export type SettingValue = string | Record; +/** + * Settings values are stored as strings. Complex values (objects/arrays) must be JSON-stringified on write + * and parsed on read. + */ +export type SettingValue = string; export interface GetSingleSettingParams { user: string; name: string; diff --git a/src/uiFactory/types.ts b/src/uiFactory/types.ts index 169fe2d4f7..13c4577512 100644 --- a/src/uiFactory/types.ts +++ b/src/uiFactory/types.ts @@ -52,6 +52,17 @@ export interface UIFactory string | undefined; + /** + * User identifier to scope settings in the remote store (Yandex users). + */ + getUserId?: () => string | undefined; + }; + yaMetricaConfig?: { yaMetricaMap: Record; goals: UiMetricaGoals; diff --git a/src/uiFactory/uiFactory.ts b/src/uiFactory/uiFactory.ts index 521b7e4f8c..e2e126fc41 100644 --- a/src/uiFactory/uiFactory.ts +++ b/src/uiFactory/uiFactory.ts @@ -20,6 +20,7 @@ const uiFactoryBase: UIFactory = { }, hasAccess: true, useDatabaseId: false, + settingsBackend: undefined, }; export function configureUIFactory( diff --git a/src/utils/hooks/useSelectedColumns.ts b/src/utils/hooks/useSelectedColumns.ts index b799179ff6..d63dd0c349 100644 --- a/src/utils/hooks/useSelectedColumns.ts +++ b/src/utils/hooks/useSelectedColumns.ts @@ -2,7 +2,7 @@ import React from 'react'; import type {TableColumnSetupItem, TableColumnSetupProps} from '@gravity-ui/uikit'; -import {settingsManager} from '../../services/settings'; +import {useSetting} from './useSetting'; type OrderedColumn = {id: string; selected?: boolean}; @@ -24,14 +24,14 @@ export const useSelectedColumns = ( defaultColumnsIds: string[], requiredColumnsIds?: string[], ) => { - const [orderedColumns, setOrderedColumns] = React.useState(() => { - const savedColumns = settingsManager.readUserSettingsValue( - storageKey, - defaultColumnsIds, - ) as unknown[]; + const [savedColumns, setSavedColumns] = useSetting(storageKey, defaultColumnsIds); - const normalizedSavedColumns = savedColumns.map(parseSavedColumn); + const normalizedSavedColumns = React.useMemo(() => { + const rawValue = Array.isArray(savedColumns) ? savedColumns : defaultColumnsIds; + return rawValue.map(parseSavedColumn); + }, [defaultColumnsIds, savedColumns]); + const orderedColumns = React.useMemo(() => { return columns.reduce((acc, column) => { const savedColumn = normalizedSavedColumns.find((c) => c && c.id === column.name); if (savedColumn) { @@ -41,7 +41,7 @@ export const useSelectedColumns = ( } return acc; }, []); - }); + }, [columns, normalizedSavedColumns]); const columnsToSelect = React.useMemo(() => { const preparedColumns = orderedColumns.reduce<(TableColumnSetupItem & {column: T})[]>( @@ -76,10 +76,9 @@ export const useSelectedColumns = ( (value) => { const preparedColumns = value.map(({id, selected}) => ({id, selected})); - settingsManager.setUserSettingsValue(storageKey, preparedColumns); - setOrderedColumns(preparedColumns); + setSavedColumns(preparedColumns); }, - [storageKey], + [setSavedColumns], ); return { diff --git a/src/utils/hooks/useSetting.ts b/src/utils/hooks/useSetting.ts index c9438ae056..d1433320bf 100644 --- a/src/utils/hooks/useSetting.ts +++ b/src/utils/hooks/useSetting.ts @@ -1,24 +1,17 @@ -import React from 'react'; +import {getSettingValue} from '../../store/reducers/settings/settings'; +import {useSetting as useStoreSetting} from '../../store/reducers/settings/useSetting'; -import {getSettingValue, setSettingValue} from '../../store/reducers/settings/settings'; - -import {useTypedDispatch} from './useTypedDispatch'; import {useTypedSelector} from './useTypedSelector'; export const useSetting = (key?: string, defaultValue?: T): [T, (value: T) => void] => { - const dispatch = useTypedDispatch(); + const {saveValue} = useStoreSetting(key); const settingValue = useTypedSelector((state) => { // Since we type setter value as T, we assume that received value is also T return (getSettingValue(state, key) ?? defaultValue) as T; }); - const setValue = React.useCallback( - (value: T) => { - dispatch(setSettingValue(key, value)); - }, - [dispatch, key], - ); + const setValue = saveValue; return [settingValue, setValue]; };