Explorar o código

Topic editing

Sergey Zakirov %!s(int64=5) %!d(string=hai) anos
pai
achega
0df5e107a4
Modificáronse 30 ficheiros con 1798 adicións e 518 borrados
  1. 3 1
      .gitignore
  2. 2 1
      kafka-ui-react-app/.eslintrc.json
  3. 608 411
      kafka-ui-react-app/package-lock.json
  4. 22 20
      kafka-ui-react-app/package.json
  5. 5 0
      kafka-ui-react-app/src/components/Topics/Details/Details.tsx
  6. 16 0
      kafka-ui-react-app/src/components/Topics/Details/Settings/SettingsEditButton.tsx
  7. 143 0
      kafka-ui-react-app/src/components/Topics/Edit/Edit.tsx
  8. 63 0
      kafka-ui-react-app/src/components/Topics/Edit/EditContainer.tsx
  9. 6 0
      kafka-ui-react-app/src/components/Topics/Topics.tsx
  10. 20 0
      kafka-ui-react-app/src/components/Topics/shared/Form/CustomParams/CustomParamAction.tsx
  11. 33 0
      kafka-ui-react-app/src/components/Topics/shared/Form/CustomParams/CustomParamButton.tsx
  12. 16 0
      kafka-ui-react-app/src/components/Topics/shared/Form/CustomParams/CustomParamOptions.tsx
  13. 56 0
      kafka-ui-react-app/src/components/Topics/shared/Form/CustomParams/CustomParamSelect.tsx
  14. 54 0
      kafka-ui-react-app/src/components/Topics/shared/Form/CustomParams/CustomParamValue.tsx
  15. 113 0
      kafka-ui-react-app/src/components/Topics/shared/Form/CustomParams/CustomParams.tsx
  16. 19 0
      kafka-ui-react-app/src/components/Topics/shared/Form/CustomParams/CustomParamsContainer.tsx
  17. 96 0
      kafka-ui-react-app/src/components/Topics/shared/Form/CustomParams/customParamsOptions.tsx
  18. 35 0
      kafka-ui-react-app/src/components/Topics/shared/Form/FormBreadcrumbs.tsx
  19. 52 0
      kafka-ui-react-app/src/components/Topics/shared/Form/TimeToRetain.tsx
  20. 29 0
      kafka-ui-react-app/src/components/Topics/shared/Form/TimeToRetainBtn.tsx
  21. 36 0
      kafka-ui-react-app/src/components/Topics/shared/Form/TimeToRetainBtns.tsx
  22. 170 0
      kafka-ui-react-app/src/components/Topics/shared/Form/TopicForm.tsx
  23. 5 0
      kafka-ui-react-app/src/lib/paths.ts
  24. 5 1
      kafka-ui-react-app/src/redux/actionType.ts
  25. 25 13
      kafka-ui-react-app/src/redux/actions/actions.ts
  26. 62 14
      kafka-ui-react-app/src/redux/actions/thunks.ts
  27. 43 40
      kafka-ui-react-app/src/redux/api/topics.ts
  28. 8 4
      kafka-ui-react-app/src/redux/interfaces/topic.ts
  29. 50 13
      kafka-ui-react-app/src/redux/reducers/topics/selectors.ts
  30. 3 0
      package-lock.json

+ 3 - 1
.gitignore

@@ -29,4 +29,6 @@ build/
 
 ### VS Code ###
 .vscode/
-/kafka-ui-api/app/node
+/kafka-ui-api/app/node
+
+.DS_Store

+ 2 - 1
kafka-ui-react-app/.eslintrc.json

@@ -41,7 +41,8 @@
       { "extensions": [".js", ".jsx", ".ts", ".tsx"] }
     ],
     "jsx-a11y/label-has-associated-control": "off",
-    "no-param-reassign": [2, { "props": false }]
+    "no-param-reassign": [2, { "props": false }],
+    "import/prefer-default-export": "off"
   },
   "overrides": [
     {

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 608 - 411
kafka-ui-react-app/package-lock.json


+ 22 - 20
kafka-ui-react-app/package.json

@@ -3,37 +3,21 @@
   "version": "0.1.0",
   "private": true,
   "dependencies": {
-    "@testing-library/jest-dom": "^4.2.4",
-    "@testing-library/react": "^9.3.2",
-    "@testing-library/user-event": "^7.1.2",
-    "@types/classnames": "^2.2.9",
-    "@types/jest": "^24.0.25",
-    "@types/lodash": "^4.14.149",
-    "@types/node": "^12.12.24",
-    "@types/react": "^16.9.17",
-    "@types/react-dom": "^16.9.0",
-    "@types/react-redux": "^7.1.5",
-    "@types/react-router-dom": "^5.1.3",
-    "@types/redux": "^3.6.0",
-    "@types/redux-thunk": "^2.1.0",
     "bulma": "^0.8.0",
     "bulma-switch": "^2.0.0",
     "classnames": "^2.2.6",
-    "json-server": "^0.15.1",
+    "immer": "^6.0.5",
     "lodash": "^4.17.15",
-    "node-sass": "^4.13.1",
     "pretty-ms": "^6.0.1",
     "react": "^16.12.0",
     "react-dom": "^16.12.0",
     "react-hook-form": "^4.5.5",
     "react-redux": "^7.1.3",
     "react-router-dom": "^5.1.2",
-    "react-scripts": "3.3.0",
     "redux": "^4.0.5",
     "redux-thunk": "^2.3.0",
     "reselect": "^4.0.0",
-    "typesafe-actions": "^5.1.0",
-    "typescript": "~3.7.4"
+    "typesafe-actions": "^5.1.0"
   },
   "lint-staged": {
     "*.{js,ts,jsx,tsx}": [
@@ -70,6 +54,19 @@
     ]
   },
   "devDependencies": {
+    "@testing-library/jest-dom": "^4.2.4",
+    "@testing-library/react": "^9.3.2",
+    "@testing-library/user-event": "^7.1.2",
+    "@types/classnames": "^2.2.9",
+    "@types/jest": "^24.0.25",
+    "@types/lodash": "^4.14.149",
+    "@types/node": "^12.12.24",
+    "@types/react": "^16.9.17",
+    "@types/react-dom": "^16.9.0",
+    "@types/react-redux": "^7.1.5",
+    "@types/react-router-dom": "^5.1.3",
+    "@types/redux": "^3.6.0",
+    "@types/redux-thunk": "^2.1.0",
     "@typescript-eslint/eslint-plugin": "^2.27.0",
     "@typescript-eslint/parser": "^2.27.0",
     "eslint": "^6.8.0",
@@ -82,7 +79,12 @@
     "eslint-plugin-react-hooks": "^2.5.1",
     "esprint": "^0.6.0",
     "husky": "^4.2.5",
+    "json-server": "^0.15.1",
     "lint-staged": ">=10",
-    "prettier": "^2.0.4"
-  }
+    "node-sass": "^4.13.1",
+    "prettier": "^2.0.4",
+    "react-scripts": "3.4.0",
+    "typescript": "~3.7.4"
+  },
+  "proxy": "http://localhost:8080"
 }

+ 5 - 0
kafka-ui-react-app/src/components/Topics/Details/Details.tsx

@@ -7,10 +7,12 @@ import {
   clusterTopicSettingsPath,
   clusterTopicPath,
   clusterTopicMessagesPath,
+  clusterTopicsTopicEditPath,
 } from 'lib/paths';
 import OverviewContainer from './Overview/OverviewContainer';
 import MessagesContainer from './Messages/MessagesContainer';
 import SettingsContainer from './Settings/SettingsContainer';
+import SettingsEditButton from './Settings/SettingsEditButton';
 
 interface Props extends Topic, TopicDetails {
   clusterName: ClusterName;
@@ -30,6 +32,9 @@ const Details: React.FC<Props> = ({ clusterName, topicName }) => {
             {topicName}
           </Breadcrumb>
         </div>
+        <SettingsEditButton
+          to={clusterTopicsTopicEditPath(clusterName, topicName)}
+        />
       </div>
 
       <div className="box">

+ 16 - 0
kafka-ui-react-app/src/components/Topics/Details/Settings/SettingsEditButton.tsx

@@ -0,0 +1,16 @@
+import React from 'react';
+import { Link } from 'react-router-dom';
+
+interface Props {
+  to: string;
+}
+
+const SettingsEditButton: React.FC<Props> = ({ to }) => (
+  <Link to={to}>
+    <button type="button" className="button is-small is-warning">
+      Edit settings
+    </button>
+  </Link>
+);
+
+export default SettingsEditButton;

+ 143 - 0
kafka-ui-react-app/src/components/Topics/Edit/Edit.tsx

@@ -0,0 +1,143 @@
+import React from 'react';
+import {
+  ClusterName,
+  TopicFormData,
+  TopicName,
+  TopicConfigByName,
+  TopicWithDetailedInfo,
+  CleanupPolicy,
+} from 'redux/interfaces';
+import { useForm, FormContext } from 'react-hook-form';
+import { camelCase } from 'lodash';
+
+import TopicForm from '../shared/Form/TopicForm';
+import FormBreadcrumbs from '../shared/Form/FormBreadcrumbs';
+
+interface Props {
+  clusterName: ClusterName;
+  topicName: TopicName;
+  topic?: TopicWithDetailedInfo;
+  isFetched: boolean;
+  isTopicDetailsFetched: boolean;
+  isTopicUpdated: boolean;
+  fetchTopicDetails: (clusterName: ClusterName, topicName: TopicName) => void;
+  fetchTopicConfig: (clusterName: ClusterName, topicName: TopicName) => void;
+  updateTopic: (clusterName: ClusterName, form: TopicFormData) => void;
+  redirectToTopicPath: (clusterName: ClusterName, topicName: TopicName) => void;
+  resetUploadedState: () => void;
+}
+
+const DEFAULTS = {
+  partitions: 1,
+  replicationFactor: 1,
+  minInsyncReplicas: 1,
+  cleanupPolicy: CleanupPolicy.Delete,
+  retentionBytes: -1,
+  maxMessageBytes: 1000012,
+};
+
+const topicParams = (topic: TopicWithDetailedInfo | undefined) => {
+  if (!topic) {
+    return DEFAULTS;
+  }
+
+  const { name, replicationFactor } = topic;
+
+  const configs = topic.config?.reduce(
+    (result: { [name: string]: string }, param) => {
+      result[camelCase(param.name)] = param.value || param.defaultValue;
+      return result;
+    },
+    {}
+  );
+
+  return {
+    ...DEFAULTS,
+    name,
+    partitions: topic.partitionCount || DEFAULTS.partitions,
+    replicationFactor,
+    ...configs,
+  };
+};
+
+let formInit = false;
+
+const Edit: React.FC<Props> = ({
+  clusterName,
+  topicName,
+  topic,
+  isFetched,
+  isTopicDetailsFetched,
+  isTopicUpdated,
+  fetchTopicDetails,
+  fetchTopicConfig,
+  updateTopic,
+  redirectToTopicPath,
+}) => {
+  const defaultValues = topicParams(topic);
+
+  const methods = useForm<TopicFormData>({ defaultValues });
+
+  const [isSubmitting, setIsSubmitting] = React.useState<boolean>(false);
+
+  React.useEffect(() => {
+    fetchTopicConfig(clusterName, topicName);
+    fetchTopicDetails(clusterName, topicName);
+  }, [fetchTopicConfig, fetchTopicDetails, clusterName, topicName]);
+
+  React.useEffect(() => {
+    if (isSubmitting && isTopicUpdated) {
+      const { name } = methods.getValues();
+      redirectToTopicPath(clusterName, name);
+    }
+  }, [isSubmitting, isTopicUpdated, redirectToTopicPath, clusterName, methods]);
+
+  if (!isFetched || !isTopicDetailsFetched || !topic || !topic.config) {
+    return null;
+  }
+
+  if (!formInit) {
+    methods.reset(defaultValues);
+    formInit = true;
+  }
+
+  const config: TopicConfigByName = {
+    byName: {},
+  };
+
+  topic.config.forEach((param) => {
+    config.byName[param.name] = param;
+  });
+
+  const onSubmit = async (data: TopicFormData) => {
+    setIsSubmitting(true);
+    updateTopic(clusterName, data);
+  };
+
+  return (
+    <div className="section">
+      <div className="level">
+        <FormBreadcrumbs
+          clusterName={clusterName}
+          topicName={topicName}
+          current="Edit Topic"
+        />
+      </div>
+
+      <div className="box">
+        {/* eslint-disable-next-line react/jsx-props-no-spreading */}
+        <FormContext {...methods}>
+          <TopicForm
+            topicName={topicName}
+            config={config}
+            isSubmitting={isSubmitting}
+            isEditing
+            onSubmit={methods.handleSubmit(onSubmit)}
+          />
+        </FormContext>
+      </div>
+    </div>
+  );
+};
+
+export default Edit;

+ 63 - 0
kafka-ui-react-app/src/components/Topics/Edit/EditContainer.tsx

@@ -0,0 +1,63 @@
+import { connect } from 'react-redux';
+import {
+  RootState,
+  ClusterName,
+  TopicFormData,
+  TopicName,
+  Action,
+} from 'redux/interfaces';
+import { withRouter, RouteComponentProps } from 'react-router-dom';
+import {
+  updateTopic,
+  fetchTopicConfig,
+  fetchTopicDetails,
+} from 'redux/actions';
+import {
+  getTopicConfigFetched,
+  getTopicUpdated,
+  getIsTopicDetailsFetched,
+  getFullTopic,
+} from 'redux/reducers/topics/selectors';
+import { clusterTopicPath } from 'lib/paths';
+import { ThunkDispatch } from 'redux-thunk';
+import Edit from './Edit';
+
+interface RouteProps {
+  clusterName: ClusterName;
+  topicName: TopicName;
+}
+
+type OwnProps = RouteComponentProps<RouteProps>;
+
+const mapStateToProps = (
+  state: RootState,
+  {
+    match: {
+      params: { topicName, clusterName },
+    },
+  }: OwnProps
+) => ({
+  clusterName,
+  topicName,
+  topic: getFullTopic(state, topicName),
+  isFetched: getTopicConfigFetched(state),
+  isTopicDetailsFetched: getIsTopicDetailsFetched(state),
+  isTopicUpdated: getTopicUpdated(state),
+});
+
+const mapDispatchToProps = (
+  dispatch: ThunkDispatch<RootState, undefined, Action>,
+  { history }: OwnProps
+) => ({
+  fetchTopicDetails: (clusterName: ClusterName, topicName: TopicName) =>
+    dispatch(fetchTopicDetails(clusterName, topicName)),
+  fetchTopicConfig: (clusterName: ClusterName, topicName: TopicName) =>
+    dispatch(fetchTopicConfig(clusterName, topicName)),
+  updateTopic: (clusterName: ClusterName, form: TopicFormData) =>
+    dispatch(updateTopic(clusterName, form)),
+  redirectToTopicPath: (clusterName: ClusterName, topicName: TopicName) => {
+    history.push(clusterTopicPath(clusterName, topicName));
+  },
+});
+
+export default withRouter(connect(mapStateToProps, mapDispatchToProps)(Edit));

+ 6 - 0
kafka-ui-react-app/src/components/Topics/Topics.tsx

@@ -2,6 +2,7 @@ import React from 'react';
 import { ClusterName } from 'redux/interfaces';
 import { Switch, Route } from 'react-router-dom';
 import PageLoader from 'components/common/PageLoader/PageLoader';
+import EditContainer from 'components/Topics/Edit/EditContainer';
 import ListContainer from './List/ListContainer';
 import DetailsContainer from './Details/DetailsContainer';
 import NewContainer from './New/NewContainer';
@@ -35,6 +36,11 @@ const Topics: React.FC<Props> = ({
           path="/ui/clusters/:clusterName/topics/new"
           component={NewContainer}
         />
+        <Route
+          exact
+          path="/ui/clusters/:clusterName/topics/:topicName/edit"
+          component={EditContainer}
+        />
         <Route
           path="/ui/clusters/:clusterName/topics/:topicName"
           component={DetailsContainer}

+ 20 - 0
kafka-ui-react-app/src/components/Topics/shared/Form/CustomParams/CustomParamAction.tsx

@@ -0,0 +1,20 @@
+import React from 'react';
+import CustomParamButton, { CustomParamButtonType } from './CustomParamButton';
+
+interface Props {
+  index: string;
+  onRemove: (index: string) => void;
+}
+
+const CustomParamAction: React.FC<Props> = ({ index, onRemove }) => (
+  <>
+    <label className="label">&nbsp;</label>
+    <CustomParamButton
+      className="is-danger"
+      type={CustomParamButtonType.minus}
+      onClick={() => onRemove(index)}
+    />
+  </>
+);
+
+export default CustomParamAction;

+ 33 - 0
kafka-ui-react-app/src/components/Topics/shared/Form/CustomParams/CustomParamButton.tsx

@@ -0,0 +1,33 @@
+import React from 'react';
+
+export enum CustomParamButtonType {
+  plus = 'fa-plus',
+  minus = 'fa-minus',
+}
+
+interface Props {
+  onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
+  className: string;
+  type: CustomParamButtonType;
+  btnText?: string;
+}
+
+const CustomParamButton: React.FC<Props> = ({
+  onClick,
+  className,
+  type,
+  btnText,
+}) => (
+  <button
+    type="button"
+    className={`button ${className} is-outlined`}
+    onClick={onClick}
+  >
+    {btnText && <span>{btnText}</span>}
+    <span className="icon">
+      <i className={`fas fa-lg ${type}`} />
+    </span>
+  </button>
+);
+
+export default CustomParamButton;

+ 16 - 0
kafka-ui-react-app/src/components/Topics/shared/Form/CustomParams/CustomParamOptions.tsx

@@ -0,0 +1,16 @@
+import React from 'react';
+import { TopicCustomParamOption } from 'redux/interfaces';
+import { CUSTOM_PARAMS_OPTIONS } from './customParamsOptions';
+
+const CustomParamOptions = () => (
+  <>
+    <option value="">Select</option>
+    {Object.values(CUSTOM_PARAMS_OPTIONS).map((opt: TopicCustomParamOption) => (
+      <option key={opt.name} value={opt.name}>
+        {opt.name}
+      </option>
+    ))}
+  </>
+);
+
+export default React.memo(CustomParamOptions);

+ 56 - 0
kafka-ui-react-app/src/components/Topics/shared/Form/CustomParams/CustomParamSelect.tsx

@@ -0,0 +1,56 @@
+import React from 'react';
+import { useFormContext, ErrorMessage } from 'react-hook-form';
+import { TopicFormCustomParam } from 'redux/interfaces';
+import CustomParamOptions from './CustomParamOptions';
+import { INDEX_PREFIX } from './CustomParams';
+
+interface Props {
+  isDisabled: boolean;
+  index: string;
+  name: string;
+}
+
+const CustomParamSelect: React.FC<Props> = ({ isDisabled, index, name }) => {
+  const { register, errors, getValues, triggerValidation } = useFormContext();
+  const optInputName = `${index}[name]`;
+
+  const selectedMustBeUniq = (selected: string) => {
+    const values = getValues({ nest: true });
+    const customParamsValues: TopicFormCustomParam = values.customParams;
+
+    const valid = !Object.entries(customParamsValues).some(
+      ([key, customParam]) => {
+        return (
+          `${INDEX_PREFIX}.${key}` !== index && selected === customParam.name
+        );
+      }
+    );
+
+    return valid || 'Custom Parameter must be unique';
+  };
+
+  return (
+    <>
+      <label className="label">Custom Parameter</label>
+      <div className="select is-block">
+        <select
+          name={optInputName}
+          ref={register({
+            required: 'Custom Parameter is required.',
+            validate: { unique: (selected) => selectedMustBeUniq(selected) },
+          })}
+          onChange={() => triggerValidation(optInputName)}
+          disabled={isDisabled}
+          defaultValue={name}
+        >
+          <CustomParamOptions />
+        </select>
+        <p className="help is-danger">
+          <ErrorMessage errors={errors} name={optInputName} />
+        </p>
+      </div>
+    </>
+  );
+};
+
+export default React.memo(CustomParamSelect);

+ 54 - 0
kafka-ui-react-app/src/components/Topics/shared/Form/CustomParams/CustomParamValue.tsx

@@ -0,0 +1,54 @@
+import React from 'react';
+import { useFormContext, ErrorMessage } from 'react-hook-form';
+import { CUSTOM_PARAMS_OPTIONS } from './customParamsOptions';
+
+interface Props {
+  isDisabled: boolean;
+  index: string;
+  name: string;
+  defaultValue: string;
+}
+
+const CustomParamValue: React.FC<Props> = ({
+  isDisabled,
+  index,
+  name,
+  defaultValue,
+}) => {
+  const { register, errors, watch, setValue } = useFormContext();
+  const selectInputName = `${index}[name]`;
+  const valInputName = `${index}[value]`;
+  const selectedParamName = watch(selectInputName, name);
+
+  React.useEffect(() => {
+    if (selectedParamName) {
+      setValue(
+        valInputName,
+        CUSTOM_PARAMS_OPTIONS[selectedParamName].defaultValue,
+        true
+      );
+    }
+  }, [selectedParamName]);
+
+  return (
+    <>
+      <label className="label">Value</label>
+      <input
+        className="input"
+        placeholder="Value"
+        ref={register({
+          required: 'Value is required.',
+        })}
+        name={name.replace(/\./g, '_')}
+        defaultValue={defaultValue}
+        autoComplete="off"
+        disabled={isDisabled}
+      />
+      <p className="help is-danger">
+        <ErrorMessage errors={errors} name={name} />
+      </p>
+    </>
+  );
+};
+
+export default React.memo(CustomParamValue);

+ 113 - 0
kafka-ui-react-app/src/components/Topics/shared/Form/CustomParams/CustomParams.tsx

@@ -0,0 +1,113 @@
+import React from 'react';
+import { omit, reject, reduce } from 'lodash';
+
+import { TopicFormCustomParams, TopicConfigByName } from 'redux/interfaces';
+import CustomParamSelect from './CustomParamSelect';
+import CustomParamValue from './CustomParamValue';
+import CustomParamAction from './CustomParamAction';
+import CustomParamButton, { CustomParamButtonType } from './CustomParamButton';
+
+export const INDEX_PREFIX = 'customParams';
+
+interface Props {
+  isSubmitting: boolean;
+  config?: TopicConfigByName;
+}
+
+interface Param {
+  [index: string]: {
+    name: string;
+    value: string;
+  };
+}
+
+const CustomParams: React.FC<Props> = ({ isSubmitting, config }) => {
+  /* eslint-disable no-param-reassign */
+  const byIndex = config
+    ? reduce(
+        config.byName,
+        (result: Param, param, paramName) => {
+          result[`${INDEX_PREFIX}.${new Date().getTime()}ts`] = {
+            name: paramName,
+            value: param.value,
+          };
+          return result;
+        },
+        {}
+      )
+    : {};
+
+  const [formCustomParams, setFormCustomParams] = React.useState<
+    TopicFormCustomParams
+  >({
+    byIndex,
+    allIndexes: Object.keys(byIndex),
+  });
+
+  console.log(byIndex);
+
+  const onAdd = (event: React.MouseEvent<HTMLButtonElement>) => {
+    event.preventDefault();
+
+    const newIndex = `${INDEX_PREFIX}.${new Date().getTime()}ts`;
+
+    setFormCustomParams({
+      ...formCustomParams,
+      byIndex: {
+        ...formCustomParams.byIndex,
+        [newIndex]: { name: '', value: '' },
+      },
+      allIndexes: [newIndex, ...formCustomParams.allIndexes],
+    });
+  };
+
+  const onRemove = (index: string) => {
+    setFormCustomParams({
+      ...formCustomParams,
+      byIndex: omit(formCustomParams.byIndex, index),
+      allIndexes: reject(formCustomParams.allIndexes, (i) => i === index),
+    });
+  };
+
+  return (
+    <>
+      <div className="columns">
+        <div className="column">
+          <CustomParamButton
+            className="is-success"
+            type={CustomParamButtonType.plus}
+            onClick={onAdd}
+            btnText="Add Custom Parameter"
+          />
+        </div>
+      </div>
+
+      {formCustomParams.allIndexes.map((index) => (
+        <div className="columns is-centered" key={index}>
+          <div className="column">
+            <CustomParamSelect
+              index={index}
+              isDisabled={isSubmitting}
+              name={formCustomParams.byIndex[index].name}
+            />
+          </div>
+
+          <div className="column">
+            <CustomParamValue
+              index={index}
+              isDisabled={isSubmitting}
+              name={formCustomParams.byIndex[index].name}
+              defaultValue={formCustomParams.byIndex[index].value}
+            />
+          </div>
+
+          <div className="column is-narrow">
+            <CustomParamAction index={index} onRemove={onRemove} />
+          </div>
+        </div>
+      ))}
+    </>
+  );
+};
+
+export default CustomParams;

+ 19 - 0
kafka-ui-react-app/src/components/Topics/shared/Form/CustomParams/CustomParamsContainer.tsx

@@ -0,0 +1,19 @@
+import { connect } from 'react-redux';
+import { RootState, TopicConfigByName } from 'redux/interfaces';
+import { withRouter, RouteComponentProps } from 'react-router-dom';
+import CustomParams from './CustomParams';
+
+interface OwnProps extends RouteComponentProps {
+  isSubmitting: boolean;
+  config?: TopicConfigByName;
+}
+
+const mapStateToProps = (
+  state: RootState,
+  { isSubmitting, config }: OwnProps
+) => ({
+  isSubmitting,
+  config,
+});
+
+export default withRouter(connect(mapStateToProps)(CustomParams));

+ 96 - 0
kafka-ui-react-app/src/components/Topics/shared/Form/CustomParams/customParamsOptions.tsx

@@ -0,0 +1,96 @@
+import { TopicCustomParamOption } from 'redux/interfaces';
+
+export interface CustomParamOption {
+  [optionName: string]: TopicCustomParamOption;
+}
+
+export const CUSTOM_PARAMS_OPTIONS: CustomParamOption = {
+  'compression.type': {
+    name: 'compression.type',
+    defaultValue: 'producer',
+  },
+  'leader.replication.throttled.replicas': {
+    name: 'leader.replication.throttled.replicas',
+    defaultValue: '',
+  },
+  'message.downconversion.enable': {
+    name: 'message.downconversion.enable',
+    defaultValue: 'true',
+  },
+  'segment.jitter.ms': {
+    name: 'segment.jitter.ms',
+    defaultValue: '0',
+  },
+  'flush.ms': {
+    name: 'flush.ms',
+    defaultValue: '9223372036854775807',
+  },
+  'follower.replication.throttled.replicas': {
+    name: 'follower.replication.throttled.replicas',
+    defaultValue: '',
+  },
+  'segment.bytes': {
+    name: 'segment.bytes',
+    defaultValue: '1073741824',
+  },
+  'flush.messages': {
+    name: 'flush.messages',
+    defaultValue: '9223372036854775807',
+  },
+  'message.format.version': {
+    name: 'message.format.version',
+    defaultValue: '2.3-IV1',
+  },
+  'file.delete.delay.ms': {
+    name: 'file.delete.delay.ms',
+    defaultValue: '60000',
+  },
+  'max.compaction.lag.ms': {
+    name: 'max.compaction.lag.ms',
+    defaultValue: '9223372036854775807',
+  },
+  'min.compaction.lag.ms': {
+    name: 'min.compaction.lag.ms',
+    defaultValue: '0',
+  },
+  'message.timestamp.type': {
+    name: 'message.timestamp.type',
+    defaultValue: 'CreateTime',
+  },
+  preallocate: {
+    name: 'preallocate',
+    defaultValue: 'false',
+  },
+  'min.cleanable.dirty.ratio': {
+    name: 'min.cleanable.dirty.ratio',
+    defaultValue: '0.5',
+  },
+  'index.interval.bytes': {
+    name: 'index.interval.bytes',
+    defaultValue: '4096',
+  },
+  'unclean.leader.election.enable': {
+    name: 'unclean.leader.election.enable',
+    defaultValue: 'true',
+  },
+  'retention.bytes': {
+    name: 'retention.bytes',
+    defaultValue: '-1',
+  },
+  'delete.retention.ms': {
+    name: 'delete.retention.ms',
+    defaultValue: '86400000',
+  },
+  'segment.ms': {
+    name: 'segment.ms',
+    defaultValue: '604800000',
+  },
+  'message.timestamp.difference.max.ms': {
+    name: 'message.timestamp.difference.max.ms',
+    defaultValue: '9223372036854775807',
+  },
+  'segment.index.bytes': {
+    name: 'segment.index.bytes',
+    defaultValue: '10485760',
+  },
+};

+ 35 - 0
kafka-ui-react-app/src/components/Topics/shared/Form/FormBreadcrumbs.tsx

@@ -0,0 +1,35 @@
+import React from 'react';
+import Breadcrumb from 'components/common/Breadcrumb/Breadcrumb';
+import { clusterTopicsPath, clusterTopicPath } from 'lib/paths';
+import { ClusterName, TopicName } from 'redux/interfaces';
+
+interface Props {
+  clusterName: ClusterName;
+  topicName?: TopicName;
+  current: string;
+}
+
+const FormBreadcrumbs: React.FC<Props> = ({
+  clusterName,
+  topicName,
+  current,
+}) => {
+  const allTopicsLink = {
+    href: clusterTopicsPath(clusterName),
+    label: 'All Topics',
+  };
+  const links = topicName
+    ? [
+        allTopicsLink,
+        { href: clusterTopicPath(clusterName, topicName), label: topicName },
+      ]
+    : [allTopicsLink];
+
+  return (
+    <div className="level-item level-left">
+      <Breadcrumb links={links}>{current}</Breadcrumb>
+    </div>
+  );
+};
+
+export default FormBreadcrumbs;

+ 52 - 0
kafka-ui-react-app/src/components/Topics/shared/Form/TimeToRetain.tsx

@@ -0,0 +1,52 @@
+import React from 'react';
+import prettyMilliseconds from 'pretty-ms';
+import { useFormContext, ErrorMessage } from 'react-hook-form';
+import { MILLISECONDS_IN_WEEK, MILLISECONDS_IN_SECOND } from 'lib/constants';
+import TimeToRetainBtns from './TimeToRetainBtns';
+
+interface Props {
+  isSubmitting: boolean;
+}
+
+const TimeToRetain: React.FC<Props> = ({ isSubmitting }) => {
+  const { register, errors, watch } = useFormContext();
+  const defaultValue = MILLISECONDS_IN_WEEK;
+  const name = 'retentionMs';
+  const watchedValue = watch(name, defaultValue.toString());
+
+  const valueHint = React.useMemo(() => {
+    const value = parseInt(watchedValue, 10);
+    return value >= MILLISECONDS_IN_SECOND ? prettyMilliseconds(value) : false;
+  }, [watchedValue]);
+
+  return (
+    <>
+      <label
+        className="label is-flex"
+        style={{ justifyContent: 'space-between' }}
+      >
+        <div>Time to retain data (in ms)</div>
+        {valueHint && <span className="has-text-info">{valueHint}</span>}
+      </label>
+      <input
+        className="input"
+        id="timeToRetain"
+        type="number"
+        defaultValue={defaultValue}
+        name={name}
+        ref={register({
+          min: { value: -1, message: 'must be greater than or equal to -1' },
+        })}
+        disabled={isSubmitting}
+      />
+
+      <p className="help is-danger">
+        <ErrorMessage errors={errors} name={name} />
+      </p>
+
+      <TimeToRetainBtns name={name} value={watchedValue} />
+    </>
+  );
+};
+
+export default TimeToRetain;

+ 29 - 0
kafka-ui-react-app/src/components/Topics/shared/Form/TimeToRetainBtn.tsx

@@ -0,0 +1,29 @@
+import React from 'react';
+import { useFormContext } from 'react-hook-form';
+import cx from 'classnames';
+import { MILLISECONDS_IN_WEEK } from 'lib/constants';
+
+interface Props {
+  inputName: string;
+  text: string;
+  value: number;
+}
+
+const TimeToRetainBtn: React.FC<Props> = ({ inputName, text, value }) => {
+  const { setValue, watch } = useFormContext();
+  const watchedValue = watch(inputName, MILLISECONDS_IN_WEEK.toString());
+
+  return (
+    <button
+      type="button"
+      className={cx('button', {
+        'is-info': watchedValue === value.toString(),
+      })}
+      onClick={() => setValue(inputName, value)}
+    >
+      {text}
+    </button>
+  );
+};
+
+export default TimeToRetainBtn;

+ 36 - 0
kafka-ui-react-app/src/components/Topics/shared/Form/TimeToRetainBtns.tsx

@@ -0,0 +1,36 @@
+import React from 'react';
+import { MILLISECONDS_IN_DAY } from 'lib/constants';
+import TimeToRetainBtn from './TimeToRetainBtn';
+
+interface Props {
+  name: string;
+  value: string;
+}
+
+const TimeToRetainBtns: React.FC<Props> = ({ name }) => (
+  <div className="buttons are-small">
+    <TimeToRetainBtn
+      text="12h"
+      inputName={name}
+      value={MILLISECONDS_IN_DAY / 2}
+    />
+    <TimeToRetainBtn text="1d" inputName={name} value={MILLISECONDS_IN_DAY} />
+    <TimeToRetainBtn
+      text="2d"
+      inputName={name}
+      value={MILLISECONDS_IN_DAY * 2}
+    />
+    <TimeToRetainBtn
+      text="7d"
+      inputName={name}
+      value={MILLISECONDS_IN_DAY * 7}
+    />
+    <TimeToRetainBtn
+      text="4w"
+      inputName={name}
+      value={MILLISECONDS_IN_DAY * 7 * 24}
+    />
+  </div>
+);
+
+export default TimeToRetainBtns;

+ 170 - 0
kafka-ui-react-app/src/components/Topics/shared/Form/TopicForm.tsx

@@ -0,0 +1,170 @@
+import React from 'react';
+import { useFormContext, ErrorMessage } from 'react-hook-form';
+import { TOPIC_NAME_VALIDATION_PATTERN, BYTES_IN_GB } from 'lib/constants';
+import { CleanupPolicy, TopicName, TopicConfigByName } from 'redux/interfaces';
+import CustomParamsContainer from './CustomParams/CustomParamsContainer';
+import TimeToRetain from './TimeToRetain';
+
+interface Props {
+  topicName?: TopicName;
+  config?: TopicConfigByName;
+  isEditing?: boolean;
+  isSubmitting: boolean;
+  onSubmit: (e: React.BaseSyntheticEvent) => Promise<void>;
+}
+
+const TopicForm: React.FC<Props> = ({
+  topicName,
+  config,
+  isEditing,
+  isSubmitting,
+  onSubmit,
+}) => {
+  const { register, errors } = useFormContext();
+
+  return (
+    <form onSubmit={onSubmit}>
+      <div className="columns">
+        <div className="column is-three-quarters">
+          <label className="label">Topic Name *</label>
+          <input
+            className="input"
+            placeholder="Topic Name"
+            ref={register({
+              required: 'Topic Name is required.',
+              pattern: {
+                value: TOPIC_NAME_VALIDATION_PATTERN,
+                message: 'Only alphanumeric, _, -, and . allowed',
+              },
+            })}
+            defaultValue={topicName}
+            name="name"
+            autoComplete="off"
+            disabled={isEditing || isSubmitting}
+          />
+          <p className="help is-danger">
+            <ErrorMessage errors={errors} name="name" />
+          </p>
+        </div>
+
+        <div className="column">
+          <label className="label">Number of partitions *</label>
+          <input
+            className="input"
+            type="number"
+            placeholder="Number of partitions"
+            defaultValue="1"
+            ref={register({ required: 'Number of partitions is required.' })}
+            name="partitions"
+            disabled={isEditing || isSubmitting}
+          />
+          <p className="help is-danger">
+            <ErrorMessage errors={errors} name="partitions" />
+          </p>
+        </div>
+      </div>
+
+      <div className="columns">
+        <div className="column">
+          <label className="label">Replication Factor *</label>
+          <input
+            className="input"
+            type="number"
+            placeholder="Replication Factor"
+            defaultValue="1"
+            ref={register({ required: 'Replication Factor is required.' })}
+            name="replicationFactor"
+            disabled={isEditing || isSubmitting}
+          />
+          <p className="help is-danger">
+            <ErrorMessage errors={errors} name="replicationFactor" />
+          </p>
+        </div>
+
+        <div className="column">
+          <label className="label">Min In Sync Replicas *</label>
+          <input
+            className="input"
+            type="number"
+            placeholder="Min In Sync Replicas"
+            defaultValue="1"
+            ref={register({ required: 'Min In Sync Replicas is required.' })}
+            name="minInsyncReplicas"
+            disabled={isSubmitting}
+          />
+          <p className="help is-danger">
+            <ErrorMessage errors={errors} name="minInSyncReplicas" />
+          </p>
+        </div>
+      </div>
+
+      <div className="columns">
+        <div className="column is-one-third">
+          <label className="label">Cleanup policy</label>
+          <div className="select is-block">
+            <select
+              defaultValue={CleanupPolicy.Delete}
+              name="cleanupPolicy"
+              ref={register}
+              disabled={isSubmitting}
+            >
+              <option value={CleanupPolicy.Delete}>Delete</option>
+              <option value={CleanupPolicy.Compact}>Compact</option>
+            </select>
+          </div>
+        </div>
+
+        <div className="column is-one-third">
+          <TimeToRetain isSubmitting={isSubmitting} />
+        </div>
+
+        <div className="column is-one-third">
+          <label className="label">Max size on disk in GB</label>
+          <div className="select is-block">
+            <select
+              defaultValue={-1}
+              name="retentionBytes"
+              ref={register}
+              disabled={isSubmitting}
+            >
+              <option value={-1}>Not Set</option>
+              <option value={BYTES_IN_GB}>1 GB</option>
+              <option value={BYTES_IN_GB * 10}>10 GB</option>
+              <option value={BYTES_IN_GB * 20}>20 GB</option>
+              <option value={BYTES_IN_GB * 50}>50 GB</option>
+            </select>
+          </div>
+        </div>
+      </div>
+
+      <div className="columns">
+        <div className="column">
+          <label className="label">Maximum message size in bytes *</label>
+          <input
+            className="input"
+            type="number"
+            defaultValue="1000012"
+            ref={register({
+              required: 'Maximum message size in bytes is required',
+            })}
+            name="maxMessageBytes"
+            disabled={isSubmitting}
+          />
+          <p className="help is-danger">
+            <ErrorMessage errors={errors} name="maxMessageBytes" />
+          </p>
+        </div>
+      </div>
+
+      <CustomParamsContainer isSubmitting={isSubmitting} config={config} />
+
+      <input
+        type="submit"
+        className="button is-primary"
+        disabled={isSubmitting}
+      />
+    </form>
+  );
+};
+
+export default TopicForm;

+ 5 - 0
kafka-ui-react-app/src/lib/paths.ts

@@ -23,3 +23,8 @@ export const clusterTopicMessagesPath = (
   clusterName: ClusterName,
   topicName: TopicName
 ) => `${clusterTopicsPath(clusterName)}/${topicName}/messages`;
+
+export const clusterTopicsTopicEditPath = (
+  clusterName: ClusterName,
+  topicName: TopicName
+) => `${clusterTopicsPath(clusterName)}/${topicName}/edit`;

+ 5 - 1
kafka-ui-react-app/src/redux/actionType.ts

@@ -27,6 +27,10 @@ export enum ActionType {
   POST_TOPIC__SUCCESS = 'POST_TOPIC__SUCCESS',
   POST_TOPIC__FAILURE = 'POST_TOPIC__FAILURE',
 
+  PATCH_TOPIC__REQUEST = 'PATCH_TOPIC__REQUEST',
+  PATCH_TOPIC__SUCCESS = 'PATCH_TOPIC__SUCCESS',
+  PATCH_TOPIC__FAILURE = 'PATCH_TOPIC__FAILURE',
+
   GET_CONSUMER_GROUPS__REQUEST = 'GET_CONSUMER_GROUPS__REQUEST',
   GET_CONSUMER_GROUPS__SUCCESS = 'GET_CONSUMER_GROUPS__SUCCESS',
   GET_CONSUMER_GROUPS__FAILURE = 'GET_CONSUMER_GROUPS__FAILURE',
@@ -34,4 +38,4 @@ export enum ActionType {
   GET_CONSUMER_GROUP_DETAILS__REQUEST = 'GET_CONSUMER_GROUP_DETAILS__REQUEST',
   GET_CONSUMER_GROUP_DETAILS__SUCCESS = 'GET_CONSUMER_GROUP_DETAILS__SUCCESS',
   GET_CONSUMER_GROUP_DETAILS__FAILURE = 'GET_CONSUMER_GROUP_DETAILS__FAILURE',
-};
+}

+ 25 - 13
kafka-ui-react-app/src/redux/actions/actions.ts

@@ -1,6 +1,5 @@
 import { createAsyncAction } from 'typesafe-actions';
 import { ActionType } from 'redux/actionType';
-import { ConsumerGroup, ConsumerGroupID, ConsumerGroupDetails } from '../interfaces/consumerGroup';
 import {
   Broker,
   BrokerMetrics,
@@ -9,58 +8,71 @@ import {
   TopicConfig,
   TopicDetails,
   TopicName,
+  ConsumerGroup,
+  ConsumerGroupDetails,
+  ConsumerGroupID,
 } from 'redux/interfaces';
 
 export const fetchBrokersAction = createAsyncAction(
   ActionType.GET_BROKERS__REQUEST,
   ActionType.GET_BROKERS__SUCCESS,
-  ActionType.GET_BROKERS__FAILURE,
+  ActionType.GET_BROKERS__FAILURE
 )<undefined, Broker[], undefined>();
 
 export const fetchBrokerMetricsAction = createAsyncAction(
   ActionType.GET_BROKER_METRICS__REQUEST,
   ActionType.GET_BROKER_METRICS__SUCCESS,
-  ActionType.GET_BROKER_METRICS__FAILURE,
+  ActionType.GET_BROKER_METRICS__FAILURE
 )<undefined, BrokerMetrics, undefined>();
 
 export const fetchClusterListAction = createAsyncAction(
   ActionType.GET_CLUSTERS__REQUEST,
   ActionType.GET_CLUSTERS__SUCCESS,
-  ActionType.GET_CLUSTERS__FAILURE,
+  ActionType.GET_CLUSTERS__FAILURE
 )<undefined, Cluster[], undefined>();
 
 export const fetchTopicListAction = createAsyncAction(
   ActionType.GET_TOPICS__REQUEST,
   ActionType.GET_TOPICS__SUCCESS,
-  ActionType.GET_TOPICS__FAILURE,
+  ActionType.GET_TOPICS__FAILURE
 )<undefined, Topic[], undefined>();
 
 export const fetchTopicDetailsAction = createAsyncAction(
   ActionType.GET_TOPIC_DETAILS__REQUEST,
   ActionType.GET_TOPIC_DETAILS__SUCCESS,
-  ActionType.GET_TOPIC_DETAILS__FAILURE,
-)<undefined, { topicName: TopicName, details: TopicDetails }, undefined>();
+  ActionType.GET_TOPIC_DETAILS__FAILURE
+)<undefined, { topicName: TopicName; details: TopicDetails }, undefined>();
 
 export const fetchTopicConfigAction = createAsyncAction(
   ActionType.GET_TOPIC_CONFIG__REQUEST,
   ActionType.GET_TOPIC_CONFIG__SUCCESS,
-  ActionType.GET_TOPIC_CONFIG__FAILURE,
-)<undefined, { topicName: TopicName, config: TopicConfig[] }, undefined>();
+  ActionType.GET_TOPIC_CONFIG__FAILURE
+)<undefined, { topicName: TopicName; config: TopicConfig[] }, undefined>();
 
 export const createTopicAction = createAsyncAction(
   ActionType.POST_TOPIC__REQUEST,
   ActionType.POST_TOPIC__SUCCESS,
-  ActionType.POST_TOPIC__FAILURE,
+  ActionType.POST_TOPIC__FAILURE
+)<undefined, Topic, undefined>();
+
+export const updateTopicAction = createAsyncAction(
+  ActionType.PATCH_TOPIC__REQUEST,
+  ActionType.PATCH_TOPIC__SUCCESS,
+  ActionType.PATCH_TOPIC__FAILURE
 )<undefined, Topic, undefined>();
 
 export const fetchConsumerGroupsAction = createAsyncAction(
   ActionType.GET_CONSUMER_GROUPS__REQUEST,
   ActionType.GET_CONSUMER_GROUPS__SUCCESS,
-  ActionType.GET_CONSUMER_GROUPS__FAILURE,
+  ActionType.GET_CONSUMER_GROUPS__FAILURE
 )<undefined, ConsumerGroup[], undefined>();
 
 export const fetchConsumerGroupDetailsAction = createAsyncAction(
   ActionType.GET_CONSUMER_GROUP_DETAILS__REQUEST,
   ActionType.GET_CONSUMER_GROUP_DETAILS__SUCCESS,
-  ActionType.GET_CONSUMER_GROUP_DETAILS__FAILURE,
-)<undefined, { consumerGroupID: ConsumerGroupID, details: ConsumerGroupDetails }, undefined>();
+  ActionType.GET_CONSUMER_GROUP_DETAILS__FAILURE
+)<
+  undefined,
+  { consumerGroupID: ConsumerGroupID; details: ConsumerGroupDetails },
+  undefined
+>();

+ 62 - 14
kafka-ui-react-app/src/redux/actions/thunks.ts

@@ -1,15 +1,19 @@
 import * as api from 'redux/api';
-import * as actions from './actions';
-import { ConsumerGroupID } from '../interfaces/consumerGroup';
 import {
+  ConsumerGroupID,
   PromiseThunk,
   Cluster,
   ClusterName,
   TopicFormData,
-  TopicName, Topic,
+  TopicName,
+  Topic,
 } from 'redux/interfaces';
 
-export const fetchBrokers = (clusterName: ClusterName): PromiseThunk<void> => async (dispatch) => {
+import * as actions from './actions';
+
+export const fetchBrokers = (
+  clusterName: ClusterName
+): PromiseThunk<void> => async (dispatch) => {
   dispatch(actions.fetchBrokersAction.request());
   try {
     const payload = await api.getBrokers(clusterName);
@@ -19,7 +23,9 @@ export const fetchBrokers = (clusterName: ClusterName): PromiseThunk<void> => as
   }
 };
 
-export const fetchBrokerMetrics = (clusterName: ClusterName): PromiseThunk<void> => async (dispatch) => {
+export const fetchBrokerMetrics = (
+  clusterName: ClusterName
+): PromiseThunk<void> => async (dispatch) => {
   dispatch(actions.fetchBrokerMetricsAction.request());
   try {
     const payload = await api.getBrokerMetrics(clusterName);
@@ -39,7 +45,9 @@ export const fetchClustersList = (): PromiseThunk<void> => async (dispatch) => {
   }
 };
 
-export const fetchTopicList = (clusterName: ClusterName): PromiseThunk<void> => async (dispatch) => {
+export const fetchTopicList = (
+  clusterName: ClusterName
+): PromiseThunk<void> => async (dispatch) => {
   dispatch(actions.fetchTopicListAction.request());
   try {
     const topics = await api.getTopics(clusterName);
@@ -49,17 +57,28 @@ export const fetchTopicList = (clusterName: ClusterName): PromiseThunk<void> =>
   }
 };
 
-export const fetchTopicDetails = (clusterName: ClusterName, topicName: TopicName): PromiseThunk<void> => async (dispatch) => {
+export const fetchTopicDetails = (
+  clusterName: ClusterName,
+  topicName: TopicName
+): PromiseThunk<void> => async (dispatch) => {
   dispatch(actions.fetchTopicDetailsAction.request());
   try {
     const topicDetails = await api.getTopicDetails(clusterName, topicName);
-    dispatch(actions.fetchTopicDetailsAction.success({ topicName, details: topicDetails }));
+    dispatch(
+      actions.fetchTopicDetailsAction.success({
+        topicName,
+        details: topicDetails,
+      })
+    );
   } catch (e) {
     dispatch(actions.fetchTopicDetailsAction.failure());
   }
 };
 
-export const fetchTopicConfig = (clusterName: ClusterName, topicName: TopicName): PromiseThunk<void> => async (dispatch) => {
+export const fetchTopicConfig = (
+  clusterName: ClusterName,
+  topicName: TopicName
+): PromiseThunk<void> => async (dispatch) => {
   dispatch(actions.fetchTopicConfigAction.request());
   try {
     const config = await api.getTopicConfig(clusterName, topicName);
@@ -69,7 +88,10 @@ export const fetchTopicConfig = (clusterName: ClusterName, topicName: TopicName)
   }
 };
 
-export const createTopic = (clusterName: ClusterName, form: TopicFormData): PromiseThunk<void> => async (dispatch) => {
+export const createTopic = (
+  clusterName: ClusterName,
+  form: TopicFormData
+): PromiseThunk<void> => async (dispatch) => {
   dispatch(actions.createTopicAction.request());
   try {
     const topic: Topic = await api.postTopic(clusterName, form);
@@ -79,7 +101,22 @@ export const createTopic = (clusterName: ClusterName, form: TopicFormData): Prom
   }
 };
 
-export const fetchConsumerGroupsList = (clusterName: ClusterName): PromiseThunk<void> => async (dispatch) => {
+export const updateTopic = (
+  clusterName: ClusterName,
+  form: TopicFormData
+): PromiseThunk<void> => async (dispatch) => {
+  dispatch(actions.updateTopicAction.request());
+  try {
+    const topic: Topic = await api.patchTopic(clusterName, form);
+    dispatch(actions.updateTopicAction.success(topic));
+  } catch (e) {
+    dispatch(actions.updateTopicAction.failure());
+  }
+};
+
+export const fetchConsumerGroupsList = (
+  clusterName: ClusterName
+): PromiseThunk<void> => async (dispatch) => {
   dispatch(actions.fetchConsumerGroupsAction.request());
   try {
     const consumerGroups = await api.getConsumerGroups(clusterName);
@@ -89,11 +126,22 @@ export const fetchConsumerGroupsList = (clusterName: ClusterName): PromiseThunk<
   }
 };
 
-export const fetchConsumerGroupDetails = (clusterName: ClusterName, consumerGroupID: ConsumerGroupID): PromiseThunk<void> => async (dispatch) => {
+export const fetchConsumerGroupDetails = (
+  clusterName: ClusterName,
+  consumerGroupID: ConsumerGroupID
+): PromiseThunk<void> => async (dispatch) => {
   dispatch(actions.fetchConsumerGroupDetailsAction.request());
   try {
-    const consumerGroupDetails = await api.getConsumerGroupDetails(clusterName, consumerGroupID);
-    dispatch(actions.fetchConsumerGroupDetailsAction.success({ consumerGroupID, details: consumerGroupDetails }));
+    const consumerGroupDetails = await api.getConsumerGroupDetails(
+      clusterName,
+      consumerGroupID
+    );
+    dispatch(
+      actions.fetchConsumerGroupDetailsAction.success({
+        consumerGroupID,
+        details: consumerGroupDetails,
+      })
+    );
   } catch (e) {
     dispatch(actions.fetchConsumerGroupDetailsAction.failure());
   }

+ 43 - 40
kafka-ui-react-app/src/redux/api/topics.ts

@@ -1,14 +1,29 @@
-import { reduce } from 'lodash';
 import {
-  ClusterName,
+  TopicName,
   Topic,
-  TopicConfig,
+  ClusterName,
   TopicDetails,
-  TopicFormCustomParam,
+  TopicConfig,
   TopicFormData,
-  TopicName,
 } from 'redux/interfaces';
-import { BASE_PARAMS, BASE_URL } from 'lib/constants';
+import { BASE_URL, BASE_PARAMS } from 'lib/constants';
+import { snakeCase } from 'lodash';
+
+interface TopicFormParams {
+  [name: string]: string;
+}
+
+const formatParams = (params: TopicFormData, omittedFields: string[] = []) => {
+  return Object.keys(params).reduce((result, paramName) => {
+    if (omittedFields.includes(paramName)) {
+      return result;
+    }
+    result[snakeCase(paramName).replace(/_/g, '.')] = params[
+      paramName
+    ] as string;
+    return result;
+  }, {} as TopicFormParams);
+};
 
 export const getTopicConfig = (
   clusterName: ClusterName,
@@ -31,49 +46,17 @@ export const getTopics = (clusterName: ClusterName): Promise<Topic[]> =>
     ...BASE_PARAMS,
   }).then((res) => res.json());
 
-interface Result {
-  [index: string]: string;
-}
-
 export const postTopic = (
   clusterName: ClusterName,
   form: TopicFormData
 ): Promise<Topic> => {
-  const {
-    name,
-    partitions,
-    replicationFactor,
-    cleanupPolicy,
-    retentionBytes,
-    retentionMs,
-    maxMessageBytes,
-    minInSyncReplicas,
-  } = form;
-
-  const customParams =
-    (form.customParams &&
-      reduce(
-        Object.values(form.customParams),
-        (result: Result, customParam: TopicFormCustomParam) => {
-          result[customParam.name] = customParam.value;
-          return result;
-        },
-        {}
-      )) ||
-    {};
+  const { name, partitions, replicationFactor } = form;
 
   const body = JSON.stringify({
     name,
     partitions,
     replicationFactor,
-    configs: {
-      'cleanup.policy': cleanupPolicy,
-      'retention.ms': retentionMs,
-      'retention.bytes': retentionBytes,
-      'max.message.bytes': maxMessageBytes,
-      'min.insync.replicas': minInSyncReplicas,
-      ...customParams,
-    },
+    configs: formatParams(form, ['customParams']),
   });
 
   return fetch(`${BASE_URL}/clusters/${clusterName}/topics`, {
@@ -82,3 +65,23 @@ export const postTopic = (
     body,
   }).then((res) => res.json());
 };
+
+export const patchTopic = (
+  clusterName: ClusterName,
+  form: TopicFormData
+): Promise<Topic> => {
+  const body = JSON.stringify({
+    configs: formatParams(form, [
+      'name',
+      'partitions',
+      'replicationFactor',
+      'customParams',
+    ]),
+  });
+
+  return fetch(`${BASE_URL}/clusters/${clusterName}/topics/${form.name}`, {
+    ...BASE_PARAMS,
+    method: 'PATCH',
+    body,
+  }).then((res) => res.json());
+};

+ 8 - 4
kafka-ui-react-app/src/redux/interfaces/topic.ts

@@ -11,6 +11,12 @@ export interface TopicConfig {
   defaultValue: string;
 }
 
+export interface TopicConfigByName {
+  byName: {
+    [paramName: string]: TopicConfig;
+  };
+}
+
 export interface TopicReplica {
   broker: number;
   leader: boolean;
@@ -67,12 +73,10 @@ export interface TopicFormData {
   name: string;
   partitions: number;
   replicationFactor: number;
-  minInSyncReplicas: number;
+  minInsyncReplicas: number;
   cleanupPolicy: string;
   retentionMs: number;
   retentionBytes: number;
   maxMessageBytes: number;
-  customParams: {
-    [index: string]: TopicFormCustomParam;
-  };
+  [name: string]: string | number;
 }

+ 50 - 13
kafka-ui-react-app/src/redux/reducers/topics/selectors.ts

@@ -1,5 +1,11 @@
 import { createSelector } from 'reselect';
-import { RootState, TopicName, FetchStatus, TopicsState } from 'redux/interfaces';
+import {
+  RootState,
+  TopicName,
+  FetchStatus,
+  TopicsState,
+  TopicConfigByName,
+} from 'redux/interfaces';
 import { createFetchingSelector } from 'redux/reducers/loader/selectors';
 
 const topicsState = ({ topics }: RootState): TopicsState => topics;
@@ -8,28 +14,36 @@ const getAllNames = (state: RootState) => topicsState(state).allNames;
 const getTopicMap = (state: RootState) => topicsState(state).byName;
 
 const getTopicListFetchingStatus = createFetchingSelector('GET_TOPICS');
-const getTopicDetailsFetchingStatus = createFetchingSelector('GET_TOPIC_DETAILS');
+const getTopicDetailsFetchingStatus = createFetchingSelector(
+  'GET_TOPIC_DETAILS'
+);
 const getTopicConfigFetchingStatus = createFetchingSelector('GET_TOPIC_CONFIG');
 const getTopicCreationStatus = createFetchingSelector('POST_TOPIC');
+const getTopicUpdateStatus = createFetchingSelector('PATCH_TOPIC');
 
 export const getIsTopicListFetched = createSelector(
   getTopicListFetchingStatus,
-  (status) => status === FetchStatus.fetched,
+  (status) => status === FetchStatus.fetched
 );
 
 export const getIsTopicDetailsFetched = createSelector(
   getTopicDetailsFetchingStatus,
-  (status) => status === FetchStatus.fetched,
+  (status) => status === FetchStatus.fetched
 );
 
 export const getTopicConfigFetched = createSelector(
   getTopicConfigFetchingStatus,
-  (status) => status === FetchStatus.fetched,
+  (status) => status === FetchStatus.fetched
 );
 
 export const getTopicCreated = createSelector(
   getTopicCreationStatus,
-  (status) => status === FetchStatus.fetched,
+  (status) => status === FetchStatus.fetched
+);
+
+export const getTopicUpdated = createSelector(
+  getTopicUpdateStatus,
+  (status) => status === FetchStatus.fetched
 );
 
 export const getTopicList = createSelector(
@@ -40,13 +54,12 @@ export const getTopicList = createSelector(
     if (!isFetched) {
       return [];
     }
-    return allNames.map((name) => byName[name])
-  },
+    return allNames.map((name) => byName[name]);
+  }
 );
 
-export const getExternalTopicList = createSelector(
-  getTopicList,
-  (topics) => topics.filter(({ internal }) => !internal),
+export const getExternalTopicList = createSelector(getTopicList, (topics) =>
+  topics.filter(({ internal }) => !internal)
 );
 
 const getTopicName = (_: RootState, topicName: TopicName) => topicName;
@@ -54,7 +67,31 @@ const getTopicName = (_: RootState, topicName: TopicName) => topicName;
 export const getTopicByName = createSelector(
   getTopicMap,
   getTopicName,
-  (topics, topicName) => topics[topicName],
+  (topics, topicName) => topics[topicName]
 );
 
-export const getTopicConfig = createSelector(getTopicByName, ({ config }) => config);
+export const getFullTopic = createSelector(getTopicByName, (topic) =>
+  topic && topic.config && !!topic.partitionCount ? topic : undefined
+);
+
+export const getTopicConfig = createSelector(
+  getTopicByName,
+  ({ config }) => config
+);
+
+export const getTopicConfigByParamName = createSelector(
+  getTopicConfig,
+  (config) => {
+    const byParamName: TopicConfigByName = {
+      byName: {},
+    };
+
+    if (config) {
+      config.forEach((param) => {
+        byParamName.byName[param.name] = param;
+      });
+    }
+
+    return byParamName;
+  }
+);

+ 3 - 0
package-lock.json

@@ -0,0 +1,3 @@
+{
+  "lockfileVersion": 1
+}

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio