瀏覽代碼

[CHORE] Alerts (#240)

Oleg Shur 4 年之前
父節點
當前提交
46cec171e3
共有 27 個文件被更改,包括 663 次插入33 次删除
  1. 2 0
      .gitignore
  2. 48 0
      kafka-ui-react-app/src/components/Alert/Alert.tsx
  3. 124 0
      kafka-ui-react-app/src/components/Alert/__tests__/Alert.spec.tsx
  4. 35 0
      kafka-ui-react-app/src/components/Alert/__tests__/__snapshots__/Alert.spec.tsx.snap
  5. 9 0
      kafka-ui-react-app/src/components/App.scss
  6. 21 2
      kafka-ui-react-app/src/components/App.tsx
  7. 2 0
      kafka-ui-react-app/src/components/AppContainer.tsx
  8. 7 13
      kafka-ui-react-app/src/components/Schemas/New/New.tsx
  9. 1 1
      kafka-ui-react-app/src/components/Topics/New/NewContainer.ts
  10. 1 1
      kafka-ui-react-app/src/components/Topics/shared/Form/CustomParams/__tests__/CustomParamSelect.spec.tsx
  11. 60 0
      kafka-ui-react-app/src/components/__test__/App.spec.tsx
  12. 51 0
      kafka-ui-react-app/src/components/__test__/__snapshots__/App.spec.tsx.snap
  13. 19 0
      kafka-ui-react-app/src/lib/errorHandling.ts
  14. 12 1
      kafka-ui-react-app/src/redux/actions/__test__/actions.spec.ts
  15. 1 1
      kafka-ui-react-app/src/redux/actions/__test__/thunks/schemas.spec.ts
  16. 11 4
      kafka-ui-react-app/src/redux/actions/actions.ts
  17. 17 5
      kafka-ui-react-app/src/redux/actions/thunks/schemas.ts
  18. 12 3
      kafka-ui-react-app/src/redux/actions/thunks/topics.ts
  19. 29 0
      kafka-ui-react-app/src/redux/interfaces/alerts.ts
  20. 3 2
      kafka-ui-react-app/src/redux/interfaces/index.ts
  21. 10 0
      kafka-ui-react-app/src/redux/reducers/alerts/__test__/fixtures.ts
  22. 83 0
      kafka-ui-react-app/src/redux/reducers/alerts/__test__/reducer.spec.ts
  23. 30 0
      kafka-ui-react-app/src/redux/reducers/alerts/__test__/selectors.spec.ts
  24. 21 0
      kafka-ui-react-app/src/redux/reducers/alerts/reducer.ts
  25. 9 0
      kafka-ui-react-app/src/redux/reducers/alerts/selectors.ts
  26. 43 0
      kafka-ui-react-app/src/redux/reducers/alerts/utils.ts
  27. 2 0
      kafka-ui-react-app/src/redux/reducers/index.ts

+ 2 - 0
.gitignore

@@ -38,3 +38,5 @@ build/
 *.tar.gz
 *.tar.gz
 *.tgz
 *.tgz
 **/charts/
 **/charts/
+
+/docker/*.override.yaml

+ 48 - 0
kafka-ui-react-app/src/components/Alert/Alert.tsx

@@ -0,0 +1,48 @@
+import React from 'react';
+import cx from 'classnames';
+import { useDispatch } from 'react-redux';
+import { dismissAlert } from 'redux/actions';
+import { Alert as AlertProps } from 'redux/interfaces';
+
+const Alert: React.FC<AlertProps> = ({
+  id,
+  type,
+  title,
+  message,
+  response,
+}) => {
+  const classNames = React.useMemo(
+    () =>
+      cx('notification', {
+        'is-danger': type === 'error',
+        'is-success': type === 'success',
+        'is-info': type === 'info',
+        'is-warning': type === 'warning',
+      }),
+    [type]
+  );
+  const dispatch = useDispatch();
+  const dismiss = React.useCallback(() => {
+    dispatch(dismissAlert(id));
+  }, []);
+
+  return (
+    <div className={classNames}>
+      <button className="delete" type="button" onClick={dismiss}>
+        x
+      </button>
+      <div>
+        <h6 className="title is-6">{title}</h6>
+        <p className="subtitle is-6">{message}</p>
+        {response && (
+          <div className="is-flex">
+            <div className="mr-3">{response.status}</div>
+            <div>{response.body?.message || response.statusText}</div>
+          </div>
+        )}
+      </div>
+    </div>
+  );
+};
+
+export default Alert;

+ 124 - 0
kafka-ui-react-app/src/components/Alert/__tests__/Alert.spec.tsx

@@ -0,0 +1,124 @@
+import React from 'react';
+import { mount } from 'enzyme';
+import { Alert as AlertProps } from 'redux/interfaces';
+import * as actions from 'redux/actions/actions';
+import Alert from '../Alert';
+
+jest.mock('react-redux', () => ({
+  ...jest.requireActual('react-redux'),
+  useDispatch: () => jest.fn(),
+}));
+
+const id = 'test-id';
+const title = 'My Alert Title';
+const message = 'My Alert Message';
+const statusCode = 123;
+const serverSideMessage = 'Server Side Message';
+const httpStatusText = 'My Status Text';
+const dismiss = jest.fn();
+
+describe('Alert', () => {
+  const setupComponent = (props: Partial<AlertProps> = {}) => (
+    <Alert
+      id={id}
+      type="error"
+      title={title}
+      message={message}
+      createdAt={1234567}
+      {...props}
+    />
+  );
+
+  it('renders with initial props', () => {
+    const wrapper = mount(setupComponent());
+    expect(wrapper.exists('.title.is-6')).toBeTruthy();
+    expect(wrapper.find('.title.is-6').text()).toEqual(title);
+    expect(wrapper.exists('.subtitle.is-6')).toBeTruthy();
+    expect(wrapper.find('.subtitle.is-6').text()).toEqual(message);
+    expect(wrapper.exists('button')).toBeTruthy();
+    expect(wrapper.exists('.is-flex')).toBeFalsy();
+  });
+
+  it('renders alert with server side message', () => {
+    const wrapper = mount(
+      setupComponent({
+        type: 'info',
+        response: {
+          status: statusCode,
+          statusText: 'My Status Text',
+          body: {
+            message: serverSideMessage,
+          },
+        },
+      })
+    );
+    expect(wrapper.exists('.is-flex')).toBeTruthy();
+    expect(wrapper.find('.is-flex').text()).toEqual(
+      `${statusCode}${serverSideMessage}`
+    );
+  });
+
+  it('renders alert with http status text', () => {
+    const wrapper = mount(
+      setupComponent({
+        type: 'info',
+        response: {
+          status: statusCode,
+          statusText: httpStatusText,
+          body: {},
+        },
+      })
+    );
+    expect(wrapper.exists('.is-flex')).toBeTruthy();
+    expect(wrapper.find('.is-flex').text()).toEqual(
+      `${statusCode}${httpStatusText}`
+    );
+  });
+
+  it('matches snapshot', () => {
+    expect(mount(setupComponent())).toMatchSnapshot();
+  });
+
+  describe('types', () => {
+    it('renders error', () => {
+      const wrapper = mount(setupComponent({ type: 'error' }));
+      expect(wrapper.exists('.notification.is-danger')).toBeTruthy();
+      expect(wrapper.exists('.notification.is-warning')).toBeFalsy();
+      expect(wrapper.exists('.notification.is-info')).toBeFalsy();
+      expect(wrapper.exists('.notification.is-success')).toBeFalsy();
+    });
+
+    it('renders warning', () => {
+      const wrapper = mount(setupComponent({ type: 'warning' }));
+      expect(wrapper.exists('.notification.is-warning')).toBeTruthy();
+      expect(wrapper.exists('.notification.is-danger')).toBeFalsy();
+      expect(wrapper.exists('.notification.is-info')).toBeFalsy();
+      expect(wrapper.exists('.notification.is-success')).toBeFalsy();
+    });
+
+    it('renders info', () => {
+      const wrapper = mount(setupComponent({ type: 'info' }));
+      expect(wrapper.exists('.notification.is-info')).toBeTruthy();
+      expect(wrapper.exists('.notification.is-warning')).toBeFalsy();
+      expect(wrapper.exists('.notification.is-danger')).toBeFalsy();
+      expect(wrapper.exists('.notification.is-success')).toBeFalsy();
+    });
+
+    it('renders success', () => {
+      const wrapper = mount(setupComponent({ type: 'success' }));
+      expect(wrapper.exists('.notification.is-success')).toBeTruthy();
+      expect(wrapper.exists('.notification.is-warning')).toBeFalsy();
+      expect(wrapper.exists('.notification.is-info')).toBeFalsy();
+      expect(wrapper.exists('.notification.is-danger')).toBeFalsy();
+    });
+  });
+
+  describe('dismiss', () => {
+    it('handles dismiss callback', () => {
+      jest.spyOn(actions, 'dismissAlert').mockImplementation(dismiss);
+      const wrapper = mount(setupComponent());
+      wrapper.find('button').simulate('click');
+      expect(dismiss).toHaveBeenCalledWith(id);
+    });
+  });
+});

+ 35 - 0
kafka-ui-react-app/src/components/Alert/__tests__/__snapshots__/Alert.spec.tsx.snap

@@ -0,0 +1,35 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Alert matches snapshot 1`] = `
+<Alert
+  createdAt={1234567}
+  id="test-id"
+  message="My Alert Message"
+  title="My Alert Title"
+  type="error"
+>
+  <div
+    className="notification is-danger"
+  >
+    <button
+      className="delete"
+      onClick={[Function]}
+      type="button"
+    >
+      x
+    </button>
+    <div>
+      <h6
+        className="title is-6"
+      >
+        My Alert Title
+      </h6>
+      <p
+        className="subtitle is-6"
+      >
+        My Alert Message
+      </p>
+    </div>
+  </div>
+</Alert>
+`;

+ 9 - 0
kafka-ui-react-app/src/components/App.scss

@@ -27,6 +27,15 @@ $navbar-width: 250px;
     padding: 20px 20px;
     padding: 20px 20px;
     overflow-y: scroll;
     overflow-y: scroll;
   }
   }
+
+  &__alerts {
+    max-width: 40%;
+    width: 500px;
+    position: fixed;
+    bottom: 15px;
+    right: 15px;
+    z-index: 1000;
+  }
 }
 }
 
 
 .react-datepicker-wrapper {
 .react-datepicker-wrapper {

+ 21 - 2
kafka-ui-react-app/src/components/App.tsx

@@ -1,18 +1,22 @@
+import './App.scss';
 import React from 'react';
 import React from 'react';
 import { Switch, Route } from 'react-router-dom';
 import { Switch, Route } from 'react-router-dom';
-import './App.scss';
+import { Alerts } from 'redux/interfaces';
 import NavContainer from './Nav/NavContainer';
 import NavContainer from './Nav/NavContainer';
 import PageLoader from './common/PageLoader/PageLoader';
 import PageLoader from './common/PageLoader/PageLoader';
 import Dashboard from './Dashboard/Dashboard';
 import Dashboard from './Dashboard/Dashboard';
 import Cluster from './Cluster/Cluster';
 import Cluster from './Cluster/Cluster';
+import Alert from './Alert/Alert';
 
 
-interface AppProps {
+export interface AppProps {
   isClusterListFetched: boolean;
   isClusterListFetched: boolean;
+  alerts: Alerts;
   fetchClustersList: () => void;
   fetchClustersList: () => void;
 }
 }
 
 
 const App: React.FC<AppProps> = ({
 const App: React.FC<AppProps> = ({
   isClusterListFetched,
   isClusterListFetched,
+  alerts,
   fetchClustersList,
   fetchClustersList,
 }) => {
 }) => {
   React.useEffect(() => {
   React.useEffect(() => {
@@ -32,6 +36,7 @@ const App: React.FC<AppProps> = ({
           </a>
           </a>
         </div>
         </div>
       </nav>
       </nav>
+
       <main className="Layout__container">
       <main className="Layout__container">
         <NavContainer className="Layout__navbar" />
         <NavContainer className="Layout__navbar" />
         {isClusterListFetched ? (
         {isClusterListFetched ? (
@@ -47,6 +52,20 @@ const App: React.FC<AppProps> = ({
           <PageLoader fullHeight />
           <PageLoader fullHeight />
         )}
         )}
       </main>
       </main>
+
+      <div className="Layout__alerts">
+        {alerts.map(({ id, type, title, message, response, createdAt }) => (
+          <Alert
+            key={id}
+            id={id}
+            type={type}
+            title={title}
+            message={message}
+            response={response}
+            createdAt={createdAt}
+          />
+        ))}
+      </div>
     </div>
     </div>
   );
   );
 };
 };

+ 2 - 0
kafka-ui-react-app/src/components/AppContainer.tsx

@@ -1,11 +1,13 @@
 import { connect } from 'react-redux';
 import { connect } from 'react-redux';
 import { fetchClustersList } from 'redux/actions';
 import { fetchClustersList } from 'redux/actions';
 import { getIsClusterListFetched } from 'redux/reducers/clusters/selectors';
 import { getIsClusterListFetched } from 'redux/reducers/clusters/selectors';
+import { getAlerts } from 'redux/reducers/alerts/selectors';
 import { RootState } from 'redux/interfaces';
 import { RootState } from 'redux/interfaces';
 import App from './App';
 import App from './App';
 
 
 const mapStateToProps = (state: RootState) => ({
 const mapStateToProps = (state: RootState) => ({
   isClusterListFetched: getIsClusterListFetched(state),
   isClusterListFetched: getIsClusterListFetched(state),
+  alerts: getAlerts(state),
 });
 });
 
 
 const mapDispatchToProps = {
 const mapDispatchToProps = {

+ 7 - 13
kafka-ui-react-app/src/components/Schemas/New/New.tsx

@@ -3,10 +3,10 @@ import { ClusterName, NewSchemaSubjectRaw } from 'redux/interfaces';
 import { useForm } from 'react-hook-form';
 import { useForm } from 'react-hook-form';
 import { ErrorMessage } from '@hookform/error-message';
 import { ErrorMessage } from '@hookform/error-message';
 import Breadcrumb from 'components/common/Breadcrumb/Breadcrumb';
 import Breadcrumb from 'components/common/Breadcrumb/Breadcrumb';
-import { clusterSchemaPath, clusterSchemasPath } from 'lib/paths';
+import { clusterSchemasPath } from 'lib/paths';
 import { NewSchemaSubject, SchemaType } from 'generated-sources';
 import { NewSchemaSubject, SchemaType } from 'generated-sources';
 import { SCHEMA_NAME_VALIDATION_PATTERN } from 'lib/constants';
 import { SCHEMA_NAME_VALIDATION_PATTERN } from 'lib/constants';
-import { useHistory, useParams } from 'react-router';
+import { useParams } from 'react-router';
 
 
 export interface NewProps {
 export interface NewProps {
   createSchema: (
   createSchema: (
@@ -17,7 +17,6 @@ export interface NewProps {
 
 
 const New: React.FC<NewProps> = ({ createSchema }) => {
 const New: React.FC<NewProps> = ({ createSchema }) => {
   const { clusterName } = useParams<{ clusterName: string }>();
   const { clusterName } = useParams<{ clusterName: string }>();
-  const history = useHistory();
   const {
   const {
     register,
     register,
     errors,
     errors,
@@ -27,16 +26,11 @@ const New: React.FC<NewProps> = ({ createSchema }) => {
 
 
   const onSubmit = React.useCallback(
   const onSubmit = React.useCallback(
     async ({ subject, schema }: NewSchemaSubjectRaw) => {
     async ({ subject, schema }: NewSchemaSubjectRaw) => {
-      try {
-        await createSchema(clusterName, {
-          subject,
-          schema,
-          schemaType: SchemaType.AVRO,
-        });
-        history.push(clusterSchemaPath(clusterName, subject));
-      } catch (e) {
-        // Show Error
-      }
+      await createSchema(clusterName, {
+        subject,
+        schema,
+        schemaType: SchemaType.AVRO,
+      });
     },
     },
     [clusterName]
     [clusterName]
   );
   );

+ 1 - 1
kafka-ui-react-app/src/components/Topics/New/NewContainer.ts

@@ -41,7 +41,7 @@ const mapDispatchToProps = (
   redirectToTopicPath: (clusterName: ClusterName, topicName: TopicName) => {
   redirectToTopicPath: (clusterName: ClusterName, topicName: TopicName) => {
     history.push(clusterTopicPath(clusterName, topicName));
     history.push(clusterTopicPath(clusterName, topicName));
   },
   },
-  resetUploadedState: () => dispatch(createTopicAction.failure()),
+  resetUploadedState: () => dispatch(createTopicAction.failure({})),
 });
 });
 
 
 export default withRouter(connect(mapStateToProps, mapDispatchToProps)(New));
 export default withRouter(connect(mapStateToProps, mapDispatchToProps)(New));

+ 1 - 1
kafka-ui-react-app/src/components/Topics/shared/Form/CustomParams/__tests__/CustomParamSelect.spec.tsx

@@ -1,6 +1,6 @@
 import React from 'react';
 import React from 'react';
 import { mount } from 'enzyme';
 import { mount } from 'enzyme';
-import { useForm, FormProvider, useFormContext } from 'react-hook-form';
+import { useForm, FormProvider } from 'react-hook-form';
 import { TOPIC_CUSTOM_PARAMS } from 'lib/constants';
 import { TOPIC_CUSTOM_PARAMS } from 'lib/constants';
 import CustomParamSelect, {
 import CustomParamSelect, {
   CustomParamSelectProps,
   CustomParamSelectProps,

+ 60 - 0
kafka-ui-react-app/src/components/__test__/App.spec.tsx

@@ -0,0 +1,60 @@
+import React from 'react';
+import { mount, shallow } from 'enzyme';
+import { Provider } from 'react-redux';
+import { StaticRouter } from 'react-router';
+import configureStore from 'redux/store/configureStore';
+import App, { AppProps } from '../App';
+
+const fetchClustersList = jest.fn();
+const store = configureStore();
+
+describe('App', () => {
+  const setupComponent = (props: Partial<AppProps> = {}) => (
+    <App
+      isClusterListFetched
+      alerts={[]}
+      fetchClustersList={fetchClustersList}
+      {...props}
+    />
+  );
+
+  it('matches snapshot with initial props', () => {
+    const wrapper = shallow(setupComponent());
+    expect(wrapper).toMatchSnapshot();
+  });
+
+  it('correctly mounts App component', () => {
+    const wrapper = mount(
+      <Provider store={store}>
+        <StaticRouter>{setupComponent()}</StaticRouter>
+      </Provider>
+    );
+    expect(wrapper.exists()).toBeTruthy();
+    expect(fetchClustersList).toHaveBeenCalledTimes(1);
+  });
+
+  it('correctly renders PageLoader', () => {
+    const wrapper = shallow(setupComponent({ isClusterListFetched: false }));
+    expect(wrapper.exists('PageLoader')).toBeTruthy();
+
+    wrapper.setProps({ isClusterListFetched: true });
+    expect(wrapper.exists('PageLoader')).toBeFalsy();
+  });
+
+  it('correctly renders alerts', () => {
+    const alert = {
+      id: 'alert-id',
+      type: 'success',
+      title: 'My Custom Title',
+      message: 'My Custom Message',
+      createdAt: 1234567890,
+    };
+    const wrapper = shallow(setupComponent());
+    expect(wrapper.exists('.Layout__alerts')).toBeTruthy();
+    expect(wrapper.exists('Alert')).toBeFalsy();
+
+    wrapper.setProps({ alerts: [alert] });
+    expect(wrapper.exists('Alert')).toBeTruthy();
+    expect(wrapper.find('Alert').length).toEqual(1);
+  });
+});

+ 51 - 0
kafka-ui-react-app/src/components/__test__/__snapshots__/App.spec.tsx.snap

@@ -0,0 +1,51 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`App matches snapshot with initial props 1`] = `
+<div
+  className="Layout"
+>
+  <nav
+    aria-label="main navigation"
+    className="navbar is-fixed-top is-white Layout__header"
+    role="navigation"
+  >
+    <div
+      className="navbar-brand"
+    >
+      <a
+        className="navbar-item title is-5 is-marginless"
+        href="/ui"
+      >
+        Kafka UI
+      </a>
+    </div>
+  </nav>
+  <main
+    className="Layout__container"
+  >
+    <Connect(Nav)
+      className="Layout__navbar"
+    />
+    <Switch>
+      <Route
+        component={[Function]}
+        exact={true}
+        path={
+          Array [
+            "/",
+            "/ui",
+            "/ui/clusters",
+          ]
+        }
+      />
+      <Route
+        component={[Function]}
+        path="/ui/clusters/:clusterName"
+      />
+    </Switch>
+  </main>
+  <div
+    className="Layout__alerts"
+  />
+</div>
+`;

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

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

+ 12 - 1
kafka-ui-react-app/src/redux/actions/__test__/actions.spec.ts

@@ -93,8 +93,19 @@ describe('Actions', () => {
     });
     });
 
 
     it('creates a FAILURE action', () => {
     it('creates a FAILURE action', () => {
-      expect(actions.createSchemaAction.failure()).toEqual({
+      expect(actions.createSchemaAction.failure({})).toEqual({
         type: 'POST_SCHEMA__FAILURE',
         type: 'POST_SCHEMA__FAILURE',
+        payload: {},
+      });
+    });
+  });
+
+  describe('dismissAlert', () => {
+    it('creates a REQUEST action', () => {
+      const id = 'alert-id1';
+      expect(actions.dismissAlert(id)).toEqual({
+        type: 'DISMISS_ALERT',
+        payload: id,
       });
       });
     });
     });
   });
   });

+ 1 - 1
kafka-ui-react-app/src/redux/actions/__test__/thunks/schemas.spec.ts

@@ -132,7 +132,7 @@ describe('Thunks', () => {
         expect(error.status).toEqual(404);
         expect(error.status).toEqual(404);
         expect(store.getActions()).toEqual([
         expect(store.getActions()).toEqual([
           actions.createSchemaAction.request(),
           actions.createSchemaAction.request(),
-          actions.createSchemaAction.failure(),
+          actions.createSchemaAction.failure({}),
         ]);
         ]);
       }
       }
     });
     });

+ 11 - 4
kafka-ui-react-app/src/redux/actions/actions.ts

@@ -1,5 +1,10 @@
-import { createAsyncAction } from 'typesafe-actions';
-import { ConsumerGroupID, TopicName, TopicsState } from 'redux/interfaces';
+import { createAction, createAsyncAction } from 'typesafe-actions';
+import {
+  ConsumerGroupID,
+  FailurePayload,
+  TopicName,
+  TopicsState,
+} from 'redux/interfaces';
 
 
 import {
 import {
   Cluster,
   Cluster,
@@ -71,7 +76,7 @@ export const createTopicAction = createAsyncAction(
   'POST_TOPIC__REQUEST',
   'POST_TOPIC__REQUEST',
   'POST_TOPIC__SUCCESS',
   'POST_TOPIC__SUCCESS',
   'POST_TOPIC__FAILURE'
   'POST_TOPIC__FAILURE'
-)<undefined, TopicsState, undefined>();
+)<undefined, TopicsState, { alert?: FailurePayload }>();
 
 
 export const updateTopicAction = createAsyncAction(
 export const updateTopicAction = createAsyncAction(
   'PATCH_TOPIC__REQUEST',
   'PATCH_TOPIC__REQUEST',
@@ -117,4 +122,6 @@ export const createSchemaAction = createAsyncAction(
   'POST_SCHEMA__REQUEST',
   'POST_SCHEMA__REQUEST',
   'POST_SCHEMA__SUCCESS',
   'POST_SCHEMA__SUCCESS',
   'POST_SCHEMA__FAILURE'
   'POST_SCHEMA__FAILURE'
-)<undefined, SchemaSubject, undefined>();
+)<undefined, SchemaSubject, { alert?: FailurePayload }>();
+
+export const dismissAlert = createAction('DISMISS_ALERT')<string>();

+ 17 - 5
kafka-ui-react-app/src/redux/actions/thunks/schemas.ts

@@ -4,10 +4,16 @@ import {
   NewSchemaSubject,
   NewSchemaSubject,
   SchemaSubject,
   SchemaSubject,
 } from 'generated-sources';
 } from 'generated-sources';
-import { PromiseThunkResult, ClusterName, SchemaName } from 'redux/interfaces';
+import {
+  PromiseThunkResult,
+  ClusterName,
+  SchemaName,
+  FailurePayload,
+} from 'redux/interfaces';
 
 
 import { BASE_PARAMS } from 'lib/constants';
 import { BASE_PARAMS } from 'lib/constants';
-import * as actions from '../actions';
+import * as actions from 'redux/actions';
+import { getResponse } from 'lib/errorHandling';
 
 
 const apiClientConf = new Configuration(BASE_PARAMS);
 const apiClientConf = new Configuration(BASE_PARAMS);
 export const schemasApiClient = new SchemasApi(apiClientConf);
 export const schemasApiClient = new SchemasApi(apiClientConf);
@@ -52,8 +58,14 @@ export const createSchema = (
       newSchemaSubject,
       newSchemaSubject,
     });
     });
     dispatch(actions.createSchemaAction.success(schema));
     dispatch(actions.createSchemaAction.success(schema));
-  } catch (e) {
-    dispatch(actions.createSchemaAction.failure());
-    throw e;
+  } catch (error) {
+    const response = await getResponse(error);
+    const alert: FailurePayload = {
+      subject: 'schema',
+      subjectId: newSchemaSubject.subject,
+      title: `Schema ${newSchemaSubject.subject}`,
+      response,
+    };
+    dispatch(actions.createSchemaAction.failure({ alert }));
   }
   }
 };
 };

+ 12 - 3
kafka-ui-react-app/src/redux/actions/thunks/topics.ts

@@ -16,9 +16,11 @@ import {
   TopicFormFormattedParams,
   TopicFormFormattedParams,
   TopicFormDataRaw,
   TopicFormDataRaw,
   TopicsState,
   TopicsState,
+  FailurePayload,
 } from 'redux/interfaces';
 } from 'redux/interfaces';
 import { BASE_PARAMS } from 'lib/constants';
 import { BASE_PARAMS } from 'lib/constants';
-import * as actions from '../actions';
+import * as actions from 'redux/actions/actions';
+import { getResponse } from 'lib/errorHandling';
 
 
 const apiClientConf = new Configuration(BASE_PARAMS);
 const apiClientConf = new Configuration(BASE_PARAMS);
 export const topicsApiClient = new TopicsApi(apiClientConf);
 export const topicsApiClient = new TopicsApi(apiClientConf);
@@ -227,8 +229,15 @@ export const createTopic = (
     };
     };
 
 
     dispatch(actions.createTopicAction.success(newState));
     dispatch(actions.createTopicAction.success(newState));
-  } catch (e) {
-    dispatch(actions.createTopicAction.failure());
+  } catch (error) {
+    const response = await getResponse(error);
+    const alert: FailurePayload = {
+      subjectId: form.name,
+      subject: 'schema',
+      title: `Schema ${form.name}`,
+      response,
+    };
+    dispatch(actions.createTopicAction.failure({ alert }));
   }
   }
 };
 };
 
 

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

@@ -0,0 +1,29 @@
+import { ErrorResponse } from 'generated-sources';
+import React from 'react';
+
+export interface ServerResponse {
+  status: number;
+  statusText: string;
+  body: ErrorResponse;
+}
+
+export interface FailurePayload {
+  title: string;
+  message?: string;
+  subject: string;
+  subjectId?: string | number;
+  response?: ServerResponse;
+}
+
+export interface Alert {
+  id: string;
+  type: 'error' | 'success' | 'warning' | 'info';
+  title: string;
+  message: React.ReactNode;
+  response?: ServerResponse;
+  createdAt: number;
+}
+
+export type Alerts = Alert[];
+
+export type AlertsState = Record<Alert['id'], Alert>;

+ 3 - 2
kafka-ui-react-app/src/redux/interfaces/index.ts

@@ -1,14 +1,13 @@
 import { ActionType } from 'typesafe-actions';
 import { ActionType } from 'typesafe-actions';
 import { ThunkAction } from 'redux-thunk';
 import { ThunkAction } from 'redux-thunk';
-
 import * as actions from 'redux/actions/actions';
 import * as actions from 'redux/actions/actions';
-
 import { TopicsState } from './topic';
 import { TopicsState } from './topic';
 import { ClusterState } from './cluster';
 import { ClusterState } from './cluster';
 import { BrokersState } from './broker';
 import { BrokersState } from './broker';
 import { LoaderState } from './loader';
 import { LoaderState } from './loader';
 import { ConsumerGroupsState } from './consumerGroup';
 import { ConsumerGroupsState } from './consumerGroup';
 import { SchemasState } from './schema';
 import { SchemasState } from './schema';
+import { AlertsState } from './alerts';
 
 
 export * from './topic';
 export * from './topic';
 export * from './cluster';
 export * from './cluster';
@@ -16,6 +15,7 @@ export * from './broker';
 export * from './consumerGroup';
 export * from './consumerGroup';
 export * from './schema';
 export * from './schema';
 export * from './loader';
 export * from './loader';
+export * from './alerts';
 
 
 export interface RootState {
 export interface RootState {
   topics: TopicsState;
   topics: TopicsState;
@@ -24,6 +24,7 @@ export interface RootState {
   consumerGroups: ConsumerGroupsState;
   consumerGroups: ConsumerGroupsState;
   schemas: SchemasState;
   schemas: SchemasState;
   loader: LoaderState;
   loader: LoaderState;
+  alerts: AlertsState;
 }
 }
 
 
 export type Action = ActionType<typeof actions>;
 export type Action = ActionType<typeof actions>;

+ 10 - 0
kafka-ui-react-app/src/redux/reducers/alerts/__test__/fixtures.ts

@@ -0,0 +1,10 @@
+export const failurePayloadWithoutId = {
+  title: 'title',
+  message: 'message',
+  subject: 'topic',
+};
+
+export const failurePayloadWithId = {
+  ...failurePayloadWithoutId,
+  subjectId: '12345',
+};

+ 83 - 0
kafka-ui-react-app/src/redux/reducers/alerts/__test__/reducer.spec.ts

@@ -0,0 +1,83 @@
+import { dismissAlert, createTopicAction } from 'redux/actions';
+import reducer from 'redux/reducers/alerts/reducer';
+import { failurePayloadWithId, failurePayloadWithoutId } from './fixtures';
+
+jest.mock('lodash', () => ({
+  ...jest.requireActual('lodash'),
+  now: () => 1234567890,
+}));
+
+describe('Clusters reducer', () => {
+  it('does not create error alert', () => {
+    expect(reducer(undefined, createTopicAction.failure({}))).toEqual({});
+  });
+
+  it('creates error alert with subjectId', () => {
+    expect(
+      reducer(
+        undefined,
+        createTopicAction.failure({
+          alert: failurePayloadWithId,
+        })
+      )
+    ).toEqual({
+      'alert-topic12345': {
+        createdAt: 1234567890,
+        id: 'alert-topic12345',
+        message: 'message',
+        response: undefined,
+        title: 'title',
+        type: 'error',
+      },
+    });
+  });
+
+  it('creates error alert without subjectId', () => {
+    expect(
+      reducer(
+        undefined,
+        createTopicAction.failure({
+          alert: failurePayloadWithoutId,
+        })
+      )
+    ).toEqual({
+      'alert-topic': {
+        createdAt: 1234567890,
+        id: 'alert-topic',
+        message: 'message',
+        response: undefined,
+        title: 'title',
+        type: 'error',
+      },
+    });
+  });
+
+  it('removes alert by ID', () => {
+    const state = reducer(
+      undefined,
+      createTopicAction.failure({
+        alert: failurePayloadWithoutId,
+      })
+    );
+    expect(reducer(state, dismissAlert('alert-topic'))).toEqual({});
+  });
+
+  it('does not remove alert if id is wrong', () => {
+    const state = reducer(
+      undefined,
+      createTopicAction.failure({
+        alert: failurePayloadWithoutId,
+      })
+    );
+    expect(reducer(state, dismissAlert('wrong-id'))).toEqual({
+      'alert-topic': {
+        createdAt: 1234567890,
+        id: 'alert-topic',
+        message: 'message',
+        response: undefined,
+        title: 'title',
+        type: 'error',
+      },
+    });
+  });
+});

+ 30 - 0
kafka-ui-react-app/src/redux/reducers/alerts/__test__/selectors.spec.ts

@@ -0,0 +1,30 @@
+import configureStore from 'redux/store/configureStore';
+import { createTopicAction } from 'redux/actions';
+import * as selectors from '../selectors';
+import { failurePayloadWithId, failurePayloadWithoutId } from './fixtures';
+
+const store = configureStore();
+
+describe('Alerts selectors', () => {
+  describe('Initial State', () => {
+    it('returns empty alert list', () => {
+      expect(selectors.getAlerts(store.getState())).toEqual([]);
+    });
+  });
+
+  describe('state', () => {
+    beforeAll(() => {
+      store.dispatch(
+        createTopicAction.failure({ alert: failurePayloadWithoutId })
+      );
+      store.dispatch(
+        createTopicAction.failure({ alert: failurePayloadWithId })
+      );
+    });
+
+    it('returns fetch status', () => {
+      const alerts = selectors.getAlerts(store.getState());
+      expect(alerts.length).toEqual(2);
+    });
+  });
+});

+ 21 - 0
kafka-ui-react-app/src/redux/reducers/alerts/reducer.ts

@@ -0,0 +1,21 @@
+import { getType } from 'typesafe-actions';
+import { dismissAlert } from 'redux/actions';
+import { Action, AlertsState } from 'redux/interfaces';
+import { addError, removeAlert } from './utils';
+
+export const initialState: AlertsState = {};
+
+const reducer = (state = initialState, action: Action): AlertsState => {
+  const { type } = action;
+
+  const matches = /(.*)__(FAILURE)$/.exec(type);
+  if (matches && matches[2]) return addError(state, action);
+
+  if (type === getType(dismissAlert)) {
+    return removeAlert(state, action);
+  }
+
+  return state;
+};
+
+export default reducer;

+ 9 - 0
kafka-ui-react-app/src/redux/reducers/alerts/selectors.ts

@@ -0,0 +1,9 @@
+import { createSelector } from 'reselect';
+import { RootState, AlertsState } from 'redux/interfaces';
+import { orderBy } from 'lodash';
+
+const alertsState = ({ alerts }: RootState): AlertsState => alerts;
+
+export const getAlerts = createSelector(alertsState, (alerts) =>
+  orderBy(Object.values(alerts), 'createdAt', 'desc')
+);

+ 43 - 0
kafka-ui-react-app/src/redux/reducers/alerts/utils.ts

@@ -0,0 +1,43 @@
+import { now, omit } from 'lodash';
+import { Action, AlertsState, Alert } from 'redux/interfaces';
+
+export const addError = (state: AlertsState, action: Action) => {
+  if (
+    'payload' in action &&
+    typeof action.payload === 'object' &&
+    'alert' in action.payload &&
+    action.payload.alert !== undefined
+  ) {
+    const {
+      subject,
+      subjectId,
+      title,
+      message,
+      response,
+    } = action.payload.alert;
+
+    const id = `alert-${subject}${subjectId || ''}`;
+
+    return {
+      ...state,
+      [id]: {
+        id,
+        type: 'error',
+        title,
+        message,
+        response,
+        createdAt: now(),
+      } as Alert,
+    };
+  }
+
+  return { ...state };
+};
+
+export const removeAlert = (state: AlertsState, action: Action) => {
+  if ('payload' in action && typeof action.payload === 'string') {
+    return omit(state, action.payload);
+  }
+
+  return { ...state };
+};

+ 2 - 0
kafka-ui-react-app/src/redux/reducers/index.ts

@@ -6,6 +6,7 @@ import brokers from './brokers/reducer';
 import consumerGroups from './consumerGroups/reducer';
 import consumerGroups from './consumerGroups/reducer';
 import schemas from './schemas/reducer';
 import schemas from './schemas/reducer';
 import loader from './loader/reducer';
 import loader from './loader/reducer';
+import alerts from './alerts/reducer';
 
 
 export default combineReducers<RootState>({
 export default combineReducers<RootState>({
   topics,
   topics,
@@ -14,4 +15,5 @@ export default combineReducers<RootState>({
   consumerGroups,
   consumerGroups,
   schemas,
   schemas,
   loader,
   loader,
+  alerts,
 });
 });