diff --git a/src/containers/Tenant/Query/QueriesHistory/QueriesHistory.tsx b/src/containers/Tenant/Query/QueriesHistory/QueriesHistory.tsx index 03dd2a22a1..e5cc8b0e0e 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.toReversed(); + }, [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..dd97429b8e 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,17 @@ 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, + goToPreviousQuery, + goToNextQuery, + } = useQueriesHistory(); + const isResultLoaded = Boolean(result); const [querySettings] = useQueryExecutionSettings(); @@ -182,6 +187,17 @@ export default function QueryEditor(props: QueryEditorProps) { base64: encodeTextWithBase64, }); + query + .then(({data}) => { + if (data?.queryId) { + updateQueryInHistory(data.queryId, data?.queryStats); + } + }) + .catch((error) => { + // Do not add query stats for failed query + console.error('Failed to update query history:', error); + }); + queryManagerInstance.registerQuery(query); } @@ -190,7 +206,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)); } @@ -279,6 +295,9 @@ export default function QueryEditor(props: QueryEditorProps) { theme={theme} handleSendExecuteClick={handleSendExecuteClick} handleGetExplainQueryClick={handleGetExplainQueryClick} + historyQueries={historyQueries} + goToPreviousQuery={goToPreviousQuery} + goToNextQuery={goToNextQuery} /> diff --git a/src/containers/Tenant/Query/QueryEditor/YqlEditor.tsx b/src/containers/Tenant/Query/QueryEditor/YqlEditor.tsx index 2606f2b863..0954658ec6 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 type {QueryInHistory} from '../../../../store/reducers/query/types'; import {SETTING_KEYS} from '../../../../store/reducers/settings/constants'; import type {QueryAction} from '../../../../types/store/query'; import { @@ -37,6 +32,9 @@ interface YqlEditorProps { theme: string; handleGetExplainQueryClick: (text: string) => void; handleSendExecuteClick: (text: string, partial?: boolean) => void; + historyQueries: QueryInHistory[]; + goToPreviousQuery: () => void; + goToNextQuery: () => void; } export function YqlEditor({ @@ -44,12 +42,14 @@ export function YqlEditor({ theme, handleSendExecuteClick, handleGetExplainQueryClick, + historyQueries, + goToPreviousQuery, + goToNextQuery, }: YqlEditorProps) { const input = useTypedSelector(selectUserInput); const dispatch = useTypedDispatch(); const [monacoGhostInstance, setMonacoGhostInstance] = React.useState>(); - const historyQueries = useTypedSelector(selectQueriesHistory); const [isCodeAssistEnabled] = useSetting(SETTING_KEYS.ENABLE_CODE_ASSISTANT); const editorOptions = useEditorOptions(); @@ -76,7 +76,7 @@ export function YqlEditor({ window.ydbEditor = undefined; }; - const {monacoGhostConfig, prepareUserQueriesCache} = useCodeAssistHelpers(); + const {monacoGhostConfig, prepareUserQueriesCache} = useCodeAssistHelpers(historyQueries); React.useEffect(() => { if (monacoGhostInstance && isCodeAssistEnabled) { @@ -160,7 +160,7 @@ export function YqlEditor({ contextMenuGroupId: CONTEXT_MENU_GROUP_ID, contextMenuOrder: 2, run: () => { - dispatch(goToPreviousQuery()); + goToPreviousQuery(); }, }); editor.addAction({ @@ -169,7 +169,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..3bb7efb8fb 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 type {QueryInHistory} from '../../../../store/reducers/query/types'; 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'; @@ -39,13 +39,12 @@ export function useEditorOptions() { return options; } -export function useCodeAssistHelpers() { +export function useCodeAssistHelpers(historyQueries: QueryInHistory[]) { const [sendCodeAssistPrompt] = codeAssistApi.useLazyGetCodeAssistSuggestionsQuery(); const [acceptSuggestion] = codeAssistApi.useAcceptSuggestionMutation(); const [discardSuggestion] = codeAssistApi.useDiscardSuggestionMutation(); const [ignoreSuggestion] = codeAssistApi.useIgnoreSuggestionMutation(); const [sendUserQueriesData] = codeAssistApi.useSendUserQueriesDataMutation(); - const historyQueries = useTypedSelector(selectQueriesHistory); 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..99dfed1dde 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; @@ -345,7 +236,7 @@ export const queryApi = api.injectEndpoints({ } }, }), - useSendQuery: build.mutation({ + useSendQuery: build.mutation<{queryStats: QueryStats; queryId: string}, SendQueryParams>({ queryFn: async ( { actionType = 'execute', @@ -355,7 +246,7 @@ export const queryApi = api.injectEndpoints({ enableTracingLevel, queryId, base64, - }: SendQueryParams, + }, {signal, dispatch, getState}, ) => { const startTime = Date.now(); @@ -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..8c536c1349 --- /dev/null +++ b/src/store/reducers/query/useQueriesHistory.ts @@ -0,0 +1,136 @@ +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 functions 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) => { + setQueries((currentQueries) => { + const newQueries = [...currentQueries, {queryText, queryId}].slice( + currentQueries.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); + + return newQueries; + }); + }); + + const updateQueryInHistory = useEventHandler((queryId: string, stats: QueryStats) => { + if (!stats) { + return; + } + + setQueries((currentQueries) => { + if (!currentQueries.length) { + return currentQueries; + } + + const index = currentQueries.findIndex((item) => item.queryId === queryId); + + if (index !== -1) { + const newQueries = [...currentQueries]; + const {durationUs, endTime} = stats; + newQueries.splice(index, 1, { + ...currentQueries[index], + durationUs, + endTime, + }); + saveHistoryQueries(newQueries); + return newQueries; + } + return currentQueries; + }); + }); + + return { + historyQueries, + historyCurrentIndex, + filteredHistoryQueries, + goToPreviousQuery, + goToNextQuery, + saveQueryToHistory, + updateQueryInHistory, + }; +}