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 = () => {
>
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;