Repository: provectus/kafka-ui
Issue: Topic Creation: Inconsistency in Deleted parameters
Labels: type/bug
good first issue
scope/frontend
status/accepted
status/confirmed

**Describe the bug**
When creating a new topic, you have the ability to add custom parameters. When selecting a custom parameter, you can delete it, but if you want to add it again, it is either greyed out or still accessible -- and it should be not.


**Set up**
http://redesign.internal.kafka-ui.provectus.io/

**Steps to Reproduce**
Steps to reproduce the behavior:

1. Go to Topics - Add a Topic
2. Add custom parameter - Select Preallocate
3. Add custom parameter - Select retention.bytes
4. Add custom parameter - Select segment.bytes
5. Delete preallocate parameter - Cannot be seelcted when clicking Add custom parameter
6.  Delete retention.bytes - Add custom parameter - preallocate not greyed out /available for selection
7. Delete segment.bytes - Add custom parameter - add deleted parameters are available for selection


**Expected behavior**
When parameter is deleted, it can be immediately added again, without inconsistencies

**Screenshots**
(If applicable, add screenshots to help explain your problem)


**Additional context**
There may a small lag in how the tool processes the add/delete functionality. It seems to affect specific parameters like preallocate, though.


Issue comments:
a similar one for the old design: #996

Pull request review comments:

Patch that resolved the issue:
diff --git a/kafka-ui-react-app/src/components/Topics/New/New.tsx b/kafka-ui-react-app/src/components/Topics/New/New.tsx
index de4075912c2..7d596e63c1a 100644
--- a/kafka-ui-react-app/src/components/Topics/New/New.tsx
+++ b/kafka-ui-react-app/src/components/Topics/New/New.tsx
@@ -37,7 +37,7 @@ const New: React.FC = () => {
 
       history.push(clusterTopicPath(clusterName, data.name));
     } catch (error) {
-      const response = await getResponse(error);
+      const response = await getResponse(error as Response);
       const alert: FailurePayload = {
         subject: ['schema', data.name].join('-'),
         title: `Schema ${data.name}`,
diff --git a/kafka-ui-react-app/src/components/Topics/shared/Form/CustomParams/CustomParamField.tsx b/kafka-ui-react-app/src/components/Topics/shared/Form/CustomParams/CustomParamField.tsx
index c2a023ba0e8..1c3163bf6d1 100644
--- a/kafka-ui-react-app/src/components/Topics/shared/Form/CustomParams/CustomParamField.tsx
+++ b/kafka-ui-react-app/src/components/Topics/shared/Form/CustomParams/CustomParamField.tsx
@@ -1,8 +1,7 @@
-import React from 'react';
+import React, { useRef } from 'react';
 import { ErrorMessage } from '@hookform/error-message';
 import { TOPIC_CUSTOM_PARAMS } from 'lib/constants';
 import { FieldArrayWithId, useFormContext } from 'react-hook-form';
-import { remove as _remove } from 'lodash';
 import { TopicFormData } from 'redux/interfaces';
 import { InputLabel } from 'components/common/Input/InputLabel.styled';
 import { FormError } from 'components/common/Input/Input.styled';
@@ -14,7 +13,7 @@ import * as C from 'components/Topics/shared/Form/TopicForm.styled';
 
 import * as S from './CustomParams.styled';
 
-interface Props {
+export interface Props {
   isDisabled: boolean;
   index: number;
   existingFields: string[];
@@ -37,19 +36,17 @@ const CustomParamField: React.FC<Props> = ({
     watch,
   } = useFormContext<TopicFormData>();
   const nameValue = watch(`customParams.${index}.name`);
-  let prevName = '';
+  const prevName = useRef(nameValue);
 
   React.useEffect(() => {
-    prevName = nameValue;
-  }, []);
-
-  React.useEffect(() => {
-    if (nameValue !== prevName) {
+    if (nameValue !== prevName.current) {
       let newExistingFields = [...existingFields];
-      if (prevName) {
-        newExistingFields = _remove(newExistingFields, (el) => el === prevName);
+      if (prevName.current) {
+        newExistingFields = newExistingFields.filter(
+          (name) => name !== prevName.current
+        );
       }
-      prevName = nameValue;
+      prevName.current = nameValue;
       newExistingFields.push(nameValue);
       setExistingFields(newExistingFields);
       setValue(`customParams.${index}.value`, TOPIC_CUSTOM_PARAMS[nameValue]);
@@ -83,7 +80,10 @@ const CustomParamField: React.FC<Props> = ({
               ))}
           </Select>
           <FormError>
-            <ErrorMessage errors={errors} name={`customParams.${index}.name`} />
+            <ErrorMessage
+              errors={errors}
+              name={`customParams.${index}.name` as const}
+            />
           </FormError>
         </div>
       </>
@@ -101,13 +101,22 @@ const CustomParamField: React.FC<Props> = ({
           disabled={isDisabled}
         />
         <FormError>
-          <ErrorMessage errors={errors} name={`customParams.${index}.value`} />
+          <ErrorMessage
+            errors={errors}
+            name={`customParams.${index}.value` as const}
+          />
         </FormError>
       </div>
 
       <S.DeleteButtonWrapper>
-        <IconButtonWrapper onClick={() => remove(index)} aria-hidden>
-          <CloseIcon />
+        <IconButtonWrapper
+          onClick={() => remove(index)}
+          onKeyDown={(e: React.KeyboardEvent) =>
+            e.code === 'Space' && remove(index)
+          }
+          title={`Delete customParam field ${index}`}
+        >
+          <CloseIcon aria-hidden />
         </IconButtonWrapper>
       </S.DeleteButtonWrapper>
     </C.Column>
diff --git a/kafka-ui-react-app/src/components/Topics/shared/Form/CustomParams/CustomParams.styled.ts b/kafka-ui-react-app/src/components/Topics/shared/Form/CustomParams/CustomParams.styled.ts
index 092f6d08573..5c7d1debcd1 100644
--- a/kafka-ui-react-app/src/components/Topics/shared/Form/CustomParams/CustomParams.styled.ts
+++ b/kafka-ui-react-app/src/components/Topics/shared/Form/CustomParams/CustomParams.styled.ts
@@ -6,11 +6,10 @@ export const ParamsWrapper = styled.div`
 `;
 
 export const DeleteButtonWrapper = styled.div`
-  height: 32px;
+  min-height: 32px;
   display: flex;
   flex-direction: column;
-  justify-content: center;
   align-items: center;
-  align-self: flex-end;
-  flex-grow: 0.25 !important;
+  justify-self: flex-start;
+  margin-top: 32px;
 `;
diff --git a/kafka-ui-react-app/src/components/Topics/shared/Form/CustomParams/CustomParams.tsx b/kafka-ui-react-app/src/components/Topics/shared/Form/CustomParams/CustomParams.tsx
index f1a6be62bde..1eb4dbe4a92 100644
--- a/kafka-ui-react-app/src/components/Topics/shared/Form/CustomParams/CustomParams.tsx
+++ b/kafka-ui-react-app/src/components/Topics/shared/Form/CustomParams/CustomParams.tsx
@@ -1,6 +1,6 @@
 import React from 'react';
 import { TopicConfigByName, TopicFormData } from 'redux/interfaces';
-import { useFieldArray, useFormContext } from 'react-hook-form';
+import { useFieldArray, useFormContext, useWatch } from 'react-hook-form';
 import { Button } from 'components/common/Button/Button';
 
 import CustomParamField from './CustomParamField';
@@ -8,28 +8,41 @@ import * as S from './CustomParams.styled';
 
 export const INDEX_PREFIX = 'customParams';
 
-interface Props {
+export interface CustomParamsProps {
   isSubmitting: boolean;
   config?: TopicConfigByName;
 }
 
-const CustomParams: React.FC<Props> = ({ isSubmitting }) => {
+const CustomParams: React.FC<CustomParamsProps> = ({ isSubmitting }) => {
   const { control } = useFormContext<TopicFormData>();
   const { fields, append, remove } = useFieldArray({
     control,
     name: INDEX_PREFIX,
   });
+  const watchFieldArray = useWatch({
+    control,
+    name: INDEX_PREFIX,
+    defaultValue: fields,
+  });
+  const controlledFields = fields.map((field, index) => {
+    return {
+      ...field,
+      ...watchFieldArray[index],
+    };
+  });
+
   const [existingFields, setExistingFields] = React.useState<string[]>([]);
+
   const removeField = (index: number): void => {
     setExistingFields(
-      existingFields.filter((field) => field === fields[index].name)
+      existingFields.filter((field) => field !== controlledFields[index].name)
     );
     remove(index);
   };
 
   return (
     <S.ParamsWrapper>
-      {fields.map((field, idx) => (
+      {controlledFields.map((field, idx) => (
         <CustomParamField
           key={field.id}
           field={field}
diff --git a/kafka-ui-react-app/src/components/Topics/shared/Form/CustomParams/__tests__/CustomParamField.spec.tsx b/kafka-ui-react-app/src/components/Topics/shared/Form/CustomParams/__tests__/CustomParamField.spec.tsx
new file mode 100644
index 00000000000..7d9ea036a0a
--- /dev/null
+++ b/kafka-ui-react-app/src/components/Topics/shared/Form/CustomParams/__tests__/CustomParamField.spec.tsx
@@ -0,0 +1,123 @@
+import React from 'react';
+import { screen, within } from '@testing-library/react';
+import { render } from 'lib/testHelpers';
+import CustomParamsField, {
+  Props,
+} from 'components/Topics/shared/Form/CustomParams/CustomParamField';
+import { FormProvider, useForm } from 'react-hook-form';
+import userEvent from '@testing-library/user-event';
+import { TOPIC_CUSTOM_PARAMS } from 'lib/constants';
+
+const isDisabled = false;
+const index = 0;
+const existingFields: string[] = [];
+const field = { name: 'name', value: 'value', id: 'id' };
+const remove = jest.fn();
+const setExistingFields = jest.fn();
+
+const SPACE_KEY = ' ';
+
+describe('CustomParamsField', () => {
+  const setupComponent = (props: Props) => {
+    const Wrapper: React.FC = ({ children }) => {
+      const methods = useForm();
+      return <FormProvider {...methods}>{children}</FormProvider>;
+    };
+
+    return render(
+      <Wrapper>
+        <CustomParamsField {...props} />
+      </Wrapper>
+    );
+  };
+
+  it('renders with props', () => {
+    setupComponent({
+      field,
+      isDisabled,
+      index,
+      remove,
+      existingFields,
+      setExistingFields,
+    });
+    expect(screen.getByRole('listbox')).toBeInTheDocument();
+    expect(screen.getByRole('textbox')).toBeInTheDocument();
+    expect(screen.getByRole('button')).toBeInTheDocument();
+  });
+
+  describe('core functionality works', () => {
+    it('click on button triggers remove', () => {
+      setupComponent({
+        field,
+        isDisabled,
+        index,
+        remove,
+        existingFields,
+        setExistingFields,
+      });
+      userEvent.click(screen.getByRole('button'));
+      expect(remove.mock.calls.length).toBe(1);
+    });
+
+    it('pressing space on button triggers remove', () => {
+      setupComponent({
+        field,
+        isDisabled,
+        index,
+        remove,
+        existingFields,
+        setExistingFields,
+      });
+      userEvent.type(screen.getByRole('button'), SPACE_KEY);
+      // userEvent.type triggers remove two times as at first it clicks on element and then presses space
+      expect(remove.mock.calls.length).toBe(2);
+    });
+
+    it('can select option', () => {
+      setupComponent({
+        field,
+        isDisabled,
+        index,
+        remove,
+        existingFields,
+        setExistingFields,
+      });
+      const listbox = screen.getByRole('listbox');
+      userEvent.selectOptions(listbox, ['compression.type']);
+
+      const option = within(listbox).getByRole('option', { selected: true });
+      expect(option).toHaveValue('compression.type');
+    });
+
+    it('selecting option updates textbox value', () => {
+      setupComponent({
+        field,
+        isDisabled,
+        index,
+        remove,
+        existingFields,
+        setExistingFields,
+      });
+      const listbox = screen.getByRole('listbox');
+      userEvent.selectOptions(listbox, ['compression.type']);
+
+      const textbox = screen.getByRole('textbox');
+      expect(textbox).toHaveValue(TOPIC_CUSTOM_PARAMS['compression.type']);
+    });
+
+    it('selecting option updates triggers setExistingFields', () => {
+      setupComponent({
+        field,
+        isDisabled,
+        index,
+        remove,
+        existingFields,
+        setExistingFields,
+      });
+      const listbox = screen.getByRole('listbox');
+      userEvent.selectOptions(listbox, ['compression.type']);
+
+      expect(setExistingFields.mock.calls.length).toBe(1);
+    });
+  });
+});
diff --git a/kafka-ui-react-app/src/components/Topics/shared/Form/CustomParams/__tests__/CustomParams.spec.tsx b/kafka-ui-react-app/src/components/Topics/shared/Form/CustomParams/__tests__/CustomParams.spec.tsx
new file mode 100644
index 00000000000..d803e65e4b0
--- /dev/null
+++ b/kafka-ui-react-app/src/components/Topics/shared/Form/CustomParams/__tests__/CustomParams.spec.tsx
@@ -0,0 +1,171 @@
+import React from 'react';
+import { screen, within } from '@testing-library/react';
+import { render } from 'lib/testHelpers';
+import CustomParams, {
+  CustomParamsProps,
+} from 'components/Topics/shared/Form/CustomParams/CustomParams';
+import { FormProvider, useForm } from 'react-hook-form';
+import userEvent from '@testing-library/user-event';
+import { TOPIC_CUSTOM_PARAMS } from 'lib/constants';
+
+describe('CustomParams', () => {
+  const setupComponent = (props: CustomParamsProps) => {
+    const Wrapper: React.FC = ({ children }) => {
+      const methods = useForm();
+      return <FormProvider {...methods}>{children}</FormProvider>;
+    };
+
+    return render(
+      <Wrapper>
+        <CustomParams {...props} />
+      </Wrapper>
+    );
+  };
+
+  beforeEach(() => {
+    setupComponent({ isSubmitting: false });
+  });
+
+  it('renders with props', () => {
+    const addParamButton = screen.getByRole('button');
+    expect(addParamButton).toBeInTheDocument();
+    expect(addParamButton).toHaveTextContent('Add Custom Parameter');
+  });
+
+  describe('works with user inputs correctly', () => {
+    it('button click creates custom param fieldset', () => {
+      const addParamButton = screen.getByRole('button');
+      userEvent.click(addParamButton);
+
+      const listbox = screen.getByRole('listbox');
+      expect(listbox).toBeInTheDocument();
+
+      const textbox = screen.getByRole('textbox');
+      expect(textbox).toBeInTheDocument();
+    });
+
+    it('can select option', () => {
+      const addParamButton = screen.getByRole('button');
+      userEvent.click(addParamButton);
+
+      const listbox = screen.getByRole('listbox');
+
+      userEvent.selectOptions(listbox, ['compression.type']);
+
+      const option = screen.getByRole('option', {
+        selected: true,
+      });
+      expect(option).toHaveValue('compression.type');
+      expect(option).toBeDisabled();
+
+      const textbox = screen.getByRole('textbox');
+      expect(textbox).toHaveValue(TOPIC_CUSTOM_PARAMS['compression.type']);
+    });
+
+    it('when selected option changes disabled options update correctly', () => {
+      const addParamButton = screen.getByRole('button');
+      userEvent.click(addParamButton);
+
+      const listbox = screen.getByRole('listbox');
+
+      userEvent.selectOptions(listbox, ['compression.type']);
+
+      const option = screen.getByRole('option', {
+        name: 'compression.type',
+      });
+      expect(option).toBeDisabled();
+
+      userEvent.selectOptions(listbox, ['delete.retention.ms']);
+      const newOption = screen.getByRole('option', {
+        name: 'delete.retention.ms',
+      });
+      expect(newOption).toBeDisabled();
+
+      expect(option).toBeEnabled();
+    });
+
+    it('multiple button clicks create multiple fieldsets', () => {
+      const addParamButton = screen.getByRole('button');
+      userEvent.click(addParamButton);
+      userEvent.click(addParamButton);
+      userEvent.click(addParamButton);
+
+      const listboxes = screen.getAllByRole('listbox');
+      expect(listboxes.length).toBe(3);
+
+      const textboxes = screen.getAllByRole('textbox');
+      expect(textboxes.length).toBe(3);
+    });
+
+    it("can't select already selected option", () => {
+      const addParamButton = screen.getByRole('button');
+      userEvent.click(addParamButton);
+      userEvent.click(addParamButton);
+
+      const listboxes = screen.getAllByRole('listbox');
+
+      const firstListbox = listboxes[0];
+      userEvent.selectOptions(firstListbox, ['compression.type']);
+
+      const firstListboxOption = within(firstListbox).getByRole('option', {
+        selected: true,
+      });
+      expect(firstListboxOption).toBeDisabled();
+
+      const secondListbox = listboxes[1];
+      const secondListboxOption = within(secondListbox).getByRole('option', {
+        name: 'compression.type',
+      });
+      expect(secondListboxOption).toBeDisabled();
+    });
+
+    it('when fieldset with selected custom property type is deleted disabled options update correctly', async () => {
+      const addParamButton = screen.getByRole('button');
+      userEvent.click(addParamButton);
+      userEvent.click(addParamButton);
+      userEvent.click(addParamButton);
+
+      const listboxes = screen.getAllByRole('listbox');
+
+      const firstListbox = listboxes[0];
+      userEvent.selectOptions(firstListbox, ['compression.type']);
+
+      const firstListboxOption = within(firstListbox).getByRole('option', {
+        selected: true,
+      });
+      expect(firstListboxOption).toBeDisabled();
+
+      const secondListbox = listboxes[1];
+      userEvent.selectOptions(secondListbox, ['delete.retention.ms']);
+      const secondListboxOption = within(secondListbox).getByRole('option', {
+        selected: true,
+      });
+      expect(secondListboxOption).toBeDisabled();
+
+      const thirdListbox = listboxes[2];
+      userEvent.selectOptions(thirdListbox, ['file.delete.delay.ms']);
+      const thirdListboxOption = within(thirdListbox).getByRole('option', {
+        selected: true,
+      });
+      expect(thirdListboxOption).toBeDisabled();
+
+      const deleteSecondFieldsetButton = screen.getByTitle(
+        'Delete customParam field 1'
+      );
+      userEvent.click(deleteSecondFieldsetButton);
+      expect(secondListbox).not.toBeInTheDocument();
+
+      expect(
+        within(firstListbox).getByRole('option', {
+          name: 'delete.retention.ms',
+        })
+      ).toBeEnabled();
+
+      expect(
+        within(thirdListbox).getByRole('option', {
+          name: 'delete.retention.ms',
+        })
+      ).toBeEnabled();
+    });
+  });
+});
diff --git a/kafka-ui-react-app/src/components/common/Icons/IconButtonWrapper.ts b/kafka-ui-react-app/src/components/common/Icons/IconButtonWrapper.ts
index 6cae47935a4..7804071f6f7 100644
--- a/kafka-ui-react-app/src/components/common/Icons/IconButtonWrapper.ts
+++ b/kafka-ui-react-app/src/components/common/Icons/IconButtonWrapper.ts
@@ -1,6 +1,9 @@
 import styled from 'styled-components';
 
-const IconButtonWrapper = styled.span`
+const IconButtonWrapper = styled.span.attrs(() => ({
+  role: 'button',
+  tabIndex: '0',
+}))`
   height: 16px !important;
   display: inline-block;
   &:hover {


Test patch: