Explorar o código

[Fixes #1510] UI, Critical: retention.bytes parameter set to 0 (#1514)

* Changes Default value of rentention.bytes

* Adds test for TopicForm
* Removes bug from Topic/New test
* Updates Topic/New test
* Adds accessible names for TopicForm
* Add id's to Select component
Damir Abdulganiev %!s(int64=3) %!d(string=hai) anos
pai
achega
982d29709b

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

@@ -35,7 +35,6 @@ const New: React.FC = () => {
         clusterName,
         topicCreation: formatTopicCreation(data),
       });
-
       history.push(clusterTopicPath(clusterName, data.name));
     } catch (error) {
       const response = await getResponse(error as Response);

+ 90 - 0
kafka-ui-react-app/src/components/Topics/New/__test__/New.spec.tsx

@@ -0,0 +1,90 @@
+import React from 'react';
+import New from 'components/Topics/New/New';
+import { Route, Router } from 'react-router';
+import configureStore from 'redux-mock-store';
+import { RootState } from 'redux/interfaces';
+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 userEvent from '@testing-library/user-event';
+import { render } from 'lib/testHelpers';
+
+import { createTopicPayload, createTopicResponsePayload } from './fixtures';
+
+const mockStore = configureStore();
+
+const clusterName = 'local';
+const topicName = 'test-topic';
+
+const initialState: Partial<RootState> = {};
+const storeMock = mockStore(initialState);
+const historyMock = createMemoryHistory();
+const createTopicAPIPath = `/api/clusters/${clusterName}/topics`;
+
+const renderComponent = (history = historyMock, store = storeMock) =>
+  render(
+    <Router history={history}>
+      <Route path={clusterTopicNewPath(':clusterName')}>
+        <Provider store={store}>
+          <New />
+        </Provider>
+      </Route>
+      <Route path={clusterTopicPath(':clusterName', ':topicName')}>
+        New topic path
+      </Route>
+    </Router>
+  );
+
+describe('New', () => {
+  beforeEach(() => {
+    fetchMock.reset();
+  });
+
+  it('validates form', async () => {
+    const mockedHistory = createMemoryHistory({
+      initialEntries: [clusterTopicNewPath(clusterName)],
+    });
+    jest.spyOn(mockedHistory, 'push');
+    renderComponent(mockedHistory);
+
+    await waitFor(() => {
+      userEvent.click(screen.getByText('Send'));
+    });
+    await waitFor(() => {
+      expect(screen.getByText('name is a required field')).toBeInTheDocument();
+    });
+    await waitFor(() => {
+      expect(mockedHistory.push).toBeCalledTimes(0);
+    });
+  });
+
+  it('submits valid form', async () => {
+    const createTopicAPIPathMock = fetchMock.postOnce(
+      createTopicAPIPath,
+      createTopicResponsePayload,
+      {
+        body: createTopicPayload,
+      }
+    );
+    const mockedHistory = createMemoryHistory({
+      initialEntries: [clusterTopicNewPath(clusterName)],
+    });
+    jest.spyOn(mockedHistory, 'push');
+    renderComponent(mockedHistory);
+
+    await waitFor(() => {
+      userEvent.type(screen.getByPlaceholderText('Topic Name'), topicName);
+      userEvent.click(screen.getByText('Send'));
+    });
+
+    await waitFor(() =>
+      expect(mockedHistory.location.pathname).toBe(
+        clusterTopicPath(clusterName, topicName)
+      )
+    );
+    expect(mockedHistory.push).toBeCalledTimes(1);
+    expect(createTopicAPIPathMock.called()).toBeTruthy();
+  });
+});

+ 36 - 0
kafka-ui-react-app/src/components/Topics/New/__test__/fixtures.ts

@@ -0,0 +1,36 @@
+import { CleanUpPolicy, Topic } from 'generated-sources';
+
+export const createTopicPayload: Record<string, unknown> = {
+  name: 'test-topic',
+  partitions: 1,
+  replicationFactor: 1,
+  configs: {
+    'cleanup.policy': 'delete',
+    'retention.ms': '604800000',
+    'retention.bytes': '-1',
+    'max.message.bytes': '1000012',
+    'min.insync.replicas': '1',
+  },
+};
+
+export const createTopicResponsePayload: Topic = {
+  name: 'local',
+  internal: false,
+  partitionCount: 1,
+  replicationFactor: 1,
+  replicas: 1,
+  inSyncReplicas: 1,
+  segmentSize: 0,
+  segmentCount: 0,
+  underReplicatedPartitions: 0,
+  cleanUpPolicy: CleanUpPolicy.DELETE,
+  partitions: [
+    {
+      partition: 0,
+      leader: 1,
+      replicas: [{ broker: 1, leader: false, inSync: true }],
+      offsetMax: 0,
+      offsetMin: 0,
+    },
+  ],
+};

+ 0 - 67
kafka-ui-react-app/src/components/Topics/New/__tests__/New.spec.tsx

@@ -1,67 +0,0 @@
-import React from 'react';
-import New from 'components/Topics/New/New';
-import { Router } from 'react-router';
-import configureStore from 'redux-mock-store';
-import { RootState } from 'redux/interfaces';
-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 userEvent from '@testing-library/user-event';
-import { render } from 'lib/testHelpers';
-
-const mockStore = configureStore();
-
-describe('New', () => {
-  const clusterName = 'local';
-  const topicName = 'test-topic';
-
-  const initialState: Partial<RootState> = {};
-  const storeMock = mockStore(initialState);
-  const historyMock = createMemoryHistory();
-
-  beforeEach(() => {
-    fetchMock.restore();
-  });
-
-  const setupComponent = (history = historyMock, store = storeMock) => (
-    <Router history={history}>
-      <Provider store={store}>
-        <New />
-      </Provider>
-    </Router>
-  );
-
-  it('validates form', async () => {
-    const mockedHistory = createMemoryHistory();
-    jest.spyOn(mockedHistory, 'push');
-    render(setupComponent(mockedHistory));
-    userEvent.click(screen.getByText('Send'));
-
-    await waitFor(() => {
-      expect(screen.getByText('name is a required field')).toBeInTheDocument();
-    });
-    await waitFor(() => {
-      expect(mockedHistory.push).toBeCalledTimes(0);
-    });
-  });
-
-  it('submits valid form', () => {
-    const mockedHistory = createMemoryHistory({
-      initialEntries: [clusterTopicNewPath(clusterName)],
-    });
-    jest.spyOn(mockedHistory, 'push');
-    render(setupComponent());
-    userEvent.type(screen.getByPlaceholderText('Topic Name'), topicName);
-    userEvent.click(screen.getByText('Send'));
-    waitFor(() => {
-      expect(mockedHistory.location.pathname).toBe(
-        clusterTopicPath(clusterName, topicName)
-      );
-    });
-    waitFor(() => {
-      expect(mockedHistory.push).toBeCalledTimes(1);
-    });
-  });
-});

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

@@ -479,6 +479,7 @@ exports[`Filters component matches the snapshot 1`] = `
             <div>
               <ul
                 class="c6"
+                id="selectSeekType"
                 role="listbox"
               >
                 <li
@@ -1122,6 +1123,7 @@ exports[`Filters component when fetching matches the snapshot 1`] = `
             <div>
               <ul
                 class="c6"
+                id="selectSeekType"
                 role="listbox"
               >
                 <li

+ 3 - 1
kafka-ui-react-app/src/components/Topics/shared/Form/TimeToRetain.tsx

@@ -31,7 +31,9 @@ const TimeToRetain: React.FC<Props> = ({ isSubmitting }) => {
   return (
     <>
       <S.Label>
-        <InputLabel>Time to retain data (in ms)</InputLabel>
+        <InputLabel htmlFor="timeToRetain">
+          Time to retain data (in ms)
+        </InputLabel>
         {valueHint && <span>{valueHint}</span>}
       </S.Label>
       <Input

+ 72 - 48
kafka-ui-react-app/src/components/Topics/shared/Form/TopicForm.tsx

@@ -1,6 +1,6 @@
 import React from 'react';
 import { useFormContext, Controller } from 'react-hook-form';
-import { BYTES_IN_GB } from 'lib/constants';
+import { NOT_SET, BYTES_IN_GB } from 'lib/constants';
 import { TopicName, TopicConfigByName } from 'redux/interfaces';
 import { ErrorMessage } from '@hookform/error-message';
 import Select, { SelectOption } from 'components/common/Select/Select';
@@ -14,7 +14,7 @@ import CustomParamsContainer from './CustomParams/CustomParamsContainer';
 import TimeToRetain from './TimeToRetain';
 import * as S from './TopicForm.styled';
 
-interface Props {
+export interface Props {
   topicName?: TopicName;
   config?: TopicConfigByName;
   isEditing?: boolean;
@@ -29,7 +29,7 @@ const CleanupPolicyOptions: Array<SelectOption> = [
 ];
 
 const RetentionBytesOptions: Array<SelectOption> = [
-  { value: -1, label: 'Not Set' },
+  { value: NOT_SET, 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' },
@@ -47,15 +47,15 @@ const TopicForm: React.FC<Props> = ({
     control,
     formState: { errors },
   } = useFormContext();
-
   return (
     <StyledForm onSubmit={onSubmit}>
       <fieldset disabled={isSubmitting}>
         <fieldset disabled={isEditing}>
           <S.Column>
             <S.NameField>
-              <InputLabel>Topic Name *</InputLabel>
+              <InputLabel htmlFor="topicFormName">Topic Name *</InputLabel>
               <Input
+                id="topicFormName"
                 name="name"
                 placeholder="Topic Name"
                 defaultValue={topicName}
@@ -69,8 +69,11 @@ const TopicForm: React.FC<Props> = ({
           {!isEditing && (
             <S.Column>
               <div>
-                <InputLabel>Number of partitions *</InputLabel>
+                <InputLabel htmlFor="topicFormNumberOfPartitions">
+                  Number of partitions *
+                </InputLabel>
                 <Input
+                  id="topicFormNumberOfPartitions"
                   type="number"
                   placeholder="Number of partitions"
                   min="1"
@@ -82,8 +85,11 @@ const TopicForm: React.FC<Props> = ({
                 </FormError>
               </div>
               <div>
-                <InputLabel>Replication Factor *</InputLabel>
+                <InputLabel htmlFor="topicFormReplicationFactor">
+                  Replication Factor *
+                </InputLabel>
                 <Input
+                  id="topicFormReplicationFactor"
                   type="number"
                   placeholder="Replication Factor"
                   min="1"
@@ -100,8 +106,11 @@ const TopicForm: React.FC<Props> = ({
 
         <S.Column>
           <div>
-            <InputLabel>Min In Sync Replicas *</InputLabel>
+            <InputLabel htmlFor="topicFormMinInSyncReplicas">
+              Min In Sync Replicas *
+            </InputLabel>
             <Input
+              id="topicFormMinInSyncReplicas"
               type="number"
               placeholder="Min In Sync Replicas"
               min="1"
@@ -113,13 +122,20 @@ const TopicForm: React.FC<Props> = ({
             </FormError>
           </div>
           <div>
-            <InputLabel>Cleanup policy</InputLabel>
+            <InputLabel
+              id="topicFormCleanupPolicyLabel"
+              htmlFor="topicFormCleanupPolicy"
+            >
+              Cleanup policy
+            </InputLabel>
             <Controller
               defaultValue={CleanupPolicyOptions[0].value}
               control={control}
               name="cleanupPolicy"
               render={({ field: { name, onChange } }) => (
                 <Select
+                  id="topicFormCleanupPolicy"
+                  aria-labelledby="topicFormCleanupPolicyLabel"
                   name={name}
                   value={CleanupPolicyOptions[0].value}
                   onChange={onChange}
@@ -131,48 +147,56 @@ const TopicForm: React.FC<Props> = ({
           </div>
         </S.Column>
 
-        <div>
-          <S.Column>
-            <div>
-              <TimeToRetain isSubmitting={isSubmitting} />
-            </div>
-          </S.Column>
-          <S.Column>
-            <div>
-              <InputLabel>Max size on disk in GB</InputLabel>
-              <Controller
-                control={control}
-                name="retentionBytes"
-                defaultValue={0}
-                render={({ field: { name, onChange } }) => (
-                  <Select
-                    name={name}
-                    value={RetentionBytesOptions[0].value}
-                    onChange={onChange}
-                    minWidth="100%"
-                    options={RetentionBytesOptions}
-                  />
-                )}
-              />
-            </div>
+        <S.Column>
+          <div>
+            <TimeToRetain isSubmitting={isSubmitting} />
+          </div>
+        </S.Column>
 
-            <div>
-              <InputLabel>Maximum message size in bytes *</InputLabel>
-              <Input
-                type="number"
-                min="1"
-                defaultValue="1000012"
-                name="maxMessageBytes"
-              />
-              <FormError>
-                <ErrorMessage errors={errors} name="maxMessageBytes" />
-              </FormError>
-            </div>
-          </S.Column>
-        </div>
+        <S.Column>
+          <div>
+            <InputLabel
+              id="topicFormRetentionBytesLabel"
+              htmlFor="topicFormRetentionBytes"
+            >
+              Max size on disk in GB
+            </InputLabel>
+            <Controller
+              control={control}
+              name="retentionBytes"
+              defaultValue={RetentionBytesOptions[0].value}
+              render={({ field: { name, onChange } }) => (
+                <Select
+                  id="topicFormRetentionBytes"
+                  aria-labelledby="topicFormRetentionBytesLabel"
+                  name={name}
+                  value={RetentionBytesOptions[0].value}
+                  onChange={onChange}
+                  minWidth="100%"
+                  options={RetentionBytesOptions}
+                />
+              )}
+            />
+          </div>
 
-        <S.CustomParamsHeading>Custom parameters</S.CustomParamsHeading>
+          <div>
+            <InputLabel htmlFor="topicFormMaxMessageBytes">
+              Maximum message size in bytes *
+            </InputLabel>
+            <Input
+              id="topicFormMaxMessageBytes"
+              type="number"
+              min="1"
+              defaultValue="1000012"
+              name="maxMessageBytes"
+            />
+            <FormError>
+              <ErrorMessage errors={errors} name="maxMessageBytes" />
+            </FormError>
+          </div>
+        </S.Column>
 
+        <S.CustomParamsHeading>Custom parameters</S.CustomParamsHeading>
         <CustomParamsContainer isSubmitting={isSubmitting} config={config} />
 
         <Button type="submit" buttonType="primary" buttonSize="L">

+ 72 - 0
kafka-ui-react-app/src/components/Topics/shared/Form/__tests__/TopicForm.spec.tsx

@@ -0,0 +1,72 @@
+import React from 'react';
+import { render } from 'lib/testHelpers';
+import { screen } from '@testing-library/dom';
+import { FormProvider, useForm } from 'react-hook-form';
+import TopicForm, { Props } from 'components/Topics/shared/Form/TopicForm';
+import userEvent from '@testing-library/user-event';
+
+const isSubmitting = false;
+const onSubmit = jest.fn();
+
+const renderComponent = (props: Props = { isSubmitting, onSubmit }) => {
+  const Wrapper: React.FC = ({ children }) => {
+    const methods = useForm();
+    return <FormProvider {...methods}>{children}</FormProvider>;
+  };
+
+  return render(
+    <Wrapper>
+      <TopicForm {...props} />
+    </Wrapper>
+  );
+};
+
+const expectByRoleAndNameToBeInDocument = (
+  role: string,
+  accessibleName: string
+) => {
+  expect(screen.getByRole(role, { name: accessibleName })).toBeInTheDocument();
+};
+
+describe('TopicForm', () => {
+  it('renders', () => {
+    renderComponent();
+
+    expectByRoleAndNameToBeInDocument('textbox', 'Topic Name *');
+
+    expectByRoleAndNameToBeInDocument('spinbutton', 'Number of partitions *');
+    expectByRoleAndNameToBeInDocument('spinbutton', 'Replication Factor *');
+
+    expectByRoleAndNameToBeInDocument('spinbutton', 'Min In Sync Replicas *');
+    expectByRoleAndNameToBeInDocument('listbox', 'Cleanup policy');
+
+    expectByRoleAndNameToBeInDocument(
+      'spinbutton',
+      'Time to retain data (in ms)'
+    );
+    expectByRoleAndNameToBeInDocument('button', '12h');
+    expectByRoleAndNameToBeInDocument('button', '2d');
+    expectByRoleAndNameToBeInDocument('button', '7d');
+    expectByRoleAndNameToBeInDocument('button', '4w');
+
+    expectByRoleAndNameToBeInDocument('listbox', 'Max size on disk in GB');
+    expectByRoleAndNameToBeInDocument(
+      'spinbutton',
+      'Maximum message size in bytes *'
+    );
+
+    expectByRoleAndNameToBeInDocument('heading', 'Custom parameters');
+
+    expectByRoleAndNameToBeInDocument('button', 'Send');
+  });
+
+  it('submits', () => {
+    renderComponent({
+      isSubmitting,
+      onSubmit: onSubmit.mockImplementation((e) => e.preventDefault()),
+    });
+
+    userEvent.click(screen.getByRole('button', { name: 'Send' }));
+    expect(onSubmit).toBeCalledTimes(1);
+  });
+});

+ 0 - 1
kafka-ui-react-app/src/components/common/Select/Select.tsx

@@ -25,7 +25,6 @@ export interface SelectOption {
 }
 
 const Select: React.FC<SelectProps> = ({
-  id,
   options = [],
   value,
   defaultValue,

+ 1 - 0
kafka-ui-react-app/src/lib/constants.ts

@@ -46,6 +46,7 @@ export const MILLISECONDS_IN_WEEK = 604_800_000;
 export const MILLISECONDS_IN_DAY = 86_400_000;
 export const MILLISECONDS_IN_SECOND = 1_000;
 
+export const NOT_SET = -1;
 export const BYTES_IN_GB = 1_073_741_824;
 
 export const PER_PAGE = 25;