diff --git a/redisinsight/ui/src/constants/api.ts b/redisinsight/ui/src/constants/api.ts index fec8cefa32..4d8f5981ae 100644 --- a/redisinsight/ui/src/constants/api.ts +++ b/redisinsight/ui/src/constants/api.ts @@ -78,6 +78,9 @@ enum ApiEndpoints { NOTIFICATIONS = 'notifications', NOTIFICATIONS_READ = 'notifications/read', + + REDISEARCH = 'redisearch', + REDISEARCH_SEARCH = 'redisearch/search', } export const DEFAULT_SEARCH_MATCH = '*' diff --git a/redisinsight/ui/src/mocks/handlers/browser/index.ts b/redisinsight/ui/src/mocks/handlers/browser/index.ts new file mode 100644 index 0000000000..14008797fc --- /dev/null +++ b/redisinsight/ui/src/mocks/handlers/browser/index.ts @@ -0,0 +1,8 @@ +import { DefaultBodyType, MockedRequest, RestHandler } from 'msw' + +import redisearch from './redisearchHandlers' + +const handlers: RestHandler>[] = [].concat( + redisearch, +) +export default handlers diff --git a/redisinsight/ui/src/mocks/handlers/browser/redisearchHandlers.ts b/redisinsight/ui/src/mocks/handlers/browser/redisearchHandlers.ts new file mode 100644 index 0000000000..b1903f6ea2 --- /dev/null +++ b/redisinsight/ui/src/mocks/handlers/browser/redisearchHandlers.ts @@ -0,0 +1,21 @@ +import { rest, RestHandler } from 'msw' +import { ApiEndpoints } from 'uiSrc/constants' +import { getMswURL } from 'uiSrc/utils/test-utils' +import { getUrl, stringToBuffer } from 'uiSrc/utils' +import { RedisResponseBuffer } from 'uiSrc/slices/interfaces' +import { INSTANCE_ID_MOCK } from '../instances/instancesHandlers' + +export const REDISEARCH_LIST_DATA_MOCK_UTF8 = ['idx: 1', 'idx:2'] +export const REDISEARCH_LIST_DATA_MOCK = [...REDISEARCH_LIST_DATA_MOCK_UTF8].map((str) => stringToBuffer(str)) + +const handlers: RestHandler[] = [ + // fetchRedisearchListAction + rest.get(getMswURL( + getUrl(INSTANCE_ID_MOCK, ApiEndpoints.REDISEARCH) + ), async (req, res, ctx) => res( + ctx.status(200), + ctx.json(REDISEARCH_LIST_DATA_MOCK), + )) +] + +export default handlers diff --git a/redisinsight/ui/src/mocks/handlers/index.ts b/redisinsight/ui/src/mocks/handlers/index.ts index 7847ecccfd..c0ff799a7c 100644 --- a/redisinsight/ui/src/mocks/handlers/index.ts +++ b/redisinsight/ui/src/mocks/handlers/index.ts @@ -3,6 +3,13 @@ import instances from './instances' import content from './content' import app from './app' import analytics from './analytics' +import browser from './browser' // @ts-ignore -export const handlers: RestHandler[] = [].concat(instances, content, app, analytics) +export const handlers: RestHandler[] = [].concat( + instances, + content, + app, + analytics, + browser, +) diff --git a/redisinsight/ui/src/slices/browser/redisearch.ts b/redisinsight/ui/src/slices/browser/redisearch.ts new file mode 100644 index 0000000000..b0dfa5b50d --- /dev/null +++ b/redisinsight/ui/src/slices/browser/redisearch.ts @@ -0,0 +1,309 @@ +import { AxiosError } from 'axios' +import { createSlice, PayloadAction } from '@reduxjs/toolkit' + +import { ApiEndpoints } from 'uiSrc/constants' +import { apiService } from 'uiSrc/services' +import { getApiErrorMessage, getUrl, isStatusSuccessful } from 'uiSrc/utils' +import { DEFAULT_SEARCH_MATCH } from 'uiSrc/constants/api' + +import { GetKeysWithDetailsResponse } from 'apiSrc/modules/browser/dto' +import { CreateRedisearchIndexDto } from 'apiSrc/modules/browser/dto/redisearch' + +import { AppDispatch, RootState } from '../store' +import { RedisResponseBuffer, StateRedisearch } from '../interfaces' +import { addErrorNotification } from '../app/notifications' + +export const initialState: StateRedisearch = { + loading: false, + error: '', + search: '', + selectedIndex: null, + data: { + total: 0, + scanned: 0, + nextCursor: '0', + keys: [], + shardsMeta: {}, + previousResultCount: 0, + lastRefreshTime: null, + }, + list: { + loading: false, + error: '', + data: [] + }, + createIndex: { + loading: false, + error: '', + }, +} + +// A slice for recipes +const redisearchSlice = createSlice({ + name: 'redisearch', + initialState, + reducers: { + setRedisearchInitialState: () => initialState, + + // load redisearch keys + loadKeys: (state) => { + state.list = { + ...state.list, + loading: true, + error: '', + } + }, + loadKeysSuccess: (state, { payload }: PayloadAction) => { + state.data = { + ...payload + } + }, + loadKeysFailure: (state, { payload }) => { + state.list = { + ...state.list, + loading: false, + error: payload, + } + }, + + // load more redisearch keys + loadMoreKeys: (state) => { + state.list = { + ...state.list, + loading: true, + error: '', + } + }, + loadMoreKeysSuccess: (state, { payload }: PayloadAction) => { + state.list = { + ...state.list, + loading: false, + data: payload, + } + }, + loadMoreKeysFailure: (state, { payload }) => { + state.list = { + ...state.list, + loading: false, + error: payload, + } + }, + + // load list of indexes + loadList: (state) => { + state.list = { + ...state.list, + loading: true, + error: '', + } + }, + loadListSuccess: (state, { payload }: PayloadAction) => { + state.list = { + ...state.list, + loading: false, + data: payload, + } + }, + loadListFailure: (state, { payload }) => { + state.list = { + ...state.list, + loading: false, + error: payload, + } + }, + + // create an index + createIndex: (state) => { + state.createIndex = { + ...state.createIndex, + loading: true, + error: '', + } + }, + createIndexSuccess: (state) => { + state.createIndex = { + ...state.createIndex, + loading: false, + } + }, + createIndexFailure: (state, { payload }: PayloadAction) => { + state.createIndex = { + ...state.createIndex, + loading: false, + error: payload, + } + }, + }, +}) + +// Actions generated from the slice +export const { + loadKeys, + loadKeysSuccess, + loadKeysFailure, + loadMoreKeys, + loadMoreKeysSuccess, + loadMoreKeysFailure, + loadList, + loadListSuccess, + loadListFailure, + createIndex, + createIndexSuccess, + createIndexFailure, + setRedisearchInitialState, +} = redisearchSlice.actions + +// Selectors +export const redisearchSelector = (state: RootState) => state.browser.redisearch +export const redisearchDataSelector = (state: RootState) => state.browser.redisearch.data +export const redisearchListSelector = (state: RootState) => state.browser.redisearch.list +export const createIndexStateSelector = (state: RootState) => state.browser.redisearch.createIndex + +// The reducer +export default redisearchSlice.reducer + +// Asynchronous thunk action +export function fetchRedisearchKeysAction( + cursor: string, + count: number, + onSuccess?: (value: GetKeysWithDetailsResponse) => void, + onFailed?: () => void, +) { + return async (dispatch: AppDispatch, stateInit: () => RootState) => { + dispatch(loadKeys()) + + try { + const state = stateInit() + const { encoding } = state.app.info + const { search: query } = state.browser.keys + const { data, status } = await apiService.post( + getUrl( + state.connections.instances.connectedInstance?.id, + ApiEndpoints.REDISEARCH_SEARCH + ), + { + limit: count, offset: cursor, query: query || DEFAULT_SEARCH_MATCH, keysInfo: false, + }, + { + params: { encoding }, + } + ) + + if (isStatusSuccessful(status)) { + dispatch(loadKeysSuccess(data)) + onSuccess?.(data) + } + } catch (_err) { + const error = _err as AxiosError + const errorMessage = getApiErrorMessage(error) + dispatch(addErrorNotification(error)) + dispatch(loadKeysFailure(errorMessage)) + onFailed?.() + } + } +} + +// export function fetchMoreRedisearchKeysAction( +// onSuccess?: (value: RedisResponseBuffer[]) => void, +// onFailed?: () => void, +// ) { +// return async (dispatch: AppDispatch, stateInit: () => RootState) => { +// dispatch(loadMoreKeys()) + +// try { +// const state = stateInit() +// const { encoding } = state.app.info +// const { data, status } = await apiService.get( +// getUrl( +// state.connections.instances.connectedInstance?.id, +// ApiEndpoints.REDISEARCH_SEARCH +// ), +// { +// params: { encoding }, +// } +// ) + +// if (isStatusSuccessful(status)) { +// dispatch(loadMoreKeysSuccess(data)) +// onSuccess?.(data) +// } +// } catch (_err) { +// const error = _err as AxiosError +// const errorMessage = getApiErrorMessage(error) +// dispatch(addErrorNotification(error)) +// dispatch(loadMoreKeysFailure(errorMessage)) +// onFailed?.() +// } +// } +// } + +export function fetchRedisearchListAction( + onSuccess?: (value: RedisResponseBuffer[]) => void, + onFailed?: () => void, +) { + return async (dispatch: AppDispatch, stateInit: () => RootState) => { + dispatch(loadList()) + + try { + const state = stateInit() + const { encoding } = state.app.info + const { data, status } = await apiService.get( + getUrl( + state.connections.instances.connectedInstance?.id, + ApiEndpoints.REDISEARCH + ), + { + params: { encoding }, + } + ) + + if (isStatusSuccessful(status)) { + dispatch(loadListSuccess(data)) + onSuccess?.(data) + } + } catch (_err) { + const error = _err as AxiosError + const errorMessage = getApiErrorMessage(error) + dispatch(addErrorNotification(error)) + dispatch(loadListFailure(errorMessage)) + onFailed?.() + } + } +} +export function createRedisearchIndexAction( + data: CreateRedisearchIndexDto, + onSuccess?: () => void, + onFailed?: () => void, +) { + return async (dispatch: AppDispatch, stateInit: () => RootState) => { + dispatch(createIndex()) + + try { + const state = stateInit() + const { encoding } = state.app.info + const { status } = await apiService.post( + getUrl( + state.connections.instances.connectedInstance?.id, + ApiEndpoints.REDISEARCH + ), + { + ...data + }, + { + params: { encoding }, + } + ) + + if (isStatusSuccessful(status)) { + dispatch(createIndexSuccess()) + onSuccess?.() + } + } catch (_err) { + const error = _err as AxiosError + const errorMessage = getApiErrorMessage(error) + dispatch(addErrorNotification(error)) + dispatch(createIndexFailure(errorMessage)) + onFailed?.() + } + } +} diff --git a/redisinsight/ui/src/slices/interfaces/index.ts b/redisinsight/ui/src/slices/interfaces/index.ts index e69a08d47a..18340801ab 100644 --- a/redisinsight/ui/src/slices/interfaces/index.ts +++ b/redisinsight/ui/src/slices/interfaces/index.ts @@ -5,3 +5,4 @@ export * from './workbench' export * from './monitor' export * from './api' export * from './bulkActions' +export * from './redisearch' diff --git a/redisinsight/ui/src/slices/interfaces/redisearch.ts b/redisinsight/ui/src/slices/interfaces/redisearch.ts new file mode 100644 index 0000000000..097be63f1a --- /dev/null +++ b/redisinsight/ui/src/slices/interfaces/redisearch.ts @@ -0,0 +1,20 @@ +import { Nullable } from 'uiSrc/utils' +import { RedisResponseBuffer } from './app' +import { KeysStoreData } from './keys' + +export interface StateRedisearch { + loading: boolean + error: string + search: string + data: KeysStoreData + selectedIndex: Nullable + list: { + loading: boolean + error: string + data: RedisResponseBuffer[] + } + createIndex: { + loading: boolean + error: string + } +} diff --git a/redisinsight/ui/src/slices/store.ts b/redisinsight/ui/src/slices/store.ts index 09aa5bec8c..c397cfaa6d 100644 --- a/redisinsight/ui/src/slices/store.ts +++ b/redisinsight/ui/src/slices/store.ts @@ -35,6 +35,7 @@ import slowLogReducer from './analytics/slowlog' import analyticsSettingsReducer from './analytics/settings' import clusterDetailsReducer from './analytics/clusterDetails' import databaseAnalysisReducer from './analytics/dbAnalysis' +import redisearchReducer from './browser/redisearch' export const history = createBrowserHistory() @@ -65,6 +66,7 @@ export const rootReducer = combineReducers({ rejson: rejsonReducer, stream: streamReducer, bulkActions: bulkActionsReducer, + redisearch: redisearchReducer, }), cli: combineReducers({ settings: cliSettingsReducer, diff --git a/redisinsight/ui/src/slices/tests/browser/redisearch.spec.ts b/redisinsight/ui/src/slices/tests/browser/redisearch.spec.ts new file mode 100644 index 0000000000..c58d25f917 --- /dev/null +++ b/redisinsight/ui/src/slices/tests/browser/redisearch.spec.ts @@ -0,0 +1,171 @@ +import { AxiosError } from 'axios' +import { cloneDeep } from 'lodash' +import { apiService } from 'uiSrc/services' +import { cleanup, initialStateDefault, mockedStore } from 'uiSrc/utils/test-utils' +import { addErrorNotification } from 'uiSrc/slices/app/notifications' +import { stringToBuffer } from 'uiSrc/utils' +import { REDISEARCH_LIST_DATA_MOCK } from 'uiSrc/mocks/handlers/browser/redisearchHandlers' +import { refreshKeyInfo } from '../../browser/keys' +import reducer, { + fetchRedisearchListAction, + initialState, + loadList, + loadListFailure, + loadListSuccess, + redisearchDataSelector, + redisearchSelector, +} from '../../browser/redisearch' + +let store: typeof mockedStore +beforeEach(() => { + cleanup() + store = cloneDeep(mockedStore) + store.clearActions() +}) + +jest.mock('uiSrc/services', () => ({ + ...jest.requireActual('uiSrc/services'), +})) + +describe('redisearch slice', () => { + describe('reducer, actions and selectors', () => { + it('should return the initial state on first run', () => { + // Arrange + const nextState = initialState + + // Act + const result = reducer(undefined, {}) + + // Assert + expect(result).toEqual(nextState) + }) + }) + + describe('loadList', () => { + it('should properly set the state before the fetch data', () => { + // Arrange + const state = { + ...initialState, + list: { + ...initialState.list, + loading: true, + } + } + + // Act + const nextState = reducer(initialState, loadList()) + + // Assert + const rootState = Object.assign(initialStateDefault, { + browser: { + redisearch: nextState, + }, + }) + expect(redisearchSelector(rootState)).toEqual(state) + expect(redisearchDataSelector(rootState)).toEqual(state.data) + }) + }) + + describe('loadListSuccess', () => { + it('should properly set the state with fetched data', () => { + // Arrange + const data = REDISEARCH_LIST_DATA_MOCK + const state = { + ...initialState, + list: { + data, + error: '', + loading: false, + } + } + + // Act + const nextState = reducer(initialState, loadListSuccess(data)) + + // Assert + const rootState = Object.assign(initialStateDefault, { + browser: { + redisearch: nextState, + }, + }) + expect(redisearchSelector(rootState)).toEqual(state) + expect(redisearchDataSelector(rootState)).toEqual(state.data) + }) + + it('should properly set the state with empty data', () => { + // Arrange + const data: any[] = [] + + const state = { + ...initialState, + list: { + data, + error: '', + loading: false, + } + } + + // Act + const nextState = reducer(initialState, loadListSuccess(data)) + + // Assert + const rootState = Object.assign(initialStateDefault, { + browser: { + redisearch: nextState, + }, + }) + expect(redisearchSelector(rootState)).toEqual(state) + expect(redisearchDataSelector(rootState)).toEqual(state.data) + }) + }) + + describe('loadListFailure', () => { + it('should properly set the error', () => { + // Arrange + const data = 'some error' + const state = { + ...initialState, + list: { + data: [], + loading: false, + error: data, + } + } + + // Act + const nextState = reducer(initialState, loadListFailure(data)) + + // Assert + const rootState = Object.assign(initialStateDefault, { + browser: { + redisearch: nextState, + }, + }) + expect(redisearchSelector(rootState)).toEqual(state) + expect(redisearchDataSelector(rootState)).toEqual(state.data) + }) + }) + + describe('thunks', () => { + describe('fetchRedisearchListAction', () => { + it('call both fetchRedisearchListAction, loadListSuccess when fetch is successed', async () => { + // Arrange + const data = REDISEARCH_LIST_DATA_MOCK + const responsePayload = { data, status: 200 } + + apiService.get = jest.fn().mockResolvedValue(responsePayload) + + // Act + await store.dispatch(fetchRedisearchListAction()) + + // Assert + const expectedActions = [ + loadList(), + loadListSuccess(responsePayload.data), + ] + + expect(store.getActions()).toEqual(expectedActions) + }) + }) + }) +}) diff --git a/redisinsight/ui/src/utils/test-utils.tsx b/redisinsight/ui/src/utils/test-utils.tsx index 7058f42f05..a27a8356ca 100644 --- a/redisinsight/ui/src/utils/test-utils.tsx +++ b/redisinsight/ui/src/utils/test-utils.tsx @@ -42,6 +42,7 @@ import { initialState as initialClusterDetails } from 'uiSrc/slices/analytics/cl import { initialState as initialStateAnalyticsSettings } from 'uiSrc/slices/analytics/settings' import { initialState as initialStateDbAnalysis } from 'uiSrc/slices/analytics/dbAnalysis' import { initialState as initialStatePubSub } from 'uiSrc/slices/pubsub/pubsub' +import { initialState as initialStateRedisearch } from 'uiSrc/slices/browser/redisearch' import { RESOURCES_BASE_URL } from 'uiSrc/services/resourcesService' import { apiService } from 'uiSrc/services' @@ -80,6 +81,7 @@ const initialStateDefault: RootState = { rejson: cloneDeep(initialStateRejson), stream: cloneDeep(initialStateStream), bulkActions: cloneDeep(initialStateBulkActions), + redisearch: cloneDeep(initialStateRedisearch), }, cli: { settings: cloneDeep(initialStateCliSettings),