Repository: provectus/kafka-ui
Issue: Clone topic functionality
Labels: scope/backend
scope/frontend
status/accepted
type/feature

### Is your proposal related to a problem?

I often find myself creating a topic that has exactly same configuration as existing one. In which case I need to have 2 windows open side-by-side to see source configuration and replicate it in new topic. That is a bit troublesome.

### Describe the solution you'd like

1. In topic list view a button to clone selected topic (when single topic selected)
2. (optional) clone with data (i.e. spawn consumer reading from beginning of the source topic to the end and writing messages to destination topic)

### Describe alternatives you've considered

N/A. Currently copying existing topic configuration needs to be done manually

### Additional context

N/A


Issue comments:
Very similar to #998. 
It is somewhat similar indeed, but #998 is about re-creating single topic to clear out all data and "reset" all offsets to 0-state. This request is more about making a "clone" of one topic in a simple way. Just clarifying this point to avoid confusion.
Yeah I get it :) That's a note for us about implementation

Pull request review comments:
a question if Copy and New Components are the same thing with or without the data we should create a component to use in both page and write test suites in that component. ?

Like in New -> pass empty props and test the filling it.
Like in Copy -> check the already existing data in it and test the submit.
why is there extra line additions here if the code did not change , in all of this file.
why should new Page tests suites change if we just added Route for copy , maybe the switch gets redirected to the wrong thing.
is it correct behaviour?
Fixed
I think that is a good idea. So there are two ways of doing it: 
-	one shared component for Create and Copy topic, which checks if there are some query params (but this logic could become more complicated with components and project grow)
-	another way to split the functionality of Create and Copy to appropriate pages or components, so they could expand without expanding the complexity

Which way we prefer?

I removed extra string wrapper, if that's what you mean
I think because the shared component or some shared stuff was changed. Without this changes there is no possibility to pass the tests and commit the changes
You have changed `toBeCalledTimes` from 1 to 0
`expect(createTopicAPIPathMock.called())` was `true` now it is `false`
I can see the same thing above. You can declare a variable on a higher level and use it twice instead of repeating the code. If createMemoryHistory has to be called inside the 'it' callbacks in the two places to function properly, you can isolate the parameter only instead of the whole.
Can we use `tableState.selectedCount === 1 && (...` instead of using ternary and returning null?
done
Don't we need to update the tests for this line after adding this condition?
Can we store `new URLSearchParams(search)` in a variable and reuse it? Please, check if we can, that way we'll prevent duplication.
done
@lazzy-panda shouldn't be a enum used here?
done

Patch that resolved the issue:
diff --git a/kafka-ui-react-app/src/components/Topics/List/List.tsx b/kafka-ui-react-app/src/components/Topics/List/List.tsx
index 334ff6a917d..362010d1fc7 100644
--- a/kafka-ui-react-app/src/components/Topics/List/List.tsx
+++ b/kafka-ui-react-app/src/components/Topics/List/List.tsx
@@ -6,7 +6,7 @@ import {
   TopicName,
 } from 'redux/interfaces';
 import { useParams } from 'react-router-dom';
-import { clusterTopicNewPath } from 'lib/paths';
+import { clusterTopicCopyPath, clusterTopicNewPath } from 'lib/paths';
 import usePagination from 'lib/hooks/usePagination';
 import ClusterContext from 'components/contexts/ClusterContext';
 import PageLoader from 'components/common/PageLoader/PageLoader';
@@ -125,6 +125,21 @@ const List: React.FC<TopicsListProps> = ({
     }
   );
 
+  const getSelectedTopic = (): string => {
+    const name = Array.from(tableState.selectedIds)[0];
+    const selectedTopic =
+      tableState.data.find(
+        (topic: TopicWithDetailedInfo) => topic.name === name
+      ) || {};
+
+    return Object.keys(selectedTopic)
+      .map((x: string) => {
+        const value = selectedTopic[x as keyof typeof selectedTopic];
+        return value && x !== 'partitions' ? `${x}=${value}` : null;
+      })
+      .join('&');
+  };
+
   const handleSwitch = React.useCallback(() => {
     setShowInternal(!showInternal);
     history.push(`${pathname}?page=1&perPage=${perPage || PER_PAGE}`);
@@ -295,6 +310,20 @@ const List: React.FC<TopicsListProps> = ({
                 >
                   Delete selected topics
                 </Button>
+                {tableState.selectedCount === 1 && (
+                  <Button
+                    buttonSize="M"
+                    buttonType="secondary"
+                    isLink
+                    to={{
+                      pathname: clusterTopicCopyPath(clusterName),
+                      search: `?${getSelectedTopic()}`,
+                    }}
+                  >
+                    Copy selected topic
+                  </Button>
+                )}
+
                 <Button
                   buttonSize="M"
                   buttonType="secondary"
diff --git a/kafka-ui-react-app/src/components/Topics/New/New.tsx b/kafka-ui-react-app/src/components/Topics/New/New.tsx
index 05faeabcca3..f2195751ae7 100644
--- a/kafka-ui-react-app/src/components/Topics/New/New.tsx
+++ b/kafka-ui-react-app/src/components/Topics/New/New.tsx
@@ -10,7 +10,7 @@ import {
 } from 'redux/actions';
 import { useDispatch } from 'react-redux';
 import { getResponse } from 'lib/errorHandling';
-import { useHistory, useParams } from 'react-router';
+import { useHistory, useLocation, useParams } from 'react-router';
 import { yupResolver } from '@hookform/resolvers/yup';
 import { topicFormValidationSchema } from 'lib/yupExtended';
 import PageHeading from 'components/common/PageHeading/PageHeading';
@@ -19,6 +19,14 @@ interface RouterParams {
   clusterName: ClusterName;
 }
 
+enum Filters {
+  NAME = 'name',
+  PARTITION_COUNT = 'partitionCount',
+  REPLICATION_FACTOR = 'replicationFactor',
+  INSYNC_REPLICAS = 'inSyncReplicas',
+  CLEANUP_POLICY = 'Delete',
+}
+
 const New: React.FC = () => {
   const methods = useForm<TopicFormData>({
     mode: 'all',
@@ -29,6 +37,15 @@ const New: React.FC = () => {
   const history = useHistory();
   const dispatch = useDispatch();
 
+  const { search } = useLocation();
+  const params = new URLSearchParams(search);
+
+  const name = params.get(Filters.NAME) || '';
+  const partitionCount = params.get(Filters.PARTITION_COUNT) || 1;
+  const replicationFactor = params.get(Filters.REPLICATION_FACTOR) || 1;
+  const inSyncReplicas = params.get(Filters.INSYNC_REPLICAS) || 1;
+  const cleanUpPolicy = params.get(Filters.CLEANUP_POLICY) || 'Delete';
+
   const onSubmit = async (data: TopicFormData) => {
     try {
       await topicsApiClient.createTopic({
@@ -50,9 +67,14 @@ const New: React.FC = () => {
 
   return (
     <>
-      <PageHeading text="Create new Topic" />
+      <PageHeading text={search ? 'Copy Topic' : 'Create new Topic'} />
       <FormProvider {...methods}>
         <TopicForm
+          topicName={name}
+          cleanUpPolicy={cleanUpPolicy}
+          partitionCount={Number(partitionCount)}
+          replicationFactor={Number(replicationFactor)}
+          inSyncReplicas={Number(inSyncReplicas)}
           isSubmitting={methods.formState.isSubmitting}
           onSubmit={methods.handleSubmit(onSubmit)}
         />
diff --git a/kafka-ui-react-app/src/components/Topics/New/__test__/New.spec.tsx b/kafka-ui-react-app/src/components/Topics/New/__test__/New.spec.tsx
index d980b33d38a..aad0120d024 100644
--- a/kafka-ui-react-app/src/components/Topics/New/__test__/New.spec.tsx
+++ b/kafka-ui-react-app/src/components/Topics/New/__test__/New.spec.tsx
@@ -7,7 +7,11 @@ import { Provider } from 'react-redux';
 import { screen, waitFor } from '@testing-library/react';
 import { createMemoryHistory } from 'history';
 import fetchMock from 'fetch-mock-jest';
-import { clusterTopicNewPath, clusterTopicPath } from 'lib/paths';
+import {
+  clusterTopicCopyPath,
+  clusterTopicNewPath,
+  clusterTopicPath,
+} from 'lib/paths';
 import userEvent from '@testing-library/user-event';
 import { render } from 'lib/testHelpers';
 
@@ -31,6 +35,11 @@ const renderComponent = (history = historyMock, store = storeMock) =>
           <New />
         </Provider>
       </Route>
+      <Route path={clusterTopicCopyPath(':clusterName')}>
+        <Provider store={store}>
+          <New />
+        </Provider>
+      </Route>
       <Route path={clusterTopicPath(':clusterName', ':topicName')}>
         New topic path
       </Route>
@@ -42,6 +51,32 @@ describe('New', () => {
     fetchMock.reset();
   });
 
+  it('checks header for create new', async () => {
+    const mockedHistory = createMemoryHistory({
+      initialEntries: [clusterTopicNewPath(clusterName)],
+    });
+    renderComponent(mockedHistory);
+    expect(
+      screen.getByRole('heading', { name: 'Create new Topic' })
+    ).toHaveTextContent('Create new Topic');
+  });
+
+  it('checks header for copy', async () => {
+    const mockedHistory = createMemoryHistory({
+      initialEntries: [
+        {
+          pathname: clusterTopicCopyPath(clusterName),
+          search: `?name=test`,
+        },
+      ],
+    });
+
+    renderComponent(mockedHistory);
+    expect(
+      screen.getByRole('heading', { name: 'Copy Topic' })
+    ).toHaveTextContent('Copy Topic');
+  });
+
   it('validates form', async () => {
     const mockedHistory = createMemoryHistory({
       initialEntries: [clusterTopicNewPath(clusterName)],
diff --git a/kafka-ui-react-app/src/components/Topics/Topics.tsx b/kafka-ui-react-app/src/components/Topics/Topics.tsx
index ce04d3341bc..391645a32b8 100644
--- a/kafka-ui-react-app/src/components/Topics/Topics.tsx
+++ b/kafka-ui-react-app/src/components/Topics/Topics.tsx
@@ -1,6 +1,7 @@
 import React from 'react';
 import { Switch } from 'react-router-dom';
 import {
+  clusterTopicCopyPath,
   clusterTopicNewPath,
   clusterTopicPath,
   clusterTopicsPath,
@@ -23,6 +24,11 @@ const Topics: React.FC = () => (
       path={clusterTopicNewPath(':clusterName')}
       component={New}
     />
+    <BreadcrumbRoute
+      exact
+      path={clusterTopicCopyPath(':clusterName')}
+      component={New}
+    />
     <BreadcrumbRoute
       path={clusterTopicPath(':clusterName', ':topicName')}
       component={TopicContainer}
diff --git a/kafka-ui-react-app/src/components/Topics/shared/Form/TopicForm.tsx b/kafka-ui-react-app/src/components/Topics/shared/Form/TopicForm.tsx
index d5cc7ea08e3..9c747491089 100644
--- a/kafka-ui-react-app/src/components/Topics/shared/Form/TopicForm.tsx
+++ b/kafka-ui-react-app/src/components/Topics/shared/Form/TopicForm.tsx
@@ -16,6 +16,10 @@ import * as S from './TopicForm.styled';
 
 export interface Props {
   topicName?: TopicName;
+  partitionCount?: number;
+  replicationFactor?: number;
+  inSyncReplicas?: number;
+  cleanUpPolicy?: string;
   isEditing?: boolean;
   isSubmitting: boolean;
   onSubmit: (e: React.BaseSyntheticEvent) => Promise<void>;
@@ -40,11 +44,19 @@ const TopicForm: React.FC<Props> = ({
   isEditing,
   isSubmitting,
   onSubmit,
+  partitionCount,
+  replicationFactor,
+  inSyncReplicas,
+  cleanUpPolicy,
 }) => {
   const {
     control,
     formState: { errors },
   } = useFormContext();
+  const getCleanUpPolicy =
+    CleanupPolicyOptions.find((option: SelectOption) => {
+      return option.value === cleanUpPolicy?.toLowerCase();
+    })?.value || CleanupPolicyOptions[0].value;
   return (
     <StyledForm onSubmit={onSubmit}>
       <fieldset disabled={isSubmitting}>
@@ -75,7 +87,7 @@ const TopicForm: React.FC<Props> = ({
                   type="number"
                   placeholder="Number of partitions"
                   min="1"
-                  defaultValue="1"
+                  defaultValue={partitionCount}
                   name="partitions"
                 />
                 <FormError>
@@ -91,7 +103,7 @@ const TopicForm: React.FC<Props> = ({
                   type="number"
                   placeholder="Replication Factor"
                   min="1"
-                  defaultValue="1"
+                  defaultValue={replicationFactor}
                   name="replicationFactor"
                 />
                 <FormError>
@@ -112,7 +124,7 @@ const TopicForm: React.FC<Props> = ({
               type="number"
               placeholder="Min In Sync Replicas"
               min="1"
-              defaultValue="1"
+              defaultValue={inSyncReplicas}
               name="minInsyncReplicas"
             />
             <FormError>
@@ -135,7 +147,7 @@ const TopicForm: React.FC<Props> = ({
                   id="topicFormCleanupPolicy"
                   aria-labelledby="topicFormCleanupPolicyLabel"
                   name={name}
-                  value={CleanupPolicyOptions[0].value}
+                  value={getCleanUpPolicy}
                   onChange={onChange}
                   minWidth="250px"
                   options={CleanupPolicyOptions}
diff --git a/kafka-ui-react-app/src/lib/paths.ts b/kafka-ui-react-app/src/lib/paths.ts
index ee3152d6ed0..78a587d086a 100644
--- a/kafka-ui-react-app/src/lib/paths.ts
+++ b/kafka-ui-react-app/src/lib/paths.ts
@@ -53,6 +53,8 @@ export const clusterTopicsPath = (clusterName: ClusterName) =>
   `${clusterPath(clusterName)}/topics`;
 export const clusterTopicNewPath = (clusterName: ClusterName) =>
   `${clusterPath(clusterName)}/topics/create-new`;
+export const clusterTopicCopyPath = (clusterName: ClusterName) =>
+  `${clusterPath(clusterName)}/topics/copy`;
 export const clusterTopicPath = (
   clusterName: ClusterName,
   topicName: TopicName


Test patch: