ソースを参照

feature/14-add-custom-params-for-topics-creation

Azat Gataullin 5 年 前
コミット
1109240169

+ 1 - 1
kafka-ui-react-app/.env

@@ -1,2 +1,2 @@
 # Kafka REST API
-REACT_APP_API_URL=http://localhost:8080/api
+REACT_APP_API_URL=http://localhost:3004/api

+ 3 - 0
kafka-ui-react-app/mock/index.js

@@ -5,6 +5,7 @@ const brokerMetrics = require('./payload/brokerMetrics.json');
 const topics = require('./payload/topics.json');
 const topicDetails = require('./payload/topicDetails.json');
 const topicConfigs = require('./payload/topicConfigs.json');
+const topicsCustomParams = require('./payload/topicsCustomParams.json');
 
 const db = {
     clusters,
@@ -13,6 +14,7 @@ const db = {
     topics: topics.map((topic) => ({...topic, id: topic.name})),
     topicDetails,
     topicConfigs,
+    topicsCustomParams,
 };
 const server = jsonServer.create();
 const router = jsonServer.router(db);
@@ -30,6 +32,7 @@ server.use(
   jsonServer.rewriter({
     '/api/*': '/$1',
     '/clusters/:clusterName/metrics/broker': '/brokerMetrics/:clusterName',
+    '/clusters/:clusterName/topics/custom_params': '/topicsCustomParams',
     '/clusters/:clusterName/topics/:id': '/topicDetails',
     '/clusters/:clusterName/topics/:id/config': '/topicConfigs',
   })

+ 32 - 0
kafka-ui-react-app/mock/payload/topicsCustomParams.json

@@ -0,0 +1,32 @@
+[
+  {
+    "name": "topic.custom.param.name-1",
+    "value": "topic.custom.param.value-1",
+    "defaultValue": "topic.custom.param.defValue-1"
+  },
+  {
+    "name": "topic.custom.param.name-2",
+    "value": "topic.custom.param.value-2",
+    "defaultValue": "topic.custom.param.defValue-2"
+  },
+  {
+    "name": "topic.custom.param.name-3",
+    "value": "topic.custom.param.value-3",
+    "defaultValue": "topic.custom.param.defValue-3"
+  },
+  {
+    "name": "topic.custom.param.name-4",
+    "value": "topic.custom.param.value-4",
+    "defaultValue": "topic.custom.param.defValue-4"
+  },
+  {
+    "name": "topic.custom.param.name-5",
+    "value": "topic.custom.param.value-5",
+    "defaultValue": "topic.custom.param.defValue-5"
+  },
+  {
+    "name": "topic.custom.param.name-6",
+    "value": "topic.custom.param.value-6",
+    "defaultValue": "topic.custom.param.defValue-6"
+  }
+]

+ 26 - 0
kafka-ui-react-app/src/components/Topics/New/CustomParams/CustomParamAction.tsx

@@ -0,0 +1,26 @@
+import React from 'react';
+import CustomParamButton from './CustomParamButton';
+import { isFirstParam } from './CustomParams';
+
+interface Props {
+  index: string;
+  onAdd: (event: React.MouseEvent<HTMLButtonElement>) => void;
+  onRemove: (index: string) => void;
+}
+
+const CustomParamAction: React.FC<Props> = ({
+  index,
+  onAdd,
+  onRemove,
+}) => (
+  <>
+    <label className='label'>&nbsp;</label>
+    {
+      isFirstParam(index)
+        ? <CustomParamButton btnColor="is-success" btnIcon='fa-plus' onClick={onAdd} />
+        : <CustomParamButton btnColor="is-danger" btnIcon='fa-minus' onClick={() => onRemove(index)} />
+    }
+  </>
+)
+
+export default CustomParamAction;

+ 21 - 0
kafka-ui-react-app/src/components/Topics/New/CustomParams/CustomParamButton.tsx

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

+ 20 - 0
kafka-ui-react-app/src/components/Topics/New/CustomParams/CustomParamOptions.tsx

@@ -0,0 +1,20 @@
+import React from 'react';
+import { TopicCustomParamOption } from 'redux/interfaces';
+import { CUSTOM_PARAMS_OPTIONS } from './customParamsOptions';
+
+interface Props {};
+
+const CustomParamOptions: React.FC<Props> = () => (
+  <>
+    <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);

+ 69 - 0
kafka-ui-react-app/src/components/Topics/New/CustomParams/CustomParamSelect.tsx

@@ -0,0 +1,69 @@
+import React from 'react';
+import { useFormContext, ErrorMessage } from 'react-hook-form';
+import CustomParamOptions from './CustomParamOptions';
+import { isFirstParam, INDEX_PREFIX } from './CustomParams';
+import { TopicFormCustomParam } from 'redux/interfaces';
+
+interface Props {
+  isDisabled: boolean;
+  index: string;
+  name: string;
+}
+
+const CustomParamSelect: React.FC<Props> = ({
+  isDisabled,
+  index,
+  name,
+}) => {
+
+  const { register, unregister, errors, getValues, triggerValidation } = useFormContext();
+  const optInputName = `${index}[name]`;
+
+  React.useEffect(
+    () => {
+      if (isFirstParam(index)) { unregister(optInputName) }
+    },
+  );
+
+  const selectedMustBeUniq = (selected: string) => {
+    const values: any = getValues({ nest: true });
+    const customParamsValues: TopicFormCustomParam = values.customParams;
+
+    let valid = true;
+
+    for (const [key, customParam] of Object.entries(customParamsValues)) {
+      if (`${INDEX_PREFIX}.${key}` === index) { continue; }
+      if (selected === customParam.name) {
+        valid = false;
+        break;
+      };
+    }
+
+    return valid ? true : '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);

+ 61 - 0
kafka-ui-react-app/src/components/Topics/New/CustomParams/CustomParamValue.tsx

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

+ 89 - 0
kafka-ui-react-app/src/components/Topics/New/CustomParams/CustomParams.tsx

@@ -0,0 +1,89 @@
+import React from 'react';
+import { omit, reject } from 'lodash';
+
+import { TopicFormCustomParams } from 'redux/interfaces';
+import CustomParamSelect from './CustomParamSelect';
+import CustomParamValue from './CustomParamValue';
+import CustomParamAction from './CustomParamAction';
+
+const DEF_INDEX = 'default';
+export const INDEX_PREFIX = 'customParams';
+export const isFirstParam = (index: string) => (index === DEF_INDEX);
+
+interface Props {
+  isSubmitting: boolean;
+}
+
+const CustomParams: React.FC<Props> = ({
+  isSubmitting,
+}) => {
+
+  const [formCustomParams, setFormCustomParams] = React.useState<TopicFormCustomParams>({
+    byIndex: { [DEF_INDEX]: { name: '', value: '' } },
+    allIndexes: [DEF_INDEX],
+  });
+
+  const onAdd = (event: React.MouseEvent<HTMLButtonElement>) => {
+    event.preventDefault();
+
+    const newIndex = `${INDEX_PREFIX}.${new Date().getTime()}`;
+
+    setFormCustomParams({
+      ...formCustomParams,
+      byIndex: {
+        ...formCustomParams.byIndex,
+        [newIndex]: { name: '', value: '' },
+      },
+      allIndexes: [
+        formCustomParams.allIndexes[0],
+        newIndex,
+        ...formCustomParams.allIndexes.slice(1),
+      ],
+    });
+  }
+
+  const onRemove = (index: string) => {
+    setFormCustomParams({
+      ...formCustomParams,
+      byIndex: omit(formCustomParams.byIndex, index),
+      allIndexes: reject(formCustomParams.allIndexes, (i) => (i === index)),
+    });
+  }
+
+  return (
+    <>
+      {
+        formCustomParams.allIndexes.map((index) => (
+          <div className="columns is-centered" key={index}>
+            <div className="column">
+              <CustomParamSelect
+                index={index}
+                isDisabled={isFirstParam(index) || isSubmitting}
+                name={formCustomParams.byIndex[index].name}
+              />
+            </div>
+
+            <div className="column">
+              <CustomParamValue
+                index={index}
+                isDisabled={isFirstParam(index) || isSubmitting}
+                name={formCustomParams.byIndex[index].name}
+                defaultValue={formCustomParams.byIndex[index].value}
+              />
+            </div>
+
+            <div className="column is-narrow">
+              <CustomParamAction
+                index={index}
+                onAdd={onAdd}
+                onRemove={onRemove}
+              />
+            </div>
+          </div>
+        ))
+      }
+    </>
+  );
+};
+
+export default CustomParams;

+ 18 - 0
kafka-ui-react-app/src/components/Topics/New/CustomParams/CustomParamsContainer.tsx

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

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

@@ -0,0 +1,96 @@
+import { TopicCustomParamOption } from 'redux/interfaces';
+
+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"
+  }
+}

+ 183 - 176
kafka-ui-react-app/src/components/Topics/New/New.tsx

@@ -1,8 +1,9 @@
 import React from 'react';
 import { ClusterName, CleanupPolicy, TopicFormData, TopicName } from 'redux/interfaces';
 import Breadcrumb from 'components/common/Breadcrumb/Breadcrumb';
+import CustomParamsContainer from "./CustomParams/CustomParamsContainer";
 import { clusterTopicsPath } from 'lib/paths';
-import { useForm, ErrorMessage } from 'react-hook-form';
+import { useForm, FormContext, ErrorMessage } from 'react-hook-form';
 import {
   TOPIC_NAME_VALIDATION_PATTERN,
   MILLISECONDS_IN_DAY,
@@ -24,20 +25,22 @@ const New: React.FC<Props> = ({
                                 redirectToTopicPath,
                                 resetUploadedState
                               }) => {
-  const {register, handleSubmit, errors, getValues} = useForm<TopicFormData>();
+  const methods = useForm<TopicFormData>();
   const [isSubmitting, setIsSubmitting] = React.useState<boolean>(false);
 
   React.useEffect(
     () => {
       if (isSubmitting && isTopicCreated) {
-        const {name} = getValues();
+        const {name} = methods.getValues();
         redirectToTopicPath(clusterName, name);
       }
     },
-    [isSubmitting, isTopicCreated, redirectToTopicPath, clusterName, getValues],
+    [isSubmitting, isTopicCreated, redirectToTopicPath, clusterName, methods.getValues],
   );
 
   const onSubmit = async (data: TopicFormData) => {
+    console.log(data);
+
     //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. Resetting loaded state is workaround, need to tweak loader logic
@@ -59,192 +62,196 @@ const New: React.FC<Props> = ({
       </div>
 
       <div className="box">
-        <form onSubmit={handleSubmit(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',
-                  },
-                })}
-                name="name"
-                autoComplete="off"
-                disabled={isSubmitting}
-              />
-              <p className="help is-danger">
-                <ErrorMessage errors={errors} name="name"/>
-              </p>
-            </div>
+        <FormContext {...methods}>
+          <form onSubmit={methods.handleSubmit(onSubmit)}>
+            <div className="columns">
+              <div className="column is-three-quarters">
+                <label className="label">
+                  Topic Name *
+                </label>
+                <input
+                  className="input"
+                  placeholder="Topic Name"
+                  ref={methods.register({
+                    required: 'Topic Name is required.',
+                    pattern: {
+                      value: TOPIC_NAME_VALIDATION_PATTERN,
+                      message: 'Only alphanumeric, _, -, and . allowed',
+                    },
+                  })}
+                  name="name"
+                  autoComplete="off"
+                  disabled={isSubmitting}
+                />
+                <p className="help is-danger">
+                  <ErrorMessage errors={methods.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={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={isSubmitting}
-              />
-              <p className="help is-danger">
-                <ErrorMessage errors={errors} name="replicationFactor"/>
-              </p>
+              <div className="column">
+                <label className="label">
+                  Number of partitions *
+                </label>
+                <input
+                  className="input"
+                  type="number"
+                  placeholder="Number of partitions"
+                  defaultValue="1"
+                  ref={methods.register({required: 'Number of partitions is required.'})}
+                  name="partitions"
+                  disabled={isSubmitting}
+                />
+                <p className="help is-danger">
+                  <ErrorMessage errors={methods.errors} name="partitions"/>
+                </p>
+              </div>
             </div>
 
-            <div className="column">
-              <label className="label">
-                Min In Sync Replicas *
-              </label>
-              <input
-                className="input"
-                type="number"
-                placeholder="Replication Factor"
-                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}
+            <div className="columns">
+              <div className="column">
+                <label className="label">
+                  Replication Factor *
+                </label>
+                <input
+                  className="input"
+                  type="number"
+                  placeholder="Replication Factor"
+                  defaultValue="1"
+                  ref={methods.register({required: 'Replication Factor is required.'})}
+                  name="replicationFactor"
                   disabled={isSubmitting}
-                >
-                  <option value={CleanupPolicy.Delete}>
-                    Delete
-                  </option>
-                  <option value={CleanupPolicy.Compact}>
-                    Compact
-                  </option>
-                </select>
+                />
+                <p className="help is-danger">
+                  <ErrorMessage errors={methods.errors} name="replicationFactor"/>
+                </p>
               </div>
-            </div>
 
-            <div className="column is-one-third">
-              <label className="label">
-                Time to retain data
-              </label>
-              <div className="select is-block">
-                <select
-                  defaultValue={MILLISECONDS_IN_DAY * 7}
-                  name="retentionMs"
-                  ref={register}
+              <div className="column">
+                <label className="label">
+                  Min In Sync Replicas *
+                </label>
+                <input
+                  className="input"
+                  type="number"
+                  placeholder="Replication Factor"
+                  defaultValue="1"
+                  ref={methods.register({required: 'Min In Sync Replicas is required.'})}
+                  name="minInSyncReplicas"
                   disabled={isSubmitting}
-                >
-                  <option value={MILLISECONDS_IN_DAY / 2}>
-                    12 hours
-                  </option>
-                  <option value={MILLISECONDS_IN_DAY}>
-                    1 day
-                  </option>
-                  <option value={MILLISECONDS_IN_DAY * 2}>
-                    2 days
-                  </option>
-                  <option value={MILLISECONDS_IN_DAY * 7}>
-                    1 week
-                  </option>
-                  <option value={MILLISECONDS_IN_DAY * 7 * 4}>
-                    4 weeks
-                  </option>
-                </select>
+                />
+                <p className="help is-danger">
+                  <ErrorMessage errors={methods.errors} name="minInSyncReplicas"/>
+                </p>
               </div>
             </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 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={methods.register}
+                    disabled={isSubmitting}
+                  >
+                    <option value={CleanupPolicy.Delete}>
+                      Delete
+                    </option>
+                    <option value={CleanupPolicy.Compact}>
+                      Compact
+                    </option>
+                  </select>
+                </div>
+              </div>
+
+              <div className="column is-one-third">
+                <label className="label">
+                  Time to retain data
+                </label>
+                <div className="select is-block">
+                  <select
+                    defaultValue={MILLISECONDS_IN_DAY * 7}
+                    name="retentionMs"
+                    ref={methods.register}
+                    disabled={isSubmitting}
+                  >
+                    <option value={MILLISECONDS_IN_DAY / 2}>
+                      12 hours
+                    </option>
+                    <option value={MILLISECONDS_IN_DAY}>
+                      1 day
+                    </option>
+                    <option value={MILLISECONDS_IN_DAY * 2}>
+                      2 days
+                    </option>
+                    <option value={MILLISECONDS_IN_DAY * 7}>
+                      1 week
+                    </option>
+                    <option value={MILLISECONDS_IN_DAY * 7 * 4}>
+                      4 weeks
+                    </option>
+                  </select>
+                </div>
+              </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={methods.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>
-
-          <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 className="columns">
+              <div className="column">
+                <label className="label">
+                  Maximum message size in bytes *
+                </label>
+                <input
+                  className="input"
+                  type="number"
+                  defaultValue="1000012"
+                  ref={methods.register({required: 'Maximum message size in bytes is required'})}
+                  name="maxMessageBytes"
+                  disabled={isSubmitting}
+                />
+                <p className="help is-danger">
+                  <ErrorMessage errors={methods.errors} name="maxMessageBytes"/>
+                </p>
+              </div>
             </div>
-          </div>
 
-          <input type="submit" className="button is-primary" disabled={isSubmitting}/>
-        </form>
+            <CustomParamsContainer isSubmitting={isSubmitting} />
+
+            <input type="submit" className="button is-primary" disabled={isSubmitting}/>
+          </form>
+        </FormContext>
       </div>
     </div>
   );

+ 6 - 0
kafka-ui-react-app/src/redux/actionType.ts

@@ -23,7 +23,13 @@ export enum ActionType {
   GET_TOPIC_CONFIG__SUCCESS = 'GET_TOPIC_CONFIG__SUCCESS',
   GET_TOPIC_CONFIG__FAILURE = 'GET_TOPIC_CONFIG__FAILURE',
 
+  GET_TOPICS_CUSTOM_PARAMS__REQUEST = 'GET_TOPICS_CUSTOM_PARAMS__REQUEST',
+  GET_TOPICS_CUSTOM_PARAMS__SUCCESS = 'GET_TOPICS_CUSTOM_PARAMS__SUCCESS',
+  GET_TOPICS_CUSTOM_PARAMS__FAILURE = 'GET_TOPICS_CUSTOM_PARAMS__FAILURE',
+
   POST_TOPIC__REQUEST = 'POST_TOPIC__REQUEST',
   POST_TOPIC__SUCCESS = 'POST_TOPIC__SUCCESS',
   POST_TOPIC__FAILURE = 'POST_TOPIC__FAILURE',
+
+  ADD_TOPIC_CUSTOM_PARAM = 'ADD_TOPIC_CUSTOM_PARAM',
 };

+ 8 - 1
kafka-ui-react-app/src/redux/actions/actions.ts

@@ -1,4 +1,4 @@
-import { createAsyncAction} from 'typesafe-actions';
+import { createAsyncAction } from 'typesafe-actions';
 import { ActionType } from 'redux/actionType';
 import {
   Broker,
@@ -8,6 +8,7 @@ import {
   TopicConfig,
   TopicDetails,
   TopicName,
+  TopicCustomParam,
 } from 'redux/interfaces';
 
 export const fetchBrokersAction = createAsyncAction(
@@ -34,6 +35,12 @@ export const fetchTopicListAction = createAsyncAction(
   ActionType.GET_TOPICS__FAILURE,
 )<undefined, Topic[], undefined>();
 
+export const fetchTopicsCustomParamsAction = createAsyncAction(
+  ActionType.GET_TOPICS_CUSTOM_PARAMS__REQUEST,
+  ActionType.GET_TOPICS_CUSTOM_PARAMS__SUCCESS,
+  ActionType.GET_TOPICS_CUSTOM_PARAMS__FAILURE,
+)<undefined, TopicCustomParam[], undefined>();
+
 export const fetchTopicDetailsAction = createAsyncAction(
   ActionType.GET_TOPIC_DETAILS__REQUEST,
   ActionType.GET_TOPIC_DETAILS__SUCCESS,

+ 10 - 0
kafka-ui-react-app/src/redux/actions/thunks.ts

@@ -48,6 +48,16 @@ export const fetchTopicList = (clusterName: ClusterName): PromiseThunk<void> =>
   }
 };
 
+export const fetchTopicsCustomParams = (clusterName: ClusterName): PromiseThunk<void> => async (dispatch) => {
+  dispatch(actions.fetchTopicsCustomParamsAction.request());
+  try {
+    const customParams = await api.getTopicsCustomParams(clusterName);
+    dispatch(actions.fetchTopicsCustomParamsAction.success(customParams));
+  } catch (e) {
+    dispatch(actions.fetchTopicsCustomParamsAction.failure());
+  }
+};
+
 export const fetchTopicDetails = (clusterName: ClusterName, topicName: TopicName): PromiseThunk<void> => async (dispatch) => {
   dispatch(actions.fetchTopicDetailsAction.request());
   try {

+ 6 - 0
kafka-ui-react-app/src/redux/api/topics.ts

@@ -5,6 +5,7 @@ import {
   TopicDetails,
   TopicConfig,
   TopicFormData,
+  TopicCustomParam,
 } from 'redux/interfaces';
 import {
   BASE_URL,
@@ -23,6 +24,11 @@ export const getTopics = (clusterName: ClusterName): Promise<Topic[]> =>
   fetch(`${BASE_URL}/clusters/${clusterName}/topics`, { ...BASE_PARAMS })
     .then(res => res.json());
 
+// TODO: the url needs to be reconsiled
+export const getTopicsCustomParams = (clusterName: ClusterName): Promise<TopicCustomParam[]> =>
+  fetch(`${BASE_URL}/clusters/${clusterName}/topics/custom_params`, { ...BASE_PARAMS })
+    .then(res => res.json());
+
 export const postTopic = (clusterName: ClusterName, form: TopicFormData): Promise<Topic> => {
   const {
     name,

+ 30 - 2
kafka-ui-react-app/src/redux/interfaces/topic.ts

@@ -23,6 +23,16 @@ export interface TopicPartition {
   replicas: TopicReplica[];
 }
 
+// extend from option
+export interface TopicCustomParam extends TopicCustomParamOption {
+  value: string;
+}
+
+export interface TopicCustomParamOption {
+  name: string;
+  defaultValue: string;
+}
+
 export interface TopicDetails {
   partitionCount?: number;
   replicationFactor?: number;
@@ -39,13 +49,28 @@ export interface Topic {
   partitions: TopicPartition[];
 }
 
+export interface TopicFormCustomParams {
+  byIndex: { [paramIndex: string]: TopicFormCustomParam };
+  allIndexes: string[];
+}
+
+export interface TopicFormCustomParam {
+  name: string,
+  value: string,
+}
+
 export interface TopicWithDetailedInfo extends Topic, TopicDetails {
   config?: TopicConfig[];
 }
 
+export type TopicCustomParamName = string;
+
 export interface TopicsState {
-  byName: { [topicName: string]: TopicWithDetailedInfo },
-  allNames: TopicName[],
+  byName: { [topicName: string]: TopicWithDetailedInfo };
+  allNames: TopicName[];
+  customParams: {
+    byName: { [paramName: string]: TopicCustomParam }
+  };
 }
 
 export interface TopicFormData {
@@ -57,4 +82,7 @@ export interface TopicFormData {
   retentionMs: number;
   retentionBytes: number;
   maxMessageBytes: number;
+  customParams: {
+    [index: string]: TopicFormCustomParam;
+  };
 };

+ 25 - 1
kafka-ui-react-app/src/redux/reducers/topics/reducer.ts

@@ -1,9 +1,12 @@
-import { Action, TopicsState, Topic } from 'redux/interfaces';
+import { Action, TopicsState, Topic, TopicCustomParam } from 'redux/interfaces';
 import { ActionType } from 'redux/actionType';
 
 export const initialState: TopicsState = {
   byName: {},
   allNames: [],
+  customParams: {
+    byName: {},
+  },
 };
 
 const updateTopicList = (state: TopicsState, payload: Topic[]): TopicsState => {
@@ -27,6 +30,25 @@ const updateTopicList = (state: TopicsState, payload: Topic[]): TopicsState => {
   );
 };
 
+const setTopicsCustomParams = (state: TopicsState, payload: TopicCustomParam[]): TopicsState => {
+  const initialMemo: TopicsState = {
+    ...state,
+    customParams: {
+      ...state.customParams,
+      byName: {},
+    }
+  };
+
+  return payload.reduce(
+    (memo: TopicsState, topicCustomParam) => {
+      memo.customParams.byName[topicCustomParam.name] = topicCustomParam;
+
+      return memo;
+    },
+    initialMemo,
+  );
+};
+
 const addToTopicList = (state: TopicsState, payload: Topic): TopicsState => {
   const newState: TopicsState = {
     ...state
@@ -64,6 +86,8 @@ const reducer = (state = initialState, action: Action): TopicsState => {
       };
     case ActionType.POST_TOPIC__SUCCESS:
       return addToTopicList(state, action.payload);
+    case ActionType.GET_TOPICS_CUSTOM_PARAMS__SUCCESS:
+      return setTopicsCustomParams(state, action.payload);
     default:
       return state;
   }