Skip to content

Commit 0daa397

Browse files
authored
Sync main with latest release branch
2 parents a3ddca8 + 3c54bcb commit 0daa397

11 files changed

Lines changed: 314 additions & 52 deletions

File tree

redisinsight/ui/src/components/notifications/error-messages.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,8 @@ export default {
8282
) => ({
8383
'data-testid': 'toast-info-azure-token-expired',
8484
customIcon: InfoIcon,
85-
showCloseButton: false,
85+
showCloseButton: true,
86+
onClose,
8687
description: (
8788
<AzureTokenExpiredErrorContent text={message} onClose={onClose} />
8889
),
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import React from 'react'
2+
import { renderHook } from '@testing-library/react-hooks'
3+
import { Provider } from 'react-redux'
4+
import configureStore from 'redux-mock-store'
5+
import { cleanup } from '@testing-library/react'
6+
7+
import { CustomErrorCodes } from 'uiSrc/constants'
8+
import { riToast } from 'uiSrc/components/base/display/toast'
9+
import { useErrorNotifications } from './useErrorNotifications'
10+
import { initialStateDefault } from 'uiSrc/utils/test-utils'
11+
12+
jest.mock('uiSrc/components/base/display/toast', () => ({
13+
riToast: Object.assign(
14+
jest.fn(() => 'mock-toast-id'),
15+
{
16+
dismiss: jest.fn(),
17+
isActive: jest.fn(() => false),
18+
Variant: {
19+
Danger: 'danger',
20+
Informative: 'informative',
21+
},
22+
},
23+
),
24+
}))
25+
26+
const mockStore = configureStore()
27+
28+
const createWrapper =
29+
(store: ReturnType<typeof mockStore>) =>
30+
({ children }: { children: React.ReactNode }) => (
31+
<Provider store={store}>{children}</Provider>
32+
)
33+
34+
const createAzureError = (id: string) => ({
35+
id,
36+
name: 'Error',
37+
message: 'Azure Entra ID token expired',
38+
additionalInfo: {
39+
errorCode: CustomErrorCodes.AzureEntraIdTokenExpired,
40+
},
41+
})
42+
43+
const createRegularError = (id: string, message: string) => ({
44+
id,
45+
name: 'Error',
46+
message,
47+
})
48+
49+
const mockedRiToastIsActive = riToast.isActive as jest.Mock
50+
51+
describe('useErrorNotifications', () => {
52+
beforeEach(() => {
53+
cleanup()
54+
jest.clearAllMocks()
55+
mockedRiToastIsActive.mockReturnValue(false)
56+
})
57+
58+
describe('Azure token expired errors', () => {
59+
it('should show only one toast for multiple Azure errors', () => {
60+
// Mock isActive to return true after the first toast is shown
61+
let toastShown = false
62+
mockedRiToastIsActive.mockImplementation(() => {
63+
const result = toastShown
64+
toastShown = true
65+
return result
66+
})
67+
68+
const errors = [createAzureError('error-1'), createAzureError('error-2')]
69+
70+
const store = mockStore({
71+
...initialStateDefault,
72+
app: {
73+
...initialStateDefault.app,
74+
notifications: {
75+
...initialStateDefault.app.notifications,
76+
errors,
77+
},
78+
},
79+
})
80+
81+
renderHook(() => useErrorNotifications(), {
82+
wrapper: createWrapper(store),
83+
})
84+
85+
// Only one toast should be shown (first error shows, second skips)
86+
expect(riToast).toHaveBeenCalledTimes(1)
87+
expect(riToast).toHaveBeenCalledWith(
88+
expect.anything(),
89+
expect.objectContaining({
90+
toastId: 'azure-token-expired',
91+
}),
92+
)
93+
})
94+
95+
it('should not show duplicate toast if one is already active', () => {
96+
mockedRiToastIsActive.mockReturnValue(true)
97+
98+
const errors = [createAzureError('error-1')]
99+
100+
const store = mockStore({
101+
...initialStateDefault,
102+
app: {
103+
...initialStateDefault.app,
104+
notifications: {
105+
...initialStateDefault.app.notifications,
106+
errors,
107+
},
108+
},
109+
})
110+
111+
renderHook(() => useErrorNotifications(), {
112+
wrapper: createWrapper(store),
113+
})
114+
115+
// Toast is already active, so riToast should not be called
116+
expect(riToast).not.toHaveBeenCalled()
117+
})
118+
119+
it('should remove all Azure error IDs from Redux when toast is dismissed', () => {
120+
const errors = [createAzureError('error-1'), createAzureError('error-2')]
121+
122+
const store = mockStore({
123+
...initialStateDefault,
124+
app: {
125+
...initialStateDefault.app,
126+
notifications: {
127+
...initialStateDefault.app.notifications,
128+
errors,
129+
},
130+
},
131+
})
132+
133+
renderHook(() => useErrorNotifications(), {
134+
wrapper: createWrapper(store),
135+
})
136+
137+
// Get the onClose callback passed to the toast
138+
const mockRiToast = riToast as unknown as jest.Mock
139+
const toastCall = mockRiToast.mock.calls[0]
140+
const toastConfig = toastCall[0]
141+
const onClose = toastConfig.onClose
142+
143+
// Call the onClose callback (simulating user closing the toast)
144+
onClose()
145+
146+
// Should dispatch removeMessage for both error IDs
147+
const actions = store.getActions()
148+
expect(actions).toContainEqual({
149+
type: 'notifications/removeMessage',
150+
payload: 'error-1',
151+
})
152+
expect(actions).toContainEqual({
153+
type: 'notifications/removeMessage',
154+
payload: 'error-2',
155+
})
156+
157+
// Should dismiss the toast
158+
expect(riToast.dismiss).toHaveBeenCalledWith('azure-token-expired')
159+
})
160+
})
161+
162+
describe('regular errors', () => {
163+
it('should show toast for regular errors', () => {
164+
const errors = [createRegularError('error-1', 'Something went wrong')]
165+
166+
const store = mockStore({
167+
...initialStateDefault,
168+
app: {
169+
...initialStateDefault.app,
170+
notifications: {
171+
...initialStateDefault.app.notifications,
172+
errors,
173+
},
174+
},
175+
})
176+
177+
renderHook(() => useErrorNotifications(), {
178+
wrapper: createWrapper(store),
179+
})
180+
181+
expect(riToast).toHaveBeenCalledWith(
182+
expect.anything(),
183+
expect.objectContaining({
184+
toastId: 'error-1',
185+
variant: 'danger',
186+
}),
187+
)
188+
})
189+
})
190+
})

redisinsight/ui/src/components/notifications/hooks/useErrorNotifications.ts

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,31 @@ import { RiToastType } from 'uiSrc/components/base/display/toast/RiToast'
1313

1414
const DEFAULT_ERROR_TITLE = 'Error'
1515

16+
const AZURE_TOKEN_EXPIRED_TOAST_ID = 'azure-token-expired'
17+
1618
export const useErrorNotifications = () => {
1719
const errorsData = useSelector(errorsSelector)
1820
const dispatch = useDispatch()
1921
const toastIdsRef = useRef(new Map<string, number | string>())
22+
const azureErrorIdsRef = useRef(new Set<string>())
23+
2024
const removeToast = (id: string) => {
2125
if (toastIdsRef.current.has(id)) {
2226
riToast.dismiss(toastIdsRef.current.get(id))
2327
toastIdsRef.current.delete(id)
2428
}
2529
dispatch(removeMessage(id))
2630
}
31+
32+
const removeAzureToast = () => {
33+
riToast.dismiss(AZURE_TOKEN_EXPIRED_TOAST_ID)
34+
azureErrorIdsRef.current.forEach((errorId) => {
35+
toastIdsRef.current.delete(errorId)
36+
dispatch(removeMessage(errorId))
37+
})
38+
azureErrorIdsRef.current.clear()
39+
}
40+
2741
const showErrorsToasts = (errors: IError[]) =>
2842
errors.forEach(
2943
({
@@ -67,18 +81,23 @@ export const useErrorNotifications = () => {
6781
additionalInfo?.errorCode ===
6882
CustomErrorCodes.AzureEntraIdTokenExpired
6983
) {
70-
errorMessage = errorMessages.AZURE_TOKEN_EXPIRED(
71-
{ message, title },
72-
() => removeToast(id),
73-
)
74-
// Show as informative toast, not error
75-
const toastId = riToast(errorMessage, {
76-
variant: riToast.Variant.Informative,
77-
toastId: id,
78-
containerId: defaultContainerId,
79-
autoClose: false,
80-
})
81-
toastIdsRef.current.set(id, toastId)
84+
// Track original error ID and use fixed toastId to prevent duplicate toasts
85+
azureErrorIdsRef.current.add(id)
86+
toastIdsRef.current.set(id, AZURE_TOKEN_EXPIRED_TOAST_ID)
87+
88+
// Only show toast if not already visible
89+
if (!riToast.isActive(AZURE_TOKEN_EXPIRED_TOAST_ID)) {
90+
errorMessage = errorMessages.AZURE_TOKEN_EXPIRED(
91+
{ message },
92+
removeAzureToast,
93+
)
94+
riToast(errorMessage, {
95+
variant: riToast.Variant.Informative,
96+
toastId: AZURE_TOKEN_EXPIRED_TOAST_ID,
97+
containerId: defaultContainerId,
98+
autoClose: false,
99+
})
100+
}
82101
return
83102
} else if (persistent) {
84103
errorMessage = errorMessages.PERSISTENT({ message, title }, () =>

redisinsight/ui/src/pages/cluster-details/ClusterDetailsPage.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,8 +136,8 @@ const ClusterDetailsPage = () => {
136136
return (
137137
<S.ClusterDetailsPageWrapper as="div" data-testid="cluster-details-page">
138138
<ClusterDetailsHeader />
139-
<ClusterDetailsGraphics nodes={nodes} loading={loading} />
140-
<ClusterNodesTable nodes={nodes} />
139+
<ClusterDetailsGraphics nodes={nodes} dataLoaded={!!data} />
140+
<ClusterNodesTable nodes={nodes} dataLoaded={!!data} />
141141
</S.ClusterDetailsPageWrapper>
142142
)
143143
}

redisinsight/ui/src/pages/cluster-details/components/ClusterNodesTable/ClusterNodesTable.spec.tsx

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,43 +29,57 @@ const mockNodes = [
2929

3030
describe('ClusterNodesTable', () => {
3131
it('should render', () => {
32-
expect(render(<ClusterNodesTable nodes={mockNodes} />)).toBeTruthy()
32+
expect(
33+
render(<ClusterNodesTable nodes={mockNodes} dataLoaded />),
34+
).toBeTruthy()
3335
})
3436

35-
it('should render loading content', () => {
36-
const { container } = render(<ClusterNodesTable nodes={[]} />)
37-
expect(container).toBeInTheDocument()
37+
it('should render loading content when data not yet loaded', () => {
38+
render(<ClusterNodesTable nodes={[]} dataLoaded={false} />)
39+
expect(
40+
screen.getByTestId('primary-nodes-table-loading'),
41+
).toBeInTheDocument()
42+
})
43+
44+
it('should render empty state when data loaded and no nodes', () => {
45+
render(<ClusterNodesTable nodes={[]} dataLoaded />)
46+
expect(screen.getByTestId('primary-nodes-table-empty')).toBeInTheDocument()
47+
expect(
48+
screen.queryByTestId('primary-nodes-table-loading'),
49+
).not.toBeInTheDocument()
3850
})
3951

4052
it('should render table', () => {
41-
const { container } = render(<ClusterNodesTable nodes={mockNodes} />)
53+
const { container } = render(
54+
<ClusterNodesTable nodes={mockNodes} dataLoaded />,
55+
)
4256
expect(container).toBeInTheDocument()
4357
expect(
4458
screen.queryByTestId('primary-nodes-table-loading'),
4559
).not.toBeInTheDocument()
4660
})
4761

4862
it('should render table with 3 items', () => {
49-
render(<ClusterNodesTable nodes={mockNodes} />)
63+
render(<ClusterNodesTable nodes={mockNodes} dataLoaded />)
5064
expect(screen.getAllByTestId('node-letter')).toHaveLength(3)
5165
})
5266

5367
it('should highlight max value for total keys', () => {
54-
render(<ClusterNodesTable nodes={mockNodes} />)
68+
render(<ClusterNodesTable nodes={mockNodes} dataLoaded />)
5569
expect(screen.getByTestId('totalKeys-value-max')).toHaveTextContent(
5670
mockNodes[2].totalKeys.toString(),
5771
)
5872
})
5973

6074
it('should not highlight max value for opsPerSecond with equals values', () => {
61-
render(<ClusterNodesTable nodes={mockNodes} />)
75+
render(<ClusterNodesTable nodes={mockNodes} dataLoaded />)
6276
expect(
6377
screen.queryByTestId('opsPerSecond-value-max'),
6478
).not.toBeInTheDocument()
6579
})
6680

6781
it('should render background color for each node', () => {
68-
render(<ClusterNodesTable nodes={mockNodes} />)
82+
render(<ClusterNodesTable nodes={mockNodes} dataLoaded />)
6983
mockNodes.forEach(({ letter, color }) => {
7084
expect(screen.getByTestId(`node-color-${letter}`)).toHaveStyle({
7185
'background-color': rgb(color),
Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React from 'react'
1+
import React, { useCallback } from 'react'
22

33
import { Table } from 'uiSrc/components/base/layout/table'
44

@@ -9,14 +9,24 @@ import {
99
import { ClusterNodesEmptyState } from './components/ClusterNodesEmptyState/ClusterNodesEmptyState'
1010
import { ClusterNodesTableProps } from './ClusterNodesTable.types'
1111

12-
const ClusterNodesTable = ({ nodes }: ClusterNodesTableProps) => (
13-
<Table
14-
columns={DEFAULT_CLUSTER_NODES_COLUMNS}
15-
data={nodes}
16-
defaultSorting={DEFAULT_SORTING}
17-
emptyState={ClusterNodesEmptyState}
18-
maxHeight="20rem"
19-
/>
20-
)
12+
const ClusterNodesTable = ({ nodes, dataLoaded }: ClusterNodesTableProps) => {
13+
// Show loading until data is received; don't show during refresh polls
14+
const showLoading = !dataLoaded
15+
16+
const renderEmptyState = useCallback(
17+
() => <ClusterNodesEmptyState loading={showLoading} />,
18+
[showLoading],
19+
)
20+
21+
return (
22+
<Table
23+
columns={DEFAULT_CLUSTER_NODES_COLUMNS}
24+
data={nodes}
25+
defaultSorting={DEFAULT_SORTING}
26+
emptyState={renderEmptyState}
27+
maxHeight="20rem"
28+
/>
29+
)
30+
}
2131

2232
export default ClusterNodesTable

redisinsight/ui/src/pages/cluster-details/components/ClusterNodesTable/ClusterNodesTable.types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { ModifiedClusterNodes } from '../../ClusterDetailsPage'
33

44
export type ClusterNodesTableProps = {
55
nodes: ModifiedClusterNodes[]
6+
dataLoaded: boolean
67
}
78

89
export type ClusterNodesTableCell = (

0 commit comments

Comments
 (0)