diff --git a/redisinsight/ui/src/components/popover-item-editor/PopoverItemEditor.spec.tsx b/redisinsight/ui/src/components/popover-item-editor/PopoverItemEditor.spec.tsx new file mode 100644 index 0000000000..6d3cb2cdef --- /dev/null +++ b/redisinsight/ui/src/components/popover-item-editor/PopoverItemEditor.spec.tsx @@ -0,0 +1,21 @@ +import React from 'react' +import { instance, mock } from 'ts-mockito' +import { render, screen, fireEvent } from 'uiSrc/utils/test-utils' +import PopoverItemEditor, { Props } from './PopoverItemEditor' + +const mockedProps = mock() + +describe('PopoverItemEditor', () => { + it('should render', () => { + expect( + render( + + <> + + ) + ).toBeTruthy() + }) +}) diff --git a/redisinsight/ui/src/components/popover-item-editor/PopoverItemEditor.tsx b/redisinsight/ui/src/components/popover-item-editor/PopoverItemEditor.tsx new file mode 100644 index 0000000000..33781138ba --- /dev/null +++ b/redisinsight/ui/src/components/popover-item-editor/PopoverItemEditor.tsx @@ -0,0 +1,123 @@ +import React, { + FormEvent, + useEffect, + useState, +} from 'react' + +import { + EuiButton, + EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiForm, + EuiPopover, +} from '@elastic/eui' +import styles from './styles.module.scss' + +export interface Props { + children: React.ReactElement + className?: string + onOpen: () => void + onApply: () => void + onDecline?: () => void + isLoading?: boolean + isDisabled?: boolean + declineOnUnmount?: boolean + btnTestId?: string + btnIconType?: string +} + +const PopoverItemEditor = (props: Props) => { + const { + onOpen, + onDecline, + onApply, + children, + isLoading, + declineOnUnmount = true, + isDisabled, + btnTestId, + btnIconType, + className + } = props + const [isPopoverOpen, setIsPopoverOpen] = useState(false) + + useEffect(() => + // componentWillUnmount + () => { + declineOnUnmount && onDecline?.() + }, + []) + + const onFormSubmit = (e: FormEvent) => { + e.preventDefault() + handleApply() + } + + const handleApply = (): void => { + setIsPopoverOpen(false) + onApply() + } + + const handleDecline = () => { + setIsPopoverOpen(false) + onDecline?.() + } + + const handleButtonClick = (e: React.MouseEvent) => { + e.stopPropagation() + onOpen?.() + setIsPopoverOpen(true) + } + + const isDisabledApply = (): boolean => !!(isLoading || isDisabled) + + const button = ( + + ) + + return ( + e.stopPropagation()} + > + +
+ {children} +
+ + + handleDecline()} data-testid="cancel-btn"> + Cancel + + + + + + Save + + + +
+
+ ) +} + +export default PopoverItemEditor diff --git a/redisinsight/ui/src/components/popover-item-editor/index.ts b/redisinsight/ui/src/components/popover-item-editor/index.ts new file mode 100644 index 0000000000..e23e3abb72 --- /dev/null +++ b/redisinsight/ui/src/components/popover-item-editor/index.ts @@ -0,0 +1,3 @@ +import PopoverItemEditor from './PopoverItemEditor' + +export default PopoverItemEditor diff --git a/redisinsight/ui/src/components/popover-item-editor/styles.module.scss b/redisinsight/ui/src/components/popover-item-editor/styles.module.scss new file mode 100644 index 0000000000..761b0a4e2f --- /dev/null +++ b/redisinsight/ui/src/components/popover-item-editor/styles.module.scss @@ -0,0 +1,7 @@ +.content { + +} + +.footer { + margin-top: 6px !important; +} diff --git a/redisinsight/ui/src/constants/texts.tsx b/redisinsight/ui/src/constants/texts.tsx index 9fabc85d26..afc5413a39 100644 --- a/redisinsight/ui/src/constants/texts.tsx +++ b/redisinsight/ui/src/constants/texts.tsx @@ -25,3 +25,12 @@ export const ScanNoResultsFoundText = ( ) + +export const lastDeliveredIDTooltipText = ( + <> + Specify the ID of the last delivered entry in the stream from the new group's perspective. + + Otherwise, $ represents the ID of the last entry in the stream,  + 0 fetches the entire stream from the beginning. + +) diff --git a/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-stream-group/AddStreamGroup.tsx b/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-stream-group/AddStreamGroup.tsx index d1e3efbc5f..47d1c72bc0 100644 --- a/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-stream-group/AddStreamGroup.tsx +++ b/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-stream-group/AddStreamGroup.tsx @@ -13,6 +13,7 @@ import { import cx from 'classnames' import React, { ChangeEvent, useEffect, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' +import { lastDeliveredIDTooltipText } from 'uiSrc/constants/texts' import { selectedKeyDataSelector } from 'uiSrc/slices/browser/keys' import { addNewGroupAction } from 'uiSrc/slices/browser/stream' @@ -111,14 +112,7 @@ const AddStreamGroup = (props: Props) => { className={styles.entryIdTooltip} position="left" title="Enter Valid ID, 0 or $" - content={( - <> - Specify the ID of the last delivered entry in the stream from the new group's perspective. - - Otherwise, $ represents the ID of the last entry in the stream,  - 0 fetches the entire stream from the beginning. - - )} + content={lastDeliveredIDTooltipText} > 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 7b0dee2f43..755fbe5fc9 100644 --- a/redisinsight/ui/src/pages/browser/components/popover-delete/PopoverDelete.tsx +++ b/redisinsight/ui/src/pages/browser/components/popover-delete/PopoverDelete.tsx @@ -34,7 +34,8 @@ const PopoverDelete = (props: Props) => { testid = '', } = props - const onButtonClick = () => { + const onButtonClick = (e: React.MouseEvent) => { + e.stopPropagation() if (item + suffix !== deleting) { showPopover(item) handleButtonClick?.() diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/groups-view/GroupsView/GroupsView.tsx b/redisinsight/ui/src/pages/browser/components/stream-details/groups-view/GroupsView/GroupsView.tsx index 05f6afa0ab..f036f960ab 100644 --- a/redisinsight/ui/src/pages/browser/components/stream-details/groups-view/GroupsView/GroupsView.tsx +++ b/redisinsight/ui/src/pages/browser/components/stream-details/groups-view/GroupsView/GroupsView.tsx @@ -25,7 +25,6 @@ export interface IConsumerGroup extends ConsumerGroupDto { export interface Props { data: IConsumerGroup[] columns: ITableColumn[] - onEditGroup: (groupId:string, editing: boolean) => void onClosePopover: () => void onSelectGroup: ({ rowData }: { rowData: any }) => void isFooterOpen?: boolean diff --git a/redisinsight/ui/src/pages/browser/components/stream-details/groups-view/GroupsView/styles.module.scss b/redisinsight/ui/src/pages/browser/components/stream-details/groups-view/GroupsView/styles.module.scss index 1ddeb97d96..032ae48f64 100644 --- a/redisinsight/ui/src/pages/browser/components/stream-details/groups-view/GroupsView/styles.module.scss +++ b/redisinsight/ui/src/pages/browser/components/stream-details/groups-view/GroupsView/styles.module.scss @@ -14,3 +14,19 @@ .tooltip { min-width: 325px; } + +.editLastId { + margin-right: 4px; +} + +.idText, .error { + display: inline-block; + color: var(--euiColorMediumShade); + font: normal normal normal 12px/18px Graphik; + margin-top: 6px; + padding-right: 6px; +} + +.error { + color: var(--euiColorDangerText); +} 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 65252b3dd5..390c18aeba 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 @@ -1,21 +1,26 @@ -import { EuiText, EuiToolTip } from '@elastic/eui' +import { EuiFieldText, EuiIcon, EuiText, EuiToolTip } from '@elastic/eui' import React, { useCallback, useEffect, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' +import PopoverItemEditor from 'uiSrc/components/popover-item-editor' +import { lastDeliveredIDTooltipText } from 'uiSrc/constants/texts' +import { selectedKeyDataSelector, updateSelectedKeyRefreshTime } from 'uiSrc/slices/browser/keys' import { streamGroupsSelector, setSelectedGroup, fetchConsumers, setStreamViewType, + modifyLastDeliveredIdAction, } 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 } from 'uiSrc/utils' import { getFormatTime } from 'uiSrc/utils/streamUtils' -import { TableCellTextAlignment } from 'uiSrc/constants' +import { KeyTypes, TableCellTextAlignment } from 'uiSrc/constants' import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' import { StreamViewType } from 'uiSrc/slices/interfaces/stream' -import { updateSelectedKeyRefreshTime } from 'uiSrc/slices/browser/keys' -import { ConsumerGroupDto } from 'apiSrc/modules/browser/dto/stream.dto' + +import { ConsumerGroupDto, UpdateConsumerGroupDto } from 'apiSrc/modules/browser/dto/stream.dto' import GroupsView from './GroupsView' @@ -26,7 +31,7 @@ export interface IConsumerGroup extends ConsumerGroupDto { } const suffix = '_stream_group' -const actionsWidth = 50 +const actionsWidth = 80 export interface Props { isFooterOpen: boolean @@ -36,13 +41,18 @@ const GroupsViewWrapper = (props: Props) => { const { lastRefreshTime, data: loadedGroups = [], + loading } = useSelector(streamGroupsSelector) const { name: key = '' } = useSelector(connectedInstanceSelector) + const { name: selectedKey } = useSelector(selectedKeyDataSelector) ?? {} const dispatch = useDispatch() const [groups, setGroups] = useState([]) const [deleting, setDeleting] = useState('') + const [editValue, setEditValue] = useState('') + const [idError, setIdError] = useState('') + const [isIdFocused, setIsIdFocused] = useState(false) useEffect(() => { dispatch(updateSelectedKeyRefreshTime(lastRefreshTime)) @@ -57,6 +67,14 @@ const GroupsViewWrapper = (props: Props) => { setGroups(streamItem) }, [loadedGroups, deleting]) + useEffect(() => { + if (!consumerGroupIdRegex.test(editValue)) { + setIdError('ID format is not correct') + return + } + setIdError('') + }, [editValue]) + const closePopover = useCallback(() => { setDeleting('') }, []) @@ -84,16 +102,6 @@ const GroupsViewWrapper = (props: Props) => { // }) } - const handleEditGroup = (groupId = '', editing: boolean) => { - const newGroupsState = groups.map((item) => { - if (item.name === groupId) { - return { ...item, editing } - } - return item - }) - setGroups(newGroupsState) - } - const handleSelectGroup = ({ rowData }: { rowData: any }) => { dispatch(setSelectedGroup(rowData)) dispatch(fetchConsumers( @@ -102,6 +110,17 @@ const GroupsViewWrapper = (props: Props) => { )) } + const handleApplyEditId = (groupName: string) => { + if (!!groupName.length && !idError && selectedKey) { + const data: UpdateConsumerGroupDto = { + keyName: selectedKey, + name: groupName, + lastDeliveredId: editValue + } + dispatch(modifyLastDeliveredIdAction(data)) + } + } + const columns: ITableColumn[] = [ { @@ -188,9 +207,46 @@ const GroupsViewWrapper = (props: Props) => { absoluteWidth: actionsWidth, maxWidth: actionsWidth, minWidth: actionsWidth, - render: function Actions(_act: any, { name }: ConsumerGroupDto) { + render: function Actions(_act: any, { lastDeliveredId, name }: ConsumerGroupDto) { + const showIdError = !isIdFocused && idError return (
+ setEditValue(lastDeliveredId)} + onApply={() => handleApplyEditId(name)} + className={styles.editLastId} + isDisabled={!editValue.length || !!idError} + isLoading={loading} + > + <> + setEditValue(e.target.value)} + onBlur={() => setIsIdFocused(false)} + onFocus={() => setIsIdFocused(true)} + append={( + + + + )} + style={{ width: 240 }} + autoComplete="off" + data-testid="last-id-field" + /> + {!showIdError && Timestamp - Sequence Number or $} + {showIdError && {idError}} + + @@ -220,7 +276,6 @@ const GroupsViewWrapper = (props: Props) => { { + state.groups.loading = true + }, + + modifyLastDeliveredIdSuccess: (state) => { + state.groups.loading = false + }, + + modifyLastDeliveredIdFailure: (state, { payload }) => { + state.groups.loading = false + state.groups.error = payload + }, + setSelectedConsumer: (state, { payload }) => { state.groups.selectedGroup = { ...state.groups.selectedGroup, @@ -248,6 +262,9 @@ export const { loadConsumerGroups, loadConsumerGroupsSuccess, loadConsumerGroupsFailure, + modifyLastDeliveredId, + modifyLastDeliveredIdSuccess, + modifyLastDeliveredIdFailure, loadConsumersSuccess, loadConsumersFailure, loadConsumerMessagesSuccess, @@ -640,3 +657,39 @@ export function fetchConsumerMessages( } } } + +// Asynchronous thunk action +export function modifyLastDeliveredIdAction( + data: UpdateConsumerGroupDto, + onSuccess?: () => void, + onFailed?: () => void +) { + return async (dispatch: AppDispatch, stateInit: () => RootState) => { + dispatch(modifyLastDeliveredId()) + + try { + const state = stateInit() + const keyName = state.browser.keys.selectedKey.data?.name + const { status } = await apiService.patch( + getUrl( + state.connections.instances.connectedInstance?.id, + ApiEndpoints.STREAMS_CONSUMER_GROUPS + ), + data + ) + + if (isStatusSuccessful(status)) { + dispatch(modifyLastDeliveredIdSuccess()) + dispatch(fetchConsumerGroups(false)) + keyName && dispatch(refreshKeyInfoAction(keyName)) + onSuccess?.() + } + } catch (_err) { + const error = _err as AxiosError + const errorMessage = getApiErrorMessage(error) + dispatch(addErrorNotification(error)) + dispatch(modifyLastDeliveredIdFailure(errorMessage)) + onFailed?.() + } + } +}