diff --git a/redisinsight/ui/src/constants/api.ts b/redisinsight/ui/src/constants/api.ts index f70a75687e..bbe3cff35a 100644 --- a/redisinsight/ui/src/constants/api.ts +++ b/redisinsight/ui/src/constants/api.ts @@ -37,6 +37,7 @@ enum ApiEndpoints { STREAMS_ENTRIES_GET = 'streams/entries/get', STREAMS_CONSUMER_GROUPS = 'streams/consumer-groups', STREAMS_CONSUMER_GROUPS_GET = 'streams/consumer-groups/get', + STREAMS_CONSUMERS = 'streams/consumer-groups/consumers', STREAMS_CONSUMERS_GET = 'streams/consumer-groups/consumers/get', STREAMS_CONSUMERS_MESSAGES_GET = 'streams/consumer-groups/consumers/pending-messages/get', STREAMS = 'streams', diff --git a/redisinsight/ui/src/pages/browser/components/popover-delete/PopoverDelete.tsx b/redisinsight/ui/src/pages/browser/components/popover-delete/PopoverDelete.tsx index 755fbe5fc9..ec56e59fea 100644 --- a/redisinsight/ui/src/pages/browser/components/popover-delete/PopoverDelete.tsx +++ b/redisinsight/ui/src/pages/browser/components/popover-delete/PopoverDelete.tsx @@ -63,6 +63,7 @@ const PopoverDelete = (props: Props) => { data-testid={testid ? `${testid}-icon` : 'remove-icon'} /> )} + onClick={(e) => e.stopPropagation()} >
diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/consumers-view/ConsumersViewWrapper.spec.tsx b/redisinsight/ui/src/pages/browser/components/stream-details/consumers-view/ConsumersViewWrapper.spec.tsx index 2dc6f23b15..891b54f764 100644 --- a/redisinsight/ui/src/pages/browser/components/stream-details/consumers-view/ConsumersViewWrapper.spec.tsx +++ b/redisinsight/ui/src/pages/browser/components/stream-details/consumers-view/ConsumersViewWrapper.spec.tsx @@ -2,7 +2,11 @@ import React from 'react' import { instance, mock } from 'ts-mockito' import { cloneDeep } from 'lodash' import { cleanup, fireEvent, mockedStore, render, screen } from 'uiSrc/utils/test-utils' -import { loadConsumerGroups, setSelectedConsumer } from 'uiSrc/slices/browser/stream' +import { + deleteConsumers, + loadConsumerGroups, + setSelectedConsumer +} from 'uiSrc/slices/browser/stream' import VirtualTable from 'uiSrc/components/virtual-table/VirtualTable' import { ConsumerDto } from 'apiSrc/modules/browser/dto/stream.dto' import ConsumersView, { Props as ConsumersViewProps } from './ConsumersView' @@ -77,4 +81,15 @@ describe('ConsumersViewWrapper', () => { expect(store.getActions()).toEqual([...afterRenderActions, setSelectedConsumer(), loadConsumerGroups(false)]) }) + + it('should delete Consumer', () => { + render() + + const afterRenderActions = [...store.getActions()] + + fireEvent.click(screen.getByTestId('remove-consumer-button-test-icon')) + fireEvent.click(screen.getByTestId('remove-consumer-button-test')) + + expect(store.getActions()).toEqual([...afterRenderActions, deleteConsumers()]) + }) }) diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/consumers-view/ConsumersViewWrapper.tsx b/redisinsight/ui/src/pages/browser/components/stream-details/consumers-view/ConsumersViewWrapper.tsx index b5f15f4df4..bf87fa0fb4 100644 --- a/redisinsight/ui/src/pages/browser/components/stream-details/consumers-view/ConsumersViewWrapper.tsx +++ b/redisinsight/ui/src/pages/browser/components/stream-details/consumers-view/ConsumersViewWrapper.tsx @@ -2,19 +2,18 @@ import React, { useCallback, useEffect, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' import { - deleteStreamEntry, setStreamViewType, selectedGroupSelector, setSelectedConsumer, - fetchConsumerMessages + fetchConsumerMessages, + deleteConsumersAction } from 'uiSrc/slices/browser/stream' import { ITableColumn } from 'uiSrc/components/virtual-table/interfaces' import PopoverDelete from 'uiSrc/pages/browser/components/popover-delete/PopoverDelete' import { TableCellAlignment, TableCellTextAlignment } from 'uiSrc/constants' -import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' import { StreamViewType } from 'uiSrc/slices/interfaces/stream' import { numberWithSpaces } from 'uiSrc/utils/numbers' -import { updateSelectedKeyRefreshTime } from 'uiSrc/slices/browser/keys' +import { selectedKeyDataSelector, updateSelectedKeyRefreshTime } from 'uiSrc/slices/browser/keys' import { ConsumerDto } from 'apiSrc/modules/browser/dto/stream.dto' import ConsumersView from './ConsumersView' @@ -29,8 +28,9 @@ export interface Props { } const ConsumersViewWrapper = (props: Props) => { - const { name: key = '' } = useSelector(connectedInstanceSelector) + const { name: key = '' } = useSelector(selectedKeyDataSelector) ?? { name: '' } const { + name: selectedGroupName = '', lastRefreshTime, data: loadedConsumers = [], } = useSelector(selectedGroupSelector) ?? {} @@ -52,7 +52,7 @@ const ConsumersViewWrapper = (props: Props) => { }, []) const handleDeleteConsumer = (consumerName = '') => { - dispatch(deleteStreamEntry(key, [consumerName])) + dispatch(deleteConsumersAction(key, selectedGroupName, [consumerName])) closePopover() } @@ -120,11 +120,10 @@ const ConsumersViewWrapper = (props: Props) => { return (
- Consumer will be removed from -
- {key} + will be removed from Consumer Group {selectedGroupName} )} item={name} @@ -134,7 +133,7 @@ const ConsumersViewWrapper = (props: Props) => { updateLoading={false} showPopover={showPopover} testid={`remove-consumer-button-${name}`} - handleDeleteItem={handleDeleteConsumer} + handleDeleteItem={() => handleDeleteConsumer(name)} handleButtonClick={handleRemoveIconClick} />
diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/groups-view/GroupsViewWrapper.spec.tsx b/redisinsight/ui/src/pages/browser/components/stream-details/groups-view/GroupsViewWrapper.spec.tsx index 4bc50c1dd3..88ae482f68 100644 --- a/redisinsight/ui/src/pages/browser/components/stream-details/groups-view/GroupsViewWrapper.spec.tsx +++ b/redisinsight/ui/src/pages/browser/components/stream-details/groups-view/GroupsViewWrapper.spec.tsx @@ -2,7 +2,11 @@ import React from 'react' import { instance, mock } from 'ts-mockito' import { cloneDeep } from 'lodash' import { cleanup, fireEvent, mockedStore, render, screen } from 'uiSrc/utils/test-utils' -import { loadConsumerGroups, setSelectedGroup } from 'uiSrc/slices/browser/stream' +import { + deleteConsumerGroups, + loadConsumerGroups, + setSelectedGroup +} from 'uiSrc/slices/browser/stream' import VirtualTable from 'uiSrc/components/virtual-table/VirtualTable' import { ConsumerGroupDto } from 'apiSrc/modules/browser/dto/stream.dto' import GroupsView, { Props as GroupsViewProps } from './GroupsView' @@ -83,4 +87,15 @@ describe('GroupsViewWrapper', () => { expect(store.getActions()).toEqual([...afterRenderActions, setSelectedGroup(), loadConsumerGroups(false)]) }) + + it('should delete Group', () => { + render() + + const afterRenderActions = [...store.getActions()] + + fireEvent.click(screen.getByTestId('remove-groups-button-test-icon')) + fireEvent.click(screen.getByTestId('remove-groups-button-test')) + + expect(store.getActions()).toEqual([...afterRenderActions, deleteConsumerGroups()]) + }) }) diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/groups-view/GroupsViewWrapper.tsx b/redisinsight/ui/src/pages/browser/components/stream-details/groups-view/GroupsViewWrapper.tsx index 4af1329833..fd16b2b9ee 100644 --- a/redisinsight/ui/src/pages/browser/components/stream-details/groups-view/GroupsViewWrapper.tsx +++ b/redisinsight/ui/src/pages/browser/components/stream-details/groups-view/GroupsViewWrapper.tsx @@ -11,13 +11,13 @@ import { fetchConsumers, setStreamViewType, modifyLastDeliveredIdAction, + deleteConsumerGroupsAction, } from 'uiSrc/slices/browser/stream' import { ITableColumn } from 'uiSrc/components/virtual-table/interfaces' import PopoverDelete from 'uiSrc/pages/browser/components/popover-delete/PopoverDelete' import { consumerGroupIdRegex, validateConsumerGroupId } from 'uiSrc/utils' import { getFormatTime } from 'uiSrc/utils/streamUtils' import { TableCellTextAlignment } from 'uiSrc/constants' -import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' import { StreamViewType } from 'uiSrc/slices/interfaces/stream' import { ConsumerGroupDto, UpdateConsumerGroupDto } from 'apiSrc/modules/browser/dto/stream.dto' @@ -43,8 +43,7 @@ const GroupsViewWrapper = (props: Props) => { data: loadedGroups = [], loading } = useSelector(streamGroupsSelector) - const { name: key = '' } = useSelector(connectedInstanceSelector) - const { name: selectedKey } = useSelector(selectedKeyDataSelector) ?? {} + const { name: selectedKey } = useSelector(selectedKeyDataSelector) ?? { name: '' } const dispatch = useDispatch() @@ -83,8 +82,8 @@ const GroupsViewWrapper = (props: Props) => { setDeleting(`${groupName + suffix}`) }, []) - const handleDeleteGroup = () => { - // dispatch(deleteStreamEntry(key, [groupName])) + const handleDeleteGroup = (name: string) => { + dispatch(deleteConsumerGroupsAction(selectedKey, [name])) closePopover() } @@ -248,11 +247,10 @@ const GroupsViewWrapper = (props: Props) => { - Group will be removed from -
- {key} + will be removed from {selectedKey} )} item={name} @@ -262,7 +260,7 @@ const GroupsViewWrapper = (props: Props) => { updateLoading={false} showPopover={showPopover} testid={`remove-groups-button-${name}`} - handleDeleteItem={handleDeleteGroup} + handleDeleteItem={() => handleDeleteGroup(name)} handleButtonClick={handleRemoveIconClick} />
diff --git a/redisinsight/ui/src/slices/browser/stream.ts b/redisinsight/ui/src/slices/browser/stream.ts index b98a664014..efb51c8a62 100644 --- a/redisinsight/ui/src/slices/browser/stream.ts +++ b/redisinsight/ui/src/slices/browser/stream.ts @@ -168,6 +168,21 @@ const streamSlice = createSlice({ state.groups.loading = false state.groups.error = payload }, + + deleteConsumerGroups: (state) => { + state.groups.loading = true + state.groups.error = '' + }, + + deleteConsumerGroupsSuccess: (state) => { + state.groups.loading = false + }, + + deleteConsumerGroupsFailure: (state, { payload }) => { + state.groups.loading = false + state.groups.error = payload + }, + setSelectedGroup: (state, { payload }) => { state.groups.selectedGroup = payload }, @@ -208,6 +223,20 @@ const streamSlice = createSlice({ state.viewType = StreamViewType.Groups }, + deleteConsumers: (state) => { + state.groups.loading = true + state.groups.error = '' + }, + + deleteConsumersSuccess: (state) => { + state.groups.loading = false + }, + + deleteConsumersFailure: (state, { payload }) => { + state.groups.loading = false + state.groups.error = payload + }, + loadConsumerMessagesSuccess: (state, { payload }: PayloadAction) => { state.groups.loading = false @@ -262,11 +291,17 @@ export const { loadConsumerGroups, loadConsumerGroupsSuccess, loadConsumerGroupsFailure, + deleteConsumerGroups, + deleteConsumerGroupsSuccess, + deleteConsumerGroupsFailure, modifyLastDeliveredId, modifyLastDeliveredIdSuccess, modifyLastDeliveredIdFailure, loadConsumersSuccess, loadConsumersFailure, + deleteConsumers, + deleteConsumersSuccess, + deleteConsumersFailure, loadConsumerMessagesSuccess, loadConsumerMessagesFailure, setSelectedGroup, @@ -606,6 +641,44 @@ export function fetchConsumerGroups( } } +export function deleteConsumerGroupsAction(keyName: string, consumerGroups: string[], onSuccessAction?: () => void) { + return async (dispatch: AppDispatch, stateInit: () => RootState) => { + dispatch(deleteConsumerGroups()) + try { + const state = stateInit() + const { status } = await apiService.delete( + getUrl( + state.connections.instances.connectedInstance?.id, + ApiEndpoints.STREAMS_CONSUMER_GROUPS + ), + { + data: { + keyName, + consumerGroups, + }, + } + ) + if (isStatusSuccessful(status)) { + onSuccessAction?.() + dispatch(deleteConsumerGroupsSuccess()) + dispatch(fetchConsumerGroups(false)) + dispatch(refreshKeyInfoAction(keyName)) + dispatch(addMessageNotification( + successMessages.REMOVED_KEY_VALUE( + keyName, + consumerGroups.join(''), + 'Group' + ) + )) + } + } catch (error) { + const errorMessage = getApiErrorMessage(error) + dispatch(addErrorNotification(error)) + dispatch(deleteConsumerGroupsFailure(errorMessage)) + } + } +} + // Asynchronous thunk action export function fetchConsumers( resetData?: boolean, @@ -630,7 +703,7 @@ export function fetchConsumers( if (isStatusSuccessful(status)) { dispatch(loadConsumersSuccess(data)) - onSuccess?.(data) + onSuccess?.() } } catch (_err) { if (!axios.isCancel(_err)) { @@ -644,6 +717,51 @@ export function fetchConsumers( } } +// Asynchronous thunk action +export function deleteConsumersAction( + keyName: string, + groupName: string, + consumerNames: string[], + onSuccessAction?: () => void +) { + return async (dispatch: AppDispatch, stateInit: () => RootState) => { + dispatch(deleteConsumers()) + try { + const state = stateInit() + const { status } = await apiService.delete( + getUrl( + state.connections.instances.connectedInstance?.id, + ApiEndpoints.STREAMS_CONSUMERS + ), + { + data: { + keyName, + groupName, + consumerNames, + }, + } + ) + if (isStatusSuccessful(status)) { + onSuccessAction?.() + dispatch(deleteConsumersSuccess()) + dispatch(fetchConsumers(false)) + dispatch(refreshKeyInfoAction(keyName)) + dispatch(addMessageNotification( + successMessages.REMOVED_KEY_VALUE( + keyName, + consumerNames.join(''), + 'Consumer' + ) + )) + } + } catch (error) { + const errorMessage = getApiErrorMessage(error) + dispatch(addErrorNotification(error)) + dispatch(deleteConsumersFailure(errorMessage)) + } + } +} + // Asynchronous thunk action export function fetchConsumerMessages( resetData?: boolean, diff --git a/redisinsight/ui/src/slices/tests/browser/stream.spec.ts b/redisinsight/ui/src/slices/tests/browser/stream.spec.ts index dae57229a1..59ab448deb 100644 --- a/redisinsight/ui/src/slices/tests/browser/stream.spec.ts +++ b/redisinsight/ui/src/slices/tests/browser/stream.spec.ts @@ -1,8 +1,10 @@ import { ConsumerDto, ConsumerGroupDto, PendingEntryDto } from 'apiSrc/modules/browser/dto/stream.dto' import { AxiosError } from 'axios' import { cloneDeep, omit } from 'lodash' +import successMessages from 'uiSrc/components/notifications/success-messages' import { SortOrder } from 'uiSrc/constants' import { apiService } from 'uiSrc/services' +import { refreshKeyInfo } from 'uiSrc/slices/browser/keys' import reducer, { initialState, setStreamInitialState, @@ -35,21 +37,21 @@ import reducer, { loadConsumerMessagesFailure, setSelectedGroup, setSelectedConsumer, - streamDataSelector, - streamGroupsSelector, - streamGroupsDataSelector, - selectedGroupSelector, - selectedConsumerSelector, - fetchMoreStreamEntries, - addNewEntriesAction, - deleteStreamEntry, fetchConsumerGroups, fetchConsumers, fetchConsumerMessages, + deleteConsumerGroups, + deleteConsumerGroupsAction, + deleteConsumerGroupsSuccess, + deleteConsumerGroupsFailure, + deleteConsumersAction, + deleteConsumers, + deleteConsumersSuccess, + deleteConsumersFailure, } from 'uiSrc/slices/browser/stream' import { StreamViewType } from 'uiSrc/slices/interfaces/stream' import { cleanup, initialStateDefault, mockedStore, } from 'uiSrc/utils/test-utils' -import { addErrorNotification } from '../../app/notifications' +import { addErrorNotification, addMessageNotification } from '../../app/notifications' jest.mock('uiSrc/services') @@ -509,9 +511,6 @@ describe('stream slice', () => { describe('loadConsumerGroupsSuccess', () => { it('should properly set groups.data = payload', () => { // Arrange - - console.log('Date.now()', Date.now()) - const data: ConsumerGroupDto[] = [{ name: '123', consumers: 123, @@ -963,5 +962,129 @@ describe('stream slice', () => { expect(store.getActions()).toEqual(expectedActions) }) }) + + describe('deleteConsumerGroupsAction', () => { + it('succeed to delete data', async () => { + // Arrange + const keyName = 'key' + const groups = ['group'] + const responsePayload = { status: 200 } + + apiService.delete = jest.fn().mockResolvedValue(responsePayload) + + const responsePayloadPost = { data: mockConsumers, status: 200 } + + apiService.post = jest.fn().mockResolvedValue(responsePayloadPost) + + // Act + await store.dispatch(deleteConsumerGroupsAction(keyName, groups)) + + // Assert + const expectedActions = [ + deleteConsumerGroups(), + deleteConsumerGroupsSuccess(), + loadConsumerGroups(false), + refreshKeyInfo(), + addMessageNotification( + successMessages.REMOVED_KEY_VALUE( + keyName, + groups.join(''), + 'Group' + ) + ) + ] + + expect(store.getActions()).toEqual(expectedActions) + }) + + it('failed to delete data', async () => { + const errorMessage = 'Something was wrong!' + const keyName = 'key' + const groups = ['group'] + const responsePayload = { + response: { + status: 500, + data: { message: errorMessage }, + }, + } + + apiService.delete = jest.fn().mockRejectedValue(responsePayload) + + // Act + await store.dispatch(deleteConsumerGroupsAction(keyName, groups)) + + // Assert + const expectedActions = [ + deleteConsumerGroups(), + addErrorNotification(responsePayload as AxiosError), + deleteConsumerGroupsFailure(errorMessage) + ] + + expect(store.getActions()).toEqual(expectedActions) + }) + }) + + describe('deleteConsumersAction', () => { + it('succeed to delete data', async () => { + // Arrange + const keyName = 'key' + const groupName = 'group' + const consumerNames = ['consumer'] + const responsePayload = { status: 200 } + + apiService.delete = jest.fn().mockResolvedValue(responsePayload) + + const responsePayloadPost = { data: mockConsumers, status: 200 } + + apiService.post = jest.fn().mockResolvedValue(responsePayloadPost) + + // Act + await store.dispatch(deleteConsumersAction(keyName, groupName, consumerNames)) + + // Assert + const expectedActions = [ + deleteConsumers(), + deleteConsumersSuccess(), + loadConsumerGroups(false), + refreshKeyInfo(), + addMessageNotification( + successMessages.REMOVED_KEY_VALUE( + keyName, + consumerNames.join(''), + 'Consumer' + ) + ) + ] + + expect(store.getActions()).toEqual(expectedActions) + }) + + it('failed to delete data', async () => { + const errorMessage = 'Something was wrong!' + const keyName = 'key' + const groupName = 'group' + const consumerNames = ['consumer'] + const responsePayload = { + response: { + status: 500, + data: { message: errorMessage }, + }, + } + + apiService.delete = jest.fn().mockRejectedValue(responsePayload) + + // Act + await store.dispatch(deleteConsumersAction(keyName, groupName, consumerNames)) + + // Assert + const expectedActions = [ + deleteConsumers(), + addErrorNotification(responsePayload as AxiosError), + deleteConsumersFailure(errorMessage) + ] + + expect(store.getActions()).toEqual(expectedActions) + }) + }) }) })