From f990fac184bad3762064beef172ffc178722f35b Mon Sep 17 00:00:00 2001 From: "Roman.Sergeenko" Date: Fri, 27 May 2022 09:39:13 +0400 Subject: [PATCH 1/2] #RI-2935 - base implementation of modify last entry id --- .../PopoverItemEditor.spec.tsx | 21 +++ .../popover-item-editor/PopoverItemEditor.tsx | 123 ++++++++++++++++++ .../components/popover-item-editor/index.ts | 3 + .../popover-item-editor/styles.module.scss | 7 + .../popover-delete/PopoverDelete.tsx | 3 +- .../groups-view/GroupsView/GroupsView.tsx | 1 - .../groups-view/GroupsView/styles.module.scss | 4 + .../groups-view/GroupsViewWrapper.tsx | 92 +++++++++---- redisinsight/ui/src/slices/browser/stream.ts | 36 +++++ 9 files changed, 265 insertions(+), 25 deletions(-) create mode 100644 redisinsight/ui/src/components/popover-item-editor/PopoverItemEditor.spec.tsx create mode 100644 redisinsight/ui/src/components/popover-item-editor/PopoverItemEditor.tsx create mode 100644 redisinsight/ui/src/components/popover-item-editor/index.ts create mode 100644 redisinsight/ui/src/components/popover-item-editor/styles.module.scss 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/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 86b58d8b27..bbb278578d 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 @@ -28,7 +28,6 @@ interface IStreamEntry extends StreamEntryDto { export interface Props { data: IStreamEntry[] 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 e9e8c44e93..223c27d966 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 @@ -75,6 +75,10 @@ width: 54px; } +.editLastId { + margin-right: 4px; +} + .actions { :global(.value-table-actions) { background-color: var(--euiColorEmptyShade) !important; 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 d86a4c1acb..8e8b317c15 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,14 +1,24 @@ -import { EuiText } from '@elastic/eui' +import { EuiFieldText, EuiIcon, EuiSpacer, EuiText, EuiToolTip } from '@elastic/eui' import React, { useCallback, useEffect, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' - -import { streamGroupsSelector, deleteStreamEntry, fetchConsumerGroups, setSelectedGroup, fetchConsumers, setStreamViewType } from 'uiSrc/slices/browser/stream' +import PopoverItemEditor from 'uiSrc/components/popover-item-editor' +import { selectedKeyDataSelector } from 'uiSrc/slices/browser/keys' + +import { + streamGroupsSelector, + deleteStreamEntry, + fetchConsumerGroups, + 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 { getFormatTime } from 'uiSrc/utils/streamUtils' -import { TableCellTextAlignment } from 'uiSrc/constants' +import { KeyTypes, TableCellTextAlignment } from 'uiSrc/constants' import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' -import { StreamEntryDto } from 'apiSrc/modules/browser/dto/stream.dto' +import { ConsumerGroupDto, StreamEntryDto } from 'apiSrc/modules/browser/dto/stream.dto' import { StreamViewType } from 'uiSrc/slices/interfaces/stream' import GroupsView from './GroupsView' @@ -20,7 +30,7 @@ export interface IStreamEntry extends StreamEntryDto { } const suffix = '_stream_groups' -const actionsWidth = 50 +const actionsWidth = 80 const minColumnWidth = 190 interface Props { @@ -32,18 +42,20 @@ const GroupsViewWrapper = (props: Props) => { data: loadedGroups = [], } = useSelector(streamGroupsSelector) const { id: instanceId, name: key = '' } = useSelector(connectedInstanceSelector) + const { name: selectedKey } = useSelector(selectedKeyDataSelector) const dispatch = useDispatch() - const [groups, setGroups] = useState([]) + const [groups, setGroups] = useState([]) const [deleting, setDeleting] = useState('') + const [editValue, setEditValue] = useState('') useEffect(() => { dispatch(fetchConsumerGroups()) }, []) useEffect(() => { - const streamGroups: IStreamEntry[] = loadedGroups?.map((item) => ({ + const streamGroups: ConsumerGroupDto[] = loadedGroups?.map((item) => ({ ...item, editing: false, })) @@ -78,16 +90,6 @@ const GroupsViewWrapper = (props: Props) => { // }) } - const handleEditGroup = (groupId = '', editing: boolean) => { - const newGroupsState = groups.map((item) => { - if (item.id === groupId) { - return { ...item, editing } - } - return item - }) - setGroups(newGroupsState) - } - const handleSelectGroup = ({ rowData }: { rowData: any }) => { dispatch(setSelectedGroup(rowData)) dispatch(fetchConsumers( @@ -96,6 +98,15 @@ const GroupsViewWrapper = (props: Props) => { )) } + const handleApplyEditId = (groupName: string) => { + const data = { + keyName: selectedKey, + name: groupName, + lastDeliveredId: editValue + } + dispatch(modifyLastDeliveredIdAction(data)) + } + const columns: ITableColumn[] = [ { @@ -135,7 +146,7 @@ const GroupsViewWrapper = (props: Props) => { isSortable: true, className: styles.cell, headerClassName: styles.cellHeader, - render: function Id(_name: string, { lastDeliveredId: id }: StreamEntryDto) { + render: function Id(_name: string, { lastDeliveredId: id }: ConsumerGroupDto) { const timestamp = id?.split('-')?.[0] return (
@@ -161,9 +172,45 @@ const GroupsViewWrapper = (props: Props) => { absoluteWidth: actionsWidth, maxWidth: actionsWidth, minWidth: actionsWidth, - render: function Actions(_act: any, { id }: StreamEntryDto) { + render: function Actions(_act: any, { lastDeliveredId, name }: ConsumerGroupDto) { return (
+ setEditValue(lastDeliveredId)} + onApply={() => handleApplyEditId(name)} + className={styles.editLastId} + > + setEditValue(e.target.value)} + append={( + + 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. + + )} + > + + + )} + style={{ width: 240 }} + autoComplete="off" + data-testid="id-field" + /> + @@ -172,13 +219,13 @@ const GroupsViewWrapper = (props: Props) => { {key} )} - item={id} + item={lastDeliveredId} suffix={suffix} deleting={deleting} closePopover={closePopover} updateLoading={false} showPopover={showPopover} - testid={`remove-groups-button-${id}`} + testid={`remove-groups-button-${lastDeliveredId}`} handleDeleteItem={handleDeleteGroup} handleButtonClick={handleRemoveIconClick} /> @@ -193,7 +240,6 @@ const GroupsViewWrapper = (props: Props) => { void, + onFailed?: () => void +) { + return async (dispatch: AppDispatch, stateInit: () => RootState) => { + //dispatch(addNewEntries()) + + 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(addNewEntriesSuccess()) + dispatch(fetchConsumerGroups(false)) + keyName && dispatch(refreshKeyInfoAction(keyName)) + onSuccess?.() + } + } catch (_err) { + const error = _err as AxiosError + const errorMessage = getApiErrorMessage(error) + dispatch(addErrorNotification(error)) + //dispatch(addNewEntriesFailure(errorMessage)) + onFailed?.() + } + } +} From 0960e72bd8cc5e578940dc81ac8508684386c9e1 Mon Sep 17 00:00:00 2001 From: "Roman.Sergeenko" Date: Fri, 27 May 2022 16:03:54 +0400 Subject: [PATCH 2/2] #RI-2936 - integrations with list --- redisinsight/ui/src/constants/texts.tsx | 9 ++ .../add-stream-group/AddStreamGroup.tsx | 10 +- .../groups-view/GroupsView/styles.module.scss | 12 +++ .../groups-view/GroupsViewWrapper.tsx | 94 +++++++++++-------- redisinsight/ui/src/slices/browser/stream.ts | 25 ++++- 5 files changed, 99 insertions(+), 51 deletions(-) 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/stream-details/groups-view/GroupsView/styles.module.scss b/redisinsight/ui/src/pages/browser/components/stream-details/groups-view/GroupsView/styles.module.scss index e553560817..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 @@ -18,3 +18,15 @@ .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 edf410170d..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,8 +1,9 @@ -import { EuiFieldText, EuiIcon, EuiSpacer, 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 { selectedKeyDataSelector } from 'uiSrc/slices/browser/keys' +import { lastDeliveredIDTooltipText } from 'uiSrc/constants/texts' +import { selectedKeyDataSelector, updateSelectedKeyRefreshTime } from 'uiSrc/slices/browser/keys' import { streamGroupsSelector, @@ -13,12 +14,13 @@ import { } 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 { 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' @@ -39,15 +41,18 @@ const GroupsViewWrapper = (props: Props) => { const { lastRefreshTime, data: loadedGroups = [], + loading } = useSelector(streamGroupsSelector) const { name: key = '' } = useSelector(connectedInstanceSelector) - const { name: selectedKey } = useSelector(selectedKeyDataSelector) + 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)) @@ -62,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('') }, []) @@ -98,12 +111,14 @@ const GroupsViewWrapper = (props: Props) => { } const handleApplyEditId = (groupName: string) => { - const data = { - keyName: selectedKey, - name: groupName, - lastDeliveredId: editValue + if (!!groupName.length && !idError && selectedKey) { + const data: UpdateConsumerGroupDto = { + keyName: selectedKey, + name: groupName, + lastDeliveredId: editValue + } + dispatch(modifyLastDeliveredIdAction(data)) } - dispatch(modifyLastDeliveredIdAction(data)) } const columns: ITableColumn[] = [ @@ -193,6 +208,7 @@ const GroupsViewWrapper = (props: Props) => { maxWidth: actionsWidth, minWidth: actionsWidth, render: function Actions(_act: any, { lastDeliveredId, name }: ConsumerGroupDto) { + const showIdError = !isIdFocused && idError return (
{ onOpen={() => setEditValue(lastDeliveredId)} onApply={() => handleApplyEditId(name)} className={styles.editLastId} + isDisabled={!editValue.length || !!idError} + isLoading={loading} > - setEditValue(e.target.value)} - append={( - - 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. - - )} - > - - - )} - style={{ width: 240 }} - autoComplete="off" - data-testid="id-field" - /> + <> + 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}} + { + 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, @@ -643,12 +660,12 @@ export function fetchConsumerMessages( // Asynchronous thunk action export function modifyLastDeliveredIdAction( - data: any, + data: UpdateConsumerGroupDto, onSuccess?: () => void, onFailed?: () => void ) { return async (dispatch: AppDispatch, stateInit: () => RootState) => { - //dispatch(addNewEntries()) + dispatch(modifyLastDeliveredId()) try { const state = stateInit() @@ -662,7 +679,7 @@ export function modifyLastDeliveredIdAction( ) if (isStatusSuccessful(status)) { - //dispatch(addNewEntriesSuccess()) + dispatch(modifyLastDeliveredIdSuccess()) dispatch(fetchConsumerGroups(false)) keyName && dispatch(refreshKeyInfoAction(keyName)) onSuccess?.() @@ -671,7 +688,7 @@ export function modifyLastDeliveredIdAction( const error = _err as AxiosError const errorMessage = getApiErrorMessage(error) dispatch(addErrorNotification(error)) - //dispatch(addNewEntriesFailure(errorMessage)) + dispatch(modifyLastDeliveredIdFailure(errorMessage)) onFailed?.() } }