Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,8 @@ export default {
) => ({
'data-testid': 'toast-info-azure-token-expired',
customIcon: InfoIcon,
showCloseButton: false,
showCloseButton: true,
onClose,
description: (
<AzureTokenExpiredErrorContent text={message} onClose={onClose} />
),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import React from 'react'
import { renderHook } from '@testing-library/react-hooks'
import { Provider } from 'react-redux'
import configureStore from 'redux-mock-store'
import { cleanup } from '@testing-library/react'

import { CustomErrorCodes } from 'uiSrc/constants'
import { riToast } from 'uiSrc/components/base/display/toast'
import { useErrorNotifications } from './useErrorNotifications'
import { initialStateDefault } from 'uiSrc/utils/test-utils'

jest.mock('uiSrc/components/base/display/toast', () => ({
riToast: Object.assign(
jest.fn(() => 'mock-toast-id'),
{
dismiss: jest.fn(),
isActive: jest.fn(() => false),
Variant: {
Danger: 'danger',
Informative: 'informative',
},
},
),
}))

const mockStore = configureStore()

const createWrapper =
(store: ReturnType<typeof mockStore>) =>
({ children }: { children: React.ReactNode }) => (
<Provider store={store}>{children}</Provider>
)

const createAzureError = (id: string) => ({
id,
name: 'Error',
message: 'Azure Entra ID token expired',
additionalInfo: {
errorCode: CustomErrorCodes.AzureEntraIdTokenExpired,
},
})

const createRegularError = (id: string, message: string) => ({
id,
name: 'Error',
message,
})

const mockedRiToastIsActive = riToast.isActive as jest.Mock

describe('useErrorNotifications', () => {
beforeEach(() => {
cleanup()
jest.clearAllMocks()
mockedRiToastIsActive.mockReturnValue(false)
})

describe('Azure token expired errors', () => {
it('should show only one toast for multiple Azure errors', () => {
// Mock isActive to return true after the first toast is shown
let toastShown = false
mockedRiToastIsActive.mockImplementation(() => {
const result = toastShown
toastShown = true
return result
})

const errors = [createAzureError('error-1'), createAzureError('error-2')]

const store = mockStore({
...initialStateDefault,
app: {
...initialStateDefault.app,
notifications: {
...initialStateDefault.app.notifications,
errors,
},
},
})

renderHook(() => useErrorNotifications(), {
wrapper: createWrapper(store),
})

// Only one toast should be shown (first error shows, second skips)
expect(riToast).toHaveBeenCalledTimes(1)
expect(riToast).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
toastId: 'azure-token-expired',
}),
)
})

it('should not show duplicate toast if one is already active', () => {
mockedRiToastIsActive.mockReturnValue(true)

const errors = [createAzureError('error-1')]

const store = mockStore({
...initialStateDefault,
app: {
...initialStateDefault.app,
notifications: {
...initialStateDefault.app.notifications,
errors,
},
},
})

renderHook(() => useErrorNotifications(), {
wrapper: createWrapper(store),
})

// Toast is already active, so riToast should not be called
expect(riToast).not.toHaveBeenCalled()
})

it('should remove all Azure error IDs from Redux when toast is dismissed', () => {
const errors = [createAzureError('error-1'), createAzureError('error-2')]

const store = mockStore({
...initialStateDefault,
app: {
...initialStateDefault.app,
notifications: {
...initialStateDefault.app.notifications,
errors,
},
},
})

renderHook(() => useErrorNotifications(), {
wrapper: createWrapper(store),
})

// Get the onClose callback passed to the toast
const mockRiToast = riToast as unknown as jest.Mock
const toastCall = mockRiToast.mock.calls[0]
const toastConfig = toastCall[0]
const onClose = toastConfig.onClose

// Call the onClose callback (simulating user closing the toast)
onClose()

// Should dispatch removeMessage for both error IDs
const actions = store.getActions()
expect(actions).toContainEqual({
type: 'notifications/removeMessage',
payload: 'error-1',
})
expect(actions).toContainEqual({
type: 'notifications/removeMessage',
payload: 'error-2',
})

// Should dismiss the toast
expect(riToast.dismiss).toHaveBeenCalledWith('azure-token-expired')
})
})

describe('regular errors', () => {
it('should show toast for regular errors', () => {
const errors = [createRegularError('error-1', 'Something went wrong')]

const store = mockStore({
...initialStateDefault,
app: {
...initialStateDefault.app,
notifications: {
...initialStateDefault.app.notifications,
errors,
},
},
})

renderHook(() => useErrorNotifications(), {
wrapper: createWrapper(store),
})

expect(riToast).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
toastId: 'error-1',
variant: 'danger',
}),
)
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,31 @@ import { RiToastType } from 'uiSrc/components/base/display/toast/RiToast'

const DEFAULT_ERROR_TITLE = 'Error'

const AZURE_TOKEN_EXPIRED_TOAST_ID = 'azure-token-expired'

export const useErrorNotifications = () => {
const errorsData = useSelector(errorsSelector)
const dispatch = useDispatch()
const toastIdsRef = useRef(new Map<string, number | string>())
const azureErrorIdsRef = useRef(new Set<string>())

const removeToast = (id: string) => {
if (toastIdsRef.current.has(id)) {
riToast.dismiss(toastIdsRef.current.get(id))
toastIdsRef.current.delete(id)
}
dispatch(removeMessage(id))
}

const removeAzureToast = () => {
riToast.dismiss(AZURE_TOKEN_EXPIRED_TOAST_ID)
azureErrorIdsRef.current.forEach((errorId) => {
toastIdsRef.current.delete(errorId)
dispatch(removeMessage(errorId))
})
azureErrorIdsRef.current.clear()
}

const showErrorsToasts = (errors: IError[]) =>
errors.forEach(
({
Expand Down Expand Up @@ -67,18 +81,23 @@ export const useErrorNotifications = () => {
additionalInfo?.errorCode ===
CustomErrorCodes.AzureEntraIdTokenExpired
) {
errorMessage = errorMessages.AZURE_TOKEN_EXPIRED(
{ message, title },
() => removeToast(id),
)
// Show as informative toast, not error
const toastId = riToast(errorMessage, {
variant: riToast.Variant.Informative,
toastId: id,
containerId: defaultContainerId,
autoClose: false,
})
toastIdsRef.current.set(id, toastId)
// Track original error ID and use fixed toastId to prevent duplicate toasts
azureErrorIdsRef.current.add(id)
toastIdsRef.current.set(id, AZURE_TOKEN_EXPIRED_TOAST_ID)

// Only show toast if not already visible
if (!riToast.isActive(AZURE_TOKEN_EXPIRED_TOAST_ID)) {
errorMessage = errorMessages.AZURE_TOKEN_EXPIRED(
{ message },
removeAzureToast,
)
riToast(errorMessage, {
variant: riToast.Variant.Informative,
toastId: AZURE_TOKEN_EXPIRED_TOAST_ID,
containerId: defaultContainerId,
autoClose: false,
})
}
return
} else if (persistent) {
errorMessage = errorMessages.PERSISTENT({ message, title }, () =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,8 @@ const ClusterDetailsPage = () => {
return (
<S.ClusterDetailsPageWrapper as="div" data-testid="cluster-details-page">
<ClusterDetailsHeader />
<ClusterDetailsGraphics nodes={nodes} loading={loading} />
<ClusterNodesTable nodes={nodes} />
<ClusterDetailsGraphics nodes={nodes} dataLoaded={!!data} />
<ClusterNodesTable nodes={nodes} dataLoaded={!!data} />
</S.ClusterDetailsPageWrapper>
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,43 +29,57 @@ const mockNodes = [

describe('ClusterNodesTable', () => {
it('should render', () => {
expect(render(<ClusterNodesTable nodes={mockNodes} />)).toBeTruthy()
expect(
render(<ClusterNodesTable nodes={mockNodes} dataLoaded />),
).toBeTruthy()
})

it('should render loading content', () => {
const { container } = render(<ClusterNodesTable nodes={[]} />)
expect(container).toBeInTheDocument()
it('should render loading content when data not yet loaded', () => {
render(<ClusterNodesTable nodes={[]} dataLoaded={false} />)
expect(
screen.getByTestId('primary-nodes-table-loading'),
).toBeInTheDocument()
})

it('should render empty state when data loaded and no nodes', () => {
render(<ClusterNodesTable nodes={[]} dataLoaded />)
expect(screen.getByTestId('primary-nodes-table-empty')).toBeInTheDocument()
expect(
screen.queryByTestId('primary-nodes-table-loading'),
).not.toBeInTheDocument()
})

it('should render table', () => {
const { container } = render(<ClusterNodesTable nodes={mockNodes} />)
const { container } = render(
<ClusterNodesTable nodes={mockNodes} dataLoaded />,
)
expect(container).toBeInTheDocument()
expect(
screen.queryByTestId('primary-nodes-table-loading'),
).not.toBeInTheDocument()
})

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

it('should highlight max value for total keys', () => {
render(<ClusterNodesTable nodes={mockNodes} />)
render(<ClusterNodesTable nodes={mockNodes} dataLoaded />)
expect(screen.getByTestId('totalKeys-value-max')).toHaveTextContent(
mockNodes[2].totalKeys.toString(),
)
})

it('should not highlight max value for opsPerSecond with equals values', () => {
render(<ClusterNodesTable nodes={mockNodes} />)
render(<ClusterNodesTable nodes={mockNodes} dataLoaded />)
expect(
screen.queryByTestId('opsPerSecond-value-max'),
).not.toBeInTheDocument()
})

it('should render background color for each node', () => {
render(<ClusterNodesTable nodes={mockNodes} />)
render(<ClusterNodesTable nodes={mockNodes} dataLoaded />)
mockNodes.forEach(({ letter, color }) => {
expect(screen.getByTestId(`node-color-${letter}`)).toHaveStyle({
'background-color': rgb(color),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React from 'react'
import React, { useCallback } from 'react'

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

Expand All @@ -9,14 +9,24 @@ import {
import { ClusterNodesEmptyState } from './components/ClusterNodesEmptyState/ClusterNodesEmptyState'
import { ClusterNodesTableProps } from './ClusterNodesTable.types'

const ClusterNodesTable = ({ nodes }: ClusterNodesTableProps) => (
<Table
columns={DEFAULT_CLUSTER_NODES_COLUMNS}
data={nodes}
defaultSorting={DEFAULT_SORTING}
emptyState={ClusterNodesEmptyState}
maxHeight="20rem"
/>
)
const ClusterNodesTable = ({ nodes, dataLoaded }: ClusterNodesTableProps) => {
// Show loading until data is received; don't show during refresh polls
const showLoading = !dataLoaded

const renderEmptyState = useCallback(
() => <ClusterNodesEmptyState loading={showLoading} />,
[showLoading],
)

return (
<Table
columns={DEFAULT_CLUSTER_NODES_COLUMNS}
data={nodes}
defaultSorting={DEFAULT_SORTING}
emptyState={renderEmptyState}
maxHeight="20rem"
/>
)
}

export default ClusterNodesTable
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ModifiedClusterNodes } from '../../ClusterDetailsPage'

export type ClusterNodesTableProps = {
nodes: ModifiedClusterNodes[]
dataLoaded: boolean
}

export type ClusterNodesTableCell = (
Expand Down
Loading
Loading