From 8a90a0bff21ef971b577b5d8ab8f6a03246fb0b3 Mon Sep 17 00:00:00 2001 From: mufazalov Date: Tue, 18 Nov 2025 22:08:04 +0300 Subject: [PATCH 1/5] refactor(QueriesHistory): move queries manipulations to hook --- .../Query/QueriesHistory/QueriesHistory.tsx | 12 +- .../Tenant/Query/QueryEditor/QueryEditor.tsx | 17 ++- .../Tenant/Query/QueryEditor/YqlEditor.tsx | 15 +-- .../Tenant/Query/QueryEditor/helpers.ts | 6 +- .../query/__test__/tabPersistence.test.tsx | 4 - src/store/reducers/query/query.ts | 126 ++---------------- src/store/reducers/query/types.ts | 11 +- src/store/reducers/query/useQueriesHistory.ts | 123 +++++++++++++++++ 8 files changed, 165 insertions(+), 149 deletions(-) create mode 100644 src/store/reducers/query/useQueriesHistory.ts diff --git a/src/containers/Tenant/Query/QueriesHistory/QueriesHistory.tsx b/src/containers/Tenant/Query/QueriesHistory/QueriesHistory.tsx index 03dd2a22a1..76393b40d0 100644 --- a/src/containers/Tenant/Query/QueriesHistory/QueriesHistory.tsx +++ b/src/containers/Tenant/Query/QueriesHistory/QueriesHistory.tsx @@ -1,3 +1,5 @@ +import React from 'react'; + import type {Column} from '@gravity-ui/react-data-table'; import {ResizeableDataTable} from '../../../../components/ResizeableDataTable/ResizeableDataTable'; @@ -5,12 +7,12 @@ import {Search} from '../../../../components/Search'; import {TableWithControlsLayout} from '../../../../components/TableWithControlsLayout/TableWithControlsLayout'; import {TruncatedQuery} from '../../../../components/TruncatedQuery/TruncatedQuery'; import { - selectQueriesHistory, selectQueriesHistoryFilter, setIsDirty, setQueryHistoryFilter, } from '../../../../store/reducers/query/query'; import type {QueryInHistory} from '../../../../store/reducers/query/types'; +import {useQueriesHistory} from '../../../../store/reducers/query/useQueriesHistory'; import {TENANT_QUERY_TABS_ID} from '../../../../store/reducers/tenant/constants'; import {setQueryTab} from '../../../../store/reducers/tenant/tenant'; import {cn} from '../../../../utils/cn'; @@ -34,9 +36,13 @@ interface QueriesHistoryProps { function QueriesHistory({changeUserInput}: QueriesHistoryProps) { const dispatch = useTypedDispatch(); - const queriesHistory = useTypedSelector(selectQueriesHistory); + const {filteredHistoryQueries} = useQueriesHistory(); + + const reversedHistory = React.useMemo(() => { + return [...filteredHistoryQueries].reverse(); + }, [filteredHistoryQueries]); + const filter = useTypedSelector(selectQueriesHistoryFilter); - const reversedHistory = [...queriesHistory].reverse(); const applyQueryClick = (query: QueryInHistory) => { changeUserInput({input: query.queryText}); diff --git a/src/containers/Tenant/Query/QueryEditor/QueryEditor.tsx b/src/containers/Tenant/Query/QueryEditor/QueryEditor.tsx index a49e344919..c3d506d03e 100644 --- a/src/containers/Tenant/Query/QueryEditor/QueryEditor.tsx +++ b/src/containers/Tenant/Query/QueryEditor/QueryEditor.tsx @@ -11,15 +11,13 @@ import { } from '../../../../store/reducers/capabilities/hooks'; import { queryApi, - saveQueryToHistory, - selectQueriesHistory, - selectQueriesHistoryCurrentIndex, selectResult, selectTenantPath, setIsDirty, setTenantPath, } from '../../../../store/reducers/query/query'; import type {QueryResult} from '../../../../store/reducers/query/types'; +import {useQueriesHistory} from '../../../../store/reducers/query/useQueriesHistory'; import {setQueryAction} from '../../../../store/reducers/queryActions/queryActions'; import {selectShowPreview, setShowPreview} from '../../../../store/reducers/schema/schema'; import {SETTING_KEYS} from '../../../../store/reducers/settings/constants'; @@ -77,10 +75,11 @@ export default function QueryEditor(props: QueryEditorProps) { const {theme, changeUserInput} = props; const savedPath = useTypedSelector(selectTenantPath); const result = useTypedSelector(selectResult); - const historyQueries = useTypedSelector(selectQueriesHistory); - const historyCurrentIndex = useTypedSelector(selectQueriesHistoryCurrentIndex); const showPreview = useTypedSelector(selectShowPreview); + const {historyQueries, historyCurrentIndex, saveQueryToHistory, updateQueryInHistory} = + useQueriesHistory(); + const isResultLoaded = Boolean(result); const [querySettings] = useQueryExecutionSettings(); @@ -182,6 +181,12 @@ export default function QueryEditor(props: QueryEditorProps) { base64: encodeTextWithBase64, }); + query.then(({data}) => { + if (data?.queryId) { + updateQueryInHistory(data.queryId, data?.queryStats); + } + }); + queryManagerInstance.registerQuery(query); } @@ -190,7 +195,7 @@ export default function QueryEditor(props: QueryEditorProps) { // Don't save partial queries in history if (!partial) { if (text !== historyQueries[historyCurrentIndex]?.queryText) { - dispatch(saveQueryToHistory({queryText: text, queryId})); + saveQueryToHistory(text, queryId); } dispatch(setIsDirty(false)); } diff --git a/src/containers/Tenant/Query/QueryEditor/YqlEditor.tsx b/src/containers/Tenant/Query/QueryEditor/YqlEditor.tsx index 2606f2b863..24e5e4fe8f 100644 --- a/src/containers/Tenant/Query/QueryEditor/YqlEditor.tsx +++ b/src/containers/Tenant/Query/QueryEditor/YqlEditor.tsx @@ -6,13 +6,8 @@ import throttle from 'lodash/throttle'; import type Monaco from 'monaco-editor'; import {MonacoEditor} from '../../../../components/MonacoEditor/MonacoEditor'; -import { - goToNextQuery, - goToPreviousQuery, - selectQueriesHistory, - selectUserInput, - setIsDirty, -} from '../../../../store/reducers/query/query'; +import {selectUserInput, setIsDirty} from '../../../../store/reducers/query/query'; +import {useQueriesHistory} from '../../../../store/reducers/query/useQueriesHistory'; import {SETTING_KEYS} from '../../../../store/reducers/settings/constants'; import type {QueryAction} from '../../../../types/store/query'; import { @@ -49,7 +44,7 @@ export function YqlEditor({ const dispatch = useTypedDispatch(); const [monacoGhostInstance, setMonacoGhostInstance] = React.useState>(); - const historyQueries = useTypedSelector(selectQueriesHistory); + const {historyQueries, goToPreviousQuery, goToNextQuery} = useQueriesHistory(); const [isCodeAssistEnabled] = useSetting(SETTING_KEYS.ENABLE_CODE_ASSISTANT); const editorOptions = useEditorOptions(); @@ -160,7 +155,7 @@ export function YqlEditor({ contextMenuGroupId: CONTEXT_MENU_GROUP_ID, contextMenuOrder: 2, run: () => { - dispatch(goToPreviousQuery()); + goToPreviousQuery(); }, }); editor.addAction({ @@ -169,7 +164,7 @@ export function YqlEditor({ contextMenuGroupId: CONTEXT_MENU_GROUP_ID, contextMenuOrder: 3, run: () => { - dispatch(goToNextQuery()); + goToNextQuery(); }, }); editor.addAction({ diff --git a/src/containers/Tenant/Query/QueryEditor/helpers.ts b/src/containers/Tenant/Query/QueryEditor/helpers.ts index d9a23a2aec..4d0c1b1358 100644 --- a/src/containers/Tenant/Query/QueryEditor/helpers.ts +++ b/src/containers/Tenant/Query/QueryEditor/helpers.ts @@ -4,10 +4,10 @@ import type {AcceptEvent, DeclineEvent, IgnoreEvent, PromptFile} from '@ydb-plat import type Monaco from 'monaco-editor'; import {codeAssistApi} from '../../../../store/reducers/codeAssist/codeAssist'; -import {selectQueriesHistory} from '../../../../store/reducers/query/query'; +import {useQueriesHistory} from '../../../../store/reducers/query/useQueriesHistory'; import {SETTING_KEYS} from '../../../../store/reducers/settings/constants'; import type {TelemetryOpenTabs} from '../../../../types/api/codeAssist'; -import {useSetting, useTypedSelector} from '../../../../utils/hooks'; +import {useSetting} from '../../../../utils/hooks'; import {YQL_LANGUAGE_ID} from '../../../../utils/monaco/constats'; import {useSavedQueries} from '../utils/useSavedQueries'; @@ -45,7 +45,7 @@ export function useCodeAssistHelpers() { const [discardSuggestion] = codeAssistApi.useDiscardSuggestionMutation(); const [ignoreSuggestion] = codeAssistApi.useIgnoreSuggestionMutation(); const [sendUserQueriesData] = codeAssistApi.useSendUserQueriesDataMutation(); - const historyQueries = useTypedSelector(selectQueriesHistory); + const {historyQueries} = useQueriesHistory(); const {savedQueries} = useSavedQueries(); const getCodeAssistSuggestions = React.useCallback( diff --git a/src/store/reducers/query/__test__/tabPersistence.test.tsx b/src/store/reducers/query/__test__/tabPersistence.test.tsx index d04a1f3026..b8bb2829e6 100644 --- a/src/store/reducers/query/__test__/tabPersistence.test.tsx +++ b/src/store/reducers/query/__test__/tabPersistence.test.tsx @@ -4,10 +4,6 @@ import type {QueryState} from '../types'; describe('QueryResultViewer tab persistence integration', () => { const initialState: QueryState = { input: '', - history: { - queries: [], - currentIndex: -1, - }, }; test('should save and retrieve tab selection for explain queries', () => { diff --git a/src/store/reducers/query/query.ts b/src/store/reducers/query/query.ts index 6895c4a423..2b31292d33 100644 --- a/src/store/reducers/query/query.ts +++ b/src/store/reducers/query/query.ts @@ -1,7 +1,6 @@ import {createSelector, createSlice} from '@reduxjs/toolkit'; import type {PayloadAction} from '@reduxjs/toolkit'; -import {settingsManager} from '../../../services/settings'; import {TracingLevelNumber} from '../../../types/api/query'; import type {QueryAction, QueryRequestParams, QuerySettings} from '../../../types/store/query'; import type {StreamDataChunk} from '../../../types/store/streaming'; @@ -11,7 +10,6 @@ import {isQueryErrorResponse} from '../../../utils/query'; import {isNumeric} from '../../../utils/utils'; import type {RootState} from '../../defaultStore'; import {api} from '../api'; -import {SETTING_KEYS} from '../settings/constants'; import {prepareQueryData} from './prepareQueryData'; import { @@ -19,17 +17,8 @@ import { setStreamQueryResponse as setStreamQueryResponseReducer, setStreamSession as setStreamSessionReducer, } from './streamingReducers'; -import type {QueryResult, QueryState} from './types'; -import {getActionAndSyntaxFromQueryMode, getQueryInHistory, prepareQueryWithPragmas} from './utils'; - -const MAXIMUM_QUERIES_IN_HISTORY = 20; - -const queriesHistoryInitial = settingsManager.readUserSettingsValue( - SETTING_KEYS.QUERIES_HISTORY, - [], -) as string[]; - -const sliceLimit = queriesHistoryInitial.length - MAXIMUM_QUERIES_IN_HISTORY; +import type {QueryResult, QueryState, QueryStats} from './types'; +import {getActionAndSyntaxFromQueryMode, prepareQueryWithPragmas} from './utils'; const rawQuery = loadFromSessionStorage(QUERY_EDITOR_CURRENT_QUERY_KEY); const input = typeof rawQuery === 'string' ? rawQuery : ''; @@ -39,16 +28,7 @@ const isDirty = Boolean(loadFromSessionStorage(QUERY_EDITOR_DIRTY_KEY)); const initialState: QueryState = { input, isDirty, - history: { - queries: queriesHistoryInitial - .slice(sliceLimit < 0 ? 0 : sliceLimit) - .map(getQueryInHistory), - currentIndex: - queriesHistoryInitial.length > MAXIMUM_QUERIES_IN_HISTORY - ? MAXIMUM_QUERIES_IN_HISTORY - 1 - : queriesHistoryInitial.length - 1, - filter: '', - }, + historyFilter: '', }; const slice = createSlice({ @@ -66,76 +46,11 @@ const slice = createSlice({ setQueryResult: (state, action: PayloadAction) => { state.result = action.payload; }, - saveQueryToHistory: ( - state, - action: PayloadAction<{queryText: string; queryId: string}>, - ) => { - const {queryText, queryId} = action.payload; - - const newQueries = [...state.history.queries, {queryText, queryId}].slice( - state.history.queries.length >= MAXIMUM_QUERIES_IN_HISTORY ? 1 : 0, - ); - settingsManager.setUserSettingsValue(SETTING_KEYS.QUERIES_HISTORY, newQueries); - const currentIndex = newQueries.length - 1; - - state.history = { - queries: newQueries, - currentIndex, - }; - }, - updateQueryInHistory: ( - state, - action: PayloadAction<{queryId: string; stats: QueryStats}>, - ) => { - const {queryId, stats} = action.payload; - - if (!stats) { - return; - } - - const index = state.history.queries.findIndex((item) => item.queryId === queryId); - - if (index === -1) { - return; - } - - const newQueries = [...state.history.queries]; - const {durationUs, endTime} = stats; - newQueries.splice(index, 1, { - ...state.history.queries[index], - durationUs, - endTime, - }); - - settingsManager.setUserSettingsValue(SETTING_KEYS.QUERIES_HISTORY, newQueries); - - state.history.queries = newQueries; - }, - goToPreviousQuery: (state) => { - const currentIndex = state.history.currentIndex; - if (currentIndex <= 0) { - return; - } - const newCurrentIndex = currentIndex - 1; - const query = state.history.queries[newCurrentIndex]; - state.input = query.queryText; - state.history.currentIndex = newCurrentIndex; - }, - goToNextQuery: (state) => { - const currentIndex = state.history.currentIndex; - if (currentIndex >= state.history.queries.length - 1) { - return; - } - const newCurrentIndex = currentIndex + 1; - const query = state.history.queries[newCurrentIndex]; - state.input = query.queryText; - state.history.currentIndex = newCurrentIndex; - }, setTenantPath: (state, action: PayloadAction) => { state.tenantPath = action.payload; }, setQueryHistoryFilter: (state, action: PayloadAction) => { - state.history.filter = action.payload; + state.historyFilter = action.payload; }, setResultTab: ( state, @@ -152,14 +67,13 @@ const slice = createSlice({ setStreamQueryResponse: setStreamQueryResponseReducer, }, selectors: { - selectQueriesHistoryFilter: (state) => state.history.filter || '', + selectQueriesHistoryFilter: (state) => state.historyFilter || '', selectTenantPath: (state) => state.tenantPath, selectResult: (state) => state.result, selectStartTime: (state) => state.result?.startTime, selectEndTime: (state) => state.result?.endTime, selectUserInput: (state) => state.input, selectIsDirty: (state) => state.isDirty, - selectQueriesHistoryCurrentIndex: (state) => state.history?.currentIndex, selectResultTab: (state) => state.selectedResultTab, }, }); @@ -175,27 +89,10 @@ export const selectQueryDuration = createSelector( }, ); -export const selectQueriesHistory = createSelector( - [ - (state: RootState) => state.query.history.queries, - (state: RootState) => state.query.history.filter, - ], - (queries, filter) => { - const normalizedFilter = filter?.toLowerCase(); - return normalizedFilter - ? queries.filter((item) => item.queryText.toLowerCase().includes(normalizedFilter)) - : queries; - }, -); - export default slice.reducer; export const { changeUserInput, setQueryResult, - saveQueryToHistory, - updateQueryInHistory, - goToPreviousQuery, - goToNextQuery, setTenantPath, setQueryHistoryFilter, addStreamingChunks, @@ -207,7 +104,6 @@ export const { export const { selectQueriesHistoryFilter, - selectQueriesHistoryCurrentIndex, selectTenantPath, selectResult, selectUserInput, @@ -228,11 +124,6 @@ interface SendQueryParams extends QueryRequestParams { // Stream query receives queryId from session chunk. type StreamQueryParams = Omit; -interface QueryStats { - durationUs?: string | number; - endTime?: string | number; -} - const DEFAULT_STREAM_CHUNK_SIZE = 1000; const DEFAULT_CONCURRENT_RESULTS = false; @@ -421,8 +312,9 @@ export const queryApi = api.injectEndpoints({ const data = prepareQueryData(response); data.traceId = response?._meta?.traceId; + const queryStats: QueryStats = {}; + if (actionType === 'execute') { - const queryStats: QueryStats = {}; if (data.stats) { const {DurationUs, Executions: [{FinishTimeMs}] = [{}]} = data.stats; queryStats.durationUs = DurationUs; @@ -432,8 +324,6 @@ export const queryApi = api.injectEndpoints({ queryStats.durationUs = (now - timeStart) * 1000; queryStats.endTime = now; } - - dispatch(updateQueryInHistory({stats: queryStats, queryId})); } dispatch( @@ -446,7 +336,7 @@ export const queryApi = api.injectEndpoints({ endTime: Date.now(), }), ); - return {data: null}; + return {data: {queryStats, queryId}}; } catch (error) { const state = getState() as RootState; if (state.query.result?.startTime !== startTime) { diff --git a/src/store/reducers/query/types.ts b/src/store/reducers/query/types.ts index 9d753b5210..e7975ab1eb 100644 --- a/src/store/reducers/query/types.ts +++ b/src/store/reducers/query/types.ts @@ -62,14 +62,15 @@ export interface QueryState { input: string; result?: QueryResult; isDirty?: boolean; - history: { - queries: QueryInHistory[]; - currentIndex: number; - filter?: string; - }; + historyFilter?: string; tenantPath?: string; selectedResultTab?: { execute?: string; explain?: string; }; } + +export interface QueryStats { + durationUs?: string | number; + endTime?: string | number; +} diff --git a/src/store/reducers/query/useQueriesHistory.ts b/src/store/reducers/query/useQueriesHistory.ts new file mode 100644 index 0000000000..f3287f1f6a --- /dev/null +++ b/src/store/reducers/query/useQueriesHistory.ts @@ -0,0 +1,123 @@ +import React from 'react'; + +import { + useEventHandler, + useSetting, + useTypedDispatch, + useTypedSelector, +} from '../../../utils/hooks'; +import {SETTING_KEYS} from '../settings/constants'; + +import {changeUserInput, selectQueriesHistoryFilter} from './query'; +import type {QueryInHistory, QueryStats} from './types'; +import {getQueryInHistory} from './utils'; + +const MAXIMUM_QUERIES_IN_HISTORY = 20; + +export function useQueriesHistory() { + const dispatch = useTypedDispatch(); + const queriesFilter = useTypedSelector(selectQueriesHistoryFilter); + + const [savedHistoryQueries, saveHistoryQueries] = useSetting( + SETTING_KEYS.QUERIES_HISTORY, + ); + + const [historyQueries, setQueries] = React.useState([]); + const [historyCurrentIndex, setCurrentIndex] = React.useState(-1); + + React.useEffect(() => { + if (!savedHistoryQueries || savedHistoryQueries.length === 0) { + setQueries([]); + setCurrentIndex(-1); + } else { + const sliceLimit = savedHistoryQueries.length - MAXIMUM_QUERIES_IN_HISTORY; + + const preparedQueries = savedHistoryQueries + .slice(sliceLimit < 0 ? 0 : sliceLimit) + .map(getQueryInHistory); + + setQueries(preparedQueries); + setCurrentIndex(preparedQueries.length - 1); + } + }, [savedHistoryQueries]); + + const filteredHistoryQueries = React.useMemo(() => { + const normalizedFilter = queriesFilter?.toLowerCase(); + return normalizedFilter + ? historyQueries.filter((item) => + item.queryText.toLowerCase().includes(normalizedFilter), + ) + : historyQueries; + }, [historyQueries, queriesFilter]); + + // These function are used inside Monaco editorDidMount + // They should be stable to work properly + const goToPreviousQuery = useEventHandler(() => { + setCurrentIndex((index) => { + if (historyQueries.length > 0 && index > 0) { + const newIndex = index - 1; + const query = historyQueries[newIndex]; + + if (query) { + dispatch(changeUserInput({input: query.queryText})); + return newIndex; + } + } + return index; + }); + }); + + const goToNextQuery = useEventHandler(() => { + setCurrentIndex((index) => { + if (historyQueries.length > 0 && index < historyQueries.length - 1) { + const newIndex = index + 1; + const query = historyQueries[newIndex]; + if (query) { + dispatch(changeUserInput({input: query.queryText})); + return newIndex; + } + } + + return index; + }); + }); + + const saveQueryToHistory = useEventHandler((queryText: string, queryId: string) => { + const newQueries = [...historyQueries, {queryText, queryId}].slice( + historyQueries.length >= MAXIMUM_QUERIES_IN_HISTORY ? 1 : 0, + ); + saveHistoryQueries(newQueries); + // Update currentIndex to point to the newly added query + const newCurrentIndex = newQueries.length - 1; + setCurrentIndex(newCurrentIndex); + }); + + const updateQueryInHistory = useEventHandler((queryId: string, stats: QueryStats) => { + if (!stats || !historyQueries.length) { + return; + } + + const index = historyQueries.findIndex((item) => item.queryId === queryId); + + if (index !== -1) { + const newQueries = [...historyQueries]; + const {durationUs, endTime} = stats; + newQueries.splice(index, 1, { + ...historyQueries[index], + durationUs, + endTime, + }); + saveHistoryQueries(newQueries); + } + }); + + return { + historyQueries, + historyCurrentIndex, + filteredHistoryQueries, + goToPreviousQuery, + goToNextQuery, + saveQueryToHistory, + updateQueryInHistory, + }; +} From 5eeadb309493f1bd1cbbb478a526e319a0a95855 Mon Sep 17 00:00:00 2001 From: mufazalov Date: Tue, 18 Nov 2025 23:02:47 +0300 Subject: [PATCH 2/5] fix: copilot comments --- .../Tenant/Query/QueryEditor/QueryEditor.tsx | 14 +++++++++----- src/store/reducers/query/query.ts | 4 ++-- src/store/reducers/query/useQueriesHistory.ts | 4 +++- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/containers/Tenant/Query/QueryEditor/QueryEditor.tsx b/src/containers/Tenant/Query/QueryEditor/QueryEditor.tsx index c3d506d03e..3d422b023b 100644 --- a/src/containers/Tenant/Query/QueryEditor/QueryEditor.tsx +++ b/src/containers/Tenant/Query/QueryEditor/QueryEditor.tsx @@ -181,11 +181,15 @@ export default function QueryEditor(props: QueryEditorProps) { base64: encodeTextWithBase64, }); - query.then(({data}) => { - if (data?.queryId) { - updateQueryInHistory(data.queryId, data?.queryStats); - } - }); + query + .then(({data}) => { + if (data?.queryId) { + updateQueryInHistory(data.queryId, data?.queryStats); + } + }) + .catch((error) => { + console.error('Failed to update query history:', error); + }); queryManagerInstance.registerQuery(query); } diff --git a/src/store/reducers/query/query.ts b/src/store/reducers/query/query.ts index 2b31292d33..99dfed1dde 100644 --- a/src/store/reducers/query/query.ts +++ b/src/store/reducers/query/query.ts @@ -236,7 +236,7 @@ export const queryApi = api.injectEndpoints({ } }, }), - useSendQuery: build.mutation({ + useSendQuery: build.mutation<{queryStats: QueryStats; queryId: string}, SendQueryParams>({ queryFn: async ( { actionType = 'execute', @@ -246,7 +246,7 @@ export const queryApi = api.injectEndpoints({ enableTracingLevel, queryId, base64, - }: SendQueryParams, + }, {signal, dispatch, getState}, ) => { const startTime = Date.now(); diff --git a/src/store/reducers/query/useQueriesHistory.ts b/src/store/reducers/query/useQueriesHistory.ts index f3287f1f6a..8b712e79fe 100644 --- a/src/store/reducers/query/useQueriesHistory.ts +++ b/src/store/reducers/query/useQueriesHistory.ts @@ -50,7 +50,7 @@ export function useQueriesHistory() { : historyQueries; }, [historyQueries, queriesFilter]); - // These function are used inside Monaco editorDidMount + // These functions are used inside Monaco editorDidMount // They should be stable to work properly const goToPreviousQuery = useEventHandler(() => { setCurrentIndex((index) => { @@ -87,6 +87,7 @@ export function useQueriesHistory() { historyQueries.length >= MAXIMUM_QUERIES_IN_HISTORY ? 1 : 0, ); saveHistoryQueries(newQueries); + setQueries(newQueries); // Update currentIndex to point to the newly added query const newCurrentIndex = newQueries.length - 1; setCurrentIndex(newCurrentIndex); @@ -108,6 +109,7 @@ export function useQueriesHistory() { endTime, }); saveHistoryQueries(newQueries); + setQueries(newQueries); } }); From 57e8345354545d0ab98dff33735eb8218731767b Mon Sep 17 00:00:00 2001 From: mufazalov Date: Wed, 19 Nov 2025 18:27:12 +0300 Subject: [PATCH 3/5] feat(useSetting); sync values with LS and store in api --- src/services/api/metaSettings.ts | 2 +- src/store/reducers/settings/api.ts | 154 +++++++++++++++++----- src/store/reducers/settings/constants.ts | 2 +- src/store/reducers/settings/useSetting.ts | 69 +--------- src/store/reducers/settings/utils.ts | 9 ++ 5 files changed, 134 insertions(+), 102 deletions(-) diff --git a/src/services/api/metaSettings.ts b/src/services/api/metaSettings.ts index a8ec9505d4..a5642913e9 100644 --- a/src/services/api/metaSettings.ts +++ b/src/services/api/metaSettings.ts @@ -25,7 +25,7 @@ export class MetaSettingsAPI extends BaseMetaAPI { preventBatching, }: GetSingleSettingParams & {preventBatching?: boolean}) { if (preventBatching) { - return this.get(this.getPath('/meta/user_settings'), {name, user}); + return this.get(this.getPath('/meta/user_settings'), {name, user}); } return new Promise((resolve, reject) => { diff --git a/src/store/reducers/settings/api.ts b/src/store/reducers/settings/api.ts index f4595de3c6..c52c105cbc 100644 --- a/src/store/reducers/settings/api.ts +++ b/src/store/reducers/settings/api.ts @@ -6,19 +6,34 @@ import type { SetSingleSettingParams, Setting, } from '../../../types/api/settings'; -import type {AppDispatch} from '../../defaultStore'; +import type {AppDispatch, RootState} from '../../defaultStore'; import {api} from '../api'; import {SETTINGS_OPTIONS} from './constants'; +import {getSettingValue, setSettingValue} from './settings'; +import { + getSettingDefault, + parseSettingValue, + readSettingValueFromLS, + setSettingValueToLS, + shouldSyncSettingToLS, + stringifySettingValue, +} from './utils'; + +const invalidParamsError = + 'Missing required parameters (name, user) or MetaSettings API is not available'; export const settingsApi = api.injectEndpoints({ endpoints: (builder) => ({ - getSingleSetting: builder.query({ - queryFn: async ({name, user}: GetSingleSettingParams, baseApi) => { + getSingleSetting: builder.query>({ + queryFn: async ({name, user}) => { try { - if (!window.api.metaSettings) { - throw new Error('MetaSettings API is not available'); + // In this case localStorage should be used for settings + // Actual value will be loaded to store in onQueryStarted + if (!name || !user || !window.api.metaSettings) { + throw new Error(invalidParamsError); } + const data = await window.api.metaSettings.getSingleSetting({ name, user, @@ -26,25 +41,70 @@ export const settingsApi = api.injectEndpoints({ preventBatching: SETTINGS_OPTIONS[name]?.preventBatching, }); - const dispatch = baseApi.dispatch as AppDispatch; - - // Try to sync local value if there is no backend value - syncLocalValueToMetaIfNoData(data, dispatch); - return {data}; } catch (error) { return {error}; } }, + async onQueryStarted(args, {dispatch, queryFulfilled}) { + const {name, user} = args; + + if (!name) { + return; + } + + const shouldUseLocalSettings = + !user || !window.api.metaSettings || shouldSyncSettingToLS(name); + + const defaultValue = getSettingDefault(name); + + // Preload value from LS or default to store + if (shouldUseLocalSettings) { + const savedValue = readSettingValueFromLS(name); + const value = savedValue ?? defaultValue; + + dispatch(setSettingValue(name, value)); + } else { + dispatch(setSettingValue(name, defaultValue)); + } + + try { + const {data} = await queryFulfilled; + + // Load api value to store if present + // In case local storage should be used + // query will finish with an error and this code will not run + const parsedValue = parseSettingValue(data?.value); + + if (isNil(data?.value)) { + // Try to sync local value if there is no backend value + syncLocalValueToMetaIfNoData({...data}, dispatch); + } else { + dispatch(setSettingValue(name, parsedValue)); + + if (shouldSyncSettingToLS(name)) { + setSettingValueToLS(name, data.value); + } + } + } catch {} + }, }), setSingleSetting: builder.mutation({ - queryFn: async (params: SetSingleSettingParams) => { + queryFn: async ({ + name, + user, + value, + }: Partial> & {value: unknown}) => { try { - if (!window.api.metaSettings) { - throw new Error('MetaSettings API is not available'); + if (!name || !user || !window.api.metaSettings) { + throw new Error(invalidParamsError); } - const data = await window.api.metaSettings.setSingleSetting(params); + const data = await window.api.metaSettings.setSingleSetting({ + name, + user, + value: stringifySettingValue(value), + }); if (data.status !== 'SUCCESS') { throw new Error('Setting status is not SUCCESS'); @@ -55,34 +115,51 @@ export const settingsApi = api.injectEndpoints({ return {error}; } }, - async onQueryStarted(args, {dispatch, queryFulfilled}) { + async onQueryStarted(args, {dispatch, queryFulfilled, getState}) { const {name, user, value} = args; - // Optimistically update existing cache entry - const patchResult = dispatch( - settingsApi.util.updateQueryData('getSingleSetting', {name, user}, (draft) => { - return {...draft, name, user, value}; - }), - ); + if (!name) { + return; + } + + // Extract previous value to revert to it if set is not succesfull + const previousSettingValue = getSettingValue(getState() as RootState, name); + + // Optimistically update store + dispatch(setSettingValue(name, value)); + + // If local storage settings should be used + // Update LS and do not do any further code + if (!user || !window.api.metaSettings) { + setSettingValueToLS(name, value); + return; + } + try { await queryFulfilled; + + // If mutation is successful, we can store new value in LS + if (shouldSyncSettingToLS(name)) { + setSettingValueToLS(name, value); + } } catch { - patchResult.undo(); + // Set previous value to store in case of error + dispatch(setSettingValue(name, previousSettingValue)); } }, }), getSettings: builder.query({ - queryFn: async ({name, user}: GetSettingsParams, baseApi) => { + queryFn: async ({name, user}: Partial, baseApi) => { try { - if (!window.api.metaSettings) { - throw new Error('MetaSettings API is not available'); + if (!window.api.metaSettings || !name || !user) { + throw new Error(invalidParamsError); } const data = await window.api.metaSettings.getSettings({name, user}); - const patches: Promise[] = []; + const patches: Promise[] = []; const dispatch = baseApi.dispatch as AppDispatch; - // Upsert received data in getSingleSetting cache + // Upsert received data in getSingleSetting cache to prevent further redundant requests name.forEach((settingName) => { const settingData = data[settingName] ?? {}; @@ -98,15 +175,18 @@ export const settingsApi = api.injectEndpoints({ cacheEntryParams, newValue, ), - ).then(() => { + ); + if (isNil(settingData.value)) { // Try to sync local value if there is no backend value - // Do it after upsert if finished to ensure proper values update order - // 1. New entry added to cache with nil value - // 2. Positive entry update - local storage value replace nil in cache - // 3.1. Set is successful, local value in cache - // 3.2. Set is not successful, cache value reverted to previous nil syncLocalValueToMetaIfNoData(settingData, dispatch); - }); + } else { + const parsedValue = parseSettingValue(settingData.value); + dispatch(setSettingValue(settingName, parsedValue)); + + if (shouldSyncSettingToLS(settingName)) { + setSettingValueToLS(settingName, settingData.value); + } + } patches.push(patch); }); @@ -124,7 +204,11 @@ export const settingsApi = api.injectEndpoints({ overrideExisting: 'throw', }); -function syncLocalValueToMetaIfNoData(params: Setting, dispatch: AppDispatch) { +function syncLocalValueToMetaIfNoData(params: Partial, dispatch: AppDispatch) { + if (!params.name) { + return; + } + const localValue = localStorage.getItem(params.name); if (isNil(params.value) && !isNil(localValue)) { diff --git a/src/store/reducers/settings/constants.ts b/src/store/reducers/settings/constants.ts index cbc1c8bed4..cd4026fc93 100644 --- a/src/store/reducers/settings/constants.ts +++ b/src/store/reducers/settings/constants.ts @@ -73,7 +73,7 @@ export const DEFAULT_USER_SETTINGS = { [SETTING_KEYS.ACL_SYNTAX]: AclSyntax.YdbShort, } as const satisfies Record; -export const SETTINGS_OPTIONS: Record = { +export const SETTINGS_OPTIONS: Record = { [SETTING_KEYS.THEME]: { preventBatching: true, }, diff --git a/src/store/reducers/settings/useSetting.ts b/src/store/reducers/settings/useSetting.ts index 4a51f3a7e4..c5b2d7ce27 100644 --- a/src/store/reducers/settings/useSetting.ts +++ b/src/store/reducers/settings/useSetting.ts @@ -1,22 +1,10 @@ import React from 'react'; -import {skipToken} from '@reduxjs/toolkit/query'; - -import {uiFactory} from '../../../uiFactory/uiFactory'; -import {useTypedDispatch} from '../../../utils/hooks/useTypedDispatch'; import {useTypedSelector} from '../../../utils/hooks/useTypedSelector'; import {selectID, selectUser} from '../authentication/authentication'; import {settingsApi} from './api'; -import type {SettingKey} from './constants'; -import {DEFAULT_USER_SETTINGS, SETTINGS_OPTIONS} from './constants'; -import {getSettingValue, setSettingValue} from './settings'; -import { - parseSettingValue, - readSettingValueFromLS, - setSettingValueToLS, - stringifySettingValue, -} from './utils'; +import {getSettingValue} from './settings'; type SaveSettingValue = (value: T | undefined) => void; @@ -25,70 +13,21 @@ export function useSetting(name?: string): { saveValue: SaveSettingValue; isLoading: boolean; } { - const dispatch = useTypedDispatch(); - - const preventSyncWithLS = Boolean(name && SETTINGS_OPTIONS[name]?.preventSyncWithLS); - const settingValue = useTypedSelector((state) => getSettingValue(state, name)) as T | undefined; const authUserSID = useTypedSelector(selectUser); const anonymousUserId = useTypedSelector(selectID); - const user = authUserSID || anonymousUserId; - const shouldUseMetaSettings = uiFactory.useMetaSettings && user && name; - - const shouldUseOnlyExternalSettings = shouldUseMetaSettings && preventSyncWithLS; - - const params = React.useMemo(() => { - return shouldUseMetaSettings ? {user, name} : skipToken; - }, [shouldUseMetaSettings, user, name]); - const {currentData: metaSetting, isLoading: isSettingLoading} = - settingsApi.useGetSingleSettingQuery(params); + const {isLoading} = settingsApi.useGetSingleSettingQuery({user, name}); const [setMetaSetting] = settingsApi.useSetSingleSettingMutation(); - // Add loading state to settings that are stored externally - const isLoading = shouldUseMetaSettings ? isSettingLoading : false; - - // Load initial value - React.useEffect(() => { - let value = name ? (DEFAULT_USER_SETTINGS[name as SettingKey] as T | undefined) : undefined; - - if (!shouldUseOnlyExternalSettings) { - const savedValue = readSettingValueFromLS(name); - value = savedValue ?? value; - } - - dispatch(setSettingValue(name, value)); - }, [name, shouldUseOnlyExternalSettings, dispatch]); - - // Sync value from backend with LS and store - React.useEffect(() => { - if (shouldUseMetaSettings && metaSetting?.value) { - if (!shouldUseOnlyExternalSettings) { - setSettingValueToLS(name, metaSetting.value); - } - const parsedValue = parseSettingValue(metaSetting.value); - dispatch(setSettingValue(name, parsedValue)); - } - }, [shouldUseMetaSettings, shouldUseOnlyExternalSettings, metaSetting, name, dispatch]); - const saveValue = React.useCallback>( (value) => { - if (shouldUseMetaSettings) { - setMetaSetting({ - user, - name: name, - value: stringifySettingValue(value), - }); - } - - if (!shouldUseOnlyExternalSettings) { - setSettingValueToLS(name, value); - } + setMetaSetting({user, name: name, value: value}); }, - [shouldUseMetaSettings, shouldUseOnlyExternalSettings, user, name, setMetaSetting], + [user, name, setMetaSetting], ); return {value: settingValue, saveValue, isLoading} as const; diff --git a/src/store/reducers/settings/utils.ts b/src/store/reducers/settings/utils.ts index 916e1e5d42..601a176c12 100644 --- a/src/store/reducers/settings/utils.ts +++ b/src/store/reducers/settings/utils.ts @@ -1,6 +1,9 @@ import type {SettingValue} from '../../../types/api/settings'; import {parseJson} from '../../../utils/utils'; +import type {SettingKey} from './constants'; +import {DEFAULT_USER_SETTINGS, SETTINGS_OPTIONS} from './constants'; + export function stringifySettingValue(value?: T): string { return typeof value === 'string' ? value : JSON.stringify(value); } @@ -34,3 +37,9 @@ export function setSettingValueToLS(name: string | undefined, value: unknown): v localStorage.setItem(name, preparedValue); } catch {} } +export function getSettingDefault(name: string) { + return DEFAULT_USER_SETTINGS[name as SettingKey]; +} +export function shouldSyncSettingToLS(name: string) { + return !SETTINGS_OPTIONS[name]?.preventSyncWithLS; +} From 83911b23e785f3cc9efde29abd515315c9434033 Mon Sep 17 00:00:00 2001 From: mufazalov Date: Mon, 17 Nov 2025 20:13:46 +0300 Subject: [PATCH 4/5] fix(TenantNavigation): use query for tenant page --- .../TenantOverview/TenantOverview.tsx | 11 +++-- .../Tenant/ObjectGeneral/ObjectGeneral.tsx | 4 +- .../Tenant/ObjectSummary/ObjectSummary.tsx | 5 ++- .../Tenant/ObjectSummary/SchemaActions.tsx | 10 +++-- .../ObjectSummary/SchemaTree/SchemaTree.tsx | 5 +++ .../TenantNavigation/useTenantNavigation.tsx | 45 ++++++++++++++----- src/containers/Tenant/TenantPages.tsx | 2 + src/containers/Tenant/utils/controls.tsx | 8 ++-- src/containers/Tenant/utils/schemaActions.tsx | 9 ++-- src/store/reducers/tenant/tenant.ts | 15 +------ src/store/reducers/tenant/types.ts | 1 - src/store/state-url-mapping.ts | 6 --- 12 files changed, 72 insertions(+), 49 deletions(-) diff --git a/src/containers/Tenant/Diagnostics/TenantOverview/TenantOverview.tsx b/src/containers/Tenant/Diagnostics/TenantOverview/TenantOverview.tsx index bb1e6f248c..bd61d040e6 100644 --- a/src/containers/Tenant/Diagnostics/TenantOverview/TenantOverview.tsx +++ b/src/containers/Tenant/Diagnostics/TenantOverview/TenantOverview.tsx @@ -12,11 +12,7 @@ import { TENANT_METRICS_TABS_IDS, TENANT_PAGES_IDS, } from '../../../../store/reducers/tenant/constants'; -import { - setDiagnosticsTab, - setTenantPage, - tenantApi, -} from '../../../../store/reducers/tenant/tenant'; +import {setDiagnosticsTab, tenantApi} from '../../../../store/reducers/tenant/tenant'; import {calculateTenantMetrics} from '../../../../store/reducers/tenants/utils'; import type {AdditionalTenantsProps} from '../../../../types/additionalProps'; import {getInfoTabLinks} from '../../../../utils/additionalProps'; @@ -24,6 +20,7 @@ import {TENANT_DEFAULT_TITLE} from '../../../../utils/constants'; import {useAutoRefreshInterval, useTypedDispatch, useTypedSelector} from '../../../../utils/hooks'; import {useClusterNameFromQuery} from '../../../../utils/hooks/useDatabaseFromQuery'; import {canShowTenantMonitoringTab} from '../../../../utils/monitoringVisibility'; +import {useTenantPage} from '../../TenantNavigation/useTenantNavigation'; import {mapDatabaseTypeToDBName} from '../../utils/schema'; import {HealthcheckPreview} from './Healthcheck/HealthcheckPreview'; @@ -53,6 +50,8 @@ export function TenantOverview({ const clusterName = useClusterNameFromQuery(); const dispatch = useTypedDispatch(); + const {handleTenantPageChange} = useTenantPage(); + const isMetaDatabasesAvailable = useDatabasesAvailable(); const {currentData: tenant, isFetching} = tenantApi.useGetTenantInfoQuery( @@ -196,7 +195,7 @@ export function TenantOverview({ ); const handleOpenMonitoring = () => { - dispatch(setTenantPage(TENANT_PAGES_IDS.diagnostics)); + handleTenantPageChange(TENANT_PAGES_IDS.diagnostics); dispatch(setDiagnosticsTab(TENANT_DIAGNOSTICS_TABS_IDS.monitoring)); }; diff --git a/src/containers/Tenant/ObjectGeneral/ObjectGeneral.tsx b/src/containers/Tenant/ObjectGeneral/ObjectGeneral.tsx index c0565c746e..ee90784ba9 100644 --- a/src/containers/Tenant/ObjectGeneral/ObjectGeneral.tsx +++ b/src/containers/Tenant/ObjectGeneral/ObjectGeneral.tsx @@ -3,10 +3,10 @@ import {useThemeValue} from '@gravity-ui/uikit'; import {TENANT_PAGES_IDS} from '../../../store/reducers/tenant/constants'; import type {AdditionalTenantsProps} from '../../../types/additionalProps'; import {cn} from '../../../utils/cn'; -import {useTypedSelector} from '../../../utils/hooks'; import Diagnostics from '../Diagnostics/Diagnostics'; import {Query} from '../Query/Query'; import {TenantNavigation} from '../TenantNavigation/TenantNavigation'; +import {useTenantPage} from '../TenantNavigation/useTenantNavigation'; import './ObjectGeneral.scss'; @@ -19,7 +19,7 @@ interface ObjectGeneralProps { function ObjectGeneral({additionalTenantProps}: ObjectGeneralProps) { const theme = useThemeValue(); - const {tenantPage} = useTypedSelector((state) => state.tenant); + const {tenantPage} = useTenantPage(); const renderPageContent = () => { switch (tenantPage) { diff --git a/src/containers/Tenant/ObjectSummary/ObjectSummary.tsx b/src/containers/Tenant/ObjectSummary/ObjectSummary.tsx index c3fe7b3e86..c1e4c854d2 100644 --- a/src/containers/Tenant/ObjectSummary/ObjectSummary.tsx +++ b/src/containers/Tenant/ObjectSummary/ObjectSummary.tsx @@ -36,6 +36,7 @@ import {prepareSystemViewType} from '../../../utils/schema'; import {EntityTitle} from '../EntityTitle/EntityTitle'; import {SchemaViewer} from '../Schema/SchemaViewer/SchemaViewer'; import {useCurrentSchema} from '../TenantContext'; +import {useTenantPage} from '../TenantNavigation/useTenantNavigation'; import {TENANT_INFO_TABS, TENANT_SCHEMA_TAB, TenantTabsGroups} from '../TenantPages'; import {useTenantQueryParams} from '../useTenantQueryParams'; import {getSummaryControls} from '../utils/controls'; @@ -91,6 +92,8 @@ export function ObjectSummary({ (state) => state.tenant, ); + const {handleTenantPageChange} = useTenantPage(); + const location = useLocation(); const queryParams = qs.parse(location.search, { @@ -411,7 +414,7 @@ export function ObjectSummary({ {showPreview && getSummaryControls( dispatch, - {setActivePath: handleSchemaChange}, + {setActivePath: handleSchemaChange, setTenantPage: handleTenantPageChange}, 'm', )(path, 'preview')} state.tenant); + const {diagnosticsTab} = useTypedSelector((state) => state.tenant); + + const {tenantPage, handleTenantPageChange} = useTenantPage(); + const diagnosticsSchemaActive = tenantPage === TENANT_PAGES_IDS.diagnostics && diagnosticsTab === TENANT_DIAGNOSTICS_TABS_IDS.schema; @@ -24,7 +28,7 @@ export function SchemaActions() {