Skip to content

Commit 74f976c

Browse files
committed
feat(ui): added "delete index"
- extend the "manage indexes" boxes with a button to allow deleting the index - added unit tests re #RI-7197
1 parent 25c426e commit 74f976c

File tree

8 files changed

+346
-19
lines changed

8 files changed

+346
-19
lines changed

redisinsight/api/src/modules/browser/redisearch/dto/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ export * from './create.redisearch-index.dto';
22
export * from './search.redisearch.dto';
33
export * from './list.redisearch-indexes.response';
44
export * from './index.info.dto';
5+
export * from './index.delete.dto';

redisinsight/ui/src/components/notifications/success-messages.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,14 @@ export default {
196196
title: 'Index has been created',
197197
message: 'Open the list of indexes to see it.',
198198
}),
199+
DELETE_INDEX: (indexName: string) => ({
200+
title: 'Index has been deleted',
201+
message: (
202+
<>
203+
<b>{formatNameShort(indexName)}</b> has been deleted from Redis Insight.
204+
</>
205+
),
206+
}),
199207
TEST_CONNECTION: () => ({
200208
title: 'Connection is successful',
201209
}),

redisinsight/ui/src/mocks/handlers/browser/redisearchHandlers.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ const handlers: RestHandler[] = [
2828
async (_req, res, ctx) =>
2929
res(ctx.status(200), ctx.json(MOCK_REDISEARCH_INDEX_INFO)),
3030
),
31+
rest.delete(
32+
getMswURL(getUrl(INSTANCE_ID_MOCK, ApiEndpoints.REDISEARCH)),
33+
async (_req, res, ctx) => res(ctx.status(204)),
34+
),
3135
]
3236

3337
export default handlers

redisinsight/ui/src/pages/vector-search/manage-indexes/IndexSection.spec.tsx

Lines changed: 175 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,89 @@
11
import React from 'react'
2-
import { cloneDeep } from 'lodash'
32
import { Provider } from 'react-redux'
3+
import { rest } from 'msw'
4+
import { configureStore, combineReducers } from '@reduxjs/toolkit'
5+
import { mswServer } from 'uiSrc/mocks/server'
46
import {
57
cleanup,
68
render,
79
screen,
8-
mockStore,
910
initialStateDefault,
1011
userEvent,
12+
getMswURL,
1113
} from 'uiSrc/utils/test-utils'
1214
import { INSTANCE_ID_MOCK } from 'uiSrc/mocks/handlers/instances/instancesHandlers'
1315
import { MOCK_REDISEARCH_INDEX_INFO } from 'uiSrc/mocks/data/redisearch'
16+
import Notifications from 'uiSrc/components/notifications'
17+
import { TelemetryEvent, sendEventTelemetry } from 'uiSrc/telemetry'
18+
import { ApiEndpoints } from 'uiSrc/constants'
19+
import { getUrl } from 'uiSrc/utils'
20+
import { RootState } from 'uiSrc/slices/store'
21+
import notificationsReducer from 'uiSrc/slices/app/notifications'
22+
import appInfoReducer from 'uiSrc/slices/app/info'
23+
import redisearchReducer from 'uiSrc/slices/browser/redisearch'
24+
import instancesReducer from 'uiSrc/slices/instances/instances'
1425
import { IndexSection, IndexSectionProps } from './IndexSection'
1526

27+
jest.mock('uiSrc/telemetry', () => ({
28+
...jest.requireActual('uiSrc/telemetry'),
29+
sendEventTelemetry: jest.fn(),
30+
}))
31+
32+
const createTestStore = () => {
33+
// TODO: Use rootReducer instead, once you realize how to solve the issue with the instancesReducer
34+
// console.error No reducer provided for key "instances"
35+
// > 81 | connections: combineReducers({
36+
const testReducer = combineReducers({
37+
app: combineReducers({
38+
notifications: notificationsReducer,
39+
info: appInfoReducer,
40+
}),
41+
browser: combineReducers({
42+
redisearch: redisearchReducer,
43+
}),
44+
connections: combineReducers({
45+
instances: instancesReducer,
46+
}),
47+
})
48+
49+
const testState: RootState = {
50+
...initialStateDefault,
51+
52+
connections: {
53+
...initialStateDefault.connections,
54+
instances: {
55+
...initialStateDefault.connections.instances,
56+
connectedInstance: {
57+
...initialStateDefault.connections.instances.connectedInstance,
58+
id: INSTANCE_ID_MOCK,
59+
name: 'test-instance',
60+
host: 'localhost',
61+
port: 6379,
62+
modules: [],
63+
},
64+
},
65+
},
66+
}
67+
68+
return configureStore({
69+
reducer: testReducer,
70+
preloadedState: testState,
71+
middleware: (getDefaultMiddleware) =>
72+
getDefaultMiddleware({ serializableCheck: false }),
73+
})
74+
}
75+
1676
const renderComponent = (props?: Partial<IndexSectionProps>) => {
1777
const defaultProps: IndexSectionProps = {
1878
index: 'test-index',
1979
}
2080

21-
// Create a mock store with proper connected instance state
22-
const stateWithConnectedInstance = cloneDeep(initialStateDefault)
23-
stateWithConnectedInstance.connections.instances.connectedInstance = {
24-
...stateWithConnectedInstance.connections.instances.connectedInstance,
25-
id: INSTANCE_ID_MOCK,
26-
name: 'test-instance',
27-
host: 'localhost',
28-
port: 6379,
29-
modules: [],
30-
}
31-
32-
const store = mockStore(stateWithConnectedInstance)
81+
const store = createTestStore()
3382

3483
return render(
3584
<Provider store={store}>
3685
<IndexSection {...defaultProps} {...props} />
86+
<Notifications />
3787
</Provider>,
3888
)
3989
}
@@ -147,4 +197,115 @@ describe('IndexSection', () => {
147197
expect(weightValue[0]).toBeInTheDocument()
148198
expect(separatorValue[0]).toBeInTheDocument()
149199
})
200+
201+
describe('delete index', () => {
202+
let confirmSpy: jest.SpyInstance
203+
let telemetryMock: jest.Mock
204+
205+
beforeEach(() => {
206+
// Mock window.confirm to return true (user confirms deletion)
207+
confirmSpy = jest.spyOn(window, 'confirm').mockReturnValue(true)
208+
209+
// Mock the telemetry function
210+
telemetryMock = sendEventTelemetry as jest.Mock
211+
telemetryMock.mockClear()
212+
})
213+
214+
afterEach(() => {
215+
confirmSpy.mockRestore()
216+
mswServer.resetHandlers()
217+
})
218+
219+
it('should delete an index when the delete button is clicked and confirmed', async () => {
220+
renderComponent()
221+
222+
const deleteButton = screen.getByText('Delete')
223+
expect(deleteButton).toBeInTheDocument()
224+
225+
// Click the delete button
226+
await userEvent.click(deleteButton)
227+
228+
// Verify that confirm was called with the correct message
229+
expect(confirmSpy).toHaveBeenCalledWith(
230+
'Are you sure you want to delete this index?',
231+
)
232+
233+
// Wait for the success notification to appear
234+
const successNotification = await screen.findByText(
235+
'Index has been deleted',
236+
)
237+
expect(successNotification).toBeInTheDocument()
238+
239+
// Verify that telemetry event was sent with correct data
240+
expect(telemetryMock).toHaveBeenCalledTimes(1)
241+
const telemetryCall = telemetryMock.mock.calls[0][0]
242+
expect(telemetryCall.event).toBe(TelemetryEvent.SEARCH_INDEX_DELETED)
243+
expect(telemetryCall.eventData.databaseId).toBe(INSTANCE_ID_MOCK)
244+
expect(telemetryCall.eventData.indexName).toBeDefined()
245+
})
246+
247+
it('should not delete an index when the deletion is cancelled', async () => {
248+
// Mock window.confirm to return false (user cancels deletion)
249+
confirmSpy.mockReturnValueOnce(false)
250+
251+
renderComponent()
252+
253+
const deleteButton = screen.getByText('Delete')
254+
expect(deleteButton).toBeInTheDocument()
255+
256+
// Click the delete button
257+
await userEvent.click(deleteButton)
258+
259+
// Verify that confirm was called with the correct message
260+
expect(confirmSpy).toHaveBeenCalledWith(
261+
'Are you sure you want to delete this index?',
262+
)
263+
264+
// Verify that no API call was made and no notification appears
265+
expect(telemetryMock).not.toHaveBeenCalled()
266+
267+
const successNotification = screen.queryByText('Index has been deleted')
268+
expect(successNotification).not.toBeInTheDocument()
269+
})
270+
271+
it('should handle deletion failure gracefully', async () => {
272+
// Override the MSW handler to return an error for this test
273+
mswServer.use(
274+
rest.delete(
275+
getMswURL(getUrl(INSTANCE_ID_MOCK, ApiEndpoints.REDISEARCH)),
276+
async (_req, res, ctx) =>
277+
res(
278+
ctx.status(500),
279+
ctx.json({
280+
error: 'Internal Server Error',
281+
statusCode: 500,
282+
message: 'Failed to delete index',
283+
}),
284+
),
285+
),
286+
)
287+
288+
renderComponent()
289+
290+
const deleteButton = screen.getByText('Delete')
291+
expect(deleteButton).toBeInTheDocument()
292+
293+
// Click the delete button
294+
await userEvent.click(deleteButton)
295+
296+
// Verify that confirm was called with the correct message
297+
expect(confirmSpy).toHaveBeenCalledWith(
298+
'Are you sure you want to delete this index?',
299+
)
300+
301+
// Wait for the error notification to appear
302+
const errorNotification = await screen.findByText(
303+
'Failed to delete index',
304+
)
305+
expect(errorNotification).toBeInTheDocument()
306+
307+
// Verify that telemetry event was not sent on error
308+
expect(telemetryMock).not.toHaveBeenCalled()
309+
})
310+
})
150311
})

redisinsight/ui/src/pages/vector-search/manage-indexes/IndexSection.tsx

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,19 @@
11
import React, { useEffect, useState } from 'react'
22
import { CategoryValueList, Section, SectionProps } from '@redis-ui/components'
3-
import { useDispatch } from 'react-redux'
3+
import { useDispatch, useSelector } from 'react-redux'
44
import { CategoryValueListItem } from '@redis-ui/components/dist/Section/components/Header/components/CategoryValueList'
55
import { RedisString } from 'uiSrc/slices/interfaces'
6-
import { bufferToString, formatLongName } from 'uiSrc/utils'
7-
import { fetchRedisearchInfoAction } from 'uiSrc/slices/browser/redisearch'
8-
import { IndexInfoDto } from 'apiSrc/modules/browser/redisearch/dto'
6+
import { bufferToString, formatLongName, stringToBuffer } from 'uiSrc/utils'
7+
import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry'
8+
import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances'
9+
import {
10+
deleteRedisearchIndexAction,
11+
fetchRedisearchInfoAction,
12+
} from 'uiSrc/slices/browser/redisearch'
13+
import {
14+
IndexInfoDto,
15+
IndexDeleteRequestBodyDto,
16+
} from 'apiSrc/modules/browser/redisearch/dto'
917
import { IndexAttributesList, IndexInfoTableData } from './IndexAttributesList'
1018

1119
export interface IndexSectionProps extends Omit<SectionProps, 'label'> {
@@ -15,6 +23,7 @@ export interface IndexSectionProps extends Omit<SectionProps, 'label'> {
1523
export const IndexSection = ({ index, ...rest }: IndexSectionProps) => {
1624
const dispatch = useDispatch()
1725
const indexName = bufferToString(index)
26+
const { id: instanceId } = useSelector(connectedInstanceSelector)
1827

1928
const [tableData, setTableData] = useState<IndexInfoTableData[]>([])
2029
const [indexSummaryInfo, setIndexSummaryInfo] = useState<
@@ -32,6 +41,30 @@ export const IndexSection = ({ index, ...rest }: IndexSectionProps) => {
3241
)
3342
}, [indexName, dispatch])
3443

44+
const handleDelete = () => {
45+
// TODO: Replace with confirmation popup once the design is ready
46+
// eslint-disable-next-line no-restricted-globals
47+
const result = confirm('Are you sure you want to delete this index?')
48+
49+
if (result) {
50+
const data: IndexDeleteRequestBodyDto = {
51+
index: stringToBuffer(indexName),
52+
}
53+
54+
dispatch(deleteRedisearchIndexAction(data, onDeletedIndexSuccess))
55+
}
56+
}
57+
58+
const onDeletedIndexSuccess = (data: IndexDeleteRequestBodyDto) => {
59+
sendEventTelemetry({
60+
event: TelemetryEvent.SEARCH_INDEX_DELETED,
61+
eventData: {
62+
databaseId: instanceId,
63+
indexName: data.index,
64+
},
65+
})
66+
}
67+
3568
return (
3669
<Section
3770
collapsible
@@ -41,7 +74,7 @@ export const IndexSection = ({ index, ...rest }: IndexSectionProps) => {
4174
label={formatLongName(indexName)}
4275
defaultOpen={false}
4376
actionButtonText="Delete" // TODO: Replace with an icon of a trash can
44-
// onAction={handleDelete} // TODO: Implement delete functionality
77+
onAction={handleDelete}
4578
data-testid={`manage-indexes-list--item--${indexName}`}
4679
{...rest}
4780
/>

redisinsight/ui/src/slices/browser/redisearch.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { SearchHistoryItem } from 'uiSrc/slices/interfaces/keys'
2323
import { GetKeysWithDetailsResponse } from 'apiSrc/modules/browser/keys/dto'
2424
import {
2525
CreateRedisearchIndexDto,
26+
IndexDeleteRequestBodyDto,
2627
ListRedisearchIndexesResponse,
2728
} from 'apiSrc/modules/browser/redisearch/dto'
2829

@@ -521,6 +522,43 @@ export function createRedisearchIndexAction(
521522
}
522523
}
523524

525+
export function deleteRedisearchIndexAction(
526+
data: IndexDeleteRequestBodyDto,
527+
onSuccess?: (data: IndexDeleteRequestBodyDto) => void,
528+
onFailed?: () => void,
529+
) {
530+
return async (dispatch: AppDispatch, stateInit: () => RootState) => {
531+
try {
532+
const state = stateInit()
533+
const { encoding } = state.app.info
534+
const { status } = await apiService.delete<void>(
535+
getUrl(
536+
state.connections.instances.connectedInstance?.id,
537+
ApiEndpoints.REDISEARCH,
538+
),
539+
{
540+
data,
541+
params: { encoding },
542+
},
543+
)
544+
545+
if (isStatusSuccessful(status)) {
546+
dispatch(
547+
addMessageNotification(
548+
successMessages.DELETE_INDEX(bufferToString(data.index as string)),
549+
),
550+
)
551+
dispatch(fetchRedisearchListAction())
552+
onSuccess?.(data)
553+
}
554+
} catch (_err) {
555+
const error = _err as AxiosError
556+
dispatch(addErrorNotification(error))
557+
onFailed?.()
558+
}
559+
}
560+
}
561+
524562
export function fetchRedisearchHistoryAction(
525563
onSuccess?: () => void,
526564
onFailed?: () => void,

0 commit comments

Comments
 (0)