diff --git a/redisinsight/ui/src/pages/vector-search/create-index/hooks/useCreateIndex.spec.ts b/redisinsight/ui/src/pages/vector-search/create-index/hooks/useCreateIndex.spec.ts index aea44ed5cd..d5b1ec0717 100644 --- a/redisinsight/ui/src/pages/vector-search/create-index/hooks/useCreateIndex.spec.ts +++ b/redisinsight/ui/src/pages/vector-search/create-index/hooks/useCreateIndex.spec.ts @@ -1,43 +1,54 @@ import { renderHook, act } from '@testing-library/react-hooks' -import { useCreateIndex } from './useCreateIndex' +import executeQuery from 'uiSrc/services/executeQuery' import { CreateSearchIndexParameters, + SampleDataContent, SampleDataType, SearchIndexType, } from '../types' +import { useCreateIndex } from './useCreateIndex' const mockLoad = jest.fn() -const mockDispatch = jest.fn() +const mockAddCommands = jest.fn() jest.mock('uiSrc/services/hooks', () => ({ useLoadData: () => ({ load: mockLoad, }), - useDispatchWbQuery: () => mockDispatch, +})) + +jest.mock('uiSrc/services/workbenchStorage', () => ({ + addCommands: (...args: any[]) => mockAddCommands(...args), })) jest.mock('uiSrc/utils/index/generateFtCreateCommand', () => ({ generateFtCreateCommand: () => 'FT.CREATE idx:bikes_vss ...', })) +jest.mock('uiSrc/services/executeQuery', () => ({ + __esModule: true, + default: jest.fn(), +})) +const mockExecute = executeQuery as jest.Mock + describe('useCreateIndex', () => { beforeEach(() => { jest.clearAllMocks() }) const defaultParams: CreateSearchIndexParameters = { - dataContent: '', - usePresetVectorIndex: true, - presetVectorIndexName: '', - tags: [], instanceId: 'test-instance-id', - searchIndexType: SearchIndexType.REDIS_QUERY_ENGINE, + dataContent: SampleDataContent.E_COMMERCE_DISCOVERY, sampleDataType: SampleDataType.PRESET_DATA, + searchIndexType: SearchIndexType.REDIS_QUERY_ENGINE, + usePresetVectorIndex: true, + indexName: 'bikes', + indexFields: [], } it('should complete flow successfully', async () => { mockLoad.mockResolvedValue(undefined) - mockDispatch.mockImplementation((_data, { afterAll }) => afterAll?.()) + mockExecute.mockResolvedValue([{ id: '1', databaseId: 'test-instance-id' }]) const { result } = renderHook(() => useCreateIndex()) @@ -46,7 +57,11 @@ describe('useCreateIndex', () => { }) expect(mockLoad).toHaveBeenCalledWith('test-instance-id', 'bikes') - expect(mockDispatch).toHaveBeenCalled() + expect(mockExecute).toHaveBeenCalledWith( + 'test-instance-id', + 'FT.CREATE idx:bikes_vss ...', + ) + expect(mockAddCommands).toHaveBeenCalled() expect(result.current.success).toBe(true) expect(result.current.error).toBeNull() expect(result.current.loading).toBe(false) @@ -62,6 +77,8 @@ describe('useCreateIndex', () => { expect(result.current.success).toBe(false) expect(result.current.error?.message).toMatch(/Instance ID is required/) expect(result.current.loading).toBe(false) + expect(mockLoad).not.toHaveBeenCalled() + expect(mockExecute).not.toHaveBeenCalled() }) it('should handle failure in data loading', async () => { @@ -78,13 +95,12 @@ describe('useCreateIndex', () => { expect(result.current.success).toBe(false) expect(result.current.error).toBe(error) expect(result.current.loading).toBe(false) + expect(mockExecute).not.toHaveBeenCalled() }) - it('should handle dispatch failure', async () => { + it('should handle execution failure', async () => { mockLoad.mockResolvedValue(undefined) - mockDispatch.mockImplementation((_data, { onFail }) => - onFail?.(new Error('Dispatch failed')), - ) + mockExecute.mockRejectedValue(new Error('Execution failed')) const { result } = renderHook(() => useCreateIndex()) @@ -92,10 +108,10 @@ describe('useCreateIndex', () => { await result.current.run(defaultParams) }) - expect(mockDispatch).toHaveBeenCalled() + expect(mockExecute).toHaveBeenCalled() expect(result.current.success).toBe(false) expect(result.current.error).toBeInstanceOf(Error) - expect(result.current.error?.message).toBe('Dispatch failed') + expect(result.current.error?.message).toBe('Execution failed') expect(result.current.loading).toBe(false) }) }) diff --git a/redisinsight/ui/src/pages/vector-search/create-index/hooks/useCreateIndex.ts b/redisinsight/ui/src/pages/vector-search/create-index/hooks/useCreateIndex.ts index 69d3bc065b..92749c917e 100644 --- a/redisinsight/ui/src/pages/vector-search/create-index/hooks/useCreateIndex.ts +++ b/redisinsight/ui/src/pages/vector-search/create-index/hooks/useCreateIndex.ts @@ -1,7 +1,10 @@ import { useCallback, useState } from 'react' -import { useLoadData, useDispatchWbQuery } from 'uiSrc/services/hooks' +import { reverse } from 'lodash' +import { useLoadData } from 'uiSrc/services/hooks' +import { addCommands } from 'uiSrc/services/workbenchStorage' import { generateFtCreateCommand } from 'uiSrc/utils/index/generateFtCreateCommand' import { CreateSearchIndexParameters, PresetDataType } from '../types' +import executeQuery from 'uiSrc/services/executeQuery' interface UseCreateIndexResult { run: (params: CreateSearchIndexParameters) => Promise @@ -20,7 +23,6 @@ export const useCreateIndex = (): UseCreateIndexResult => { const [error, setError] = useState(null) const { load } = useLoadData() - const dispatchCreateIndex = useDispatchWbQuery() const run = useCallback( async ({ instanceId }: CreateSearchIndexParameters) => { @@ -40,22 +42,22 @@ export const useCreateIndex = (): UseCreateIndexResult => { await load(instanceId, collectionName) // Step 2: Create the search index - await new Promise((resolve, reject) => { - dispatchCreateIndex(generateFtCreateCommand(), { - afterAll: () => { - setSuccess(true) - resolve() - }, - onFail: reject, - }) - }) + const cmd = generateFtCreateCommand() + const data = await executeQuery(instanceId, cmd) + + // Step 3: Persist results locally so Vector Search history (CommandsView) shows it + if (Array.isArray(data) && data.length) { + await addCommands(reverse(data)) + } + + setSuccess(true) } catch (e) { setError(e instanceof Error ? e : new Error(String(e))) } finally { setLoading(false) } }, - [load, dispatchCreateIndex], + [load, executeQuery], ) return { diff --git a/redisinsight/ui/src/services/executeQuery.spec.ts b/redisinsight/ui/src/services/executeQuery.spec.ts new file mode 100644 index 0000000000..fa03231810 --- /dev/null +++ b/redisinsight/ui/src/services/executeQuery.spec.ts @@ -0,0 +1,88 @@ +import { rest } from 'msw' +import { ApiEndpoints } from 'uiSrc/constants' +import { mswServer } from 'uiSrc/mocks/server' +import { getMswURL } from 'uiSrc/utils/test-utils' +import { getUrl } from 'uiSrc/utils' +import { RunQueryMode, ResultsMode } from 'uiSrc/slices/interfaces' +import executeQuery from './executeQuery' + +describe('executeQuery', () => { + const instanceId = 'test-instance-id' + const command = 'FT.CREATE idx:bikes_vss ...' + + beforeEach(() => { + mswServer.resetHandlers() + jest.clearAllMocks() + }) + + it.each([null, undefined])( + 'returns empty array and does not call API when data is %s', + async (data) => { + const result = await executeQuery(instanceId, data as any) + expect(result).toEqual([]) + }, + ) + + it('calls API with correct parameters and returns result', async () => { + const mockResponse = [{ id: '1', databaseId: instanceId }] + + mswServer.use( + rest.post( + getMswURL( + getUrl(instanceId, ApiEndpoints.WORKBENCH_COMMAND_EXECUTIONS), + ), + async (req, res, ctx) => { + const body = await req.json() + expect(body).toEqual({ + commands: [command], + mode: RunQueryMode.ASCII, + resultsMode: ResultsMode.Default, + type: 'SEARCH', + }) + return res(ctx.status(200), ctx.json(mockResponse)) + }, + ), + ) + + const returned = await executeQuery(instanceId, command) + expect(returned).toEqual(mockResponse) + }) + + it('invokes afterAll callback on success', async () => { + const mockResponse = [{ id: '1', databaseId: instanceId }] + + mswServer.use( + rest.post( + getMswURL( + getUrl(instanceId, ApiEndpoints.WORKBENCH_COMMAND_EXECUTIONS), + ), + async (_req, res, ctx) => res(ctx.status(200), ctx.json(mockResponse)), + ), + ) + + const afterAll = jest.fn() + + await executeQuery(instanceId, command, { afterAll }) + + expect(afterAll).toHaveBeenCalled() + }) + + it('invokes onFail and rethrows on error', async () => { + mswServer.use( + rest.post( + getMswURL( + getUrl(instanceId, ApiEndpoints.WORKBENCH_COMMAND_EXECUTIONS), + ), + async (_req, res, ctx) => res(ctx.status(500)), + ), + ) + + const onFail = jest.fn() + + await expect( + executeQuery(instanceId, command, { onFail }), + ).rejects.toThrow() + + expect(onFail).toHaveBeenCalled() + }) +}) diff --git a/redisinsight/ui/src/services/executeQuery.ts b/redisinsight/ui/src/services/executeQuery.ts new file mode 100644 index 0000000000..9dba65dcd4 --- /dev/null +++ b/redisinsight/ui/src/services/executeQuery.ts @@ -0,0 +1,31 @@ +import { RunQueryMode, ResultsMode } from 'uiSrc/slices/interfaces' +import { executeApiCall } from 'uiSrc/pages/vector-search/query/utils' + +export interface ExecuteQueryOptions { + afterAll?: () => void + onFail?: (error?: unknown) => void +} + +const executeQuery = async ( + instanceId: string, + data: string | null | undefined, + options: ExecuteQueryOptions = {}, +): Promise>> => { + if (!data) return [] as unknown as Awaited> + + try { + const result = await executeApiCall( + instanceId, + [data], + RunQueryMode.ASCII, + ResultsMode.Default, + ) + options.afterAll?.() + return result + } catch (e) { + options.onFail?.(e) + throw e + } +} + +export default executeQuery diff --git a/redisinsight/ui/src/services/hooks/index.ts b/redisinsight/ui/src/services/hooks/index.ts index b9f87d9b01..f3e3a1986b 100644 --- a/redisinsight/ui/src/services/hooks/index.ts +++ b/redisinsight/ui/src/services/hooks/index.ts @@ -2,5 +2,4 @@ export * from './hooks' export * from './useWebworkers' export * from './useCabability' export * from './useStateWithContext' -export * from './useDispatchWbQuery' export * from './useLoadData' diff --git a/redisinsight/ui/src/services/hooks/useDispatchWbQuery.spec.ts b/redisinsight/ui/src/services/hooks/useDispatchWbQuery.spec.ts deleted file mode 100644 index 681cdc0f31..0000000000 --- a/redisinsight/ui/src/services/hooks/useDispatchWbQuery.spec.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { act } from '@testing-library/react' -import { renderHook, mockedStore } from 'uiSrc/utils/test-utils' - -import { useDispatchWbQuery } from './useDispatchWbQuery' - -describe('useDispatchWbQuery', () => { - beforeEach(() => { - jest.clearAllMocks() - mockedStore.clearActions() - }) - - it('should dispatch sendWbQueryAction when data is provided', () => { - const data = 'HSET foo bar' - - const afterAll = jest.fn() - const afterEach = jest.fn() - const onFail = jest.fn() - - const { result } = renderHook(() => useDispatchWbQuery()) - const dispatchWbQuery = result.current as ReturnType< - typeof useDispatchWbQuery - > - - act(() => { - dispatchWbQuery(data, { - afterAll, - afterEach, - onFail, - }) - }) - - const actions = mockedStore.getActions() - - expect(actions).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - type: 'workbenchResults/sendWBCommand', - payload: expect.objectContaining({ - commands: [data], - commandId: expect.any(String), - }), - }), - ]), - ) - - expect(actions).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - type: 'appContext/setDbIndexState', - payload: true, - }), - ]), - ) - - expect(actions.length).toBeGreaterThanOrEqual(2) - }) - - it('should not dispatch if data is null', () => { - const { result } = renderHook(() => useDispatchWbQuery()) - const dispatchWbQuery = result.current as ReturnType< - typeof useDispatchWbQuery - > - - act(() => { - dispatchWbQuery(null) - }) - - expect(mockedStore.getActions()).toHaveLength(0) - }) - - it('should not dispatch if data is undefined', () => { - const { result } = renderHook(() => useDispatchWbQuery()) - const dispatchWbQuery = result.current as ReturnType< - typeof useDispatchWbQuery - > - - act(() => { - dispatchWbQuery(undefined) - }) - - expect(mockedStore.getActions()).toHaveLength(0) - }) -}) diff --git a/redisinsight/ui/src/services/hooks/useDispatchWbQuery.ts b/redisinsight/ui/src/services/hooks/useDispatchWbQuery.ts deleted file mode 100644 index 067b5f58a1..0000000000 --- a/redisinsight/ui/src/services/hooks/useDispatchWbQuery.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { useCallback } from 'react' -import { useDispatch } from 'react-redux' -import { sendWbQueryAction } from 'uiSrc/slices/workbench/wb-results' - -export interface UseDispatchWbQueryOptions { - afterAll?: () => void - afterEach?: () => void - onFail?: () => void -} - -export const useDispatchWbQuery = () => { - const dispatch = useDispatch() - - return useCallback( - (data: string | null | undefined, options?: UseDispatchWbQueryOptions) => { - if (!data) return - - dispatch( - sendWbQueryAction( - data, - undefined, - undefined, - { - afterAll: options?.afterAll, - afterEach: options?.afterEach, - }, - options?.onFail, - ), - ) - }, - [dispatch], - ) -}