[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
This commit is contained in:
Damir Abdulganiev 2022-02-01 17:08:46 +03:00 committed by GitHub
parent 4390923e48
commit 982d29709b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 276 additions and 118 deletions

View file

@ -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);

View file

@ -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();
});
});

View file

@ -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,
},
],
};

View file

@ -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);
});
});
});

View file

@ -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

View file

@ -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

View file

@ -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>
<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">

View file

@ -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);
});
});

View file

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

View file

@ -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;