Ver código fonte

Use react-hot-toaster for alerts (#2312)

* Use react-hot-toaster for alerts

* Fix linting problems
Oleg Shur 2 anos atrás
pai
commit
bffe316063
30 arquivos alterados com 237 adições e 394 exclusões
  1. 1 0
      kafka-ui-react-app/package.json
  2. 22 0
      kafka-ui-react-app/pnpm-lock.yaml
  3. 0 28
      kafka-ui-react-app/src/components/Alerts/Alerts.tsx
  4. 0 49
      kafka-ui-react-app/src/components/Alerts/__tests__/Alerts.spec.tsx
  5. 3 14
      kafka-ui-react-app/src/components/App.styled.ts
  6. 8 4
      kafka-ui-react-app/src/components/App.tsx
  7. 8 40
      kafka-ui-react-app/src/components/KsqlDb/Query/Query.tsx
  8. 2 4
      kafka-ui-react-app/src/components/Schemas/Details/Details.tsx
  9. 2 4
      kafka-ui-react-app/src/components/Schemas/Edit/Edit.tsx
  10. 2 4
      kafka-ui-react-app/src/components/Schemas/List/GlobalSchemaSelector/GlobalSchemaSelector.tsx
  11. 2 4
      kafka-ui-react-app/src/components/Schemas/New/New.tsx
  12. 10 22
      kafka-ui-react-app/src/components/Topics/Topic/SendMessage/SendMessage.tsx
  13. 18 15
      kafka-ui-react-app/src/components/Topics/Topic/SendMessage/__test__/SendMessage.spec.tsx
  14. 2 2
      kafka-ui-react-app/src/components/common/Alert/Alert.styled.ts
  15. 5 6
      kafka-ui-react-app/src/components/common/Alert/Alert.tsx
  16. 1 4
      kafka-ui-react-app/src/components/common/Alert/__tests__/Alert.spec.tsx
  17. 1 2
      kafka-ui-react-app/src/components/common/Metrics/Indicator.tsx
  18. 1 2
      kafka-ui-react-app/src/components/common/Metrics/Metrics.styled.tsx
  19. 0 18
      kafka-ui-react-app/src/lib/errorHandling.ts
  20. 76 0
      kafka-ui-react-app/src/lib/errorHandling.tsx
  21. 6 15
      kafka-ui-react-app/src/lib/hooks/useDataSaver.ts
  22. 0 19
      kafka-ui-react-app/src/redux/interfaces/alerts.ts
  23. 0 1
      kafka-ui-react-app/src/redux/interfaces/index.ts
  24. 0 100
      kafka-ui-react-app/src/redux/reducers/alerts/alertsSlice.ts
  25. 15 2
      kafka-ui-react-app/src/redux/reducers/consumerGroups/consumerGroupsSlice.ts
  26. 0 2
      kafka-ui-react-app/src/redux/reducers/index.ts
  27. 4 1
      kafka-ui-react-app/src/redux/reducers/schemas/schemasSlice.ts
  28. 10 8
      kafka-ui-react-app/src/redux/reducers/topicMessages/topicMessagesSlice.ts
  29. 35 24
      kafka-ui-react-app/src/redux/reducers/topics/topicsSlice.ts
  30. 3 0
      kafka-ui-react-app/src/theme/theme.ts

+ 1 - 0
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",

+ 22 - 0
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==}
 

+ 0 - 28
kafka-ui-react-app/src/components/Alerts/Alerts.tsx

@@ -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 }) => (
-        <Alert
-          key={id}
-          type={type}
-          title={title}
-          message={message}
-          onDissmiss={dismiss(id)}
-        />
-      ))}
-    </>
-  );
-};
-
-export default Alerts;

+ 0 - 49
kafka-ui-react-app/src/components/Alerts/__tests__/Alerts.spec.tsx

@@ -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(<Alerts />, { 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);
-  });
-});

+ 3 - 14
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};

+ 8 - 4
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 = () => {
               />
             </Routes>
           </S.Container>
-          <S.AlertsContainer role="toolbar">
-            <Alerts />
-          </S.AlertsContainer>
+          <Toaster position="bottom-right" />
         </S.Layout>
       </ThemeProvider>
     </QueryClientProvider>

+ 8 - 40
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();
       };

+ 2 - 4
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);
     }
   };
 

+ 2 - 4
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);
     }
   };
 

+ 2 - 4
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);

+ 2 - 4
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);
     }
   };
 

+ 10 - 22
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) => `<li>${e}</li>`).join('');
-        dispatch(
-          alertAdded({
-            id: `${clusterName}-${topicName}-createTopicMessageError`,
-            type: 'error',
-            title: 'Validation Error',
-            message: `<ul>${errorsHtml}</ul>`,
-            createdAt: now(),
-          })
-        );
+        showAlert('error', {
+          id: `${clusterName}-${topicName}-createTopicMessageError`,
+          title: 'Validation Error',
+          message: `<ul>${errorsHtml}</ul>`,
+        });
         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}`);
     }

+ 18 - 15
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(
-      <>
-        <WithRoute path={clusterTopicSendMessagePath()}>
-          <SendMessage />
-        </WithRoute>
-        <S.AlertsContainer role="toolbar">
-          <Alerts />
-        </S.AlertsContainer>
-      </>,
+      <WithRoute path={clusterTopicSendMessagePath()}>
+        <SendMessage />
+      </WithRoute>,
       {
         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}`
       );

+ 2 - 2
kafka-ui-react-app/src/components/Alerts/Alert.styled.ts → 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;

+ 5 - 6
kafka-ui-react-app/src/components/Alerts/Alert.tsx → 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<AlertProps> = ({ title, type, message, onDissmiss }) => (
         dangerouslySetInnerHTML={{ __html: message }}
       />
     </div>
-
     <IconButtonWrapper role="button" onClick={onDissmiss}>
       <CloseIcon />
     </IconButtonWrapper>

+ 1 - 4
kafka-ui-react-app/src/components/Alerts/__tests__/Alert.spec.tsx → 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<AlertProps> = {}) =>
     render(
       <Alert
-        id={id}
         type="error"
         title={title}
         message={message}

+ 1 - 2
kafka-ui-react-app/src/components/common/Metrics/Indicator.tsx

@@ -1,5 +1,4 @@
 import React, { PropsWithChildren } from 'react';
-import { AlertType } from 'redux/interfaces';
 
 import * as S from './Metrics.styled';
 
@@ -8,7 +7,7 @@ export interface Props {
   isAlert?: boolean;
   label: React.ReactNode;
   title?: string;
-  alertType?: AlertType;
+  alertType?: 'success' | 'error' | 'warning' | 'info';
 }
 
 const Indicator: React.FC<PropsWithChildren<Props>> = ({

+ 1 - 2
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]};

+ 0 - 18
kafka-ui-react-app/src/lib/errorHandling.ts

@@ -1,18 +0,0 @@
-import { ServerResponse } from 'redux/interfaces';
-
-export const getResponse = async (
-  response: Response
-): Promise<ServerResponse> => {
-  let body;
-  try {
-    body = await response.json();
-  } catch (e) {
-    // do nothing;
-  }
-  return {
-    status: response.status,
-    statusText: response.statusText,
-    url: response.url,
-    message: body?.message,
-  };
-};

+ 76 - 0
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<ServerResponse> => {
+  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) => (
+      <Alert
+        title={title || ''}
+        type={type}
+        message={message}
+        onDissmiss={() => 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<string, string> = {};
+  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,
+  });
+};

+ 6 - 15
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, string> | 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!',
+      });
     }
   };
 

+ 0 - 19
kafka-ui-react-app/src/redux/interfaces/alerts.ts

@@ -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;
-}

+ 0 - 1
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<typeof rootReducer>;
 export type AppDispatch = typeof store.dispatch;

+ 0 - 100
kafka-ui-react-app/src/redux/reducers/alerts/alertsSlice.ts

@@ -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<Alert>({
-  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<Alert>) {
-      alertsAdapter.upsertOne(state, action.payload);
-    },
-    serverErrorAlertAdded: (
-      state,
-      { payload }: PayloadAction<ServerResponse>
-    ) => {
-      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<RootState>(
-  (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;

+ 15 - 2
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));
     }
   }

+ 0 - 2
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,

+ 4 - 1
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));
     }
   }

+ 10 - 8
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));
     }
   }

+ 35 - 24
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));
   }
 });

+ 3 - 0
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],
   },