Selaa lähdekoodia

[UI] Topic Creation Page

Oleg Shuralev 5 vuotta sitten
vanhempi
commit
e24423e613

+ 29 - 13
frontend/src/components/Topics/List/List.tsx

@@ -1,14 +1,18 @@
 import React from 'react';
 import React from 'react';
-import { TopicWithDetailedInfo } from 'types';
+import { TopicWithDetailedInfo, ClusterId } from 'types';
 import ListItem from './ListItem';
 import ListItem from './ListItem';
 import Breadcrumb from 'components/common/Breadcrumb/Breadcrumb';
 import Breadcrumb from 'components/common/Breadcrumb/Breadcrumb';
+import { NavLink } from 'react-router-dom';
+import { clusterTopicNewPath } from 'lib/paths';
 
 
 interface Props {
 interface Props {
+  clusterId: ClusterId;
   topics: (TopicWithDetailedInfo)[];
   topics: (TopicWithDetailedInfo)[];
   externalTopics: (TopicWithDetailedInfo)[];
   externalTopics: (TopicWithDetailedInfo)[];
 }
 }
 
 
 const List: React.FC<Props> = ({
 const List: React.FC<Props> = ({
+  clusterId,
   topics,
   topics,
   externalTopics,
   externalTopics,
 }) => {
 }) => {
@@ -23,18 +27,30 @@ const List: React.FC<Props> = ({
       <Breadcrumb>All Topics</Breadcrumb>
       <Breadcrumb>All Topics</Breadcrumb>
 
 
       <div className="box">
       <div className="box">
-        <div className="field">
-          <input
-            id="switchRoundedDefault"
-            type="checkbox"
-            name="switchRoundedDefault"
-            className="switch is-rounded"
-            checked={showInternal}
-            onChange={handleSwitch}
-          />
-          <label htmlFor="switchRoundedDefault">
-            Show Internal Topics
-          </label>
+        <div className="level">
+          <div className="level-item level-left">
+            <div className="field">
+              <input
+                id="switchRoundedDefault"
+                type="checkbox"
+                name="switchRoundedDefault"
+                className="switch is-rounded"
+                checked={showInternal}
+                onChange={handleSwitch}
+              />
+              <label htmlFor="switchRoundedDefault">
+                Show Internal Topics
+              </label>
+            </div>
+          </div>
+          <div className="level-item level-right">
+            <NavLink
+              className="button is-primary"
+              to={clusterTopicNewPath(clusterId)}
+            >
+              Add a Topic
+            </NavLink>
+          </div>
         </div>
         </div>
       </div>
       </div>
       <div className="box">
       <div className="box">

+ 12 - 3
frontend/src/components/Topics/List/ListContainer.ts

@@ -2,11 +2,20 @@ import { connect } from 'react-redux';
 import { RootState } from 'types';
 import { RootState } from 'types';
 import { getTopicList, getExternalTopicList } from 'redux/reducers/topics/selectors';
 import { getTopicList, getExternalTopicList } from 'redux/reducers/topics/selectors';
 import List from './List';
 import List from './List';
+import { withRouter, RouteComponentProps } from 'react-router-dom';
 
 
-const mapStateToProps = (state: RootState) => ({
+interface RouteProps {
+  clusterId: string;
+}
+
+interface OwnProps extends RouteComponentProps<RouteProps> { }
+
+const mapStateToProps = (state: RootState, { match: { params: { clusterId } } }: OwnProps) => ({
+  clusterId,
   topics: getTopicList(state),
   topics: getTopicList(state),
   externalTopics: getExternalTopicList(state),
   externalTopics: getExternalTopicList(state),
 });
 });
 
 
-
-export default connect(mapStateToProps)(List);
+export default withRouter(
+  connect(mapStateToProps)(List)
+);

+ 218 - 0
frontend/src/components/Topics/New/New.tsx

@@ -0,0 +1,218 @@
+import React from 'react';
+import { ClusterId, CleanupPolicy, TopicFormData } from 'types';
+import Breadcrumb from 'components/common/Breadcrumb/Breadcrumb';
+import { clusterTopicsPath } from 'lib/paths';
+import { useForm, ErrorMessage } from 'react-hook-form';
+import {
+  TOPIC_NAME_VALIDATION_PATTERN,
+  MILLISECONDS_IN_DAY,
+  BYTES_IN_GB,
+} from 'lib/constants';
+
+interface Props {
+  clusterId: ClusterId;
+}
+
+const New: React.FC<Props> = ({
+  clusterId,
+}) => {
+  const { register, handleSubmit, errors } = useForm<TopicFormData>(); // initialise the hook
+
+  const onSubmit = (data: TopicFormData) => {
+    console.log(data);
+  };
+
+  return (
+    <div className="section">
+      <div className="level">
+        <div className="level-item level-left">
+          <Breadcrumb links={[
+            { href: clusterTopicsPath(clusterId), label: 'All Topics' },
+          ]}>
+            New Topic
+          </Breadcrumb>
+        </div>
+      </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"
+              />
+              <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"
+              />
+              <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"
+              />
+              <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="Replication Factor"
+                defaultValue="1"
+                ref={register({ required: 'Min In Sync Replicas is required.' })}
+                name="minInSyncReplicas"
+              />
+              <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">
+                  <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={register}
+                >
+                  <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={register}
+                >
+                  <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"
+              />
+              <p className="help is-danger">
+                <ErrorMessage errors={errors} name="maxMessageBytes" />
+              </p>
+            </div>
+          </div>
+
+          <input type="submit" className="button is-primary"/>
+        </form>
+      </div>
+    </div>
+  );
+}
+
+export default New;

+ 19 - 0
frontend/src/components/Topics/New/NewContainer.ts

@@ -0,0 +1,19 @@
+import { connect } from 'react-redux';
+import { RootState } from 'types';
+import New from './New';
+import { withRouter, RouteComponentProps } from 'react-router-dom';
+
+interface RouteProps {
+  clusterId: string;
+}
+
+interface OwnProps extends RouteComponentProps<RouteProps> { }
+
+const mapStateToProps = (state: RootState, { match: { params: { clusterId } } }: OwnProps) => ({
+  clusterId,
+});
+
+
+export default withRouter(
+  connect(mapStateToProps)(New)
+);

+ 3 - 1
frontend/src/components/Topics/Topics.tsx

@@ -1,4 +1,5 @@
 import React from 'react';
 import React from 'react';
+import { ClusterId } from 'types';
 import {
 import {
   Switch,
   Switch,
   Route,
   Route,
@@ -6,7 +7,7 @@ import {
 import ListContainer from './List/ListContainer';
 import ListContainer from './List/ListContainer';
 import DetailsContainer from './Details/DetailsContainer';
 import DetailsContainer from './Details/DetailsContainer';
 import PageLoader from 'components/common/PageLoader/PageLoader';
 import PageLoader from 'components/common/PageLoader/PageLoader';
-import { ClusterId } from 'types';
+import NewContainer from './New/NewContainer';
 
 
 interface Props {
 interface Props {
   clusterId: string;
   clusterId: string;
@@ -26,6 +27,7 @@ const Topics: React.FC<Props> = ({
     return (
     return (
       <Switch>
       <Switch>
         <Route exact path="/clusters/:clusterId/topics" component={ListContainer} />
         <Route exact path="/clusters/:clusterId/topics" component={ListContainer} />
+        <Route exact path="/clusters/:clusterId/topics/new" component={NewContainer} />
         <Route path="/clusters/:clusterId/topics/:topicName" component={DetailsContainer} />
         <Route path="/clusters/:clusterId/topics/:topicName" component={DetailsContainer} />
       </Switch>
       </Switch>
     );
     );

+ 4 - 0
frontend/src/lib/constants.ts

@@ -7,3 +7,7 @@ export const BASE_PARAMS: RequestInit = {
 };
 };
 
 
 export const BASE_URL = 'http://localhost:3004';
 export const BASE_URL = 'http://localhost:3004';
+
+export const TOPIC_NAME_VALIDATION_PATTERN = RegExp(/^[.,A-Za-z0-9_-]+$/);
+export const MILLISECONDS_IN_DAY = 86_400_000;
+export const BYTES_IN_GB = 1_073_741_824;

+ 1 - 0
frontend/src/lib/paths.ts

@@ -5,6 +5,7 @@ const clusterPath = (clusterId: ClusterId) => `/clusters/${clusterId}`;
 export const clusterBrokersPath = (clusterId: ClusterId) => `${clusterPath(clusterId)}/brokers`;
 export const clusterBrokersPath = (clusterId: ClusterId) => `${clusterPath(clusterId)}/brokers`;
 
 
 export const clusterTopicsPath = (clusterId: ClusterId) => `${clusterPath(clusterId)}/topics`;
 export const clusterTopicsPath = (clusterId: ClusterId) => `${clusterPath(clusterId)}/topics`;
+export const clusterTopicNewPath = (clusterId: ClusterId) => `${clusterPath(clusterId)}/topics/new`;
 
 
 export const clusterTopicPath = (clusterId: ClusterId, topicName: TopicName) => `${clusterTopicsPath(clusterId)}/${topicName}`;
 export const clusterTopicPath = (clusterId: ClusterId, topicName: TopicName) => `${clusterTopicsPath(clusterId)}/${topicName}`;
 export const clusterTopicSettingsPath = (clusterId: ClusterId, topicName: TopicName) => `${clusterTopicsPath(clusterId)}/${topicName}/settings`;
 export const clusterTopicSettingsPath = (clusterId: ClusterId, topicName: TopicName) => `${clusterTopicsPath(clusterId)}/${topicName}/settings`;

+ 4 - 0
frontend/src/theme/bulma_overrides.scss

@@ -28,6 +28,10 @@
   animation: fadein .5s;
   animation: fadein .5s;
 }
 }
 
 
+.select.is-block select {
+  width: 100%;
+}
+
 @keyframes fadein {
 @keyframes fadein {
   from { opacity: 0; }
   from { opacity: 0; }
   to   { opacity: 1; }
   to   { opacity: 1; }

+ 17 - 0
frontend/src/types/topic.ts

@@ -1,4 +1,10 @@
 export type TopicName = string;
 export type TopicName = string;
+
+export enum CleanupPolicy {
+  Delete = 'delete',
+  Compact = 'compact',
+}
+
 export interface TopicConfig {
 export interface TopicConfig {
   name: string;
   name: string;
   value: string;
   value: string;
@@ -41,3 +47,14 @@ export interface TopicsState {
   byName: { [topicName: string]: TopicWithDetailedInfo },
   byName: { [topicName: string]: TopicWithDetailedInfo },
   allNames: TopicName[],
   allNames: TopicName[],
 }
 }
+
+export interface TopicFormData {
+  name: string;
+  partitions: number;
+  replicationFactor: number;
+  minInSyncReplicas: number;
+  cleanupPolicy: string;
+  retentionMs: number;
+  retentionBytes: number;
+  maxMessageBytes: number;
+};