diff --git a/redisinsight/ui/src/components/query/query-card/QueryCardHeader/QueryCardHeader.spec.tsx b/redisinsight/ui/src/components/query/query-card/QueryCardHeader/QueryCardHeader.spec.tsx index 59844a90ed..be72cb1593 100644 --- a/redisinsight/ui/src/components/query/query-card/QueryCardHeader/QueryCardHeader.spec.tsx +++ b/redisinsight/ui/src/components/query/query-card/QueryCardHeader/QueryCardHeader.spec.tsx @@ -12,7 +12,7 @@ import { } from 'uiSrc/utils/test-utils' import { TelemetryEvent, sendEventTelemetry } from 'uiSrc/telemetry' import { INSTANCE_ID_MOCK } from 'uiSrc/mocks/handlers/instances/instancesHandlers' -import QueryCardHeader, { Props } from './QueryCardHeader' +import QueryCardHeader, { HIDE_FIELDS, Props } from './QueryCardHeader' const mockedProps = mock() @@ -34,7 +34,16 @@ jest.mock('uiSrc/services', () => ({ jest.mock('uiSrc/slices/app/plugins', () => ({ ...jest.requireActual('uiSrc/slices/app/plugins'), appPluginsSelector: jest.fn().mockReturnValue({ - visualizations: [], + visualizations: [ + { + id: '1', + uniqId: '1', + name: 'test', + plugin: '', + activationMethod: 'render', + matchCommands: ['FT.SEARCH'], + } + ], }), })) @@ -80,6 +89,32 @@ describe('QueryCardHeader', () => { expect(screen.getByTestId('copy-command')).toBeDisabled() }) + it('should hide Profiler button', async () => { + render( + , + ) + + expect(screen.queryByTestId('run-profile-type')).not.toBeInTheDocument() + }) + + it('should hide Change View Type button', async () => { + render( + , + ) + + expect(screen.queryByTestId('select-view-type')).not.toBeInTheDocument() + }) + it('event telemetry WORKBENCH_COMMAND_COPIED should be call after click on copy btn', async () => { const command = 'info' const sendEventTelemetryMock = jest.fn() diff --git a/redisinsight/ui/src/components/query/query-card/QueryCardHeader/QueryCardHeader.tsx b/redisinsight/ui/src/components/query/query-card/QueryCardHeader/QueryCardHeader.tsx index 94ca58316f..3e589ff3b2 100644 --- a/redisinsight/ui/src/components/query/query-card/QueryCardHeader/QueryCardHeader.tsx +++ b/redisinsight/ui/src/components/query/query-card/QueryCardHeader/QueryCardHeader.tsx @@ -72,6 +72,7 @@ export interface Props { executionTime?: number emptyCommand?: boolean db?: number + hideFields?: string[] toggleOpen: () => void toggleFullScreen: () => void setSelectedValue: (type: WBQueryType, value: string) => void @@ -80,6 +81,11 @@ export interface Props { onQueryProfile: (type: ProfileQueryType) => void } +export const HIDE_FIELDS = { + viewType: 'viewType', + profiler: 'profiler', +} + const getExecutionTimeString = (value: number): string => { if (value < 1) { return '0.001 msec' @@ -137,6 +143,7 @@ const QueryCardHeader = (props: Props) => { onQueryReRun, onQueryProfile, db, + hideFields = [], } = props const { visualizations = [] } = useSelector(appPluginsSelector) @@ -410,54 +417,58 @@ const QueryCardHeader = (props: Props) => { )} - - {isOpen && canCommandProfile && !summaryText && ( -
-
- - onQueryProfile(value as ProfileQueryType) - } - options={profileOptions} - data-testid="run-profile-type" - valueRender={({ option, isOptionValue }) => { - if (isOptionValue) { - return option.dropdownDisplay as JSX.Element + {!hideFields?.includes(HIDE_FIELDS.profiler) && ( + + {isOpen && canCommandProfile && !summaryText && ( +
+
+ + onQueryProfile(value as ProfileQueryType) } - return option.inputDisplay as JSX.Element - }} - /> + options={profileOptions} + data-testid="run-profile-type" + valueRender={({ option, isOptionValue }) => { + if (isOptionValue) { + return option.dropdownDisplay as JSX.Element + } + return option.inputDisplay as JSX.Element + }} + /> +
-
- )} - - - {isOpen && options.length > 1 && !summaryText && ( -
-
- { - if (isOptionValue) { - return option.dropdownDisplay as JSX.Element - } - return option.inputDisplay as JSX.Element - }} - value={selectedValue} - onChange={(value: string) => onChangeView(value)} - data-testid="select-view-type" - /> + )} + + )} + {!hideFields?.includes(HIDE_FIELDS.viewType) && ( + + {isOpen && options.length > 1 && !summaryText && ( +
+
+ { + if (isOptionValue) { + return option.dropdownDisplay as JSX.Element + } + return option.inputDisplay as JSX.Element + }} + value={selectedValue} + onChange={(value: string) => onChangeView(value)} + data-testid="select-view-type" + /> +
-
- )} - + )} + + )} { - const hasIndexes = false + const hasIndexes = true if (!hasIndexes) { return } - // TODO: QueryScreen - return } diff --git a/redisinsight/ui/src/pages/vector-search/components/QueryCard.tsx b/redisinsight/ui/src/pages/vector-search/components/QueryCard.tsx new file mode 100644 index 0000000000..3cc8f8ae55 --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/components/QueryCard.tsx @@ -0,0 +1,305 @@ +import React, { useEffect, useState } from 'react' +import { useSelector } from 'react-redux' +import cx from 'classnames' +import { useParams } from 'react-router-dom' +import { isNull } from 'lodash' +import { KeyboardKeys as keys } from 'uiSrc/constants/keys' + +import { LoadingContent } from 'uiSrc/components/base/layout' +import { + DEFAULT_TEXT_VIEW_TYPE, + ProfileQueryType, + WBQueryType, +} from 'uiSrc/pages/workbench/constants' +import { + ResultsMode, + ResultsSummary, + RunQueryMode, +} from 'uiSrc/slices/interfaces/workbench' +import { + getVisualizationsByCommand, + getWBQueryType, + isGroupResults, + isSilentModeWithoutError, + Maybe, +} from 'uiSrc/utils' +import { appPluginsSelector } from 'uiSrc/slices/app/plugins' +import { + CommandExecutionResult, + IPluginVisualization, +} from 'uiSrc/slices/interfaces' +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' + +import QueryCardHeader from 'uiSrc/components/query/query-card/QueryCardHeader/QueryCardHeader' +import QueryCardCommonResult, { + CommonErrorResponse, +} from 'uiSrc/components/query/query-card/QueryCardCommonResult' +import QueryCardCliResultWrapper from 'uiSrc/components/query/query-card/QueryCardCliResultWrapper' +import QueryCardCliPlugin from 'uiSrc/components/query/query-card/QueryCardCliPlugin' +import queryStyles from 'uiSrc/components/query/query-card/styles.module.scss' + +export interface Props { + id: string + command: string + isOpen: boolean + result: Maybe + activeMode: RunQueryMode + mode?: RunQueryMode + activeResultsMode?: ResultsMode + resultsMode?: ResultsMode + emptyCommand?: boolean + summary?: ResultsSummary + createdAt?: Date + loading?: boolean + clearing?: boolean + isNotStored?: boolean + executionTime?: number + db?: number + hideFields?: string[] + onQueryDelete: () => void + onQueryReRun: () => void + onQueryOpen: () => void + onQueryProfile: (type: ProfileQueryType) => void +} + +const getDefaultPlugin = (views: IPluginVisualization[], query: string) => + getVisualizationsByCommand(query, views).find((view) => view.default) + ?.uniqId || DEFAULT_TEXT_VIEW_TYPE.id + +export const getSummaryText = ( + summary?: ResultsSummary, + mode?: ResultsMode, +) => { + if (summary) { + const { total, success, fail } = summary + const summaryText = `${total} Command(s) - ${success} success` + if (!isSilentModeWithoutError(mode, summary?.fail)) { + return `${summaryText}, ${fail} error(s)` + } + return summaryText + } + return summary +} + +const QueryCard = (props: Props) => { + const { + id, + command = '', + result, + activeMode, + mode, + activeResultsMode, + resultsMode, + summary, + isOpen, + createdAt, + onQueryOpen, + onQueryDelete, + onQueryProfile, + onQueryReRun, + loading, + clearing, + emptyCommand, + isNotStored, + executionTime, + db, + hideFields, + } = props + + const { visualizations = [] } = useSelector(appPluginsSelector) + + const { instanceId = '' } = useParams<{ instanceId: string }>() + const [isFullScreen, setIsFullScreen] = useState(false) + const [queryType, setQueryType] = useState( + getWBQueryType(command, visualizations), + ) + const [viewTypeSelected, setViewTypeSelected] = + useState(queryType) + const [message, setMessage] = useState('') + const [selectedViewValue, setSelectedViewValue] = useState( + getDefaultPlugin(visualizations, command || '') || queryType, + ) + + useEffect(() => { + window.addEventListener('keydown', handleEscFullScreen) + return () => { + window.removeEventListener('keydown', handleEscFullScreen) + } + }, [isFullScreen]) + + const handleEscFullScreen = (event: KeyboardEvent) => { + if (event.key === keys.ESCAPE && isFullScreen) { + toggleFullScreen() + } + } + + const toggleFullScreen = () => { + setIsFullScreen((isFull) => { + sendEventTelemetry({ + event: TelemetryEvent.WORKBENCH_RESULTS_IN_FULL_SCREEN, + eventData: { + databaseId: instanceId, + state: isFull ? 'Close' : 'Open', + }, + }) + + return !isFull + }) + } + + useEffect(() => { + setQueryType(getWBQueryType(command, visualizations)) + }, [command]) + + useEffect(() => { + if (visualizations.length) { + const type = getWBQueryType(command, visualizations) + setQueryType(type) + setViewTypeSelected(type) + setSelectedViewValue( + getDefaultPlugin(visualizations, command) || queryType, + ) + } + }, [visualizations]) + + const toggleOpen = () => { + if (isFullScreen || isSilentModeWithoutError(resultsMode, summary?.fail)) + return + + onQueryOpen() + } + + const changeViewTypeSelected = (type: WBQueryType, value: string) => { + setViewTypeSelected(type) + setSelectedViewValue(value) + } + + const commonError = CommonErrorResponse(id, command, result) + + const isSizeLimitExceededResponse = ( + result: Maybe, + ) => { + const resultObj = result?.[0] + // response.includes - to be backward compatible with responses which don't include sizeLimitExceeded flag + return ( + resultObj?.sizeLimitExceeded === true || + resultObj?.response?.includes?.('Results have been deleted') + ) + } + + return ( +
+
+ + {isOpen && ( + <> + {React.isValidElement(commonError) && + (!isGroupResults(resultsMode) || isNull(command)) ? ( + + ) : ( + <> + {isSizeLimitExceededResponse(result) ? ( + + ) : ( + <> + {isGroupResults(resultsMode) && ( + + )} + {(resultsMode === ResultsMode.Default || !resultsMode) && ( + <> + {viewTypeSelected === WBQueryType.Plugin && ( + <> + {!loading && result !== undefined ? ( + + ) : ( +
+ +
+ )} + + )} + {viewTypeSelected === WBQueryType.Text && ( + + )} + + )} + + )} + + )} + + )} +
+
+ ) +} + +export default React.memo(QueryCard) diff --git a/redisinsight/ui/src/pages/vector-search/components/commands-view/CommandsView/CommandsView.tsx b/redisinsight/ui/src/pages/vector-search/components/commands-view/CommandsView/CommandsView.tsx new file mode 100644 index 0000000000..b29aa3ae01 --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/components/commands-view/CommandsView/CommandsView.tsx @@ -0,0 +1,162 @@ +import React from 'react' +import cx from 'classnames' + +import { CodeButtonParams } from 'uiSrc/constants' +import { ProfileQueryType } from 'uiSrc/pages/workbench/constants' +import { generateProfileQueryForCommand } from 'uiSrc/pages/workbench/utils/profile' +import { Nullable } from 'uiSrc/utils' +import { CommandExecutionUI } from 'uiSrc/slices/interfaces' +import { RunQueryMode, ResultsMode } from 'uiSrc/slices/interfaces/workbench' + +import { EmptyButton } from 'uiSrc/components/base/forms/buttons' +import { DeleteIcon } from 'uiSrc/components/base/icons' +import { ProgressBarLoader } from 'uiSrc/components/base/display' +import QueryCard from '../../QueryCard' + +import styles from './styles.module.scss' + +export interface Props { + isResultsLoaded: boolean + items: CommandExecutionUI[] + clearing: boolean + processing: boolean + activeMode: RunQueryMode + activeResultsMode?: ResultsMode + scrollDivRef: React.Ref + noResultsPlaceholder?: React.ReactNode + hideFields?: string[] + onQueryReRun: ( + query: string, + commandId?: Nullable, + executeParams?: CodeButtonParams, + ) => void + onQueryDelete: (commandId: string) => void + onAllQueriesDelete: () => void + onQueryOpen: (commandId: string) => void + onQueryProfile: ( + query: string, + commandId?: Nullable, + executeParams?: CodeButtonParams, + ) => void +} +const CommandsView = (props: Props) => { + const { + isResultsLoaded, + items = [], + clearing, + processing, + activeMode, + activeResultsMode, + noResultsPlaceholder, + hideFields, + onQueryReRun, + onQueryProfile, + onQueryDelete, + onAllQueriesDelete, + onQueryOpen, + scrollDivRef, + } = props + + const handleQueryProfile = ( + profileType: ProfileQueryType, + commandExecution: { + command: string + mode?: RunQueryMode + resultsMode?: ResultsMode + }, + ) => { + const { command, mode, resultsMode } = commandExecution + const profileQuery = generateProfileQueryForCommand(command, profileType) + if (profileQuery) { + onQueryProfile(profileQuery, null, { + mode, + results: resultsMode, + clearEditor: false, + }) + } + } + + return ( +
+ {!isResultsLoaded && ( + + )} + {!!items?.length && ( +
+ onAllQueriesDelete?.()} + disabled={clearing || processing} + data-testid="clear-history-btn" + > + Clear Results + +
+ )} +
+
+ {items?.length + ? items.map( + ({ + command = '', + isOpen = false, + result = undefined, + summary = undefined, + id = '', + loading, + createdAt, + mode, + resultsMode, + emptyCommand, + isNotStored, + executionTime, + db, + }) => ( + onQueryOpen(id)} + onQueryProfile={(profileType) => + handleQueryProfile(profileType, { + command, + mode, + resultsMode, + }) + } + onQueryReRun={() => + onQueryReRun(command, null, { + mode, + results: resultsMode, + clearEditor: false, + }) + } + onQueryDelete={() => onQueryDelete(id)} + /> + ), + ) + : null} + {isResultsLoaded && !items.length && (noResultsPlaceholder ?? null)} +
+
+ ) +} + +export default React.memo(CommandsView) diff --git a/redisinsight/ui/src/pages/vector-search/components/commands-view/CommandsView/index.ts b/redisinsight/ui/src/pages/vector-search/components/commands-view/CommandsView/index.ts new file mode 100644 index 0000000000..4dc31b50e8 --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/components/commands-view/CommandsView/index.ts @@ -0,0 +1,3 @@ +import CommandsView from './CommandsView' + +export default CommandsView diff --git a/redisinsight/ui/src/pages/vector-search/components/commands-view/CommandsView/styles.module.scss b/redisinsight/ui/src/pages/vector-search/components/commands-view/CommandsView/styles.module.scss new file mode 100644 index 0000000000..94e0db31c4 --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/components/commands-view/CommandsView/styles.module.scss @@ -0,0 +1,43 @@ +.wrapper { + flex: 1; + height: 100%; + width: 100%; + background-color: var(--euiColorEmptyShade); + border: 1px solid var(--euiColorLightShade); + + display: flex; + flex-direction: column; + + position: relative; +} + +.container { + @include eui.scrollBar; + color: var(--euiTextSubduedColor) !important; + + flex: 1; + width: 100%; + overflow: auto; +} + +.header { + height: 42px; + display: flex; + align-items: center; + justify-content: flex-end; + padding: 0 12px; + + flex-shrink: 0; + border-bottom: 1px solid var(--tableDarkestBorderColor); +} + +.clearAllBtn { + font-size: 14px !important; + + :global { + .euiIcon { + width: 14px !important; + height: 14px !important; + } + } +} diff --git a/redisinsight/ui/src/pages/vector-search/components/commands-view/CommandsViewWrapper.tsx b/redisinsight/ui/src/pages/vector-search/components/commands-view/CommandsViewWrapper.tsx new file mode 100644 index 0000000000..a9a0fc3770 --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/components/commands-view/CommandsViewWrapper.tsx @@ -0,0 +1,35 @@ +import React from 'react' +import { Nullable } from 'uiSrc/utils' +import { CommandExecutionUI } from 'uiSrc/slices/interfaces' +import { RunQueryMode, ResultsMode } from 'uiSrc/slices/interfaces/workbench' +import { CodeButtonParams } from 'uiSrc/constants' +import CommandsView from './CommandsView' + +export interface Props { + isResultsLoaded: boolean + items: CommandExecutionUI[] + clearing: boolean + processing: boolean + activeMode: RunQueryMode + activeResultsMode: ResultsMode + scrollDivRef: React.Ref + noResultsPlaceholder?: React.ReactNode + hideFields?: string[] + onQueryReRun: ( + query: string, + commandId?: Nullable, + executeParams?: CodeButtonParams, + ) => void + onQueryOpen: (commandId: string) => void + onQueryDelete: (commandId: string) => void + onAllQueriesDelete: () => void + onQueryProfile: ( + query: string, + commandId?: Nullable, + executeParams?: CodeButtonParams, + ) => void +} + +const CommandsViewWrapper = (props: Props) => + +export default React.memo(CommandsViewWrapper) diff --git a/redisinsight/ui/src/pages/vector-search/components/commands-view/index.ts b/redisinsight/ui/src/pages/vector-search/components/commands-view/index.ts new file mode 100644 index 0000000000..92c999ddbe --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/components/commands-view/index.ts @@ -0,0 +1,3 @@ +import CommandsViewWrapper from './CommandsViewWrapper' + +export default CommandsViewWrapper diff --git a/redisinsight/ui/src/pages/vector-search/query/VectorSearchQuery.styles.ts b/redisinsight/ui/src/pages/vector-search/query/VectorSearchQuery.styles.ts new file mode 100644 index 0000000000..3f394f018f --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/query/VectorSearchQuery.styles.ts @@ -0,0 +1,15 @@ +import styled from 'styled-components' +import { ResizableContainer } from 'uiSrc/components/base/layout' + +export const StyledResizableContainer = styled(ResizableContainer)` + padding: 10px; +` + +export const StyledNoResultsWrapper = styled.div` + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + align-items: center; + justify-content: center; +` diff --git a/redisinsight/ui/src/pages/vector-search/query/VectorSearchQuery.tsx b/redisinsight/ui/src/pages/vector-search/query/VectorSearchQuery.tsx index 2c5e99607a..4a7f51b61d 100644 --- a/redisinsight/ui/src/pages/vector-search/query/VectorSearchQuery.tsx +++ b/redisinsight/ui/src/pages/vector-search/query/VectorSearchQuery.tsx @@ -1,11 +1,92 @@ import React from 'react' +import { + ResizablePanel, + ResizablePanelHandle, +} from 'uiSrc/components/base/layout' +import QueryWrapper from 'uiSrc/pages/workbench/components/query' +import { HIDE_FIELDS } from 'uiSrc/components/query/query-card/QueryCardHeader/QueryCardHeader' +import { + StyledNoResultsWrapper, + StyledResizableContainer, +} from './VectorSearchQuery.styles' import { HeaderActions } from './HeaderActions' +import { useQuery } from './useQuery' import { CreateIndexWrapper } from '../create-index/styles' +import CommandsViewWrapper from '../components/commands-view' -// TODO: implement this component -// https://www.figma.com/design/oO2eYRuuLmfzUYLkvCkFhM/Search-page?node-id=645-37412&t=TSwcttCYa4Ld9WzC-4 -export const VectorSearchQuery = () => ( - - - -) +export const VectorSearchQuery = () => { + const { + query, + setQuery, + items, + clearing, + processing, + isResultsLoaded, + activeMode, + resultsMode, + scrollDivRef, + onSubmit, + onQueryOpen, + onQueryDelete, + onAllQueriesDelete, + onQueryChangeMode, + onChangeGroupMode, + onQueryReRun, + onQueryProfile, + } = useQuery() + + return ( + + + + + + {}} + onSubmit={() => onSubmit()} + onQueryChangeMode={onQueryChangeMode} + onChangeGroupMode={onChangeGroupMode} + queryProps={{ useLiteActions: true }} + /> + + + + + + + TODO: Not sure yet what to put here + + } + /> + + + + ) +} diff --git a/redisinsight/ui/src/pages/vector-search/query/useQuery.ts b/redisinsight/ui/src/pages/vector-search/query/useQuery.ts new file mode 100644 index 0000000000..1bf58e6a45 --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/query/useQuery.ts @@ -0,0 +1,299 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { useParams } from 'react-router-dom' +import { chunk, reverse } from 'lodash' +import { + Nullable, + getCommandsForExecution, + getExecuteParams, + isGroupResults, + isSilentMode, +} from 'uiSrc/utils' +import { CodeButtonParams } from 'uiSrc/constants' +import { + RunQueryMode, + ResultsMode, + CommandExecutionUI, + CommandExecution, +} from 'uiSrc/slices/interfaces' +import { PIPELINE_COUNT_DEFAULT } from 'uiSrc/constants/api' +import { + addCommands, + clearCommands, + findCommand, + removeCommand, +} from 'uiSrc/services/workbenchStorage' +import { + createErrorResult, + createGroupItem, + executeApiCall, + generateCommandId, + limitHistoryLength, + loadHistoryData, + prepareNewItems, + scrollToElement, + sortCommandsByDate, +} from './utils' + +const useQuery = () => { + const { instanceId } = useParams<{ instanceId: string }>() + const scrollDivRef = useRef(null) + + const [query, setQuery] = useState('') + const [items, setItems] = useState([]) + const [clearing, setClearing] = useState(false) + const [processing, setProcessing] = useState(false) + const [isLoaded, setIsLoaded] = useState(false) + + const resultsMode = ResultsMode.Default + const activeRunQueryMode = RunQueryMode.ASCII + + useEffect(() => { + const loadHistory = async () => { + try { + const historyData = await loadHistoryData(instanceId) + setItems(historyData) + } catch (error) { + // Silently handle error + } finally { + setIsLoaded(true) + } + } + + loadHistory() + }, [instanceId]) + + const handleApiSuccess = useCallback( + async ( + data: CommandExecution[], + commandId: string, + isNewCommand: boolean, + ) => { + setItems((prevItems) => { + const updatedItems = prevItems.map((item) => { + const result = data.find((_, i) => item.id === commandId + i) + if (result) { + return { + ...result, + loading: false, + error: '', + isOpen: !isSilentMode(resultsMode), + } + } + return item + }) + return sortCommandsByDate(updatedItems) + }) + + await addCommands(reverse(data)) + + if (isNewCommand) { + scrollToElement(scrollDivRef.current, 'start') + } + }, + [resultsMode], + ) + + const handleApiError = useCallback((error: unknown) => { + const message = + error instanceof Error ? error.message : 'Failed to execute command' + + setItems((prevItems) => + prevItems.map((item) => { + if (item.loading) { + return { + ...item, + loading: false, + error: message, + result: createErrorResult(message), + isOpen: true, + } + } + return item + }), + ) + setProcessing(false) + }, []) + + const executeCommandBatch = useCallback( + async ( + commandInit: string, + commandId: Nullable | undefined, + executeParams: CodeButtonParams, + ) => { + const currentExecuteParams = { + activeRunQueryMode, + resultsMode, + batchSize: PIPELINE_COUNT_DEFAULT, + } + + const { batchSize } = getExecuteParams( + executeParams, + currentExecuteParams, + ) + const commandsForExecuting = getCommandsForExecution(commandInit) + const chunkSize = isGroupResults(resultsMode) + ? commandsForExecuting.length + : batchSize > 1 + ? batchSize + : 1 + + const [commands, ...restCommands] = chunk(commandsForExecuting, chunkSize) + + if (!commands?.length) { + setProcessing(false) + return + } + + const newCommandId = commandId || generateCommandId() + const newItems = prepareNewItems(commands, newCommandId) + + setItems((prevItems) => { + const updatedItems = isGroupResults(resultsMode) + ? [createGroupItem(newItems.length, newCommandId), ...prevItems] + : [...newItems, ...prevItems] + return limitHistoryLength(updatedItems) + }) + + const data = await executeApiCall( + instanceId, + commands, + activeRunQueryMode, + resultsMode, + ) + + await handleApiSuccess(data, newCommandId, !commandId) + + // Handle remaining command batches + if (restCommands.length > 0) { + const nextCommands = restCommands[0] + if (nextCommands?.length) { + await executeCommandBatch( + nextCommands.join('\n'), + undefined, + executeParams, + ) + } + } else { + setProcessing(false) + } + }, + [activeRunQueryMode, resultsMode, instanceId, handleApiSuccess], + ) + + const onSubmit = useCallback( + async ( + commandInit: string = query, + commandId?: Nullable, + executeParams: CodeButtonParams = {}, + ) => { + if (!commandInit?.length) return + + setProcessing(true) + + try { + await executeCommandBatch(commandInit, commandId, executeParams) + } catch (error) { + handleApiError(error) + } + }, + [query, executeCommandBatch, handleApiError], + ) + + const handleQueryDelete = useCallback( + async (commandId: string) => { + try { + await removeCommand(instanceId, commandId) + setItems((prevItems) => + prevItems.filter((item) => item.id !== commandId), + ) + } catch (error) { + // Silently handle error + } + }, + [instanceId], + ) + + const handleAllQueriesDelete = useCallback(async () => { + try { + setClearing(true) + await clearCommands(instanceId) + setItems([]) + } catch (error) { + // Keep clearing state false on error + } finally { + setClearing(false) + } + }, [instanceId]) + + const handleQueryOpen = useCallback(async (commandId: string) => { + try { + setItems((prevItems) => + prevItems.map((item) => + item.id === commandId ? { ...item, loading: true } : item, + ), + ) + + const command = await findCommand(commandId) + setItems((prevItems) => + prevItems.map((item) => { + if (item.id !== commandId) return item + + if (command) { + return { + ...item, + ...command, + loading: false, + isOpen: !item.isOpen, + error: '', + } + } + + return { ...item, loading: false } + }), + ) + } catch (error) { + setItems((prevItems) => + prevItems.map((item) => + item.id === commandId + ? { + ...item, + loading: false, + error: 'Failed to load command details', + } + : item, + ), + ) + } + }, []) + + const handleQueryProfile = useCallback(() => {}, []) + const handleChangeQueryRunMode = useCallback(() => {}, []) + const handleChangeGroupMode = useCallback(() => {}, []) + + return { + // State + query, + setQuery, + items, + clearing, + processing, + isResultsLoaded: isLoaded, + + // Configuration + activeMode: activeRunQueryMode, + resultsMode, + scrollDivRef, + + // Actions + onSubmit, + onQueryOpen: handleQueryOpen, + onQueryDelete: handleQueryDelete, + onAllQueriesDelete: handleAllQueriesDelete, + onQueryChangeMode: handleChangeQueryRunMode, + onChangeGroupMode: handleChangeGroupMode, + onQueryReRun: onSubmit, + onQueryProfile: handleQueryProfile, + } +} + +export { useQuery } diff --git a/redisinsight/ui/src/pages/vector-search/query/utils.ts b/redisinsight/ui/src/pages/vector-search/query/utils.ts new file mode 100644 index 0000000000..3e7c873803 --- /dev/null +++ b/redisinsight/ui/src/pages/vector-search/query/utils.ts @@ -0,0 +1,116 @@ +import { scrollIntoView, getUrl, isStatusSuccessful } from 'uiSrc/utils' +import { EMPTY_COMMAND, ApiEndpoints } from 'uiSrc/constants' +import { + RunQueryMode, + ResultsMode, + CommandExecutionUI, + CommandExecution, + CommandExecutionType, +} from 'uiSrc/slices/interfaces' +import { apiService } from 'uiSrc/services' +import { WORKBENCH_HISTORY_MAX_LENGTH } from 'uiSrc/pages/workbench/constants' +import { CommandExecutionStatus } from 'uiSrc/slices/interfaces/cli' +import { getLocalWbHistory } from 'uiSrc/services/workbenchStorage' + +export const sortCommandsByDate = ( + commands: CommandExecutionUI[], +): CommandExecutionUI[] => + commands.sort((a, b) => { + const dateA = new Date(a.createdAt || 0).getTime() + const dateB = new Date(b.createdAt || 0).getTime() + return dateB - dateA + }) + +export const prepareNewItems = ( + commands: string[], + commandId: string, +): CommandExecutionUI[] => + commands.map((command, i) => ({ + command, + id: commandId + i, + loading: true, + isOpen: true, + error: '', + })) + +export const createGroupItem = ( + itemCount: number, + commandId: string, +): CommandExecutionUI => ({ + command: `${itemCount} - Command(s)`, + id: commandId, + loading: true, + isOpen: true, + error: '', +}) + +export const createErrorResult = (message: string) => [ + { + response: message, + status: CommandExecutionStatus.Fail, + }, +] + +export const scrollToElement = ( + element: HTMLDivElement | null, + inline: ScrollLogicalPosition = 'start', +) => { + if (!element) return + + requestAnimationFrame(() => { + scrollIntoView(element, { + behavior: 'smooth', + block: 'nearest', + inline, + }) + }) +} + +export const limitHistoryLength = ( + items: CommandExecutionUI[], +): CommandExecutionUI[] => + items.length > WORKBENCH_HISTORY_MAX_LENGTH + ? items.slice(0, WORKBENCH_HISTORY_MAX_LENGTH) + : items + +export const loadHistoryData = async ( + instanceId: string, +): Promise => { + const commandsHistory = await getLocalWbHistory(instanceId) + if (!Array.isArray(commandsHistory)) { + return [] + } + + const processedHistory = commandsHistory.map((item) => ({ + ...item, + command: item.command || EMPTY_COMMAND, + emptyCommand: !item.command, + })) + + return sortCommandsByDate(processedHistory) +} + +export const executeApiCall = async ( + instanceId: string, + commands: string[], + activeRunQueryMode: RunQueryMode, + resultsMode: ResultsMode, +): Promise => { + const { data, status } = await apiService.post( + getUrl(instanceId, ApiEndpoints.WORKBENCH_COMMAND_EXECUTIONS), + { + commands, + mode: activeRunQueryMode, + resultsMode, + type: CommandExecutionType.Search, + }, + ) + + if (!isStatusSuccessful(status)) { + throw new Error(`API call failed with status: ${status}`) + } + + return data +} + +export const generateCommandId = (): string => `${Date.now()}`