Repository: provectus/kafka-ui
Issue: Bug on Topic create form
Labels: type/bug
scope/frontend

**Describe the bug**
We need to fix loader on Topic page. After success topic creation the first time, we won't wait for creation any more, because state is loaded, and we will try to get entity immediately after pressing the button, and we will receive null
going to object page on the second creation. 

**Steps to Reproduce**
1. Create topic 1 
2. Create topic 2

**Expected behavior**
Topic form should work without FetchStatus reducer


**AC:**
- Topic new form works without fetchStatus reducer and tracks async request status using react-hook-form 
- Topic new form covered by tests. 

**Notes**
Related PR: https://github.com/provectus/kafka-ui/pull/575

Issue comments:

Pull request review comments:

Patch that resolved the issue:
diff --git a/kafka-ui-react-app/src/components/Topics/New/New.tsx b/kafka-ui-react-app/src/components/Topics/New/New.tsx
index a6f8af9d9e2..3ef46aca488 100644
--- a/kafka-ui-react-app/src/components/Topics/New/New.tsx
+++ b/kafka-ui-react-app/src/components/Topics/New/New.tsx
@@ -1,40 +1,46 @@
 import React from 'react';
-import { ClusterName, TopicName, TopicFormData } from 'redux/interfaces';
+import { ClusterName, TopicFormData, FailurePayload } from 'redux/interfaces';
 import { useForm, FormProvider } from 'react-hook-form';
 import Breadcrumb from 'components/common/Breadcrumb/Breadcrumb';
-import { clusterTopicsPath } from 'lib/paths';
+import { clusterTopicPath, clusterTopicsPath } from 'lib/paths';
 import TopicForm from 'components/Topics/shared/Form/TopicForm';
+import {
+  formatTopicCreation,
+  topicsApiClient,
+  createTopicAction,
+} from 'redux/actions';
+import { useDispatch } from 'react-redux';
+import { getResponse } from 'lib/errorHandling';
+import { useHistory, useParams } from 'react-router';
 
-interface Props {
+interface RouterParams {
   clusterName: ClusterName;
-  isTopicCreated: boolean;
-  createTopic: (clusterName: ClusterName, form: TopicFormData) => void;
-  redirectToTopicPath: (clusterName: ClusterName, topicName: TopicName) => void;
-  resetUploadedState: () => void;
 }
 
-const New: React.FC<Props> = ({
-  clusterName,
-  isTopicCreated,
-  createTopic,
-  redirectToTopicPath,
-}) => {
+const New: React.FC = () => {
   const methods = useForm<TopicFormData>();
-  const [isSubmitting, setIsSubmitting] = React.useState<boolean>(false);
-
-  React.useEffect(() => {
-    if (isSubmitting && isTopicCreated) {
-      const { name } = methods.getValues();
-      redirectToTopicPath(clusterName, name);
-    }
-  }, [isSubmitting, isTopicCreated, redirectToTopicPath, clusterName, methods]);
+  const { clusterName } = useParams<RouterParams>();
+  const history = useHistory();
+  const dispatch = useDispatch();
 
   const onSubmit = async (data: TopicFormData) => {
-    // TODO: need to fix loader. After success loading the first time, we won't wait for creation any more, because state is
-    // loaded, and we will try to get entity immediately after pressing the button, and we will receive null
-    // going to object page on the second creation. Setting of isSubmitting after createTopic is a workaround, need to tweak loader logic
-    createTopic(clusterName, data);
-    setIsSubmitting(true); // Keep this action after createTopic to prevent redirect before create.
+    try {
+      await topicsApiClient.createTopic({
+        clusterName,
+        topicCreation: formatTopicCreation(data),
+      });
+
+      history.push(clusterTopicPath(clusterName, data.name));
+    } catch (error) {
+      const response = await getResponse(error);
+      const alert: FailurePayload = {
+        subject: ['schema', data.name].join('-'),
+        title: `Schema ${data.name}`,
+        response,
+      };
+
+      dispatch(createTopicAction.failure({ alert }));
+    }
   };
 
   return (
@@ -52,10 +58,9 @@ const New: React.FC<Props> = ({
       </div>
 
       <div className="box">
-        {/* eslint-disable react/jsx-props-no-spreading */}
         <FormProvider {...methods}>
           <TopicForm
-            isSubmitting={isSubmitting}
+            isSubmitting={methods.formState.isSubmitting}
             onSubmit={methods.handleSubmit(onSubmit)}
           />
         </FormProvider>
diff --git a/kafka-ui-react-app/src/components/Topics/New/NewContainer.ts b/kafka-ui-react-app/src/components/Topics/New/NewContainer.ts
deleted file mode 100644
index 41ea03e3419..00000000000
--- a/kafka-ui-react-app/src/components/Topics/New/NewContainer.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-import { connect } from 'react-redux';
-import {
-  RootState,
-  ClusterName,
-  TopicName,
-  Action,
-  TopicFormData,
-} from 'redux/interfaces';
-import { withRouter, RouteComponentProps } from 'react-router-dom';
-import { createTopic, createTopicAction } from 'redux/actions';
-import { getTopicCreated } from 'redux/reducers/topics/selectors';
-import { clusterTopicPath } from 'lib/paths';
-import { ThunkDispatch } from 'redux-thunk';
-
-import New from './New';
-
-interface RouteProps {
-  clusterName: ClusterName;
-}
-
-type OwnProps = RouteComponentProps<RouteProps>;
-
-const mapStateToProps = (
-  state: RootState,
-  {
-    match: {
-      params: { clusterName },
-    },
-  }: OwnProps
-) => ({
-  clusterName,
-  isTopicCreated: getTopicCreated(state),
-});
-
-const mapDispatchToProps = (
-  dispatch: ThunkDispatch<RootState, undefined, Action>,
-  { history }: OwnProps
-) => ({
-  createTopic: (clusterName: ClusterName, form: TopicFormData) => {
-    dispatch(createTopic(clusterName, form));
-  },
-  redirectToTopicPath: (clusterName: ClusterName, topicName: TopicName) => {
-    history.push(clusterTopicPath(clusterName, topicName));
-  },
-  resetUploadedState: () => dispatch(createTopicAction.failure({})),
-});
-
-export default withRouter(connect(mapStateToProps, mapDispatchToProps)(New));
diff --git a/kafka-ui-react-app/src/components/Topics/New/__tests__/New.spec.tsx b/kafka-ui-react-app/src/components/Topics/New/__tests__/New.spec.tsx
new file mode 100644
index 00000000000..d7c7d10d6cd
--- /dev/null
+++ b/kafka-ui-react-app/src/components/Topics/New/__tests__/New.spec.tsx
@@ -0,0 +1,69 @@
+import React from 'react';
+import New from 'components/Topics/New/New';
+import { Router } from 'react-router';
+import configureStore from 'redux-mock-store';
+import { RootState } from 'redux/interfaces';
+import { Provider } from 'react-redux';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { createMemoryHistory } from 'history';
+import fetchMock from 'fetch-mock-jest';
+import { clusterTopicNewPath, clusterTopicPath } from 'lib/paths';
+
+const mockStore = configureStore();
+
+describe('New', () => {
+  const clusterName = 'local';
+  const topicName = 'test-topic';
+
+  const initialState: Partial<RootState> = {};
+  const storeMock = mockStore(initialState);
+  const historyMock = createMemoryHistory();
+
+  beforeEach(() => {
+    fetchMock.restore();
+  });
+
+  const setupComponent = (history = historyMock, store = storeMock) => (
+    <Router history={history}>
+      <Provider store={store}>
+        <New />
+      </Provider>
+    </Router>
+  );
+
+  it('validates form', async () => {
+    const mockedHistory = createMemoryHistory();
+    jest.spyOn(mockedHistory, 'push');
+
+    render(setupComponent(mockedHistory));
+
+    await waitFor(async () => {
+      fireEvent.click(await screen.findByText('Send'));
+      const errorText = await screen.findByText('Topic Name is required.');
+      expect(mockedHistory.push).toBeCalledTimes(0);
+      expect(errorText).toBeTruthy();
+    });
+  });
+
+  it('submits valid form', async () => {
+    const mockedHistory = createMemoryHistory({
+      initialEntries: [clusterTopicNewPath(clusterName)],
+    });
+    jest.spyOn(mockedHistory, 'push');
+
+    render(setupComponent());
+
+    const input = await screen.findByPlaceholderText('Topic Name');
+    fireEvent.change(input, { target: { value: topicName } });
+    expect(input).toHaveValue(topicName);
+
+    waitFor(async () => {
+      fireEvent.click(await screen.findByText('Send'));
+
+      expect(mockedHistory.location.pathname).toBe(
+        clusterTopicPath(clusterName, topicName)
+      );
+      expect(mockedHistory.push).toBeCalledTimes(1);
+    });
+  });
+});
diff --git a/kafka-ui-react-app/src/components/Topics/Topics.tsx b/kafka-ui-react-app/src/components/Topics/Topics.tsx
index 6a78e22ee51..dc9a7cdda8b 100644
--- a/kafka-ui-react-app/src/components/Topics/Topics.tsx
+++ b/kafka-ui-react-app/src/components/Topics/Topics.tsx
@@ -8,7 +8,7 @@ import {
 
 import ListContainer from './List/ListContainer';
 import TopicContainer from './Topic/TopicContainer';
-import NewContainer from './New/NewContainer';
+import New from './New/New';
 
 const Topics: React.FC = () => (
   <Switch>
@@ -17,11 +17,7 @@ const Topics: React.FC = () => (
       path={clusterTopicsPath(':clusterName')}
       component={ListContainer}
     />
-    <Route
-      exact
-      path={clusterTopicNewPath(':clusterName')}
-      component={NewContainer}
-    />
+    <Route exact path={clusterTopicNewPath(':clusterName')} component={New} />
     <Route
       path={clusterTopicPath(':clusterName', ':topicName')}
       component={TopicContainer}
diff --git a/kafka-ui-react-app/src/components/Topics/shared/Form/TopicForm.tsx b/kafka-ui-react-app/src/components/Topics/shared/Form/TopicForm.tsx
index eb52d147a03..8435ea657ae 100644
--- a/kafka-ui-react-app/src/components/Topics/shared/Form/TopicForm.tsx
+++ b/kafka-ui-react-app/src/components/Topics/shared/Form/TopicForm.tsx
@@ -156,7 +156,7 @@ const TopicForm: React.FC<Props> = ({
 
         <CustomParamsContainer isSubmitting={isSubmitting} config={config} />
 
-        <input type="submit" className="button is-primary" />
+        <input type="submit" className="button is-primary" value="Send" />
       </fieldset>
     </form>
   );
diff --git a/kafka-ui-react-app/src/redux/actions/thunks/topics.ts b/kafka-ui-react-app/src/redux/actions/thunks/topics.ts
index 73f37a500cc..b9f6206d8b4 100644
--- a/kafka-ui-react-app/src/redux/actions/thunks/topics.ts
+++ b/kafka-ui-react-app/src/redux/actions/thunks/topics.ts
@@ -162,7 +162,7 @@ const topicReducer = (
   };
 };
 
-const formatTopicCreation = (form: TopicFormData): TopicCreation => {
+export const formatTopicCreation = (form: TopicFormData): TopicCreation => {
   const {
     name,
     partitions,
@@ -212,40 +212,6 @@ const formatTopicUpdate = (form: TopicFormDataRaw): TopicUpdate => {
   };
 };
 
-export const createTopic =
-  (clusterName: ClusterName, form: TopicFormData): PromiseThunkResult =>
-  async (dispatch, getState) => {
-    dispatch(actions.createTopicAction.request());
-    try {
-      const topic: Topic = await topicsApiClient.createTopic({
-        clusterName,
-        topicCreation: formatTopicCreation(form),
-      });
-
-      const state = getState().topics;
-      const newState = {
-        ...state,
-        byName: {
-          ...state.byName,
-          [topic.name]: {
-            ...topic,
-          },
-        },
-        allNames: [...state.allNames, topic.name],
-      };
-
-      dispatch(actions.createTopicAction.success(newState));
-    } catch (error) {
-      const response = await getResponse(error);
-      const alert: FailurePayload = {
-        subject: ['schema', form.name].join('-'),
-        title: `Schema ${form.name}`,
-        response,
-      };
-      dispatch(actions.createTopicAction.failure({ alert }));
-    }
-  };
-
 export const updateTopic =
   (
     clusterName: ClusterName,
diff --git a/kafka-ui-react-app/src/redux/reducers/topics/selectors.ts b/kafka-ui-react-app/src/redux/reducers/topics/selectors.ts
index 9502d5e4bdd..e454bf0f9be 100644
--- a/kafka-ui-react-app/src/redux/reducers/topics/selectors.ts
+++ b/kafka-ui-react-app/src/redux/reducers/topics/selectors.ts
@@ -156,7 +156,7 @@ export const getTopicsOrderBy = createSelector(
 
 export const getIsTopicInternal = createSelector(
   getTopicByName,
-  ({ internal }) => !!internal
+  (topic) => !!topic?.internal
 );
 
 export const getTopicConsumerGroups = createSelector(


Test patch: