فهرست منبع

Bugfix/select (#1397)

* new select styles init

* Custom select compound component

* Select component fix styles

* Added react hook form controller

* Moved from compound component

* fixed vslues & onChange for controller

* fixed tests & code cleanup

* fix review

* fixed linter

* fixed discussions

Co-authored-by: Ekaterina Petrova <epetrova@provectus.com>
Ekaterina Petrova 3 سال پیش
والد
کامیت
205d8d000d
25فایلهای تغییر یافته به همراه674 افزوده شده و 412 حذف شده
  1. 5 0
      kafka-ui-react-app/src/components/Connect/List/__tests__/__snapshots__/ListItem.spec.tsx.snap
  2. 20 7
      kafka-ui-react-app/src/components/Connect/New/New.tsx
  3. 1 3
      kafka-ui-react-app/src/components/ConsumerGroups/Details/Details.tsx
  4. 39 16
      kafka-ui-react-app/src/components/ConsumerGroups/Details/ResetOffsets/ResetOffsets.tsx
  5. 5 2
      kafka-ui-react-app/src/components/ConsumerGroups/Details/ResetOffsets/__test__/ResetOffsets.spec.tsx
  6. 4 4
      kafka-ui-react-app/src/components/ConsumerGroups/Details/__tests__/Details.spec.tsx
  7. 31 22
      kafka-ui-react-app/src/components/Schemas/Edit/Edit.tsx
  8. 8 16
      kafka-ui-react-app/src/components/Schemas/List/GlobalSchemaSelector/GlobalSchemaSelector.tsx
  9. 12 7
      kafka-ui-react-app/src/components/Schemas/List/GlobalSchemaSelector/__test__/GlobalSchemaSelector.spec.tsx
  10. 24 13
      kafka-ui-react-app/src/components/Schemas/New/New.tsx
  11. 0 4
      kafka-ui-react-app/src/components/Topics/Topic/Details/Messages/Filters/Filters.styled.ts
  12. 23 19
      kafka-ui-react-app/src/components/Topics/Topic/Details/Messages/Filters/Filters.tsx
  13. 108 94
      kafka-ui-react-app/src/components/Topics/Topic/Details/Messages/Filters/__tests__/__snapshots__/Filters.spec.tsx.snap
  14. 5 0
      kafka-ui-react-app/src/components/Topics/Topic/Details/__test__/__snapshots__/Details.spec.tsx.snap
  15. 26 23
      kafka-ui-react-app/src/components/Topics/shared/Form/CustomParams/CustomParamField.tsx
  16. 15 9
      kafka-ui-react-app/src/components/Topics/shared/Form/CustomParams/__tests__/CustomParamField.spec.tsx
  17. 55 69
      kafka-ui-react-app/src/components/Topics/shared/Form/CustomParams/__tests__/CustomParams.spec.tsx
  18. 44 15
      kafka-ui-react-app/src/components/Topics/shared/Form/TopicForm.tsx
  19. 0 0
      kafka-ui-react-app/src/components/common/Form/Form.styled.ts
  20. 2 2
      kafka-ui-react-app/src/components/common/Select/LiveIcon.styled.tsx
  21. 68 6
      kafka-ui-react-app/src/components/common/Select/Select.styled.ts
  22. 76 36
      kafka-ui-react-app/src/components/common/Select/Select.tsx
  23. 69 45
      kafka-ui-react-app/src/components/common/Select/__tests__/__snapshots__/Select.spec.tsx.snap
  24. 29 0
      kafka-ui-react-app/src/lib/hooks/useClickOutside.ts
  25. 5 0
      kafka-ui-react-app/src/theme/theme.ts

+ 5 - 0
kafka-ui-react-app/src/components/Connect/List/__tests__/__snapshots__/ListItem.spec.tsx.snap

@@ -227,6 +227,11 @@ exports[`Connectors ListItem matches snapshot 1`] = `
         },
       },
       "selectStyles": Object {
+        "backgroundColor": Object {
+          "active": "#E3E6E8",
+          "hover": "#E3E6E8",
+          "normal": "#FFFFFF",
+        },
         "borderColor": Object {
           "active": "#454F54",
           "disabled": "#E3E6E8",

+ 20 - 7
kafka-ui-react-app/src/components/Connect/New/New.tsx

@@ -112,6 +112,11 @@ const New: React.FC<NewProps> = ({
     return null;
   }
 
+  const connectOptions = connects.map(({ name: connectName }) => ({
+    value: connectName,
+    label: connectName,
+  }));
+
   return (
     <FormProvider {...methods}>
       <PageHeading text="Create new connector" />
@@ -121,13 +126,21 @@ const New: React.FC<NewProps> = ({
       >
         <div className={['field', connectNameFieldClassName].join(' ')}>
           <InputLabel>Connect *</InputLabel>
-          <Select selectSize="M" name="connectName" disabled={isSubmitting}>
-            {connects.map(({ name }) => (
-              <option key={name} value={name}>
-                {name}
-              </option>
-            ))}
-          </Select>
+          <Controller
+            control={control}
+            name="connectName"
+            render={({ field: { name, onChange } }) => (
+              <Select
+                selectSize="M"
+                name={name}
+                disabled={isSubmitting}
+                onChange={onChange}
+                value={connectOptions[0].value}
+                minWidth="100%"
+                options={connectOptions}
+              />
+            )}
+          />
           <FormError>
             <ErrorMessage errors={errors} name="connectName" />
           </FormError>

+ 1 - 3
kafka-ui-react-app/src/components/ConsumerGroups/Details/Details.tsx

@@ -77,9 +77,7 @@ const Details: React.FC = () => {
         <PageHeading text={consumerGroupID}>
           {!isReadOnly && (
             <Dropdown label={<VerticalElipsisIcon />} right>
-              <DropdownItem onClick={onResetOffsets}>
-                Reset offsets
-              </DropdownItem>
+              <DropdownItem onClick={onResetOffsets}>Reset offset</DropdownItem>
               <DropdownItem
                 style={{ color: Colors.red[50] }}
                 onClick={() => setIsConfirmationModalVisible(true)}

+ 39 - 16
kafka-ui-react-app/src/components/ConsumerGroups/Details/ResetOffsets/ResetOffsets.tsx

@@ -182,24 +182,47 @@ const ResetOffsets: React.FC = () => {
         <form onSubmit={handleSubmit(onSubmit)}>
           <MainSelectorsWrapperStyled>
             <div>
-              <InputLabel htmlFor="topic">Topic</InputLabel>
-              <Select name="topic" id="topic" selectSize="M">
-                {uniqueTopics.map((topic) => (
-                  <option key={topic} value={topic}>
-                    {topic}
-                  </option>
-                ))}
-              </Select>
+              <InputLabel id="topicLabel">Topic</InputLabel>
+              <Controller
+                control={control}
+                name="topic"
+                render={({ field: { name, onChange, value } }) => (
+                  <Select
+                    id="topic"
+                    selectSize="M"
+                    aria-labelledby="topicLabel"
+                    minWidth="100%"
+                    name={name}
+                    onChange={onChange}
+                    value={value}
+                    options={uniqueTopics.map((topic) => ({
+                      value: topic,
+                      label: topic,
+                    }))}
+                  />
+                )}
+              />
             </div>
             <div>
-              <InputLabel htmlFor="resetType">Reset Type</InputLabel>
-              <Select name="resetType" id="resetType" selectSize="M">
-                {Object.values(ConsumerGroupOffsetsResetType).map((type) => (
-                  <option key={type} value={type}>
-                    {type}
-                  </option>
-                ))}
-              </Select>
+              <InputLabel id="resetTypeLabel">Reset Type</InputLabel>
+              <Controller
+                control={control}
+                name="resetType"
+                render={({ field: { name, onChange, value } }) => (
+                  <Select
+                    id="resetType"
+                    selectSize="M"
+                    aria-labelledby="resetTypeLabel"
+                    minWidth="100%"
+                    name={name}
+                    onChange={onChange}
+                    value={value}
+                    options={Object.values(ConsumerGroupOffsetsResetType).map(
+                      (type) => ({ value: type, label: type })
+                    )}
+                  />
+                )}
+              />
             </div>
             <div>
               <InputLabel>Partitions</InputLabel>

+ 5 - 2
kafka-ui-react-app/src/components/ConsumerGroups/Details/ResetOffsets/__test__/ResetOffsets.spec.tsx

@@ -37,7 +37,8 @@ const resetConsumerGroupOffsetsMockCalled = () =>
   ).toBeTruthy();
 
 const selectresetTypeAndPartitions = async (resetType: string) => {
-  userEvent.selectOptions(screen.getByLabelText('Reset Type'), resetType);
+  userEvent.click(screen.getByLabelText('Reset Type'));
+  userEvent.click(screen.getByText(resetType));
   userEvent.click(screen.getByText('Select...'));
   await waitFor(() => {
     userEvent.click(screen.getByText('Partition #0'));
@@ -48,7 +49,9 @@ const resetConsumerGroupOffsetsWith = async (
   resetType: string,
   offset: null | number = null
 ) => {
-  userEvent.selectOptions(screen.getByLabelText('Reset Type'), resetType);
+  userEvent.click(screen.getByLabelText('Reset Type'));
+  const options = screen.getAllByText(resetType);
+  userEvent.click(options.length > 1 ? options[1] : options[0]);
   userEvent.click(screen.getByText('Select...'));
   await waitFor(() => {
     userEvent.click(screen.getByText('Partition #0'));

+ 4 - 4
kafka-ui-react-app/src/components/ConsumerGroups/Details/__tests__/Details.spec.tsx

@@ -41,7 +41,7 @@ describe('Details component', () => {
     fetchMock.reset();
   });
 
-  describe('when consumer gruops are NOT fetched', () => {
+  describe('when consumer groups are NOT fetched', () => {
     it('renders progress bar for initial state', () => {
       fetchMock.getOnce(
         `/api/clusters/${clusterName}/consumer-groups/${groupId}`,
@@ -73,8 +73,8 @@ describe('Details component', () => {
       expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
     });
 
-    it('handles [Reset offsets] click', async () => {
-      userEvent.click(screen.getByText('Reset offsets'));
+    it('handles [Reset offset] click', async () => {
+      userEvent.click(screen.getByText('Reset offset'));
       expect(history.location.pathname).toEqual(
         clusterConsumerGroupResetOffsetsPath(clusterName, groupId)
       );
@@ -90,7 +90,7 @@ describe('Details component', () => {
       expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
     });
 
-    it('hanles [Delete consumer group] click', async () => {
+    it('handles [Delete consumer group] click', async () => {
       expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
       userEvent.click(screen.getByText('Delete consumer group'));
 

+ 31 - 22
kafka-ui-react-app/src/components/Schemas/Edit/Edit.tsx

@@ -85,35 +85,44 @@ const Edit: React.FC = () => {
           <div>
             <div>
               <InputLabel>Type</InputLabel>
-              <Select
+              <Controller
+                control={control}
+                rules={{ required: true }}
                 name="schemaType"
-                required
-                defaultValue={schema.schemaType}
-                disabled={isSubmitting}
-              >
-                {Object.keys(SchemaType).map((type: string) => (
-                  <option key={type} value={type}>
-                    {type}
-                  </option>
-                ))}
-              </Select>
+                render={({ field: { name, onChange } }) => (
+                  <Select
+                    name={name}
+                    value={schema.schemaType}
+                    onChange={onChange}
+                    minWidth="100%"
+                    disabled={isSubmitting}
+                    options={Object.keys(SchemaType).map((type) => ({
+                      value: type,
+                      label: type,
+                    }))}
+                  />
+                )}
+              />
             </div>
 
             <div>
               <InputLabel>Compatibility level</InputLabel>
-              <Select
+              <Controller
+                control={control}
                 name="compatibilityLevel"
-                defaultValue={schema.compatibilityLevel}
-                disabled={isSubmitting}
-              >
-                {Object.keys(CompatibilityLevelCompatibilityEnum).map(
-                  (level: string) => (
-                    <option key={level} value={level}>
-                      {level}
-                    </option>
-                  )
+                render={({ field: { name, onChange } }) => (
+                  <Select
+                    name={name}
+                    value={schema.compatibilityLevel}
+                    onChange={onChange}
+                    minWidth="100%"
+                    disabled={isSubmitting}
+                    options={Object.keys(
+                      CompatibilityLevelCompatibilityEnum
+                    ).map((level) => ({ value: level, label: level }))}
+                  />
                 )}
-              </Select>
+              />
             </div>
           </div>
           <S.EditorsWrapper>

+ 8 - 16
kafka-ui-react-app/src/components/Schemas/List/GlobalSchemaSelector/GlobalSchemaSelector.tsx

@@ -45,12 +45,8 @@ const GlobalSchemaSelector: React.FC = () => {
     fetchData();
   }, []);
 
-  const handleChangeCompatibilityLevel = (
-    event: React.ChangeEvent<HTMLSelectElement>
-  ) => {
-    setNextCompatibilityLevel(
-      event.target.value as CompatibilityLevelCompatibilityEnum
-    );
+  const handleChangeCompatibilityLevel = (level: string | number) => {
+    setNextCompatibilityLevel(level as CompatibilityLevelCompatibilityEnum);
     setIsConfirmationVisible(true);
   };
 
@@ -62,10 +58,10 @@ const GlobalSchemaSelector: React.FC = () => {
           clusterName,
           compatibilityLevel: { compatibility: nextCompatibilityLevel },
         });
-        dispatch(fetchSchemas(clusterName));
         setCurrentCompatibilityLevel(nextCompatibilityLevel);
         setNextCompatibilityLevel(undefined);
         setIsConfirmationVisible(false);
+        dispatch(fetchSchemas(clusterName));
       } catch (e) {
         const err = await getResponse(e as Response);
         dispatch(serverErrorAlertAdded(err));
@@ -81,18 +77,14 @@ const GlobalSchemaSelector: React.FC = () => {
       <div>Global Compatibility Level: </div>
       <Select
         selectSize="M"
-        value={currentCompatibilityLevel}
+        defaultValue={currentCompatibilityLevel}
+        minWidth="200px"
         onChange={handleChangeCompatibilityLevel}
         disabled={isFetching || isUpdating || isConfirmationVisible}
-      >
-        {Object.keys(CompatibilityLevelCompatibilityEnum).map(
-          (level: string) => (
-            <option key={level} value={level}>
-              {level}
-            </option>
-          )
+        options={Object.keys(CompatibilityLevelCompatibilityEnum).map(
+          (level) => ({ value: level, label: level })
         )}
-      </Select>
+      />
       <ConfirmationModal
         isOpen={isConfirmationVisible}
         onCancel={() => setIsConfirmationVisible(false)}

+ 12 - 7
kafka-ui-react-app/src/components/Schemas/List/GlobalSchemaSelector/__test__/GlobalSchemaSelector.spec.tsx

@@ -1,5 +1,5 @@
 import React from 'react';
-import { screen, waitFor } from '@testing-library/react';
+import { screen, waitFor, within } from '@testing-library/react';
 import { render } from 'lib/testHelpers';
 import { CompatibilityLevelCompatibilityEnum } from 'generated-sources';
 import GlobalSchemaSelector from 'components/Schemas/List/GlobalSchemaSelector/GlobalSchemaSelector';
@@ -10,15 +10,20 @@ import fetchMock from 'fetch-mock';
 
 const clusterName = 'testClusterName';
 
-const selectForwardOption = () =>
-  userEvent.selectOptions(
-    screen.getByRole('listbox'),
-    CompatibilityLevelCompatibilityEnum.FORWARD
+const selectForwardOption = () => {
+  const dropdownElement = screen.getByRole('listbox');
+  // clicks to open dropdown
+  userEvent.click(within(dropdownElement).getByRole('option'));
+  userEvent.click(
+    screen.getByText(CompatibilityLevelCompatibilityEnum.FORWARD)
   );
+};
 
 const expectOptionIsSelected = (option: string) => {
-  const optionElement: HTMLOptionElement = screen.getByText(option);
-  expect(optionElement.selected).toBeTruthy();
+  const dropdownElement = screen.getByRole('listbox');
+  const selectedOption = within(dropdownElement).getAllByRole('option');
+  expect(selectedOption.length).toEqual(1);
+  expect(selectedOption[0]).toHaveTextContent(option);
 };
 
 describe('GlobalSchemaSelector', () => {

+ 24 - 13
kafka-ui-react-app/src/components/Schemas/New/New.tsx

@@ -1,6 +1,6 @@
 import React from 'react';
 import { NewSchemaSubjectRaw } from 'redux/interfaces';
-import { FormProvider, useForm } from 'react-hook-form';
+import { FormProvider, useForm, Controller } from 'react-hook-form';
 import { ErrorMessage } from '@hookform/error-message';
 import { clusterSchemaPath } from 'lib/paths';
 import { SchemaType } from 'generated-sources';
@@ -9,7 +9,7 @@ import { useHistory, useParams } from 'react-router';
 import { InputLabel } from 'components/common/Input/InputLabel.styled';
 import Input from 'components/common/Input/Input';
 import { FormError } from 'components/common/Input/Input.styled';
-import Select from 'components/common/Select/Select';
+import Select, { SelectOption } from 'components/common/Select/Select';
 import { Button } from 'components/common/Button/Button';
 import { Textarea } from 'components/common/Textbox/Textarea.styled';
 import PageHeading from 'components/common/PageHeading/PageHeading';
@@ -23,6 +23,12 @@ import { getResponse } from 'lib/errorHandling';
 
 import * as S from './New.styled';
 
+const SchemaTypeOptions: Array<SelectOption> = [
+  { value: SchemaType.AVRO, label: 'AVRO' },
+  { value: SchemaType.JSON, label: 'JSON' },
+  { value: SchemaType.PROTOBUF, label: 'PROTOBUF' },
+];
+
 const New: React.FC = () => {
   const { clusterName } = useParams<{ clusterName: string }>();
   const history = useHistory();
@@ -31,6 +37,7 @@ const New: React.FC = () => {
   const {
     register,
     handleSubmit,
+    control,
     formState: { isDirty, isSubmitting, errors },
   } = methods;
 
@@ -91,18 +98,22 @@ const New: React.FC = () => {
 
         <div>
           <InputLabel>Schema Type *</InputLabel>
-          <Select
-            selectSize="M"
+          <Controller
+            control={control}
+            rules={{ required: 'Schema Type is required.' }}
             name="schemaType"
-            hookFormOptions={{
-              required: 'Schema Type is required.',
-            }}
-            disabled={isSubmitting}
-          >
-            <option value={SchemaType.AVRO}>AVRO</option>
-            <option value={SchemaType.JSON}>JSON</option>
-            <option value={SchemaType.PROTOBUF}>PROTOBUF</option>
-          </Select>
+            render={({ field: { name, onChange } }) => (
+              <Select
+                selectSize="M"
+                name={name}
+                value={SchemaTypeOptions[0].value}
+                onChange={onChange}
+                minWidth="50%"
+                disabled={isSubmitting}
+                options={SchemaTypeOptions}
+              />
+            )}
+          />
           <FormError>
             <ErrorMessage errors={errors} name="schemaType" />
           </FormError>

+ 0 - 4
kafka-ui-react-app/src/components/Topics/Topic/Details/Messages/Filters/Filters.styled.ts

@@ -11,10 +11,6 @@ export const FiltersWrapper = styled.div`
     display: flex;
     justify-content: space-between;
     padding-top: 16px;
-
-    & > div:last-child {
-      width: 10%;
-    }
   }
 `;
 

+ 23 - 19
kafka-ui-react-app/src/components/Topics/Topic/Details/Messages/Filters/Filters.tsx

@@ -48,6 +48,15 @@ export interface FiltersProps {
 
 const PER_PAGE = 100;
 
+const SeekTypeOptions = [
+  { value: SeekType.OFFSET, label: 'Offset' },
+  { value: SeekType.TIMESTAMP, label: 'Timestamp' },
+];
+const SeekDirectionOptions = [
+  { value: SeekDirection.FORWARD, label: 'Oldest First' },
+  { value: SeekDirection.BACKWARD, label: 'Newest First' },
+];
+
 const Filters: React.FC<FiltersProps> = ({
   clusterName,
   topicName,
@@ -76,7 +85,7 @@ const Filters: React.FC<FiltersProps> = ({
   );
 
   const [attempt, setAttempt] = React.useState(0);
-  const [seekType, setSeekType] = React.useState<SeekType>(
+  const [currentSeekType, setCurrentSeekType] = React.useState<SeekType>(
     (searchParams.get('seekType') as SeekType) || SeekType.OFFSET
   );
   const [offset, setOffset] = React.useState<string>(
@@ -99,11 +108,11 @@ const Filters: React.FC<FiltersProps> = ({
 
   const isSubmitDisabled = React.useMemo(() => {
     if (isSeekTypeControlVisible) {
-      return seekType === SeekType.TIMESTAMP && !timestamp;
+      return currentSeekType === SeekType.TIMESTAMP && !timestamp;
     }
 
     return false;
-  }, [isSeekTypeControlVisible, seekType, timestamp]);
+  }, [isSeekTypeControlVisible, currentSeekType, timestamp]);
 
   const partitionMap = React.useMemo(
     () =>
@@ -128,11 +137,11 @@ const Filters: React.FC<FiltersProps> = ({
     };
 
     if (isSeekTypeControlVisible) {
-      props.seekType = seekType;
+      props.seekType = currentSeekType;
       props.seekTo = selectedPartitions.map(({ value }) => {
         let seekToOffset;
 
-        if (seekType === SeekType.OFFSET) {
+        if (currentSeekType === SeekType.OFFSET) {
           if (offset) {
             seekToOffset = offset;
           } else {
@@ -244,16 +253,13 @@ const Filters: React.FC<FiltersProps> = ({
             <S.SeekTypeSelectorWrapper>
               <Select
                 id="selectSeekType"
-                onChange={({ target: { value } }) =>
-                  setSeekType(value as SeekType)
-                }
-                value={seekType}
+                onChange={(option) => setCurrentSeekType(option as SeekType)}
+                value={currentSeekType}
                 selectSize="M"
-              >
-                <option value={SeekType.OFFSET}>Offset</option>
-                <option value={SeekType.TIMESTAMP}>Timestamp</option>
-              </Select>
-              {seekType === SeekType.OFFSET ? (
+                minWidth="100px"
+                options={SeekTypeOptions}
+              />
+              {currentSeekType === SeekType.OFFSET ? (
                 <Input
                   id="offset"
                   type="text"
@@ -311,13 +317,11 @@ const Filters: React.FC<FiltersProps> = ({
         </S.FilterInputs>
         <Select
           selectSize="M"
-          onChange={(e) => toggleSeekDirection(e.target.value)}
+          onChange={(option) => toggleSeekDirection(option as string)}
           value={seekDirection}
           minWidth="120px"
-        >
-          <option value={SeekDirection.FORWARD}>Oldest First</option>
-          <option value={SeekDirection.BACKWARD}>Newest First</option>
-        </Select>
+          options={SeekDirectionOptions}
+        />
       </div>
       <S.FiltersMetrics>
         <p style={{ fontSize: 14 }}>{isFetching && phaseMessage}</p>

+ 108 - 94
kafka-ui-react-app/src/components/Topics/Topic/Details/Messages/Filters/__tests__/__snapshots__/Filters.spec.tsx.snap

@@ -181,48 +181,69 @@ exports[`Filters component matches the snapshot 1`] = `
   position: relative;
 }
 
-.c7 {
+.c6 {
+  position: relative;
+  list-style: none;
+  display: -webkit-box;
+  display: -webkit-flex;
+  display: -ms-flexbox;
+  display: flex;
+  -webkit-align-items: center;
+  -webkit-box-align: center;
+  -ms-flex-align: center;
+  align-items: center;
   height: 32px;
   border: 1px #ABB5BA solid;
   border-radius: 4px;
   font-size: 14px;
-  width: 100%;
+  width: -webkit-fit-content;
+  width: -moz-fit-content;
+  width: fit-content;
   padding-left: 12px;
   padding-right: 16px;
   color: #171A1C;
-  min-width: auto;
+  min-width: 100px;
   background-image: url('data:image/svg+xml,%3Csvg width="10" height="6" viewBox="0 0 10 6" fill="none" xmlns="http://www.w3.org/2000/svg"%3E%3Cpath d="M1 1L5 5L9 1" stroke="%23454F54"/%3E%3C/svg%3E%0A') !important;
   background-repeat: no-repeat !important;
   background-position-x: calc(100% - 8px) !important;
   background-position-y: 55% !important;
-  -webkit-appearance: none !important;
-  -moz-appearance: none !important;
-  appearance: none !important;
 }
 
-.c7:hover {
+.c6:hover {
   color: #171A1C;
   border-color: #73848C;
 }
 
-.c7:focus {
+.c6:focus {
   outline: none;
   color: #171A1C;
   border-color: #454F54;
 }
 
-.c7:disabled {
+.c6:disabled {
   color: #ABB5BA;
   border-color: #E3E6E8;
   cursor: not-allowed;
 }
 
 .c11 {
+  position: relative;
+  list-style: none;
+  display: -webkit-box;
+  display: -webkit-flex;
+  display: -ms-flexbox;
+  display: flex;
+  -webkit-align-items: center;
+  -webkit-box-align: center;
+  -ms-flex-align: center;
+  align-items: center;
   height: 32px;
   border: 1px #ABB5BA solid;
   border-radius: 4px;
   font-size: 14px;
-  width: 100%;
+  width: -webkit-fit-content;
+  width: -moz-fit-content;
+  width: fit-content;
   padding-left: 12px;
   padding-right: 16px;
   color: #171A1C;
@@ -231,9 +252,6 @@ exports[`Filters component matches the snapshot 1`] = `
   background-repeat: no-repeat !important;
   background-position-x: calc(100% - 8px) !important;
   background-position-y: 55% !important;
-  -webkit-appearance: none !important;
-  -moz-appearance: none !important;
-  appearance: none !important;
 }
 
 .c11:hover {
@@ -253,8 +271,12 @@ exports[`Filters component matches the snapshot 1`] = `
   cursor: not-allowed;
 }
 
-.c6 {
-  position: relative;
+.c7 {
+  padding-right: 16px;
+  list-style-position: inside;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
 }
 
 .c10 {
@@ -332,10 +354,6 @@ exports[`Filters component matches the snapshot 1`] = `
   padding-top: 16px;
 }
 
-.c0 > div:first-child > div:last-child {
-  width: 10%;
-}
-
 .c1 {
   display: -webkit-box;
   display: -webkit-flex;
@@ -458,25 +476,19 @@ exports[`Filters component matches the snapshot 1`] = `
           <div
             class="c5"
           >
-            <div
-              class="select-wrapper c6"
-            >
-              <select
-                class="c7"
-                id="selectSeekType"
+            <div>
+              <ul
+                class="c6"
                 role="listbox"
               >
-                <option
-                  value="OFFSET"
+                <li
+                  class="c7"
+                  role="option"
+                  tabindex="0"
                 >
                   Offset
-                </option>
-                <option
-                  value="TIMESTAMP"
-                >
-                  Timestamp
-                </option>
-              </select>
+                </li>
+              </ul>
             </div>
             <div
               class="c2 offset-selector"
@@ -558,24 +570,19 @@ exports[`Filters component matches the snapshot 1`] = `
             Submit
           </button>
         </div>
-        <div
-          class="select-wrapper c6"
-        >
-          <select
+        <div>
+          <ul
             class="c11"
             role="listbox"
           >
-            <option
-              value="FORWARD"
+            <li
+              class="c7"
+              role="option"
+              tabindex="0"
             >
               Oldest First
-            </option>
-            <option
-              value="BACKWARD"
-            >
-              Newest First
-            </option>
-          </select>
+            </li>
+          </ul>
         </div>
       </div>
       <div
@@ -818,48 +825,69 @@ exports[`Filters component when fetching matches the snapshot 1`] = `
   position: relative;
 }
 
-.c7 {
+.c6 {
+  position: relative;
+  list-style: none;
+  display: -webkit-box;
+  display: -webkit-flex;
+  display: -ms-flexbox;
+  display: flex;
+  -webkit-align-items: center;
+  -webkit-box-align: center;
+  -ms-flex-align: center;
+  align-items: center;
   height: 32px;
   border: 1px #ABB5BA solid;
   border-radius: 4px;
   font-size: 14px;
-  width: 100%;
+  width: -webkit-fit-content;
+  width: -moz-fit-content;
+  width: fit-content;
   padding-left: 12px;
   padding-right: 16px;
   color: #171A1C;
-  min-width: auto;
+  min-width: 100px;
   background-image: url('data:image/svg+xml,%3Csvg width="10" height="6" viewBox="0 0 10 6" fill="none" xmlns="http://www.w3.org/2000/svg"%3E%3Cpath d="M1 1L5 5L9 1" stroke="%23454F54"/%3E%3C/svg%3E%0A') !important;
   background-repeat: no-repeat !important;
   background-position-x: calc(100% - 8px) !important;
   background-position-y: 55% !important;
-  -webkit-appearance: none !important;
-  -moz-appearance: none !important;
-  appearance: none !important;
 }
 
-.c7:hover {
+.c6:hover {
   color: #171A1C;
   border-color: #73848C;
 }
 
-.c7:focus {
+.c6:focus {
   outline: none;
   color: #171A1C;
   border-color: #454F54;
 }
 
-.c7:disabled {
+.c6:disabled {
   color: #ABB5BA;
   border-color: #E3E6E8;
   cursor: not-allowed;
 }
 
 .c11 {
+  position: relative;
+  list-style: none;
+  display: -webkit-box;
+  display: -webkit-flex;
+  display: -ms-flexbox;
+  display: flex;
+  -webkit-align-items: center;
+  -webkit-box-align: center;
+  -ms-flex-align: center;
+  align-items: center;
   height: 32px;
   border: 1px #ABB5BA solid;
   border-radius: 4px;
   font-size: 14px;
-  width: 100%;
+  width: -webkit-fit-content;
+  width: -moz-fit-content;
+  width: fit-content;
   padding-left: 12px;
   padding-right: 16px;
   color: #171A1C;
@@ -868,9 +896,6 @@ exports[`Filters component when fetching matches the snapshot 1`] = `
   background-repeat: no-repeat !important;
   background-position-x: calc(100% - 8px) !important;
   background-position-y: 55% !important;
-  -webkit-appearance: none !important;
-  -moz-appearance: none !important;
-  appearance: none !important;
 }
 
 .c11:hover {
@@ -890,8 +915,12 @@ exports[`Filters component when fetching matches the snapshot 1`] = `
   cursor: not-allowed;
 }
 
-.c6 {
-  position: relative;
+.c7 {
+  padding-right: 16px;
+  list-style-position: inside;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
 }
 
 .c10 {
@@ -969,10 +998,6 @@ exports[`Filters component when fetching matches the snapshot 1`] = `
   padding-top: 16px;
 }
 
-.c0 > div:first-child > div:last-child {
-  width: 10%;
-}
-
 .c1 {
   display: -webkit-box;
   display: -webkit-flex;
@@ -1094,25 +1119,19 @@ exports[`Filters component when fetching matches the snapshot 1`] = `
           <div
             class="c5"
           >
-            <div
-              class="select-wrapper c6"
-            >
-              <select
-                class="c7"
-                id="selectSeekType"
+            <div>
+              <ul
+                class="c6"
                 role="listbox"
               >
-                <option
-                  value="OFFSET"
+                <li
+                  class="c7"
+                  role="option"
+                  tabindex="0"
                 >
                   Offset
-                </option>
-                <option
-                  value="TIMESTAMP"
-                >
-                  Timestamp
-                </option>
-              </select>
+                </li>
+              </ul>
             </div>
             <div
               class="c2 offset-selector"
@@ -1194,24 +1213,19 @@ exports[`Filters component when fetching matches the snapshot 1`] = `
             Cancel
           </button>
         </div>
-        <div
-          class="select-wrapper c6"
-        >
-          <select
+        <div>
+          <ul
             class="c11"
             role="listbox"
           >
-            <option
-              value="FORWARD"
+            <li
+              class="c7"
+              role="option"
+              tabindex="0"
             >
               Oldest First
-            </option>
-            <option
-              value="BACKWARD"
-            >
-              Newest First
-            </option>
-          </select>
+            </li>
+          </ul>
         </div>
       </div>
       <div

+ 5 - 0
kafka-ui-react-app/src/components/Topics/Topic/Details/__test__/__snapshots__/Details.spec.tsx.snap

@@ -238,6 +238,11 @@ exports[`Details when it has readonly flag does not render the Action button a T
         },
       },
       "selectStyles": Object {
+        "backgroundColor": Object {
+          "active": "#E3E6E8",
+          "hover": "#E3E6E8",
+          "normal": "#FFFFFF",
+        },
         "borderColor": Object {
           "active": "#454F54",
           "disabled": "#E3E6E8",

+ 26 - 23
kafka-ui-react-app/src/components/Topics/shared/Form/CustomParams/CustomParamField.tsx

@@ -1,7 +1,7 @@
 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 { FieldArrayWithId, useFormContext, Controller } from 'react-hook-form';
 import { TopicFormData } from 'redux/interfaces';
 import { InputLabel } from 'components/common/Input/InputLabel.styled';
 import { FormError } from 'components/common/Input/Input.styled';
@@ -34,6 +34,7 @@ const CustomParamField: React.FC<Props> = ({
     formState: { errors },
     setValue,
     watch,
+    control,
   } = useFormContext<TopicFormData>();
   const nameValue = watch(`customParams.${index}.name`);
   const prevName = useRef(nameValue);
@@ -49,7 +50,9 @@ const CustomParamField: React.FC<Props> = ({
       prevName.current = nameValue;
       newExistingFields.push(nameValue);
       setExistingFields(newExistingFields);
-      setValue(`customParams.${index}.value`, TOPIC_CUSTOM_PARAMS[nameValue]);
+      setValue(`customParams.${index}.value`, TOPIC_CUSTOM_PARAMS[nameValue], {
+        shouldValidate: true,
+      });
     }
   }, [nameValue]);
 
@@ -58,27 +61,27 @@ const CustomParamField: React.FC<Props> = ({
       <>
         <div>
           <InputLabel>Custom Parameter</InputLabel>
-          <Select
-            name={`customParams.${index}.name` as const}
-            hookFormOptions={{
-              required: 'Custom Parameter is required.',
-            }}
-            disabled={isDisabled}
-            defaultValue={field.name}
-          >
-            <option value="">Select</option>
-            {Object.keys(TOPIC_CUSTOM_PARAMS)
-              .sort()
-              .map((opt) => (
-                <option
-                  key={opt}
-                  value={opt}
-                  disabled={existingFields.includes(opt)}
-                >
-                  {opt}
-                </option>
-              ))}
-          </Select>
+          <Controller
+            control={control}
+            rules={{ required: 'Custom Parameter is required.' }}
+            name={`customParams.${index}.name`}
+            render={({ field: { name, onChange } }) => (
+              <Select
+                name={name}
+                placeholder="Select"
+                disabled={isDisabled}
+                minWidth="270px"
+                onChange={onChange}
+                options={Object.keys(TOPIC_CUSTOM_PARAMS)
+                  .sort()
+                  .map((opt) => ({
+                    value: opt,
+                    label: opt,
+                    disabled: existingFields.includes(opt),
+                  }))}
+              />
+            )}
+          />
           <FormError>
             <ErrorMessage
               errors={errors}

+ 15 - 9
kafka-ui-react-app/src/components/Topics/shared/Form/CustomParams/__tests__/CustomParamField.spec.tsx

@@ -1,5 +1,5 @@
 import React from 'react';
-import { screen, within } from '@testing-library/react';
+import {screen, waitFor, within} from '@testing-library/react';
 import { render } from 'lib/testHelpers';
 import CustomParamsField, {
   Props,
@@ -17,6 +17,11 @@ const setExistingFields = jest.fn();
 
 const SPACE_KEY = ' ';
 
+const selectOption = async (listbox: HTMLElement, option: string) => {
+  await waitFor(() => userEvent.click(listbox));
+  await waitFor(() => userEvent.click(screen.getByText(option)));
+};
+
 describe('CustomParamsField', () => {
   const setupComponent = (props: Props) => {
     const Wrapper: React.FC = ({ children }) => {
@@ -73,7 +78,7 @@ describe('CustomParamsField', () => {
       expect(remove.mock.calls.length).toBe(2);
     });
 
-    it('can select option', () => {
+    it('can select option', async () => {
       setupComponent({
         field,
         isDisabled,
@@ -83,13 +88,14 @@ describe('CustomParamsField', () => {
         setExistingFields,
       });
       const listbox = screen.getByRole('listbox');
-      userEvent.selectOptions(listbox, ['compression.type']);
+      await selectOption(listbox, 'compression.type');
 
-      const option = within(listbox).getByRole('option', { selected: true });
-      expect(option).toHaveValue('compression.type');
+      const selectedOption = within(listbox).getAllByRole('option');
+      expect(selectedOption.length).toEqual(1);
+      expect(selectedOption[0]).toHaveTextContent('compression.type');
     });
 
-    it('selecting option updates textbox value', () => {
+    it('selecting option updates textbox value', async () => {
       setupComponent({
         field,
         isDisabled,
@@ -99,13 +105,13 @@ describe('CustomParamsField', () => {
         setExistingFields,
       });
       const listbox = screen.getByRole('listbox');
-      userEvent.selectOptions(listbox, ['compression.type']);
+      await selectOption(listbox, 'compression.type');
 
       const textbox = screen.getByRole('textbox');
       expect(textbox).toHaveValue(TOPIC_CUSTOM_PARAMS['compression.type']);
     });
 
-    it('selecting option updates triggers setExistingFields', () => {
+    it('selecting option updates triggers setExistingFields', async () => {
       setupComponent({
         field,
         isDisabled,
@@ -115,7 +121,7 @@ describe('CustomParamsField', () => {
         setExistingFields,
       });
       const listbox = screen.getByRole('listbox');
-      userEvent.selectOptions(listbox, ['compression.type']);
+      await selectOption(listbox, 'compression.type');
 
       expect(setExistingFields.mock.calls.length).toBe(1);
     });

+ 55 - 69
kafka-ui-react-app/src/components/Topics/shared/Form/CustomParams/__tests__/CustomParams.spec.tsx

@@ -1,5 +1,5 @@
 import React from 'react';
-import { screen, within } from '@testing-library/react';
+import { screen, waitFor, within } from '@testing-library/react';
 import { render } from 'lib/testHelpers';
 import CustomParams, {
   CustomParamsProps,
@@ -8,6 +8,30 @@ import { FormProvider, useForm } from 'react-hook-form';
 import userEvent from '@testing-library/user-event';
 import { TOPIC_CUSTOM_PARAMS } from 'lib/constants';
 
+const selectOption = async (listbox: HTMLElement, option: string) => {
+  await waitFor(() => userEvent.click(listbox));
+  await waitFor(() => userEvent.click(screen.getByText(option)));
+};
+
+const expectOptionIsSelected = (listbox: HTMLElement, option: string) => {
+  const selectedOption = within(listbox).getAllByRole('option');
+  expect(selectedOption.length).toEqual(1);
+  expect(selectedOption[0]).toHaveTextContent(option);
+};
+
+const expectOptionIsDisabled = async (
+  listbox: HTMLElement,
+  option: string,
+  disabled: boolean
+) => {
+  await waitFor(() => userEvent.click(listbox));
+  const selectedOption = within(listbox).getAllByText(option);
+  expect(selectedOption[1]).toHaveStyleRule(
+    'cursor',
+    disabled ? 'not-allowed' : 'pointer'
+  );
+};
+
 describe('CustomParams', () => {
   const setupComponent = (props: CustomParamsProps) => {
     const Wrapper: React.FC = ({ children }) => {
@@ -33,9 +57,9 @@ describe('CustomParams', () => {
   });
 
   describe('works with user inputs correctly', () => {
-    it('button click creates custom param fieldset', () => {
+    it('button click creates custom param fieldset', async () => {
       const addParamButton = screen.getByRole('button');
-      userEvent.click(addParamButton);
+      await waitFor(() => userEvent.click(addParamButton));
 
       const listbox = screen.getByRole('listbox');
       expect(listbox).toBeInTheDocument();
@@ -44,51 +68,39 @@ describe('CustomParams', () => {
       expect(textbox).toBeInTheDocument();
     });
 
-    it('can select option', () => {
+    it('can select option', async () => {
       const addParamButton = screen.getByRole('button');
-      userEvent.click(addParamButton);
+      await waitFor(() => 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();
+      await selectOption(listbox, 'compression.type');
+      expectOptionIsSelected(listbox, 'compression.type');
+      expectOptionIsDisabled(listbox, 'compression.type', true);
 
       const textbox = screen.getByRole('textbox');
       expect(textbox).toHaveValue(TOPIC_CUSTOM_PARAMS['compression.type']);
     });
 
-    it('when selected option changes disabled options update correctly', () => {
+    it('when selected option changes disabled options update correctly', async () => {
       const addParamButton = screen.getByRole('button');
-      userEvent.click(addParamButton);
+      await waitFor(() => userEvent.click(addParamButton));
 
       const listbox = screen.getByRole('listbox');
 
-      userEvent.selectOptions(listbox, ['compression.type']);
-
-      const option = screen.getByRole('option', {
-        name: 'compression.type',
-      });
-      expect(option).toBeDisabled();
+      await selectOption(listbox, 'compression.type');
+      expectOptionIsDisabled(listbox, 'compression.type', true);
 
-      userEvent.selectOptions(listbox, ['delete.retention.ms']);
-      const newOption = screen.getByRole('option', {
-        name: 'delete.retention.ms',
-      });
-      expect(newOption).toBeDisabled();
-
-      expect(option).toBeEnabled();
+      await selectOption(listbox, 'delete.retention.ms');
+      expectOptionIsDisabled(listbox, 'delete.retention.ms', true);
+      expectOptionIsDisabled(listbox, 'compression.type', false);
     });
 
-    it('multiple button clicks create multiple fieldsets', () => {
+    it('multiple button clicks create multiple fieldsets', async () => {
       const addParamButton = screen.getByRole('button');
-      userEvent.click(addParamButton);
-      userEvent.click(addParamButton);
-      userEvent.click(addParamButton);
+      await waitFor(() => userEvent.click(addParamButton));
+      await waitFor(() => userEvent.click(addParamButton));
+      await waitFor(() => userEvent.click(addParamButton));
 
       const listboxes = screen.getAllByRole('listbox');
       expect(listboxes.length).toBe(3);
@@ -97,7 +109,7 @@ describe('CustomParams', () => {
       expect(textboxes.length).toBe(3);
     });
 
-    it("can't select already selected option", () => {
+    it("can't select already selected option", async () => {
       const addParamButton = screen.getByRole('button');
       userEvent.click(addParamButton);
       userEvent.click(addParamButton);
@@ -105,18 +117,11 @@ describe('CustomParams', () => {
       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();
+      await selectOption(firstListbox, 'compression.type');
+      expectOptionIsDisabled(firstListbox, 'compression.type', true);
 
       const secondListbox = listboxes[1];
-      const secondListboxOption = within(secondListbox).getByRole('option', {
-        name: 'compression.type',
-      });
-      expect(secondListboxOption).toBeDisabled();
+      expectOptionIsDisabled(secondListbox, 'compression.type', true);
     });
 
     it('when fieldset with selected custom property type is deleted disabled options update correctly', async () => {
@@ -128,26 +133,16 @@ describe('CustomParams', () => {
       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();
+      await selectOption(firstListbox, 'compression.type');
+      expectOptionIsDisabled(firstListbox, 'compression.type', true);
 
       const secondListbox = listboxes[1];
-      userEvent.selectOptions(secondListbox, ['delete.retention.ms']);
-      const secondListboxOption = within(secondListbox).getByRole('option', {
-        selected: true,
-      });
-      expect(secondListboxOption).toBeDisabled();
+      await selectOption(secondListbox, 'delete.retention.ms');
+      expectOptionIsDisabled(secondListbox, 'delete.retention.ms', true);
 
       const thirdListbox = listboxes[2];
-      userEvent.selectOptions(thirdListbox, ['file.delete.delay.ms']);
-      const thirdListboxOption = within(thirdListbox).getByRole('option', {
-        selected: true,
-      });
-      expect(thirdListboxOption).toBeDisabled();
+      await selectOption(thirdListbox, 'file.delete.delay.ms');
+      expectOptionIsDisabled(thirdListbox, 'file.delete.delay.ms', true);
 
       const deleteSecondFieldsetButton = screen.getByTitle(
         'Delete customParam field 1'
@@ -155,17 +150,8 @@ describe('CustomParams', () => {
       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();
+      expectOptionIsDisabled(firstListbox, 'delete.retention.ms', false);
+      expectOptionIsDisabled(thirdListbox, 'delete.retention.ms', false);
     });
   });
 });

+ 44 - 15
kafka-ui-react-app/src/components/Topics/shared/Form/TopicForm.tsx

@@ -1,14 +1,14 @@
 import React from 'react';
-import { useFormContext } from 'react-hook-form';
+import { useFormContext, Controller } from 'react-hook-form';
 import { BYTES_IN_GB } from 'lib/constants';
 import { TopicName, TopicConfigByName } from 'redux/interfaces';
 import { ErrorMessage } from '@hookform/error-message';
-import Select from 'components/common/Select/Select';
+import Select, { SelectOption } from 'components/common/Select/Select';
 import Input from 'components/common/Input/Input';
 import { Button } from 'components/common/Button/Button';
 import { InputLabel } from 'components/common/Input/InputLabel.styled';
 import { FormError } from 'components/common/Input/Input.styled';
-import { StyledForm } from 'components/common/Form/Form.styles';
+import { StyledForm } from 'components/common/Form/Form.styled';
 
 import CustomParamsContainer from './CustomParams/CustomParamsContainer';
 import TimeToRetain from './TimeToRetain';
@@ -22,6 +22,20 @@ interface Props {
   onSubmit: (e: React.BaseSyntheticEvent) => Promise<void>;
 }
 
+const CleanupPolicyOptions: Array<SelectOption> = [
+  { value: 'delete', label: 'Delete' },
+  { value: 'compact', label: 'Compact' },
+  { value: 'compact,delete', label: 'Compact,Delete' },
+];
+
+const RetentionBytesOptions: Array<SelectOption> = [
+  { value: -1, label: 'Not Set' },
+  { value: BYTES_IN_GB, label: '1 GB' },
+  { value: BYTES_IN_GB * 10, label: '10 GB' },
+  { value: BYTES_IN_GB * 20, label: '20 GB' },
+  { value: BYTES_IN_GB * 50, label: '50 GB' },
+];
+
 const TopicForm: React.FC<Props> = ({
   topicName,
   config,
@@ -30,6 +44,7 @@ const TopicForm: React.FC<Props> = ({
   onSubmit,
 }) => {
   const {
+    control,
     formState: { errors },
   } = useFormContext();
 
@@ -99,11 +114,19 @@ const TopicForm: React.FC<Props> = ({
           </div>
           <div>
             <InputLabel>Cleanup policy</InputLabel>
-            <Select defaultValue="delete" name="cleanupPolicy" minWidth="250px">
-              <option value="delete">Delete</option>
-              <option value="compact">Compact</option>
-              <option value="compact,delete">Compact,Delete</option>
-            </Select>
+            <Controller
+              control={control}
+              name="cleanupPolicy"
+              render={({ field: { name, onChange } }) => (
+                <Select
+                  name={name}
+                  value={CleanupPolicyOptions[0].value}
+                  onChange={onChange}
+                  minWidth="250px"
+                  options={CleanupPolicyOptions}
+                />
+              )}
+            />
           </div>
         </S.Column>
 
@@ -116,13 +139,19 @@ const TopicForm: React.FC<Props> = ({
           <S.Column>
             <div>
               <InputLabel>Max size on disk in GB</InputLabel>
-              <Select defaultValue={-1} name="retentionBytes">
-                <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>
+              <Controller
+                control={control}
+                name="retentionBytes"
+                render={({ field: { name, onChange } }) => (
+                  <Select
+                    name={name}
+                    value={RetentionBytesOptions[0].value}
+                    onChange={onChange}
+                    minWidth="100%"
+                    options={RetentionBytesOptions}
+                  />
+                )}
+              />
             </div>
 
             <div>

+ 0 - 0
kafka-ui-react-app/src/components/common/Form/Form.styles.ts → kafka-ui-react-app/src/components/common/Form/Form.styled.ts


+ 2 - 2
kafka-ui-react-app/src/components/common/Select/LiveIcon.styled.tsx

@@ -5,9 +5,9 @@ interface Props {
   className?: string;
 }
 
-const LiveIcon: React.FC<Props> = ({ className }) => {
+const LiveIcon: React.FC<Props> = () => {
   return (
-    <i className={className}>
+    <i>
       <svg
         width="16"
         height="16"

+ 68 - 6
kafka-ui-react-app/src/components/common/Select/Select.styled.ts

@@ -4,23 +4,42 @@ interface Props {
   selectSize: 'M' | 'L';
   isLive?: boolean;
   minWidth?: string;
+  disabled?: boolean;
 }
 
-export const Select = styled.select<Props>`
+interface OptionProps {
+  disabled?: boolean;
+}
+
+export const Select = styled.ul<Props>`
+  position: relative;
+  list-style: none;
+  display: flex;
+  align-items: center;
   height: ${(props) => (props.selectSize === 'M' ? '32px' : '40px')};
-  border: 1px ${(props) => props.theme.selectStyles.borderColor.normal} solid;
+  border: 1px
+    ${({ theme, disabled }) =>
+      disabled
+        ? theme.selectStyles.borderColor.disabled
+        : theme.selectStyles.borderColor.normal}
+    solid;
   border-radius: 4px;
   font-size: 14px;
-  width: 100%;
+  width: fit-content;
   padding-left: ${(props) => (props.isLive ? '36px' : '12px')};
   padding-right: 16px;
-  color: ${(props) => props.theme.selectStyles.color.normal};
+  color: ${({ theme, disabled }) =>
+    disabled
+      ? theme.selectStyles.color.disabled
+      : theme.selectStyles.color.normal};
   min-width: ${({ minWidth }) => minWidth || 'auto'};
-  background-image: url('data:image/svg+xml,%3Csvg width="10" height="6" viewBox="0 0 10 6" fill="none" xmlns="http://www.w3.org/2000/svg"%3E%3Cpath d="M1 1L5 5L9 1" stroke="%23454F54"/%3E%3C/svg%3E%0A') !important;
+  background-image: ${({ disabled }) =>
+    `url('data:image/svg+xml,%3Csvg width="10" height="6" viewBox="0 0 10 6" fill="none" xmlns="http://www.w3.org/2000/svg"%3E%3Cpath d="M1 1L5 5L9 1" stroke="${
+      disabled ? '%23ABB5BA' : '%23454F54'
+    }"/%3E%3C/svg%3E%0A') !important`};
   background-repeat: no-repeat !important;
   background-position-x: calc(100% - 8px) !important;
   background-position-y: 55% !important;
-  appearance: none !important;
 
   &:hover {
     color: ${(props) => props.theme.selectStyles.color.hover};
@@ -37,3 +56,46 @@ export const Select = styled.select<Props>`
     cursor: not-allowed;
   }
 `;
+
+export const OptionList = styled.ul`
+  position: absolute;
+  top: 100%;
+  left: 0;
+  max-height: 114px;
+  margin-top: 4px;
+  background-color: ${(props) =>
+    props.theme.selectStyles.backgroundColor.normal};
+  border: 1px ${(props) => props.theme.selectStyles.borderColor.normal} solid;
+  border-radius: 4px;
+  font-size: 14px;
+  line-height: 18px;
+  width: 100%;
+  color: ${(props) => props.theme.selectStyles.color.normal};
+  overflow-y: scroll;
+  z-index: 10;
+`;
+
+export const Option = styled.li<OptionProps>`
+  list-style: none;
+  padding: 10px 12px;
+  transition: all 0.2s ease-in-out;
+  cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')};
+
+  &:hover {
+    background-color: ${(props) =>
+      props.theme.selectStyles.backgroundColor.hover};
+  }
+
+  &:active {
+    background-color: ${(props) =>
+      props.theme.selectStyles.backgroundColor.active};
+  }
+`;
+
+export const SelectedOption = styled.li`
+  padding-right: 16px;
+  list-style-position: inside;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+`;

+ 76 - 36
kafka-ui-react-app/src/components/common/Select/Select.tsx

@@ -1,56 +1,96 @@
-import styled from 'styled-components';
-import React from 'react';
-import { RegisterOptions, useFormContext } from 'react-hook-form';
+import React, { useState, useRef } from 'react';
+import useClickOutside from 'lib/hooks/useClickOutside';
 
-import LiveIcon from './LiveIcon.styled';
 import * as S from './Select.styled';
+import LiveIcon from './LiveIcon.styled';
 
-export interface SelectProps
-  extends React.SelectHTMLAttributes<HTMLSelectElement> {
+export interface SelectProps {
+  options?: Array<SelectOption>;
+  id?: string;
   name?: string;
   selectSize?: 'M' | 'L';
   isLive?: boolean;
-  hookFormOptions?: RegisterOptions;
   minWidth?: string;
+  value?: string | number;
+  defaultValue?: string | number;
+  placeholder?: string;
+  disabled?: boolean;
+  onChange?: (option: string | number) => void;
+}
+
+export interface SelectOption {
+  label: string | number;
+  value: string | number;
+  disabled?: boolean;
 }
 
 const Select: React.FC<SelectProps> = ({
-  className,
-  children,
+  id,
+  options = [],
+  value,
+  defaultValue,
   selectSize = 'L',
+  placeholder = '',
   isLive,
-  name,
-  hookFormOptions,
+  disabled = false,
+  onChange,
   ...props
 }) => {
-  const methods = useFormContext();
+  const [selectedOption, setSelectedOption] = useState(value);
+  const [showOptions, setShowOptions] = useState(false);
+
+  const showOptionsHandler = () => {
+    if (!disabled) setShowOptions(!showOptions);
+  };
+
+  const selectContainerRef = useRef(null);
+  const clickOutsideHandler = () => setShowOptions(false);
+  useClickOutside(selectContainerRef, clickOutsideHandler);
+
+  const updateSelectedOption = (option: SelectOption) => {
+    if (disabled) return;
+
+    setSelectedOption(option.value);
+    if (onChange) onChange(option.value);
+    setShowOptions(false);
+  };
+
   return (
-    <div className={`select-wrapper ${className}`}>
+    <div ref={selectContainerRef}>
       {isLive && <LiveIcon />}
-      {name ? (
-        <S.Select
-          role="listbox"
-          selectSize={selectSize}
-          isLive={isLive}
-          {...methods.register(name, { ...hookFormOptions })}
-          {...props}
-        >
-          {children}
-        </S.Select>
-      ) : (
-        <S.Select
-          role="listbox"
-          selectSize={selectSize}
-          isLive={isLive}
-          {...props}
-        >
-          {children}
-        </S.Select>
-      )}
+      <S.Select
+        role="listbox"
+        selectSize={selectSize}
+        isLive={isLive}
+        disabled={disabled}
+        onClick={showOptionsHandler}
+        onKeyDown={showOptionsHandler}
+        {...props}
+      >
+        <S.SelectedOption role="option" tabIndex={0}>
+          {options.find(
+            (option) => option.value === (defaultValue || selectedOption)
+          )?.label || placeholder}
+        </S.SelectedOption>
+        {showOptions && (
+          <S.OptionList>
+            {options?.map((option) => (
+              <S.Option
+                value={option.value}
+                key={option.value}
+                disabled={option.disabled}
+                onClick={() => updateSelectedOption(option)}
+                tabIndex={0}
+                role="option"
+              >
+                {option.label}
+              </S.Option>
+            ))}
+          </S.OptionList>
+        )}
+      </S.Select>
     </div>
   );
 };
 
-export default styled(Select)`
-  position: relative;
-`;
+export default Select;

+ 69 - 45
kafka-ui-react-app/src/components/common/Select/__tests__/__snapshots__/Select.spec.tsx.snap

@@ -2,22 +2,24 @@
 
 exports[`Custom Select when live matches the snapshot 1`] = `
 <body>
-  .c1 {
-  position: absolute;
-  left: 12px;
-  top: 50%;
-  -webkit-transform: translateY(-50%);
-  -ms-transform: translateY(-50%);
-  transform: translateY(-50%);
-  line-height: 0;
-}
-
-.c2 {
+  .c0 {
+  position: relative;
+  list-style: none;
+  display: -webkit-box;
+  display: -webkit-flex;
+  display: -ms-flexbox;
+  display: flex;
+  -webkit-align-items: center;
+  -webkit-box-align: center;
+  -ms-flex-align: center;
+  align-items: center;
   height: 40px;
   border: 1px #ABB5BA solid;
   border-radius: 4px;
   font-size: 14px;
-  width: 100%;
+  width: -webkit-fit-content;
+  width: -moz-fit-content;
+  width: fit-content;
   padding-left: 36px;
   padding-right: 16px;
   color: #171A1C;
@@ -26,39 +28,36 @@ exports[`Custom Select when live matches the snapshot 1`] = `
   background-repeat: no-repeat !important;
   background-position-x: calc(100% - 8px) !important;
   background-position-y: 55% !important;
-  -webkit-appearance: none !important;
-  -moz-appearance: none !important;
-  appearance: none !important;
 }
 
-.c2:hover {
+.c0:hover {
   color: #171A1C;
   border-color: #73848C;
 }
 
-.c2:focus {
+.c0:focus {
   outline: none;
   color: #171A1C;
   border-color: #454F54;
 }
 
-.c2:disabled {
+.c0:disabled {
   color: #ABB5BA;
   border-color: #E3E6E8;
   cursor: not-allowed;
 }
 
-.c0 {
-  position: relative;
+.c1 {
+  padding-right: 16px;
+  list-style-position: inside;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
 }
 
 <div>
-    <div
-      class="select-wrapper c0"
-    >
-      <i
-        class="c1"
-      >
+    <div>
+      <i>
         <svg
           fill="none"
           height="16"
@@ -80,22 +79,41 @@ exports[`Custom Select when live matches the snapshot 1`] = `
           />
         </svg>
       </i>
-      <select
-        class="c2"
+      <ul
+        class="c0"
+        name="test"
         role="listbox"
-      />
+      >
+        <li
+          class="c1"
+          role="option"
+          tabindex="0"
+        />
+      </ul>
     </div>
   </div>
 </body>
 `;
 
 exports[`Custom Select when non-live matches the snapshot 1`] = `
-.c1 {
+.c0 {
+  position: relative;
+  list-style: none;
+  display: -webkit-box;
+  display: -webkit-flex;
+  display: -ms-flexbox;
+  display: flex;
+  -webkit-align-items: center;
+  -webkit-box-align: center;
+  -ms-flex-align: center;
+  align-items: center;
   height: 40px;
   border: 1px #ABB5BA solid;
   border-radius: 4px;
   font-size: 14px;
-  width: 100%;
+  width: -webkit-fit-content;
+  width: -moz-fit-content;
+  width: fit-content;
   padding-left: 12px;
   padding-right: 16px;
   color: #171A1C;
@@ -104,41 +122,47 @@ exports[`Custom Select when non-live matches the snapshot 1`] = `
   background-repeat: no-repeat !important;
   background-position-x: calc(100% - 8px) !important;
   background-position-y: 55% !important;
-  -webkit-appearance: none !important;
-  -moz-appearance: none !important;
-  appearance: none !important;
 }
 
-.c1:hover {
+.c0:hover {
   color: #171A1C;
   border-color: #73848C;
 }
 
-.c1:focus {
+.c0:focus {
   outline: none;
   color: #171A1C;
   border-color: #454F54;
 }
 
-.c1:disabled {
+.c0:disabled {
   color: #ABB5BA;
   border-color: #E3E6E8;
   cursor: not-allowed;
 }
 
-.c0 {
-  position: relative;
+.c1 {
+  padding-right: 16px;
+  list-style-position: inside;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
 }
 
 <body>
   <div>
-    <div
-      class="select-wrapper c0"
-    >
-      <select
-        class="c1"
+    <div>
+      <ul
+        class="c0"
+        name="test"
         role="listbox"
-      />
+      >
+        <li
+          class="c1"
+          role="option"
+          tabindex="0"
+        />
+      </ul>
     </div>
   </div>
 </body>

+ 29 - 0
kafka-ui-react-app/src/lib/hooks/useClickOutside.ts

@@ -0,0 +1,29 @@
+import { RefObject, useEffect } from 'react';
+
+type Event = MouseEvent | TouchEvent;
+
+const useClickOutside = <T extends HTMLElement = HTMLElement>(
+  ref: RefObject<T>,
+  handler: (event: Event) => void
+) => {
+  useEffect(() => {
+    const listener = (event: Event) => {
+      const el = ref?.current;
+      if (!el || el.contains((event?.target as Node) || null)) {
+        return;
+      }
+
+      handler(event);
+    };
+
+    document.addEventListener('mousedown', listener);
+    document.addEventListener('touchstart', listener);
+
+    return () => {
+      document.removeEventListener('mousedown', listener);
+      document.removeEventListener('touchstart', listener);
+    };
+  }, [ref, handler]);
+};
+
+export default useClickOutside;

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

@@ -165,6 +165,11 @@ const theme = {
     },
   },
   selectStyles: {
+    backgroundColor: {
+      normal: Colors.neutral[0],
+      hover: Colors.neutral[10],
+      active: Colors.neutral[10],
+    },
     color: {
       normal: Colors.neutral[90],
       hover: Colors.neutral[90],