diff --git a/redisinsight/ui/src/constants/keys.ts b/redisinsight/ui/src/constants/keys.ts index 98a946e04b..1f9a927a45 100644 --- a/redisinsight/ui/src/constants/keys.ts +++ b/redisinsight/ui/src/constants/keys.ts @@ -1,3 +1,4 @@ +import { StreamViewType } from 'uiSrc/slices/interfaces/stream' import { CommandGroup } from './commands' export enum KeyTypes { @@ -114,11 +115,27 @@ export const KEY_TYPES_ACTIONS: KeyTypesActions = Object.freeze({ name: 'Edit Value', }, }, - [KeyTypes.ReJSON]: {}, - [KeyTypes.Stream]: { - addItems: { - name: 'New Entry', - }, + [KeyTypes.ReJSON]: {} +}) + +export const STREAM_ADD_GROUP_VIEW_TYPES = [ + StreamViewType.Groups, + StreamViewType.Consumers, + StreamViewType.Messages +] + +export const STREAM_ADD_ACTION = Object.freeze({ + [StreamViewType.Data]: { + name: 'New Entry' + }, + [StreamViewType.Groups]: { + name: 'New Group' + }, + [StreamViewType.Consumers]: { + name: 'New Group' + }, + [StreamViewType.Messages]: { + name: 'New Group' } }) diff --git a/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-stream-group/AddStreamGroup.spec.tsx b/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-stream-group/AddStreamGroup.spec.tsx new file mode 100644 index 0000000000..b0c40cd795 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-stream-group/AddStreamGroup.spec.tsx @@ -0,0 +1,65 @@ +import React from 'react' +import { fireEvent, render, screen } from 'uiSrc/utils/test-utils' +import { instance, mock } from 'ts-mockito' +import AddStreamGroup, { Props } from './AddStreamGroup' + +const GROUP_NAME_FIELD = 'group-name-field' +const ID_FIELD = 'id-field' + +const mockedProps = mock() + +describe('AddStreamGroup', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should set member value properly', () => { + render() + const groupNameInput = screen.getByTestId(GROUP_NAME_FIELD) + fireEvent.change( + groupNameInput, + { target: { value: 'group name' } } + ) + expect(groupNameInput).toHaveValue('group name') + }) + + it('should set score value properly if input wrong value', () => { + render() + const idInput = screen.getByTestId(ID_FIELD) + fireEvent.change( + idInput, + { target: { value: 'aa1x-5' } } + ) + expect(idInput).toHaveValue('1-5') + }) + + it('should able to save with valid data', () => { + render() + const groupNameInput = screen.getByTestId(GROUP_NAME_FIELD) + const idInput = screen.getByTestId(ID_FIELD) + fireEvent.change( + groupNameInput, + { target: { value: 'name' } } + ) + fireEvent.change( + idInput, + { target: { value: '11111-3' } } + ) + expect(screen.getByTestId('save-groups-btn')).not.toBeDisabled() + }) + + it('should not able to save with valid data', () => { + render() + const groupNameInput = screen.getByTestId(GROUP_NAME_FIELD) + const idInput = screen.getByTestId(ID_FIELD) + fireEvent.change( + groupNameInput, + { target: { value: 'name' } } + ) + fireEvent.change( + idInput, + { target: { value: '11111----' } } + ) + expect(screen.getByTestId('save-groups-btn')).toBeDisabled() + }) +}) 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 new file mode 100644 index 0000000000..d1e3efbc5f --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-stream-group/AddStreamGroup.tsx @@ -0,0 +1,172 @@ +import { + EuiButton, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiIcon, + EuiPanel, + EuiSpacer, + EuiTextColor, + EuiToolTip +} from '@elastic/eui' +import cx from 'classnames' +import React, { ChangeEvent, useEffect, useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' + +import { selectedKeyDataSelector } from 'uiSrc/slices/browser/keys' +import { addNewGroupAction } from 'uiSrc/slices/browser/stream' +import { consumerGroupIdRegex, validateConsumerGroupId } from 'uiSrc/utils' +import { CreateConsumerGroupsDto } from 'apiSrc/modules/browser/dto/stream.dto' + +import styles from './styles.module.scss' + +export interface Props { + onCancel: (isCancelled?: boolean) => void +} + +const AddStreamGroup = (props: Props) => { + const { onCancel } = props + const { name: keyName = '' } = useSelector(selectedKeyDataSelector) ?? { name: undefined } + + const [isFormValid, setIsFormValid] = useState(false) + const [groupName, setGroupName] = useState('') + const [id, setId] = useState('$') + const [idError, setIdError] = useState('') + const [isIdFocused, setIsIdFocused] = useState(false) + + const dispatch = useDispatch() + + useEffect(() => { + const isValid = !!groupName.length && !idError + setIsFormValid(isValid) + }, [groupName, idError]) + + useEffect(() => { + if (!consumerGroupIdRegex.test(id)) { + setIdError('ID format is not correct') + return + } + setIdError('') + }, [id]) + + const submitData = () => { + if (isFormValid) { + const data: CreateConsumerGroupsDto = { + keyName, + consumerGroups: [{ + name: groupName, + lastDeliveredId: id, + }], + } + dispatch(addNewGroupAction(data, onCancel)) + } + } + + const showIdError = !isIdFocused && idError + + return ( + <> + + + + + + + + ) => setGroupName(e.target.value)} + autoComplete="off" + data-testid="group-name-field" + /> + + + + + ) => setId(validateConsumerGroupId(e.target.value))} + onBlur={() => setIsIdFocused(false)} + onFocus={() => setIsIdFocused(true)} + 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. + + )} + > + + + )} + autoComplete="off" + data-testid="id-field" + /> + + {!showIdError && Timestamp - Sequence Number or $} + {showIdError && {idError}} + + + + + + + + + +
+ onCancel(true)} data-testid="cancel-stream-groups-btn"> + Cancel + +
+
+ +
+ + Save + +
+
+
+
+ + ) +} + +export default AddStreamGroup diff --git a/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-stream-group/index.ts b/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-stream-group/index.ts new file mode 100644 index 0000000000..cdce9fc4da --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-stream-group/index.ts @@ -0,0 +1,3 @@ +import AddStreamGroup from './AddStreamGroup' + +export default AddStreamGroup diff --git a/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-stream-group/styles.module.scss b/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-stream-group/styles.module.scss new file mode 100644 index 0000000000..3536c4aecb --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/key-details-add-items/add-stream-group/styles.module.scss @@ -0,0 +1,30 @@ +.content { + display: flex; + flex-direction: column; + width: 100%; + border: none !important; + border-top: 1px solid var(--euiColorPrimary); + padding: 12px 20px; + max-height: 234px; + scroll-padding-bottom: 30px; + + .groupNameWrapper { + flex-grow: 2 !important; + } + + .timestampWrapper { + min-width: 215px; + } + + .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/key-details-add-items/index.ts b/redisinsight/ui/src/pages/browser/components/key-details-add-items/index.ts index a37d080cac..c389c49cbe 100644 --- a/redisinsight/ui/src/pages/browser/components/key-details-add-items/index.ts +++ b/redisinsight/ui/src/pages/browser/components/key-details-add-items/index.ts @@ -2,6 +2,7 @@ import AddHashFields from './add-hash-fields/AddHashFields' import AddListElements from './add-list-elements/AddListElements' import AddSetMembers from './add-set-members/AddSetMembers' import AddStreamEntries, { StreamEntryFields } from './add-stream-entity' +import AddStreamGroup from './add-stream-group' import AddZsetMembers from './add-zset-members/AddZsetMembers' export { @@ -10,5 +11,6 @@ export { AddSetMembers, AddStreamEntries, StreamEntryFields, - AddZsetMembers + AddZsetMembers, + AddStreamGroup } diff --git a/redisinsight/ui/src/pages/browser/components/key-details-header/KeyDetailsHeader.tsx b/redisinsight/ui/src/pages/browser/components/key-details-header/KeyDetailsHeader.tsx index 742140a210..5ee524aee0 100644 --- a/redisinsight/ui/src/pages/browser/components/key-details-header/KeyDetailsHeader.tsx +++ b/redisinsight/ui/src/pages/browser/components/key-details-header/KeyDetailsHeader.tsx @@ -6,25 +6,26 @@ import { EuiFlexGroup, EuiFlexItem, EuiIcon, + EuiLoadingContent, EuiPopover, EuiText, EuiToolTip, - EuiLoadingContent, } from '@elastic/eui' +import cx from 'classnames' +import { isNull } from 'lodash' import React, { ChangeEvent, useEffect, useRef, useState } from 'react' import { useSelector } from 'react-redux' -import { isNull } from 'lodash' -import cx from 'classnames' import AutoSizer from 'react-virtualized-auto-sizer' import { GroupBadge } from 'uiSrc/components' -import { KeyTypes, KEY_TYPES_ACTIONS, LENGTH_NAMING_BY_TYPE, ModulesKeyTypes } from 'uiSrc/constants' -import { selectedKeyDataSelector, selectedKeySelector, keysSelector } from 'uiSrc/slices/browser/keys' +import InlineItemEditor from 'uiSrc/components/inline-item-editor/InlineItemEditor' +import { KEY_TYPES_ACTIONS, KeyTypes, LENGTH_NAMING_BY_TYPE, ModulesKeyTypes, STREAM_ADD_ACTION } from 'uiSrc/constants' +import { AddCommonFieldsFormConfig } from 'uiSrc/pages/browser/components/add-key/constants/fields-config' +import { keysSelector, selectedKeyDataSelector, selectedKeySelector } from 'uiSrc/slices/browser/keys' +import { streamSelector } from 'uiSrc/slices/browser/stream' import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' +import { getBasedOnViewTypeEvent, sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import { formatBytes, formatNameShort, MAX_TTL_NUMBER, replaceSpaces, validateTTLNumber } from 'uiSrc/utils' -import { sendEventTelemetry, TelemetryEvent, getBasedOnViewTypeEvent } from 'uiSrc/telemetry' -import { AddCommonFieldsFormConfig } from 'uiSrc/pages/browser/components/add-key/constants/fields-config' -import InlineItemEditor from 'uiSrc/components/inline-item-editor/InlineItemEditor' import AutoRefresh from '../auto-refresh' import styles from './styles.module.scss' @@ -75,6 +76,7 @@ const KeyDetailsHeader = ({ const { ttl: ttlProp, name: keyProp = '', type, size, length } = useSelector(selectedKeyDataSelector) ?? initialKeyInfo const { id: instanceId } = useSelector(connectedInstanceSelector) const { viewType } = useSelector(keysSelector) + const { viewType: streamViewType } = useSelector(streamSelector) const [isPopoverDeleteOpen, setIsPopoverDeleteOpen] = useState(false) @@ -93,7 +95,7 @@ const KeyDetailsHeader = ({ const keyNameRef = useRef(null) - const tooltipContent = formatNameShort(keyProp) + const tooltipContent = formatNameShort(keyProp || '') const onMouseEnterKey = () => { setKeyIsHovering(true) @@ -266,7 +268,7 @@ const KeyDetailsHeader = ({ const Actions = (width: number) => ( <> - {'addItems' in KEY_TYPES_ACTIONS[keyType] && ( + {KEY_TYPES_ACTIONS[keyType] && 'addItems' in KEY_TYPES_ACTIONS[keyType] && ( MIDDLE_SCREEN_RESOLUTION ? '' : KEY_TYPES_ACTIONS[keyType].addItems?.name} position="left" @@ -296,7 +298,37 @@ const KeyDetailsHeader = ({ )} - {'removeItems' in KEY_TYPES_ACTIONS[keyType] && ( + {keyType === KeyTypes.Stream && ( + MIDDLE_SCREEN_RESOLUTION ? '' : STREAM_ADD_ACTION[streamViewType].name} + position="left" + anchorClassName={cx(styles.actionBtn, { [styles.withText]: width > MIDDLE_SCREEN_RESOLUTION })} + > + <> + {width > MIDDLE_SCREEN_RESOLUTION ? ( + + {STREAM_ADD_ACTION[streamViewType].name} + + ) : ( + + )} + + + )} + {KEY_TYPES_ACTIONS[keyType] && 'removeItems' in KEY_TYPES_ACTIONS[keyType] && ( )} - {'editItem' in KEY_TYPES_ACTIONS[keyType] && ( + {KEY_TYPES_ACTIONS[keyType] && 'editItem' in KEY_TYPES_ACTIONS[keyType] && (
- {(keyType && KEY_TYPES_ACTIONS[keyType]) && Actions(width)} + {keyType && Actions(width)} { const isKeySelected = !isNull(useSelector(selectedKeyDataSelector)) const { id: instanceId } = useSelector(connectedInstanceSelector) const { viewType } = useSelector(keysSelector) + const { viewType: streamViewType } = useSelector(streamSelector) const [isAddItemPanelOpen, setIsAddItemPanelOpen] = useState(false) const [isRemoveItemPanelOpen, setIsRemoveItemPanelOpen] = useState(false) const [editItem, setEditItem] = useState(false) @@ -203,7 +206,14 @@ const KeyDetails = ({ ...props }: Props) => { )} {selectedKeyType === KeyTypes.Stream && ( - + <> + {streamViewType === StreamViewType.Data && ( + + )} + {STREAM_ADD_GROUP_VIEW_TYPES.includes(streamViewType) && ( + + )} + )}
)} diff --git a/redisinsight/ui/src/slices/browser/stream.ts b/redisinsight/ui/src/slices/browser/stream.ts index bbc09cd721..b441dfd3e6 100644 --- a/redisinsight/ui/src/slices/browser/stream.ts +++ b/redisinsight/ui/src/slices/browser/stream.ts @@ -13,6 +13,7 @@ import { AddStreamEntriesResponse, ConsumerDto, ConsumerGroupDto, + CreateConsumerGroupsDto, GetStreamEntriesResponse, PendingEntryDto, } from 'apiSrc/modules/browser/dto/stream.dto' @@ -108,6 +109,17 @@ const streamSlice = createSlice({ state.loading = false state.error = payload }, + addNewGroup: (state) => { + state.loading = true + state.error = '' + }, + addNewGroupSuccess: (state) => { + state.loading = false + }, + addNewGroupFailure: (state, { payload }) => { + state.loading = false + state.error = payload + }, // delete Stream entries removeStreamEntries: (state) => { state.loading = true @@ -223,6 +235,9 @@ export const { addNewEntries, addNewEntriesSuccess, addNewEntriesFailure, + addNewGroup, + addNewGroupSuccess, + addNewGroupFailure, removeStreamEntries, removeStreamEntriesSuccess, removeStreamEntriesFailure, @@ -478,6 +493,41 @@ export function deleteStreamEntry(key: string, entries: string[], onSuccessActio } } +// Asynchronous thunk action +export function addNewGroupAction( + data: CreateConsumerGroupsDto, + onSuccess?: () => void, + onFail?: () => void +) { + return async (dispatch: AppDispatch, stateInit: () => RootState) => { + dispatch(addNewGroup()) + + try { + const state = stateInit() + const { status } = await apiService.post( + getUrl( + state.connections.instances.connectedInstance?.id, + ApiEndpoints.STREAMS_CONSUMER_GROUPS + ), + data + ) + + if (isStatusSuccessful(status)) { + dispatch(addNewGroupSuccess()) + dispatch(fetchConsumerGroups(false)) + dispatch(refreshKeyInfoAction(data.keyName)) + onSuccess?.() + } + } catch (_err) { + const error = _err as AxiosError + const errorMessage = getApiErrorMessage(error) + dispatch(addErrorNotification(error)) + dispatch(addNewGroupFailure(errorMessage)) + onFail?.() + } + } +} + // Asynchronous thunk action export function fetchConsumerGroups( resetData?: boolean, diff --git a/redisinsight/ui/src/utils/tests/validations.spec.ts b/redisinsight/ui/src/utils/tests/validations.spec.ts index 9de7b6481f..348429bab5 100644 --- a/redisinsight/ui/src/utils/tests/validations.spec.ts +++ b/redisinsight/ui/src/utils/tests/validations.spec.ts @@ -15,7 +15,8 @@ import { MAX_REFRESH_RATE, errorValidateRefreshRateNumber, errorValidateNegativeInteger, -} from '../validations' + validateConsumerGroupId +} from 'uiSrc/utils' const text1 = '123 123 123' const text2 = 'lorem lorem12312 lorem' @@ -248,4 +249,17 @@ describe('Validations utils', () => { expect(result).toBe(expected) }) }) + + describe('validateConsumerGroupId', () => { + it.each([ + ['123', '123'], + ['123-1', '123-1'], + ['$', '$'], + ['11.zx-1', '11-1'], + ])('for input: %s (input), should be output: %s', + (input, expected) => { + const result = validateConsumerGroupId(input) + expect(result).toBe(expected) + }) + }) }) diff --git a/redisinsight/ui/src/utils/validations.ts b/redisinsight/ui/src/utils/validations.ts index a01b03d85f..53443c4e08 100644 --- a/redisinsight/ui/src/utils/validations.ts +++ b/redisinsight/ui/src/utils/validations.ts @@ -8,10 +8,12 @@ export const MAX_REFRESH_RATE = 999.9 export const MIN_REFRESH_RATE = 1.0 export const entryIdRegex = /^(\*)$|^(([0-9]+)(-)((\*)$|([0-9]+$)))/ +export const consumerGroupIdRegex = /^(\$)$|^0$|^(([0-9]+)(-)([0-9]+$))/ export const validateField = (text: string) => text.replace(/\s/g, '') export const validateEntryId = (initValue: string) => initValue.replace(/[^0-9-*]+/gi, '') +export const validateConsumerGroupId = (initValue: string) => initValue.replace(/[^0-9-$]+/gi, '') export const validateCountNumber = (initValue: string) => { const value = initValue.replace(/[^0-9]+/gi, '')