diff --git a/redisinsight/ui/src/constants/api.ts b/redisinsight/ui/src/constants/api.ts index a3985389f3..c0fe5dba8b 100644 --- a/redisinsight/ui/src/constants/api.ts +++ b/redisinsight/ui/src/constants/api.ts @@ -68,6 +68,9 @@ enum ApiEndpoints { SLOW_LOGS = 'slow-logs', SLOW_LOGS_CONFIG = 'slow-logs/config', + + PUB_SUB = 'pub-sub', + PUB_SUB_MESSAGES = 'pub-sub/messages' } export const DEFAULT_SEARCH_MATCH = '*' diff --git a/redisinsight/ui/src/pages/pubSub/PubSubPage.tsx b/redisinsight/ui/src/pages/pubSub/PubSubPage.tsx index 68c069ee61..6a5c805f36 100644 --- a/redisinsight/ui/src/pages/pubSub/PubSubPage.tsx +++ b/redisinsight/ui/src/pages/pubSub/PubSubPage.tsx @@ -3,7 +3,7 @@ import React from 'react' import InstanceHeader from 'uiSrc/components/instance-header' import { SubscriptionType } from 'uiSrc/constants/pubSub' -import { MessagesListWrapper, SubscriptionPanel } from './components' +import { MessagesListWrapper, PublishMessage, SubscriptionPanel } from './components' import styles from './styles.module.scss' @@ -27,7 +27,7 @@ const PubSubPage = () => {
- footer +
diff --git a/redisinsight/ui/src/pages/pubSub/components/index.ts b/redisinsight/ui/src/pages/pubSub/components/index.ts index 4fa84983f8..340dd9793c 100644 --- a/redisinsight/ui/src/pages/pubSub/components/index.ts +++ b/redisinsight/ui/src/pages/pubSub/components/index.ts @@ -1,7 +1,9 @@ import SubscriptionPanel from './subscription-panel' import MessagesListWrapper from './messages-list' +import PublishMessage from './publish-message' export { SubscriptionPanel, MessagesListWrapper, + PublishMessage } diff --git a/redisinsight/ui/src/pages/pubSub/components/publish-message/PublishMessage.spec.tsx b/redisinsight/ui/src/pages/pubSub/components/publish-message/PublishMessage.spec.tsx new file mode 100644 index 0000000000..1668d85ffe --- /dev/null +++ b/redisinsight/ui/src/pages/pubSub/components/publish-message/PublishMessage.spec.tsx @@ -0,0 +1,29 @@ +import { fireEvent } from '@testing-library/react' +import { cloneDeep } from 'lodash' +import React from 'react' +import { publishMessage } from 'uiSrc/slices/pubsub/pubsub' +import { cleanup, clearStoreActions, mockedStore, render, screen } from 'uiSrc/utils/test-utils' + +import PublishMessage from './PublishMessage' + +let store: typeof mockedStore + +beforeEach(() => { + cleanup() + store = cloneDeep(mockedStore) + store.clearActions() +}) + +describe('PublishMessage', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should dispatch subscribe action after submit', () => { + render() + const expectedActions = [publishMessage()] + fireEvent.click(screen.getByTestId('publish-message-submit')) + + expect(clearStoreActions(store.getActions())).toEqual(clearStoreActions(expectedActions)) + }) +}) diff --git a/redisinsight/ui/src/pages/pubSub/components/publish-message/PublishMessage.tsx b/redisinsight/ui/src/pages/pubSub/components/publish-message/PublishMessage.tsx new file mode 100644 index 0000000000..e4cad931f0 --- /dev/null +++ b/redisinsight/ui/src/pages/pubSub/components/publish-message/PublishMessage.tsx @@ -0,0 +1,128 @@ +import { + EuiBadge, + EuiButton, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiForm, + EuiFormRow, + EuiIcon +} from '@elastic/eui' +import cx from 'classnames' +import React, { ChangeEvent, FormEvent, useEffect, useRef, useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { useParams } from 'react-router-dom' +import { appContextPubSub, setPubSubFieldsContext } from 'uiSrc/slices/app/context' +import { publishMessageAction } from 'uiSrc/slices/pubsub/pubsub' +import { ReactComponent as UserIcon } from 'uiSrc/assets/img/icons/user.svg' + +import styles from './styles.module.scss' + +const HIDE_BADGE_TIMER = 3000 + +const PublishMessage = () => { + const { channel: channelContext, message: messageContext } = useSelector(appContextPubSub) + const [channel, setChannel] = useState(channelContext) + const [message, setMessage] = useState(messageContext) + const [isShowBadge, setIsShowBadge] = useState(false) + const [affectedClients, setAffectedClients] = useState(0) + + const fieldsRef = useRef({ channel, message }) + const timeOutRef = useRef() + + const { instanceId } = useParams<{ instanceId: string }>() + const dispatch = useDispatch() + + useEffect(() => () => { + dispatch(setPubSubFieldsContext(fieldsRef.current)) + timeOutRef.current && clearTimeout(timeOutRef.current) + }, []) + + useEffect(() => { + fieldsRef.current = { channel, message } + }, [channel, message]) + + useEffect(() => { + if (isShowBadge) { + timeOutRef.current = setTimeout(() => { + isShowBadge && setIsShowBadge(false) + }, HIDE_BADGE_TIMER) + + return + } + + timeOutRef.current && clearTimeout(timeOutRef.current) + }, [isShowBadge]) + + const onSuccess = (affected: number) => { + setMessage('') + setAffectedClients(affected) + setIsShowBadge(true) + } + + const onFormSubmit = (event: FormEvent): void => { + event.preventDefault() + setIsShowBadge(false) + dispatch(publishMessageAction(instanceId, channel, message, onSuccess)) + } + + return ( + + + + + + ) => setChannel(e.target.value)} + autoComplete="off" + data-testid="field-channel-name" + /> + + + + + <> + ) => setMessage(e.target.value)} + autoComplete="off" + data-testid="field-message" + /> + + + {affectedClients} + + + + + + + + + + + Publish + + + + + ) +} + +export default PublishMessage diff --git a/redisinsight/ui/src/pages/pubSub/components/publish-message/index.ts b/redisinsight/ui/src/pages/pubSub/components/publish-message/index.ts new file mode 100644 index 0000000000..9ee703c3c1 --- /dev/null +++ b/redisinsight/ui/src/pages/pubSub/components/publish-message/index.ts @@ -0,0 +1,3 @@ +import PublishMessage from './PublishMessage' + +export default PublishMessage diff --git a/redisinsight/ui/src/pages/pubSub/components/publish-message/styles.module.scss b/redisinsight/ui/src/pages/pubSub/components/publish-message/styles.module.scss new file mode 100644 index 0000000000..6ca6a50fc2 --- /dev/null +++ b/redisinsight/ui/src/pages/pubSub/components/publish-message/styles.module.scss @@ -0,0 +1,46 @@ +.container { + .channelWrapper { + min-width: 180px; + } + .messageWrapper { + flex-grow: 3 !important; + position: relative; + + .messageField { + &.showBadge { + padding-right: 80px; + } + } + } + + .badge { + position: absolute; + background-color: var(--pubSubClientsBadge) !important; + top: 50%; + right: 8px; + transform: translateY(-50%); + color: var(--htmlColor) !important; + opacity: 0; + pointer-events: none; + transition: opacity 250ms ease-in-out; + + &.show { + opacity: 1; + pointer-events: auto; + } + + :global(.euiBadge__text) { + display: flex; + align-items: center; + } + + .iconCheckBadge { + margin-right: 6px; + } + + .iconUserBadge { + color: var(--htmlColor) !important; + margin-bottom: 2px; + } + } +} diff --git a/redisinsight/ui/src/pages/pubSub/components/subscription-panel/SubscriptionPanel.tsx b/redisinsight/ui/src/pages/pubSub/components/subscription-panel/SubscriptionPanel.tsx index 69b9772c3f..5ec7081323 100644 --- a/redisinsight/ui/src/pages/pubSub/components/subscription-panel/SubscriptionPanel.tsx +++ b/redisinsight/ui/src/pages/pubSub/components/subscription-panel/SubscriptionPanel.tsx @@ -30,44 +30,44 @@ const SubscriptionPanel = () => { const displayMessages = count !== 0 || isSubscribed return ( -
- - - - - + + + + + + + + + You are { !isSubscribed && 'not' } subscribed + + + {displayMessages && ( + + Messages: {count} - - You are { !isSubscribed && 'not' } subscribed - - {displayMessages && ( - - Messages: {count} - - )} - + )} + - - - - { isSubscribed ? 'Unsubscribe' : 'Subscribe' } - - - -
+ + + + { isSubscribed ? 'Unsubscribe' : 'Subscribe' } + + + ) } diff --git a/redisinsight/ui/src/pages/pubSub/styles.module.scss b/redisinsight/ui/src/pages/pubSub/styles.module.scss index 46331cd935..803268b312 100644 --- a/redisinsight/ui/src/pages/pubSub/styles.module.scss +++ b/redisinsight/ui/src/pages/pubSub/styles.module.scss @@ -18,9 +18,8 @@ } .footerPanel { - height: 80px; margin-top: 16px; - padding: 10px 18px; + padding: 10px 18px 28px; } .header { diff --git a/redisinsight/ui/src/slices/app/context.ts b/redisinsight/ui/src/slices/app/context.ts index 81690c1332..65c4282beb 100644 --- a/redisinsight/ui/src/slices/app/context.ts +++ b/redisinsight/ui/src/slices/app/context.ts @@ -31,6 +31,10 @@ export const initialState: StateAppContext = { panelSizes: { vertical: {} } + }, + pubsub: { + channel: '', + message: '' } } @@ -118,6 +122,10 @@ const appContextSlice = createSlice({ resetBrowserTree: (state) => { state.browser.tree.selectedLeaf = {} state.browser.tree.openNodes = {} + }, + setPubSubFieldsContext: (state, { payload }: { payload: { channel: string, message: string } }) => { + state.pubsub.channel = payload.channel + state.pubsub.message = payload.message } }, }) @@ -142,6 +150,7 @@ export const { setWorkbenchEAItem, resetWorkbenchEAItem, setWorkbenchEAItemScrollTop, + setPubSubFieldsContext } = appContextSlice.actions // Selectors @@ -157,6 +166,8 @@ export const appContextSelectedKey = (state: RootState) => state.app.context.browser.keyList.selectedKey export const appContextWorkbenchEA = (state: RootState) => state.app.context.workbench.enablementArea +export const appContextPubSub = (state: RootState) => + state.app.context.pubsub // The reducer export default appContextSlice.reducer diff --git a/redisinsight/ui/src/slices/interfaces/app.ts b/redisinsight/ui/src/slices/interfaces/app.ts index f64729074c..fc65fd0ba2 100644 --- a/redisinsight/ui/src/slices/interfaces/app.ts +++ b/redisinsight/ui/src/slices/interfaces/app.ts @@ -70,6 +70,10 @@ export interface StateAppContext { [key: string]: number } } + }, + pubsub: { + channel: string + message: string } } diff --git a/redisinsight/ui/src/slices/interfaces/pubsub.ts b/redisinsight/ui/src/slices/interfaces/pubsub.ts index e1addea38a..5b17db3a2b 100644 --- a/redisinsight/ui/src/slices/interfaces/pubsub.ts +++ b/redisinsight/ui/src/slices/interfaces/pubsub.ts @@ -14,6 +14,7 @@ export interface PubSubMessage { export interface StatePubSub { loading: boolean + publishing: boolean error: string subscriptions: SubscriptionDto[] isSubscribeTriggered: boolean diff --git a/redisinsight/ui/src/slices/pubsub/pubsub.ts b/redisinsight/ui/src/slices/pubsub/pubsub.ts index e93881a0e8..e5c8f94ba2 100644 --- a/redisinsight/ui/src/slices/pubsub/pubsub.ts +++ b/redisinsight/ui/src/slices/pubsub/pubsub.ts @@ -1,11 +1,18 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit' +import { AxiosError } from 'axios' +import { ApiEndpoints } from 'uiSrc/constants' +import { apiService } from 'uiSrc/services' +import { addErrorNotification } from 'uiSrc/slices/app/notifications' import { StatePubSub } from 'uiSrc/slices/interfaces/pubsub' -import { RootState } from 'uiSrc/slices/store' +import { AppDispatch, RootState } from 'uiSrc/slices/store' import { SubscriptionDto } from 'apiSrc/modules/pub-sub/dto/subscription.dto' import { MessagesResponse } from 'apiSrc/modules/pub-sub/dto/messages.response' +import { PublishResponse } from 'apiSrc/modules/pub-sub/dto/publish.response' +import { getApiErrorMessage, getUrl, isStatusSuccessful } from 'uiSrc/utils' export const initialState: StatePubSub = { loading: false, + publishing: false, error: '', subscriptions: [], isSubscribeTriggered: false, @@ -58,6 +65,17 @@ const pubSubSlice = createSlice({ state.isSubscribed = false state.isSubscribeTriggered = false state.isConnected = false + }, + publishMessage: (state) => { + state.publishing = true + }, + publishMessageSuccess: (state) => { + state.publishing = false + state.error = '' + }, + publishMessageError: (state, { payload }) => { + state.publishing = false + state.error = payload } } }) @@ -70,9 +88,48 @@ export const { setIsPubSubUnSubscribed, concatPubSubMessages, setLoading, - disconnectPubSub + disconnectPubSub, + publishMessage, + publishMessageSuccess, + publishMessageError } = pubSubSlice.actions export const pubSubSelector = (state: RootState) => state.pubsub export default pubSubSlice.reducer + +// Asynchronous thunk action +export function publishMessageAction( + instanceId: string, + channel: string, + message: string, + onSuccessAction?: (affected: number) => void, + onFailAction?: () => void, +) { + return async (dispatch: AppDispatch) => { + try { + dispatch(publishMessage()) + const { data, status } = await apiService.post( + getUrl( + instanceId, + ApiEndpoints.PUB_SUB_MESSAGES + ), + { + channel, + message + } + ) + + if (isStatusSuccessful(status)) { + dispatch(publishMessageSuccess()) + onSuccessAction?.(data.affected) + } + } catch (_err) { + const error = _err as AxiosError + const errorMessage = getApiErrorMessage(error) + dispatch(addErrorNotification(error)) + dispatch(publishMessageError(errorMessage)) + onFailAction?.() + } + } +} diff --git a/redisinsight/ui/src/slices/tests/pubsub/pubsub.spec.ts b/redisinsight/ui/src/slices/tests/pubsub/pubsub.spec.ts index e6901acdf2..d019cb4f7b 100644 --- a/redisinsight/ui/src/slices/tests/pubsub/pubsub.spec.ts +++ b/redisinsight/ui/src/slices/tests/pubsub/pubsub.spec.ts @@ -1,6 +1,17 @@ +import { AxiosError } from 'axios' import { cloneDeep } from 'lodash' -import reducer, { initialState } from 'uiSrc/slices/slowlog/slowlog' -import { cleanup, mockedStore } from 'uiSrc/utils/test-utils' +import { apiService } from 'uiSrc/services' +import { addErrorNotification } from 'uiSrc/slices/app/notifications' +import reducer, { + concatPubSubMessages, + initialState, + PUB_SUB_ITEMS_MAX_COUNT, + publishMessage, + publishMessageAction, + publishMessageError, + publishMessageSuccess, pubSubSelector +} from 'uiSrc/slices/pubsub/pubsub' +import { cleanup, initialStateDefault, mockedStore } from 'uiSrc/utils/test-utils' let store: typeof mockedStore @@ -22,5 +33,115 @@ describe('pubsub slice', () => { // Assert expect(result).toEqual(nextState) }) + + describe('concatPubSubMessages', () => { + it('should properly set payload to items', () => { + const payload = { + count: 2, + messages: [ + { + message: '1', + channel: '2', + time: 123123123 + }, + { + message: '2', + channel: '2', + time: 123123123 + } + ] + } + + // Arrange + const state: typeof initialState = { + ...initialState, + count: payload.count, + messages: payload.messages + } + + // Act + const nextState = reducer(initialState, concatPubSubMessages(payload)) + + // Assert + const rootState = Object.assign(initialStateDefault, { + pubsub: nextState, + }) + expect(pubSubSelector(rootState)).toEqual(state) + }) + + it('should properly set items no more than MONITOR_ITEMS_MAX_COUNT', () => { + const payload = { + count: PUB_SUB_ITEMS_MAX_COUNT + 10, + messages: new Array(PUB_SUB_ITEMS_MAX_COUNT + 10) + } + + // Arrange + const state: typeof initialState = { + ...initialState, + count: PUB_SUB_ITEMS_MAX_COUNT + 10, + messages: new Array(PUB_SUB_ITEMS_MAX_COUNT) + } + + // Act + const nextState = reducer(initialState, concatPubSubMessages(payload)) + + // Assert + const rootState = Object.assign(initialStateDefault, { + pubsub: nextState, + }) + expect(pubSubSelector(rootState)).toEqual(state) + }) + }) + }) + + // thunks + describe('thunks', () => { + describe('publishMessageAction', () => { + it('succeed to fetch data', async () => { + const data = { affected: 1 } + const responsePayload = { data, status: 200 } + + apiService.post = jest.fn().mockResolvedValue(responsePayload) + + // Act + await store.dispatch( + publishMessageAction('123', 'channel', 'message') + ) + + // Assert + const expectedActions = [ + publishMessage(), + publishMessageSuccess(), + ] + + expect(store.getActions()).toEqual(expectedActions) + }) + + it('failed to fetch data', async () => { + const errorMessage = 'Something was wrong!' + const responsePayload = { + response: { + status: 500, + data: { message: errorMessage }, + }, + } + + apiService.post = jest.fn().mockRejectedValue(responsePayload) + + // Act + await store.dispatch( + publishMessageAction('123', 'channel', 'message') + ) + + // Assert + const expectedActions = [ + publishMessage(), + addErrorNotification(responsePayload as AxiosError), + publishMessageError(errorMessage) + ] + + expect(store.getActions()).toEqual(expectedActions) + }) + }) }) }) diff --git a/redisinsight/ui/src/styles/themes/dark_theme/_dark_theme.lazy.scss b/redisinsight/ui/src/styles/themes/dark_theme/_dark_theme.lazy.scss index 7c6bcd80ff..225e1c8304 100644 --- a/redisinsight/ui/src/styles/themes/dark_theme/_dark_theme.lazy.scss +++ b/redisinsight/ui/src/styles/themes/dark_theme/_dark_theme.lazy.scss @@ -167,4 +167,7 @@ // Profiler --monitorTimeColor: #{$monitorTimeColor}; + + // Pub/Sub + --pubSubClientsBadge: #{$pubSubClientsBadge}; } diff --git a/redisinsight/ui/src/styles/themes/dark_theme/_theme_color.scss b/redisinsight/ui/src/styles/themes/dark_theme/_theme_color.scss index 300f0d6ff6..2a7cbf77f4 100644 --- a/redisinsight/ui/src/styles/themes/dark_theme/_theme_color.scss +++ b/redisinsight/ui/src/styles/themes/dark_theme/_theme_color.scss @@ -127,3 +127,6 @@ $wbRunResultsBg: #000; // Profiler $monitorTimeColor: #608b4e; + +// PubSub +$pubSubClientsBadge: #008000; diff --git a/redisinsight/ui/src/styles/themes/light_theme/_light_theme.lazy.scss b/redisinsight/ui/src/styles/themes/light_theme/_light_theme.lazy.scss index fe7656b24b..b7d5e6753f 100644 --- a/redisinsight/ui/src/styles/themes/light_theme/_light_theme.lazy.scss +++ b/redisinsight/ui/src/styles/themes/light_theme/_light_theme.lazy.scss @@ -169,4 +169,7 @@ // Profiler --monitorTimeColor: #{$monitorTimeColor}; + + // Pub/Sub + --pubSubClientsBadge: #{$pubSubClientsBadge}; } diff --git a/redisinsight/ui/src/styles/themes/light_theme/_theme_color.scss b/redisinsight/ui/src/styles/themes/light_theme/_theme_color.scss index 72e8d29268..d387f94c0e 100644 --- a/redisinsight/ui/src/styles/themes/light_theme/_theme_color.scss +++ b/redisinsight/ui/src/styles/themes/light_theme/_theme_color.scss @@ -124,3 +124,6 @@ $wbRunResultsBg: #fff; // Profiler $monitorTimeColor: #008000; + +// Pub/Sub +$pubSubClientsBadge: #B5CEA8;