diff --git a/kafka-ui-react-app/package.json b/kafka-ui-react-app/package.json index 9b3e523f6c..8dfb1fcd5d 100644 --- a/kafka-ui-react-app/package.json +++ b/kafka-ui-react-app/package.json @@ -34,6 +34,7 @@ "react-datepicker": "^4.8.0", "react-dom": "^18.1.0", "react-hook-form": "7.6.9", + "react-hot-toast": "^2.3.0", "react-is": "^18.2.0", "react-multi-select-component": "^4.0.6", "react-redux": "^8.0.2", diff --git a/kafka-ui-react-app/pnpm-lock.yaml b/kafka-ui-react-app/pnpm-lock.yaml index d2128a2b5b..32fcce3a9c 100644 --- a/kafka-ui-react-app/pnpm-lock.yaml +++ b/kafka-ui-react-app/pnpm-lock.yaml @@ -70,6 +70,7 @@ specifiers: react-datepicker: ^4.8.0 react-dom: ^18.1.0 react-hook-form: 7.6.9 + react-hot-toast: ^2.3.0 react-is: ^18.2.0 react-multi-select-component: ^4.0.6 react-redux: ^8.0.2 @@ -119,6 +120,7 @@ dependencies: react-datepicker: 4.8.0_ef5jwxihqo6n7gxfmzogljlgcm react-dom: 18.1.0_react@18.1.0 react-hook-form: 7.6.9_react@18.1.0 + react-hot-toast: 2.3.0_ef5jwxihqo6n7gxfmzogljlgcm react-is: 18.2.0 react-multi-select-component: 4.0.6_react@18.1.0 react-redux: 8.0.2_nfqigfgwurfoimtkde74cji6ga @@ -4957,6 +4959,12 @@ packages: /globrex/0.1.2: resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + /goober/2.1.10: + resolution: {integrity: sha512-7PpuQMH10jaTWm33sQgBQvz45pHR8N4l3Cu3WMGEWmHShAcTuuP7I+5/DwKo39fwti5A80WAjvqgz6SSlgWmGA==} + peerDependencies: + csstype: ^3.0.10 + dev: false + /graceful-fs/4.2.10: resolution: {integrity: sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==} @@ -6632,6 +6640,20 @@ packages: react: 18.1.0 dev: false + /react-hot-toast/2.3.0_ef5jwxihqo6n7gxfmzogljlgcm: + resolution: {integrity: sha512-/RxV+bfjld7tSJR1SCLzMAXgFuNW7fCpK6+vbYqfmbGSWcqTMz2rizrvfWKvtcPH5HK0NqxmBaC5SrAy1F42zA==} + engines: {node: '>=10'} + peerDependencies: + react: '>=16' + react-dom: '>=16' + dependencies: + goober: 2.1.10 + react: 18.1.0 + react-dom: 18.1.0_react@18.1.0 + transitivePeerDependencies: + - csstype + dev: false + /react-is/16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} diff --git a/kafka-ui-react-app/src/components/Alerts/Alerts.tsx b/kafka-ui-react-app/src/components/Alerts/Alerts.tsx deleted file mode 100644 index 11249dceae..0000000000 --- a/kafka-ui-react-app/src/components/Alerts/Alerts.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import React from 'react'; -import { alertDissmissed, selectAll } from 'redux/reducers/alerts/alertsSlice'; -import { useAppSelector, useAppDispatch } from 'lib/hooks/redux'; -import Alert from 'components/Alerts/Alert'; - -const Alerts: React.FC = () => { - const alerts = useAppSelector(selectAll); - const dispatch = useAppDispatch(); - const dismiss = (id: string) => () => { - dispatch(alertDissmissed(id)); - }; - - return ( - <> - {alerts.map(({ id, type, title, message }) => ( - - ))} - - ); -}; - -export default Alerts; diff --git a/kafka-ui-react-app/src/components/Alerts/__tests__/Alerts.spec.tsx b/kafka-ui-react-app/src/components/Alerts/__tests__/Alerts.spec.tsx deleted file mode 100644 index ee9fd0bacf..0000000000 --- a/kafka-ui-react-app/src/components/Alerts/__tests__/Alerts.spec.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import React from 'react'; -import { ServerResponse } from 'redux/interfaces'; -import { act, screen } from '@testing-library/react'; -import Alerts from 'components/Alerts/Alerts'; -import { render } from 'lib/testHelpers'; -import { store } from 'redux/store'; -import { UnknownAsyncThunkRejectedWithValueAction } from '@reduxjs/toolkit/dist/matchers'; -import userEvent from '@testing-library/user-event'; - -const payload: ServerResponse = { - status: 422, - statusText: 'Unprocessable Entity', - message: 'Unprocessable Entity', - url: 'https://test.com/clusters', -}; -const action: UnknownAsyncThunkRejectedWithValueAction = { - type: 'any/action/rejected', - payload, - meta: { - arg: 'test', - requestId: 'test-request-id', - requestStatus: 'rejected', - aborted: false, - condition: false, - rejectedWithValue: true, - }, - error: { message: 'Rejected' }, -}; - -describe('Alerts', () => { - it('renders alerts', async () => { - store.dispatch(action); - - await act(() => { - render(, { store }); - }); - - expect(screen.getAllByRole('alert').length).toEqual(1); - - const dissmissAlertButtons = screen.getAllByRole('button'); - expect(dissmissAlertButtons.length).toEqual(1); - - const dissmissButton = dissmissAlertButtons[0]; - - userEvent.click(dissmissButton); - - expect(screen.queryAllByRole('alert').length).toEqual(0); - }); -}); diff --git a/kafka-ui-react-app/src/components/App.styled.ts b/kafka-ui-react-app/src/components/App.styled.ts index cd79fe6249..71353f0876 100644 --- a/kafka-ui-react-app/src/components/App.styled.ts +++ b/kafka-ui-react-app/src/components/App.styled.ts @@ -44,7 +44,9 @@ export const Sidebar = styled.div<{ $visible: boolean }>( background: ${theme.menu.backgroundColor.normal}; @media screen and (max-width: 1023px) { ${$visible && - `transform: translate3d(${theme.layout.navBarWidth}, 0, 0)`}; + css` + transform: translate3d(${theme.layout.navBarWidth}, 0, 0); + `}; left: -${theme.layout.navBarWidth}; z-index: 100; } @@ -234,19 +236,6 @@ export const Hyperlink = styled(Link)( ` ); -export const AlertsContainer = styled.div` - max-width: 40%; - width: 500px; - position: fixed; - bottom: 15px; - right: 15px; - z-index: 1000; - - @media screen and (max-width: 1023px) { - max-width: initial; - } -`; - export const LogoutButton = styled(Button)( ({ theme }) => css` color: ${theme.button.primary.invertedColors.normal}; diff --git a/kafka-ui-react-app/src/components/App.tsx b/kafka-ui-react-app/src/components/App.tsx index 0d23fc70b3..9de19134ea 100644 --- a/kafka-ui-react-app/src/components/App.tsx +++ b/kafka-ui-react-app/src/components/App.tsx @@ -7,10 +7,11 @@ import PageLoader from 'components/common/PageLoader/PageLoader'; import Dashboard from 'components/Dashboard/Dashboard'; import ClusterPage from 'components/Cluster/Cluster'; import Version from 'components/Version/Version'; -import Alerts from 'components/Alerts/Alerts'; import { ThemeProvider } from 'styled-components'; import theme from 'theme/theme'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { showServerError } from 'lib/errorHandling'; +import { Toaster } from 'react-hot-toast'; import * as S from './App.styled'; import Logo from './common/Logo/Logo'; @@ -22,6 +23,11 @@ const queryClient = new QueryClient({ queries: { suspense: true, }, + mutations: { + onError(error) { + showServerError(error as Response); + }, + }, }, }); @@ -113,9 +119,7 @@ const App: React.FC = () => { /> - - - + diff --git a/kafka-ui-react-app/src/components/KsqlDb/Query/Query.tsx b/kafka-ui-react-app/src/components/KsqlDb/Query/Query.tsx index 6dfee76000..d7ddd6df17 100644 --- a/kafka-ui-react-app/src/components/KsqlDb/Query/Query.tsx +++ b/kafka-ui-react-app/src/components/KsqlDb/Query/Query.tsx @@ -8,17 +8,14 @@ import { import { getKsqlExecution } from 'redux/reducers/ksqlDb/selectors'; import { BASE_PARAMS } from 'lib/constants'; import { KsqlResponse, KsqlTableResponse } from 'generated-sources'; -import { alertAdded, alertDissmissed } from 'redux/reducers/alerts/alertsSlice'; -import now from 'lodash/now'; import { ClusterNameRoute } from 'lib/paths'; import { useAppDispatch, useAppSelector } from 'lib/hooks/redux'; +import { showAlert, showSuccessAlert } from 'lib/errorHandling'; import type { FormValues } from './QueryForm/QueryForm'; import * as S from './Query.styled'; import QueryForm from './QueryForm/QueryForm'; -const AUTO_DISMISS_TIME = 8_000; - export const getFormattedErrorFromTableData = ( responseValues: KsqlTableResponse['values'] ): { title: string; message: string } => { @@ -116,15 +113,7 @@ const Query: FC = () => { table.values ); const id = `${url}-executionError`; - dispatch( - alertAdded({ - id, - type: 'error', - title, - message, - createdAt: now(), - }) - ); + showAlert('error', { id, title, message }); break; } case 'Schema': { @@ -146,19 +135,7 @@ const Query: FC = () => { } case 'Query Result': { const id = `${url}-querySuccess`; - dispatch( - alertAdded({ - id, - type: 'success', - title: 'Query succeed', - message: '', - createdAt: now(), - }) - ); - - setTimeout(() => { - dispatch(alertDissmissed(id)); - }, AUTO_DISMISS_TIME); + showSuccessAlert({ id, title: 'Query succeed', message: '' }); break; } case 'Source Description': @@ -175,20 +152,11 @@ const Query: FC = () => { sse.onerror = () => { // if it's open - we know that server responded without opening SSE if (!sseRef.current.isOpen) { - const id = `${url}-connectionClosedError`; - dispatch( - alertAdded({ - id, - type: 'error', - title: 'SSE connection closed', - message: '', - createdAt: now(), - }) - ); - - setTimeout(() => { - dispatch(alertDissmissed(id)); - }, AUTO_DISMISS_TIME); + showAlert('error', { + id: `${url}-connectionClosedError`, + title: '', + message: 'SSE connection closed', + }); } destroySSE(); }; diff --git a/kafka-ui-react-app/src/components/Schemas/Details/Details.tsx b/kafka-ui-react-app/src/components/Schemas/Details/Details.tsx index 299b0984b1..9424409d76 100644 --- a/kafka-ui-react-app/src/components/Schemas/Details/Details.tsx +++ b/kafka-ui-react-app/src/components/Schemas/Details/Details.tsx @@ -26,8 +26,7 @@ import { selectAllSchemaVersions, getSchemaLatest, } from 'redux/reducers/schemas/schemasSlice'; -import { serverErrorAlertAdded } from 'redux/reducers/alerts/alertsSlice'; -import { getResponse } from 'lib/errorHandling'; +import { showServerError } from 'lib/errorHandling'; import { resetLoaderById } from 'redux/reducers/loader/loaderSlice'; import { TableTitle } from 'components/common/table/TableTitle/TableTitle.styled'; import useAppParams from 'lib/hooks/useAppParams'; @@ -73,8 +72,7 @@ const Details: React.FC = () => { }); navigate('../'); } catch (e) { - const err = await getResponse(e as Response); - dispatch(serverErrorAlertAdded(err)); + showServerError(e as Response); } }; diff --git a/kafka-ui-react-app/src/components/Schemas/Edit/Edit.tsx b/kafka-ui-react-app/src/components/Schemas/Edit/Edit.tsx index 1b960675ec..7f4ea4491b 100644 --- a/kafka-ui-react-app/src/components/Schemas/Edit/Edit.tsx +++ b/kafka-ui-react-app/src/components/Schemas/Edit/Edit.tsx @@ -22,11 +22,10 @@ import { getAreSchemaLatestFulfilled, schemaUpdated, } from 'redux/reducers/schemas/schemasSlice'; -import { serverErrorAlertAdded } from 'redux/reducers/alerts/alertsSlice'; -import { getResponse } from 'lib/errorHandling'; import PageLoader from 'components/common/PageLoader/PageLoader'; import { resetLoaderById } from 'redux/reducers/loader/loaderSlice'; import { schemasApiClient } from 'lib/api'; +import { showServerError } from 'lib/errorHandling'; import * as S from './Edit.styled'; @@ -91,8 +90,7 @@ const Edit: React.FC = () => { navigate(clusterSchemaPath(clusterName, subject)); } catch (e) { - const err = await getResponse(e as Response); - dispatch(serverErrorAlertAdded(err)); + showServerError(e as Response); } }; diff --git a/kafka-ui-react-app/src/components/Schemas/List/GlobalSchemaSelector/GlobalSchemaSelector.tsx b/kafka-ui-react-app/src/components/Schemas/List/GlobalSchemaSelector/GlobalSchemaSelector.tsx index dfe84c8f02..0bdd601a6f 100644 --- a/kafka-ui-react-app/src/components/Schemas/List/GlobalSchemaSelector/GlobalSchemaSelector.tsx +++ b/kafka-ui-react-app/src/components/Schemas/List/GlobalSchemaSelector/GlobalSchemaSelector.tsx @@ -2,15 +2,14 @@ import React from 'react'; import ConfirmationModal from 'components/common/ConfirmationModal/ConfirmationModal'; import Select from 'components/common/Select/Select'; import { CompatibilityLevelCompatibilityEnum } from 'generated-sources'; -import { getResponse } from 'lib/errorHandling'; import { useAppDispatch } from 'lib/hooks/redux'; import usePagination from 'lib/hooks/usePagination'; import useSearch from 'lib/hooks/useSearch'; import useAppParams from 'lib/hooks/useAppParams'; -import { serverErrorAlertAdded } from 'redux/reducers/alerts/alertsSlice'; import { fetchSchemas } from 'redux/reducers/schemas/schemasSlice'; import { ClusterNameRoute } from 'lib/paths'; import { schemasApiClient } from 'lib/api'; +import { showServerError } from 'lib/errorHandling'; import * as S from './GlobalSchemaSelector.styled'; @@ -69,8 +68,7 @@ const GlobalSchemaSelector: React.FC = () => { fetchSchemas({ clusterName, page, perPage, search: searchText }) ); } catch (e) { - const err = await getResponse(e as Response); - dispatch(serverErrorAlertAdded(err)); + showServerError(e as Response); } } setIsUpdating(false); diff --git a/kafka-ui-react-app/src/components/Schemas/New/New.tsx b/kafka-ui-react-app/src/components/Schemas/New/New.tsx index e1474c1583..c3d9ba7003 100644 --- a/kafka-ui-react-app/src/components/Schemas/New/New.tsx +++ b/kafka-ui-react-app/src/components/Schemas/New/New.tsx @@ -16,8 +16,7 @@ import PageHeading from 'components/common/PageHeading/PageHeading'; import { schemaAdded } from 'redux/reducers/schemas/schemasSlice'; import { useAppDispatch } from 'lib/hooks/redux'; import useAppParams from 'lib/hooks/useAppParams'; -import { serverErrorAlertAdded } from 'redux/reducers/alerts/alertsSlice'; -import { getResponse } from 'lib/errorHandling'; +import { showServerError } from 'lib/errorHandling'; import { schemasApiClient } from 'lib/api'; import * as S from './New.styled'; @@ -58,8 +57,7 @@ const New: React.FC = () => { dispatch(schemaAdded(resp)); navigate(clusterSchemaPath(clusterName, subject)); } catch (e) { - const err = await getResponse(e as Response); - dispatch(serverErrorAlertAdded(err)); + showServerError(e as Response); } }; diff --git a/kafka-ui-react-app/src/components/Topics/Topic/SendMessage/SendMessage.tsx b/kafka-ui-react-app/src/components/Topics/Topic/SendMessage/SendMessage.tsx index 5889b7cb3b..3e7734a111 100644 --- a/kafka-ui-react-app/src/components/Topics/Topic/SendMessage/SendMessage.tsx +++ b/kafka-ui-react-app/src/components/Topics/Topic/SendMessage/SendMessage.tsx @@ -11,8 +11,6 @@ import { fetchTopicDetails, } from 'redux/reducers/topics/topicsSlice'; import { useAppDispatch, useAppSelector } from 'lib/hooks/redux'; -import { alertAdded } from 'redux/reducers/alerts/alertsSlice'; -import now from 'lodash/now'; import { Button } from 'components/common/Button/Button'; import Editor from 'components/common/Editor/Editor'; import PageLoader from 'components/common/PageLoader/PageLoader'; @@ -25,7 +23,7 @@ import Select, { SelectOption } from 'components/common/Select/Select'; import useAppParams from 'lib/hooks/useAppParams'; import Heading from 'components/common/heading/Heading.styled'; import { messagesApiClient } from 'lib/api'; -import { getResponse } from 'lib/errorHandling'; +import { showAlert, showServerError } from 'lib/errorHandling'; import validateMessage from './validateMessage'; import * as S from './SendMessage.styled'; @@ -123,15 +121,11 @@ const SendMessage: React.FC = () => { } if (errors.length > 0) { const errorsHtml = errors.map((e) => `
  • ${e}
  • `).join(''); - dispatch( - alertAdded({ - id: `${clusterName}-${topicName}-createTopicMessageError`, - type: 'error', - title: 'Validation Error', - message: `
      ${errorsHtml}
    `, - createdAt: now(), - }) - ); + showAlert('error', { + id: `${clusterName}-${topicName}-createTopicMessageError`, + title: 'Validation Error', + message: `
      ${errorsHtml}
    `, + }); return; } const headers = data.headers ? JSON.parse(data.headers) : undefined; @@ -148,16 +142,10 @@ const SendMessage: React.FC = () => { }); dispatch(fetchTopicDetails({ clusterName, topicName })); } catch (e) { - const err = await getResponse(e as Response); - dispatch( - alertAdded({ - id: `${clusterName}-${topicName}-sendTopicMessagesError`, - type: 'error', - title: `Error in sending a message to ${topicName}`, - message: err?.message || '', - createdAt: now(), - }) - ); + showServerError(e as Response, { + id: `${clusterName}-${topicName}-sendTopicMessagesError`, + message: `Error in sending a message to ${topicName}`, + }); } navigate(`../${clusterTopicMessagesRelativePath}`); } diff --git a/kafka-ui-react-app/src/components/Topics/Topic/SendMessage/__test__/SendMessage.spec.tsx b/kafka-ui-react-app/src/components/Topics/Topic/SendMessage/__test__/SendMessage.spec.tsx index ec37ec3320..76610d820a 100644 --- a/kafka-ui-react-app/src/components/Topics/Topic/SendMessage/__test__/SendMessage.spec.tsx +++ b/kafka-ui-react-app/src/components/Topics/Topic/SendMessage/__test__/SendMessage.spec.tsx @@ -12,8 +12,7 @@ import { store } from 'redux/store'; import { fetchTopicDetails } from 'redux/reducers/topics/topicsSlice'; import { externalTopicPayload } from 'redux/reducers/topics/__test__/fixtures'; import validateMessage from 'components/Topics/Topic/SendMessage/validateMessage'; -import Alerts from 'components/Alerts/Alerts'; -import * as S from 'components/App.styled'; +import { showServerError } from 'lib/errorHandling'; import { testSchema } from './fixtures'; @@ -32,6 +31,11 @@ jest.mock('components/Topics/Topic/SendMessage/validateMessage', () => jest.fn() ); +jest.mock('lib/errorHandling', () => ({ + ...jest.requireActual('lib/errorHandling'), + showServerError: jest.fn(), +})); + const mockNavigate = jest.fn(); jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), @@ -44,14 +48,9 @@ const topicName = externalTopicPayload.name; const renderComponent = async () => { await act(() => { render( - <> - - - - - - - , + + + , { initialEntries: [clusterTopicSendMessagePath(clusterName, topicName)], store, @@ -101,9 +100,7 @@ describe('SendMessage', () => { `/api/clusters/${clusterName}/topics/${topicName}/messages/schema`, testSchema ); - await act(() => { - renderComponent(); - }); + await renderComponent(); expect(fetchTopicMessageSchemaMock.called()).toBeTruthy(); }); @@ -121,7 +118,6 @@ describe('SendMessage', () => { it('calls sendTopicMessage on submit', async () => { const sendTopicMessageMock = fetchMock.postOnce(messagesUrl, 200); const fetchTopicDetailsMock = fetchMock.getOnce(detailsUrl, 200); - await renderAndSubmitData(); expect(sendTopicMessageMock.called(messagesUrl)).toBeTruthy(); expect(fetchTopicDetailsMock.called(detailsUrl)).toBeTruthy(); @@ -131,6 +127,8 @@ describe('SendMessage', () => { }); it('should make the sendTopicMessage but most find an error within it', async () => { + const showServerErrorMock = jest.fn(); + (showServerError as jest.Mock).mockImplementation(showServerErrorMock); const sendTopicMessageMock = fetchMock.postOnce(messagesUrl, { throws: 'Error', }); @@ -138,7 +136,12 @@ describe('SendMessage', () => { await renderAndSubmitData(); expect(sendTopicMessageMock.called()).toBeTruthy(); expect(fetchTopicDetailsMock.called(detailsUrl)).toBeFalsy(); - expect(screen.getByRole('alert')).toBeInTheDocument(); + + expect(showServerErrorMock).toHaveBeenCalledWith('Error', { + id: 'testCluster-external.topic-sendTopicMessagesError', + message: 'Error in sending a message to external.topic', + }); + expect(mockNavigate).toHaveBeenLastCalledWith( `../${clusterTopicMessagesRelativePath}` ); diff --git a/kafka-ui-react-app/src/components/Alerts/Alert.styled.ts b/kafka-ui-react-app/src/components/common/Alert/Alert.styled.ts similarity index 86% rename from kafka-ui-react-app/src/components/Alerts/Alert.styled.ts rename to kafka-ui-react-app/src/components/common/Alert/Alert.styled.ts index 298f874903..179db51ead 100644 --- a/kafka-ui-react-app/src/components/Alerts/Alert.styled.ts +++ b/kafka-ui-react-app/src/components/common/Alert/Alert.styled.ts @@ -1,7 +1,7 @@ -import { AlertType } from 'redux/interfaces'; +import { ToastType } from 'react-hot-toast'; import styled from 'styled-components'; -export const Alert = styled.div<{ $type: AlertType }>` +export const Alert = styled.div<{ $type: ToastType }>` background-color: ${({ $type, theme }) => theme.alert.color[$type]}; min-width: 400px; min-height: 64px; diff --git a/kafka-ui-react-app/src/components/Alerts/Alert.tsx b/kafka-ui-react-app/src/components/common/Alert/Alert.tsx similarity index 79% rename from kafka-ui-react-app/src/components/Alerts/Alert.tsx rename to kafka-ui-react-app/src/components/common/Alert/Alert.tsx index af564b2396..6a82e6f13c 100644 --- a/kafka-ui-react-app/src/components/Alerts/Alert.tsx +++ b/kafka-ui-react-app/src/components/common/Alert/Alert.tsx @@ -1,14 +1,14 @@ import React from 'react'; import CloseIcon from 'components/common/Icons/CloseIcon'; import IconButtonWrapper from 'components/common/Icons/IconButtonWrapper'; -import { Alert as AlertType } from 'redux/interfaces'; +import { ToastType } from 'react-hot-toast'; import * as S from './Alert.styled'; -interface AlertProps { - title: AlertType['title']; - type: AlertType['type']; - message: AlertType['message']; +export interface AlertProps { + title: string; + type: ToastType; + message: string; onDissmiss(): void; } @@ -21,7 +21,6 @@ const Alert: React.FC = ({ title, type, message, onDissmiss }) => ( dangerouslySetInnerHTML={{ __html: message }} /> - diff --git a/kafka-ui-react-app/src/components/Alerts/__tests__/Alert.spec.tsx b/kafka-ui-react-app/src/components/common/Alert/__tests__/Alert.spec.tsx similarity index 87% rename from kafka-ui-react-app/src/components/Alerts/__tests__/Alert.spec.tsx rename to kafka-ui-react-app/src/components/common/Alert/__tests__/Alert.spec.tsx index 382eff6fe9..a77f451b5e 100644 --- a/kafka-ui-react-app/src/components/Alerts/__tests__/Alert.spec.tsx +++ b/kafka-ui-react-app/src/components/common/Alert/__tests__/Alert.spec.tsx @@ -1,11 +1,9 @@ import React from 'react'; import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { Alert as AlertProps } from 'redux/interfaces'; -import Alert from 'components/Alerts/Alert'; import { render } from 'lib/testHelpers'; +import Alert, { AlertProps } from 'components/common/Alert/Alert'; -const id = 'test-id'; const title = 'My Alert Title'; const message = 'My Alert Message'; const dismiss = jest.fn(); @@ -14,7 +12,6 @@ describe('Alert', () => { const setupComponent = (props: Partial = {}) => render( > = ({ diff --git a/kafka-ui-react-app/src/components/common/Metrics/Metrics.styled.tsx b/kafka-ui-react-app/src/components/common/Metrics/Metrics.styled.tsx index 9924c5ba13..2d66121891 100644 --- a/kafka-ui-react-app/src/components/common/Metrics/Metrics.styled.tsx +++ b/kafka-ui-react-app/src/components/common/Metrics/Metrics.styled.tsx @@ -1,5 +1,4 @@ import styled, { css } from 'styled-components'; -import { AlertType } from 'redux/interfaces'; export const Wrapper = styled.div` padding: 1.5rem 1rem; @@ -75,7 +74,7 @@ export const CircularAlert = styled.circle.attrs({ cy: 2, r: 2, })<{ - $type: AlertType; + $type: 'error' | 'success' | 'warning' | 'info'; }>( ({ theme, $type }) => css` fill: ${theme.circularAlert.color[$type]}; diff --git a/kafka-ui-react-app/src/lib/errorHandling.ts b/kafka-ui-react-app/src/lib/errorHandling.ts deleted file mode 100644 index 1240a666c7..0000000000 --- a/kafka-ui-react-app/src/lib/errorHandling.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { ServerResponse } from 'redux/interfaces'; - -export const getResponse = async ( - response: Response -): Promise => { - let body; - try { - body = await response.json(); - } catch (e) { - // do nothing; - } - return { - status: response.status, - statusText: response.statusText, - url: response.url, - message: body?.message, - }; -}; diff --git a/kafka-ui-react-app/src/lib/errorHandling.tsx b/kafka-ui-react-app/src/lib/errorHandling.tsx new file mode 100644 index 0000000000..b839cfff35 --- /dev/null +++ b/kafka-ui-react-app/src/lib/errorHandling.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import Alert from 'components/common/Alert/Alert'; +import toast, { ToastType } from 'react-hot-toast'; +import { ErrorResponse } from 'generated-sources'; + +interface ServerResponse { + status: number; + statusText: string; + url?: string; + message?: ErrorResponse['message']; +} + +export const getResponse = async ( + response: Response +): Promise => { + let body; + try { + body = await response.json(); + } catch (e) { + // do nothing; + } + return { + status: response.status, + statusText: response.statusText, + url: response.url, + message: body?.message, + }; +}; + +interface AlertOptions { + id?: string; + title?: string; + message: string; +} + +export const showAlert = ( + type: ToastType, + { title, message, id }: AlertOptions +) => { + toast.custom( + (t) => ( + toast.remove(t.id)} + /> + ), + { id } + ); +}; + +export const showSuccessAlert = async (options: AlertOptions) => { + showAlert('success', { + ...options, + title: options.title || 'Success', + }); +}; + +export const showServerError = async ( + response: Response, + options?: AlertOptions +) => { + let body: Record = {}; + try { + body = await response.json(); + } catch (e) { + // do nothing; + } + showAlert('error', { + id: response.url, + title: `${response.status} ${response.statusText}`, + message: body?.message || 'An error occurred', + ...options, + }); +}; diff --git a/kafka-ui-react-app/src/lib/hooks/useDataSaver.ts b/kafka-ui-react-app/src/lib/hooks/useDataSaver.ts index 9170865958..20c9cd2ac2 100644 --- a/kafka-ui-react-app/src/lib/hooks/useDataSaver.ts +++ b/kafka-ui-react-app/src/lib/hooks/useDataSaver.ts @@ -1,29 +1,20 @@ import isObject from 'lodash/isObject'; -import { alertAdded, alertDissmissed } from 'redux/reducers/alerts/alertsSlice'; -import { useAppDispatch } from 'lib/hooks/redux'; - -const AUTO_DISMISS_TIME = 2000; +import { showSuccessAlert } from 'lib/errorHandling'; const useDataSaver = ( subject: string, data: Record | string ) => { - const dispatch = useAppDispatch(); const copyToClipboard = () => { if (navigator.clipboard) { const str = typeof data === 'string' ? String(data) : JSON.stringify(data); navigator.clipboard.writeText(str); - dispatch( - alertAdded({ - id: subject, - type: 'success', - title: '', - message: 'Copied successfully!', - createdAt: Date.now(), - }) - ); - setTimeout(() => dispatch(alertDissmissed(subject)), AUTO_DISMISS_TIME); + showSuccessAlert({ + id: subject, + title: '', + message: 'Copied successfully!', + }); } }; diff --git a/kafka-ui-react-app/src/redux/interfaces/alerts.ts b/kafka-ui-react-app/src/redux/interfaces/alerts.ts deleted file mode 100644 index 7473ec7f00..0000000000 --- a/kafka-ui-react-app/src/redux/interfaces/alerts.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { ErrorResponse } from 'generated-sources'; - -export interface ServerResponse { - status: number; - statusText: string; - url?: string; - message?: ErrorResponse['message']; -} - -export type AlertType = 'error' | 'success' | 'warning' | 'info'; - -export interface Alert { - id: string; - type: AlertType; - title: string; - message: string; - response?: ServerResponse; - createdAt: number; -} diff --git a/kafka-ui-react-app/src/redux/interfaces/index.ts b/kafka-ui-react-app/src/redux/interfaces/index.ts index c77496e848..0c8ddf234e 100644 --- a/kafka-ui-react-app/src/redux/interfaces/index.ts +++ b/kafka-ui-react-app/src/redux/interfaces/index.ts @@ -6,7 +6,6 @@ export * from './cluster'; export * from './consumerGroup'; export * from './schema'; export * from './loader'; -export * from './alerts'; export type RootState = ReturnType; export type AppDispatch = typeof store.dispatch; diff --git a/kafka-ui-react-app/src/redux/reducers/alerts/alertsSlice.ts b/kafka-ui-react-app/src/redux/reducers/alerts/alertsSlice.ts deleted file mode 100644 index 1890ceb025..0000000000 --- a/kafka-ui-react-app/src/redux/reducers/alerts/alertsSlice.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { - createAsyncThunk, - createEntityAdapter, - createSlice, - nanoid, - PayloadAction, -} from '@reduxjs/toolkit'; -import { UnknownAsyncThunkRejectedWithValueAction } from '@reduxjs/toolkit/dist/matchers'; -import now from 'lodash/now'; -import { Alert, RootState, ServerResponse } from 'redux/interfaces'; - -const alertsAdapter = createEntityAdapter({ - selectId: (alert) => alert.id, -}); - -const isServerResponse = (payload: unknown): payload is ServerResponse => { - if ((payload as ServerResponse).status) { - return true; - } - return false; -}; - -const transformResponseToAlert = (payload: ServerResponse) => { - const { status, statusText, message, url } = payload; - const alert: Alert = { - id: url || nanoid(), - type: 'error', - title: `${status} ${statusText}`, - message: message || '', - response: payload, - createdAt: now(), - }; - - return alert; -}; - -const alertsSlice = createSlice({ - name: 'alerts', - initialState: alertsAdapter.getInitialState(), - reducers: { - alertDissmissed: alertsAdapter.removeOne, - alertAdded(state, action: PayloadAction) { - alertsAdapter.upsertOne(state, action.payload); - }, - serverErrorAlertAdded: ( - state, - { payload }: PayloadAction - ) => { - alertsAdapter.upsertOne(state, transformResponseToAlert(payload)); - }, - }, - extraReducers: (builder) => { - builder.addMatcher( - (action): action is UnknownAsyncThunkRejectedWithValueAction => - action.type.endsWith('/rejected'), - (state, { meta, payload }) => { - const { rejectedWithValue } = meta; - if (rejectedWithValue && isServerResponse(payload)) { - alertsAdapter.upsertOne(state, transformResponseToAlert(payload)); - } - } - ); - }, -}); - -export const { selectAll } = alertsAdapter.getSelectors( - (state) => state.alerts -); - -export const { alertDissmissed, alertAdded, serverErrorAlertAdded } = - alertsSlice.actions; - -export const showSuccessAlert = createAsyncThunk< - number, - { id: string; message: string }, - { fulfilledMeta: null } ->( - 'alerts/showSuccessAlert', - async ({ id, message }, { dispatch, fulfillWithValue }) => { - const creationDate = Date.now(); - - dispatch( - alertAdded({ - id, - message, - title: '', - type: 'success', - createdAt: creationDate, - }) - ); - - setTimeout(() => { - dispatch(alertDissmissed(id)); - }, 3000); - - return fulfillWithValue(creationDate, null); - } -); - -export default alertsSlice.reducer; diff --git a/kafka-ui-react-app/src/redux/reducers/consumerGroups/consumerGroupsSlice.ts b/kafka-ui-react-app/src/redux/reducers/consumerGroups/consumerGroupsSlice.ts index 063ca58609..2b7709b009 100644 --- a/kafka-ui-react-app/src/redux/reducers/consumerGroups/consumerGroupsSlice.ts +++ b/kafka-ui-react-app/src/redux/reducers/consumerGroups/consumerGroupsSlice.ts @@ -12,7 +12,11 @@ import { SortOrder, } from 'generated-sources'; import { AsyncRequestStatus } from 'lib/constants'; -import { getResponse } from 'lib/errorHandling'; +import { + getResponse, + showServerError, + showSuccessAlert, +} from 'lib/errorHandling'; import { ClusterName, ConsumerGroupID, @@ -49,6 +53,7 @@ export const fetchConsumerGroupsPaged = createAsyncThunk< search, }); } catch (error) { + showServerError(error as Response); return rejectWithValue(await getResponse(error as Response)); } } @@ -66,6 +71,7 @@ export const fetchConsumerGroupDetails = createAsyncThunk< id: consumerGroupID, }); } catch (error) { + showServerError(error as Response); return rejectWithValue(await getResponse(error as Response)); } } @@ -82,9 +88,12 @@ export const deleteConsumerGroup = createAsyncThunk< clusterName, id: consumerGroupID, }); - + showSuccessAlert({ + message: `Consumer ${consumerGroupID} group deleted`, + }); return consumerGroupID; } catch (error) { + showServerError(error as Response); return rejectWithValue(await getResponse(error as Response)); } } @@ -114,8 +123,12 @@ export const resetConsumerGroupOffsets = createAsyncThunk< resetToTimestamp: requestBody.resetToTimestamp?.getTime(), }, }); + showSuccessAlert({ + message: `Consumer ${consumerGroupID} group offsets reset`, + }); return consumerGroupID; } catch (error) { + showServerError(error as Response); return rejectWithValue(await getResponse(error as Response)); } } diff --git a/kafka-ui-react-app/src/redux/reducers/index.ts b/kafka-ui-react-app/src/redux/reducers/index.ts index ee5421f89e..92b9246318 100644 --- a/kafka-ui-react-app/src/redux/reducers/index.ts +++ b/kafka-ui-react-app/src/redux/reducers/index.ts @@ -1,6 +1,5 @@ import { combineReducers } from '@reduxjs/toolkit'; import loader from 'redux/reducers/loader/loaderSlice'; -import alerts from 'redux/reducers/alerts/alertsSlice'; import schemas from 'redux/reducers/schemas/schemasSlice'; import topicMessages from 'redux/reducers/topicMessages/topicMessagesSlice'; import topics from 'redux/reducers/topics/topicsSlice'; @@ -9,7 +8,6 @@ import ksqlDb from 'redux/reducers/ksqlDb/ksqlDbSlice'; export default combineReducers({ loader, - alerts, topics, topicMessages, consumerGroups, diff --git a/kafka-ui-react-app/src/redux/reducers/schemas/schemasSlice.ts b/kafka-ui-react-app/src/redux/reducers/schemas/schemasSlice.ts index 7d52b78741..049e0bedd7 100644 --- a/kafka-ui-react-app/src/redux/reducers/schemas/schemasSlice.ts +++ b/kafka-ui-react-app/src/redux/reducers/schemas/schemasSlice.ts @@ -12,7 +12,7 @@ import { } from 'generated-sources'; import { schemasApiClient } from 'lib/api'; import { AsyncRequestStatus } from 'lib/constants'; -import { getResponse } from 'lib/errorHandling'; +import { getResponse, showServerError } from 'lib/errorHandling'; import { ClusterName, RootState } from 'redux/interfaces'; import { createFetchingSelector } from 'redux/reducers/loader/selectors'; @@ -24,6 +24,7 @@ export const fetchLatestSchema = createAsyncThunk< try { return await schemasApiClient.getLatestSchema(schemaParams); } catch (error) { + showServerError(error as Response); return rejectWithValue(await getResponse(error as Response)); } }); @@ -43,6 +44,7 @@ export const fetchSchemas = createAsyncThunk< search: search || undefined, }); } catch (error) { + showServerError(error as Response); return rejectWithValue(await getResponse(error as Response)); } } @@ -61,6 +63,7 @@ export const fetchSchemaVersions = createAsyncThunk< subject, }); } catch (error) { + showServerError(error as Response); return rejectWithValue(await getResponse(error as Response)); } } diff --git a/kafka-ui-react-app/src/redux/reducers/topicMessages/topicMessagesSlice.ts b/kafka-ui-react-app/src/redux/reducers/topicMessages/topicMessagesSlice.ts index 375c99e533..49e12d453e 100644 --- a/kafka-ui-react-app/src/redux/reducers/topicMessages/topicMessagesSlice.ts +++ b/kafka-ui-react-app/src/redux/reducers/topicMessages/topicMessagesSlice.ts @@ -1,8 +1,11 @@ import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; import { TopicMessagesState, ClusterName, TopicName } from 'redux/interfaces'; import { TopicMessage } from 'generated-sources'; -import { getResponse } from 'lib/errorHandling'; -import { showSuccessAlert } from 'redux/reducers/alerts/alertsSlice'; +import { + getResponse, + showServerError, + showSuccessAlert, +} from 'lib/errorHandling'; import { fetchTopicDetails } from 'redux/reducers/topics/topicsSlice'; import { messagesApiClient } from 'lib/api'; @@ -22,15 +25,14 @@ export const clearTopicMessages = createAsyncThunk< partitions, }); dispatch(fetchTopicDetails({ clusterName, topicName })); - dispatch( - showSuccessAlert({ - id: `message-${topicName}-${clusterName}-${partitions}`, - message: 'Messages successfully cleared!', - }) - ); + showSuccessAlert({ + id: `message-${topicName}-${clusterName}-${partitions}`, + message: 'Messages successfully cleared!', + }); return undefined; } catch (err) { + showServerError(err as Response); return rejectWithValue(await getResponse(err as Response)); } } diff --git a/kafka-ui-react-app/src/redux/reducers/topics/topicsSlice.ts b/kafka-ui-react-app/src/redux/reducers/topics/topicsSlice.ts index 629d747110..271cc7798a 100644 --- a/kafka-ui-react-app/src/redux/reducers/topics/topicsSlice.ts +++ b/kafka-ui-react-app/src/redux/reducers/topics/topicsSlice.ts @@ -25,9 +25,12 @@ import { TopicFormDataRaw, ClusterName, } from 'redux/interfaces'; -import { getResponse } from 'lib/errorHandling'; +import { + getResponse, + showServerError, + showSuccessAlert, +} from 'lib/errorHandling'; import { clearTopicMessages } from 'redux/reducers/topicMessages/topicMessagesSlice'; -import { showSuccessAlert } from 'redux/reducers/alerts/alertsSlice'; import { consumerGroupsApiClient, messagesApiClient, @@ -41,6 +44,7 @@ export const fetchTopicsList = createAsyncThunk< try { return await topicsApiClient.getTopics(payload); } catch (err) { + showServerError(err as Response); return rejectWithValue(await getResponse(err as Response)); } }); @@ -55,6 +59,7 @@ export const fetchTopicDetails = createAsyncThunk< return { topicDetails, topicName }; } catch (err) { + showServerError(err as Response); return rejectWithValue(await getResponse(err as Response)); } }); @@ -69,6 +74,7 @@ export const fetchTopicConfig = createAsyncThunk< return { topicConfig, topicName }; } catch (err) { + showServerError(err as Response); return rejectWithValue(await getResponse(err as Response)); } }); @@ -124,9 +130,12 @@ export const createTopic = createAsyncThunk< clusterName, topicCreation: formatTopicCreation(data), }); - + showSuccessAlert({ + message: `Topic ${data.name} created successfully`, + }); return undefined; } catch (err) { + showServerError(err as Response); return rejectWithValue(await getResponse(err as Response)); } }); @@ -143,6 +152,7 @@ export const fetchTopicConsumerGroups = createAsyncThunk< return { consumerGroups, topicName }; } catch (err) { + showServerError(err as Response); return rejectWithValue(await getResponse(err as Response)); } }); @@ -188,6 +198,7 @@ export const updateTopic = createAsyncThunk< return { topic }; } catch (err) { + showServerError(err as Response); return rejectWithValue(await getResponse(err as Response)); } } @@ -196,18 +207,17 @@ export const updateTopic = createAsyncThunk< export const deleteTopic = createAsyncThunk< { topicName: TopicName }, DeleteTopicRequest ->('topic/deleteTopic', async (payload, { rejectWithValue, dispatch }) => { +>('topic/deleteTopic', async (payload, { rejectWithValue }) => { try { const { topicName, clusterName } = payload; await topicsApiClient.deleteTopic(payload); - dispatch( - showSuccessAlert({ - id: `message-${topicName}-${clusterName}`, - message: 'Topic successfully deleted!', - }) - ); + showSuccessAlert({ + id: `message-${topicName}-${clusterName}`, + message: 'Topic successfully deleted!', + }); return { topicName }; } catch (err) { + showServerError(err as Response); return rejectWithValue(await getResponse(err as Response)); } }); @@ -215,19 +225,17 @@ export const deleteTopic = createAsyncThunk< export const recreateTopic = createAsyncThunk< { topic: Topic }, RecreateTopicRequest ->('topic/recreateTopic', async (payload, { rejectWithValue, dispatch }) => { +>('topic/recreateTopic', async (payload, { rejectWithValue }) => { try { const { topicName, clusterName } = payload; const topic = await topicsApiClient.recreateTopic(payload); - dispatch( - showSuccessAlert({ - id: `message-${topicName}-${clusterName}`, - message: 'Topic successfully recreated!', - }) - ); - + showSuccessAlert({ + id: `message-${topicName}-${clusterName}`, + message: 'Topic successfully recreated!', + }); return { topic }; } catch (err) { + showServerError(err as Response); return rejectWithValue(await getResponse(err as Response)); } }); @@ -241,6 +249,7 @@ export const fetchTopicMessageSchema = createAsyncThunk< const schema = await messagesApiClient.getTopicSchema(payload); return { schema, topicName }; } catch (err) { + showServerError(err as Response); return rejectWithValue(await getResponse(err as Response)); } }); @@ -263,15 +272,14 @@ export const updateTopicPartitionsCount = createAsyncThunk< topicName, partitionsIncrease: { totalPartitionsCount: partitions }, }); - dispatch( - showSuccessAlert({ - id: `message-${topicName}-${clusterName}-${partitions}`, - message: 'Number of partitions successfully increased!', - }) - ); + showSuccessAlert({ + id: `message-${topicName}-${clusterName}-${partitions}`, + message: 'Number of partitions successfully increased!', + }); dispatch(fetchTopicDetails({ clusterName, topicName })); return undefined; } catch (err) { + showServerError(err as Response); return rejectWithValue(await getResponse(err as Response)); } } @@ -298,6 +306,7 @@ export const updateTopicReplicationFactor = createAsyncThunk< return undefined; } catch (err) { + showServerError(err as Response); return rejectWithValue(await getResponse(err as Response)); } } @@ -320,6 +329,7 @@ export const deleteTopics = createAsyncThunk< return undefined; } catch (err) { + showServerError(err as Response); return rejectWithValue(await getResponse(err as Response)); } }); @@ -340,6 +350,7 @@ export const clearTopicsMessages = createAsyncThunk< return undefined; } catch (err) { + showServerError(err as Response); return rejectWithValue(await getResponse(err as Response)); } }); diff --git a/kafka-ui-react-app/src/theme/theme.ts b/kafka-ui-react-app/src/theme/theme.ts index c5d54a8b59..bf79a8148f 100644 --- a/kafka-ui-react-app/src/theme/theme.ts +++ b/kafka-ui-react-app/src/theme/theme.ts @@ -148,6 +148,9 @@ const theme = { success: Colors.green[10], warning: Colors.yellow[10], info: Colors.neutral[10], + loading: Colors.neutral[10], + blank: Colors.neutral[10], + custom: Colors.neutral[10], }, shadow: Colors.transparency[20], },