Added key-value form for stream parameters (#2191)

* Added key-value form for stream parameters

* Removed unused variable

* fixing some test cases and fixing width of stream props

* adding key value validation and tests

* fixing placeholder padding and font size

* remove unnecessary code

Co-authored-by: rAzizbekyan <razizbekyan@provectus.com>
Co-authored-by: Robert Azizbekyan <103438454+rAzizbekyan@users.noreply.github.com>
This commit is contained in:
Kirill Morozov 2022-07-12 11:40:52 +03:00 committed by GitHub
parent cbd4e4a52a
commit 0b76b12518
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 172 additions and 236 deletions

View file

@ -198,15 +198,23 @@ const Query: FC = () => {
const submitHandler = useCallback( const submitHandler = useCallback(
(values: FormValues) => { (values: FormValues) => {
const streamsProperties = values.streamsProperties.reduce(
(acc, current) => ({
...acc,
[current.key as keyof string]: current.value,
}),
{} as { [key: string]: string }
);
setFetching(true); setFetching(true);
dispatch( dispatch(
executeKsql({ executeKsql({
clusterName, clusterName,
ksqlCommandV2: { ksqlCommandV2: {
...values, ...values,
streamsProperties: values.streamsProperties streamsProperties:
? JSON.parse(values.streamsProperties) values.streamsProperties[0].key !== ''
: undefined, ? JSON.parse(JSON.stringify(streamsProperties))
: undefined,
}, },
}) })
); );

View file

@ -27,8 +27,47 @@ export const KSQLButtons = styled.div`
gap: 16px; gap: 16px;
`; `;
export const StreamPropertiesContainer = styled.label`
display: flex;
flex-direction: column;
gap: 10px;
width: 50%;
`;
export const InputsContainer = styled.div`
display: flex;
justify-content: center;
gap: 10px;
`;
export const StreamPropertiesInputWrapper = styled.div`
& > input {
height: 40px;
border: 1px solid grey;
border-radius: 4px;
min-width: 300px;
font-size: 16px;
padding-left: 15px;
}
`;
export const DeleteButtonWrapper = styled.div`
min-height: 32px;
display: flex;
flex-direction: column;
align-items: center;
justify-self: flex-start;
margin-top: 10px;
`;
export const LabelContainer = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
`;
export const Fieldset = styled.fieldset` export const Fieldset = styled.fieldset`
width: 100%; width: 50%;
`; `;
export const Editor = styled(BaseEditor)( export const Editor = styled(BaseEditor)(

View file

@ -1,11 +1,12 @@
import React from 'react'; import React from 'react';
import { FormError } from 'components/common/Input/Input.styled'; import { FormError } from 'components/common/Input/Input.styled';
import { ErrorMessage } from '@hookform/error-message'; import { ErrorMessage } from '@hookform/error-message';
import { useForm, Controller, useFieldArray } from 'react-hook-form';
import { Button } from 'components/common/Button/Button';
import IconButtonWrapper from 'components/common/Icons/IconButtonWrapper';
import CloseIcon from 'components/common/Icons/CloseIcon';
import { yupResolver } from '@hookform/resolvers/yup'; import { yupResolver } from '@hookform/resolvers/yup';
import yup from 'lib/yupExtended'; import yup from 'lib/yupExtended';
import { useForm, Controller } from 'react-hook-form';
import { Button } from 'components/common/Button/Button';
import { SchemaType } from 'generated-sources';
import * as S from './QueryForm.styled'; import * as S from './QueryForm.styled';
@ -17,16 +18,22 @@ export interface Props {
submitHandler: (values: FormValues) => void; submitHandler: (values: FormValues) => void;
} }
export type StreamsPropertiesType = {
key: string;
value: string;
};
export type FormValues = { export type FormValues = {
ksql: string; ksql: string;
streamsProperties: string; streamsProperties: StreamsPropertiesType[];
}; };
const streamsPropertiesSchema = yup.object().shape({
key: yup.string().trim(),
value: yup.string().trim(),
});
const validationSchema = yup.object({ const validationSchema = yup.object({
ksql: yup.string().trim().required(), ksql: yup.string().trim().required(),
streamsProperties: yup.lazy((value) => streamsProperties: yup.array().of(streamsPropertiesSchema),
value === '' ? yup.string().trim() : yup.string().trim().isJsonObject()
),
}); });
const QueryForm: React.FC<Props> = ({ const QueryForm: React.FC<Props> = ({
@ -46,9 +53,16 @@ const QueryForm: React.FC<Props> = ({
resolver: yupResolver(validationSchema), resolver: yupResolver(validationSchema),
defaultValues: { defaultValues: {
ksql: '', ksql: '',
streamsProperties: '', streamsProperties: [{ key: '', value: '' }],
}, },
}); });
const { fields, append, remove } = useFieldArray<
FormValues,
'streamsProperties'
>({
control,
name: 'streamsProperties',
});
return ( return (
<S.QueryWrapper> <S.QueryWrapper>
@ -93,48 +107,69 @@ const QueryForm: React.FC<Props> = ({
<ErrorMessage errors={errors} name="ksql" /> <ErrorMessage errors={errors} name="ksql" />
</FormError> </FormError>
</S.Fieldset> </S.Fieldset>
<S.Fieldset aria-labelledby="streamsPropertiesLabel">
<S.KSQLInputHeader> <S.StreamPropertiesContainer>
<label id="streamsPropertiesLabel"> Stream properties:
Stream properties (JSON format) {fields.map((item, index) => (
</label> <S.InputsContainer key={item.id}>
<Button <S.StreamPropertiesInputWrapper>
onClick={() => setValue('streamsProperties', '')} <Controller
buttonType="primary" control={control}
buttonSize="S" name={`streamsProperties.${index}.key`}
isInverted render={({ field }) => (
> <input
Clear {...field}
</Button> placeholder="Key"
</S.KSQLInputHeader> aria-label="key"
<Controller type="text"
control={control} />
name="streamsProperties" )}
render={({ field }) => ( />
<S.Editor <FormError>
{...field} <ErrorMessage
commands={[ errors={errors}
{ name={`streamsProperties.${index}.key`}
// commands is array of key bindings. />
// name for the key binding. </FormError>
name: 'commandName', </S.StreamPropertiesInputWrapper>
// key combination used for the command. <S.StreamPropertiesInputWrapper>
bindKey: { win: 'Ctrl-Enter', mac: 'Command-Enter' }, <Controller
// function to execute when keys are pressed. control={control}
exec: () => { name={`streamsProperties.${index}.value`}
handleSubmit(submitHandler)(); render={({ field }) => (
}, <input
}, {...field}
]} placeholder="Value"
schemaType={SchemaType.JSON} aria-label="value"
readOnly={fetching} type="text"
/> />
)} )}
/> />
<FormError> <FormError>
<ErrorMessage errors={errors} name="streamsProperties" /> <ErrorMessage
</FormError> errors={errors}
</S.Fieldset> name={`streamsProperties.${index}.value`}
/>
</FormError>
</S.StreamPropertiesInputWrapper>
<S.DeleteButtonWrapper onClick={() => remove(index)}>
<IconButtonWrapper aria-label="deleteProperty">
<CloseIcon aria-hidden />
</IconButtonWrapper>
</S.DeleteButtonWrapper>
</S.InputsContainer>
))}
<Button
type="button"
buttonSize="M"
buttonType="secondary"
onClick={() => append({ key: '', value: '' })}
>
<i className="fas fa-plus" />
Add Stream Property
</Button>
</S.StreamPropertiesContainer>
</S.KSQLInputsWrapper> </S.KSQLInputsWrapper>
<S.KSQLButtons> <S.KSQLButtons>
<Button <Button

View file

@ -1,7 +1,7 @@
import { render } from 'lib/testHelpers'; import { render } from 'lib/testHelpers';
import React from 'react'; import React from 'react';
import QueryForm, { Props } from 'components/KsqlDb/Query/QueryForm/QueryForm'; import QueryForm, { Props } from 'components/KsqlDb/Query/QueryForm/QueryForm';
import { screen, within } from '@testing-library/dom'; import { screen, waitFor, within } from '@testing-library/dom';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { act } from '@testing-library/react'; import { act } from '@testing-library/react';
@ -26,20 +26,11 @@ describe('QueryForm', () => {
// Represents SQL editor // Represents SQL editor
expect(within(KSQLBlock).getByRole('textbox')).toBeInTheDocument(); expect(within(KSQLBlock).getByRole('textbox')).toBeInTheDocument();
const streamPropertiesBlock = screen.getByLabelText( const streamPropertiesBlock = screen.getByRole('textbox', { name: 'key' });
'Stream properties (JSON format)'
);
expect(streamPropertiesBlock).toBeInTheDocument(); expect(streamPropertiesBlock).toBeInTheDocument();
expect( expect(screen.getByText('Stream properties:')).toBeInTheDocument();
within(streamPropertiesBlock).getByText('Stream properties (JSON format)') expect(screen.getByRole('button', { name: 'Clear' })).toBeInTheDocument();
).toBeInTheDocument(); expect(screen.queryAllByRole('textbox')[0]).toBeInTheDocument();
expect(
within(streamPropertiesBlock).getByRole('button', { name: 'Clear' })
).toBeInTheDocument();
// Represents JSON editor
expect(
within(streamPropertiesBlock).getByRole('textbox')
).toBeInTheDocument();
// Form controls // Form controls
expect(screen.getByRole('button', { name: 'Execute' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Execute' })).toBeInTheDocument();
@ -69,58 +60,10 @@ describe('QueryForm', () => {
await act(() => await act(() =>
userEvent.click(screen.getByRole('button', { name: 'Execute' })) userEvent.click(screen.getByRole('button', { name: 'Execute' }))
); );
expect(screen.getByText('ksql is a required field')).toBeInTheDocument(); waitFor(() => {
expect(submitFn).not.toBeCalled(); expect(screen.getByText('ksql is a required field')).toBeInTheDocument();
}); expect(submitFn).not.toBeCalled();
it('renders error with non-JSON streamProperties', async () => {
renderComponent({
fetching: false,
hasResults: false,
handleClearResults: jest.fn(),
handleSSECancel: jest.fn(),
submitHandler: jest.fn(),
}); });
await act(() => {
// the use of `paste` is a hack that i found somewhere,
// `type` won't work
userEvent.paste(
within(
screen.getByLabelText('Stream properties (JSON format)')
).getByRole('textbox'),
'not-a-JSON-string'
);
userEvent.click(screen.getByRole('button', { name: 'Execute' }));
});
expect(
screen.getByText('streamsProperties is not JSON object')
).toBeInTheDocument();
});
it('renders without error with correct JSON', async () => {
renderComponent({
fetching: false,
hasResults: false,
handleClearResults: jest.fn(),
handleSSECancel: jest.fn(),
submitHandler: jest.fn(),
});
await act(() => {
userEvent.paste(
within(
screen.getByLabelText('Stream properties (JSON format)')
).getByRole('textbox'),
'{"totallyJSON": "string"}'
);
userEvent.click(screen.getByRole('button', { name: 'Execute' }));
});
expect(
screen.queryByText('streamsProperties is not JSON object')
).not.toBeInTheDocument();
}); });
it('submits with correct inputs', async () => { it('submits with correct inputs', async () => {
@ -134,18 +77,9 @@ describe('QueryForm', () => {
}); });
await act(() => { await act(() => {
userEvent.paste( userEvent.paste(screen.getAllByRole('textbox')[0], 'show tables;');
within(screen.getByLabelText('KSQL')).getByRole('textbox'), userEvent.paste(screen.getByRole('textbox', { name: 'key' }), 'test');
'show tables;' userEvent.paste(screen.getByRole('textbox', { name: 'value' }), 'test');
);
userEvent.paste(
within(
screen.getByLabelText('Stream properties (JSON format)')
).getByRole('textbox'),
'{"totallyJSON": "string"}'
);
userEvent.click(screen.getByRole('button', { name: 'Execute' })); userEvent.click(screen.getByRole('button', { name: 'Execute' }));
}); });
@ -223,41 +157,7 @@ describe('QueryForm', () => {
expect(submitFn.mock.calls.length).toBe(1); expect(submitFn.mock.calls.length).toBe(1);
}); });
it('submits form with ctrl+enter on streamProperties editor', async () => { it('add new property', async () => {
const submitFn = jest.fn();
renderComponent({
fetching: false,
hasResults: false,
handleClearResults: jest.fn(),
handleSSECancel: jest.fn(),
submitHandler: submitFn,
});
await act(() => {
userEvent.paste(
within(screen.getByLabelText('KSQL')).getByRole('textbox'),
'show tables;'
);
userEvent.paste(
within(
screen.getByLabelText('Stream properties (JSON format)')
).getByRole('textbox'),
'{"some":"json"}'
);
userEvent.type(
within(
screen.getByLabelText('Stream properties (JSON format)')
).getByRole('textbox'),
'{ctrl}{enter}'
);
});
expect(submitFn.mock.calls.length).toBe(1);
});
it('clears KSQL with Clear button', async () => {
renderComponent({ renderComponent({
fetching: false, fetching: false,
hasResults: false, hasResults: false,
@ -267,22 +167,15 @@ describe('QueryForm', () => {
}); });
await act(() => { await act(() => {
userEvent.paste(
within(screen.getByLabelText('KSQL')).getByRole('textbox'),
'show tables;'
);
userEvent.click( userEvent.click(
within(screen.getByLabelText('KSQL')).getByRole('button', { screen.getByRole('button', { name: 'Add Stream Property' })
name: 'Clear',
})
); );
}); });
expect(screen.getAllByRole('textbox', { name: 'key' }).length).toEqual(2);
expect(screen.queryByText('show tables;')).not.toBeInTheDocument();
}); });
it('clears streamProperties with Clear button', async () => { it('delete stream property', async () => {
renderComponent({ await renderComponent({
fetching: false, fetching: false,
hasResults: false, hasResults: false,
handleClearResults: jest.fn(), handleClearResults: jest.fn(),
@ -291,20 +184,13 @@ describe('QueryForm', () => {
}); });
await act(() => { await act(() => {
userEvent.paste(
within(
screen.getByLabelText('Stream properties (JSON format)')
).getByRole('textbox'),
'{"some":"json"}'
);
userEvent.click( userEvent.click(
within( screen.getByRole('button', { name: 'Add Stream Property' })
screen.getByLabelText('Stream properties (JSON format)')
).getByRole('button', {
name: 'Clear',
})
); );
}); });
expect(screen.queryByText('{"some":"json"}')).not.toBeInTheDocument(); await act(() => {
userEvent.click(screen.getAllByLabelText('deleteProperty')[0]);
});
expect(screen.getAllByRole('textbox', { name: 'key' }).length).toEqual(1);
}); });
}); });

View file

@ -3,11 +3,11 @@ import React from 'react';
import Query, { import Query, {
getFormattedErrorFromTableData, getFormattedErrorFromTableData,
} from 'components/KsqlDb/Query/Query'; } from 'components/KsqlDb/Query/Query';
import { screen, within } from '@testing-library/dom'; import { screen } from '@testing-library/dom';
import fetchMock from 'fetch-mock'; import fetchMock from 'fetch-mock';
import userEvent from '@testing-library/user-event';
import { clusterKsqlDbQueryPath } from 'lib/paths'; import { clusterKsqlDbQueryPath } from 'lib/paths';
import { act } from '@testing-library/react'; import { act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
const clusterName = 'testLocal'; const clusterName = 'testLocal';
const renderComponent = () => const renderComponent = () =>
@ -25,9 +25,7 @@ describe('Query', () => {
renderComponent(); renderComponent();
expect(screen.getByLabelText('KSQL')).toBeInTheDocument(); expect(screen.getByLabelText('KSQL')).toBeInTheDocument();
expect( expect(screen.getByLabelText('Stream properties:')).toBeInTheDocument();
screen.getByLabelText('Stream properties (JSON format)')
).toBeInTheDocument();
}); });
afterEach(() => fetchMock.reset()); afterEach(() => fetchMock.reset());
@ -41,12 +39,10 @@ describe('Query', () => {
Object.defineProperty(window, 'EventSource', { Object.defineProperty(window, 'EventSource', {
value: EventSourceMock, value: EventSourceMock,
}); });
const inputs = screen.getAllByRole('textbox');
const textAreaElement = inputs[0] as HTMLTextAreaElement;
await act(() => { await act(() => {
userEvent.paste( userEvent.paste(textAreaElement, 'show tables;');
within(screen.getByLabelText('KSQL')).getByRole('textbox'),
'show tables;'
);
userEvent.click(screen.getByRole('button', { name: 'Execute' })); userEvent.click(screen.getByRole('button', { name: 'Execute' }));
}); });
@ -63,47 +59,19 @@ describe('Query', () => {
Object.defineProperty(window, 'EventSource', { Object.defineProperty(window, 'EventSource', {
value: EventSourceMock, value: EventSourceMock,
}); });
await act(() => { await act(() => {
userEvent.paste( const inputs = screen.getAllByRole('textbox');
within(screen.getByLabelText('KSQL')).getByRole('textbox'), const textAreaElement = inputs[0] as HTMLTextAreaElement;
'show tables;' userEvent.paste(textAreaElement, 'show tables;');
); });
userEvent.paste( await act(() => {
within( userEvent.paste(screen.getByLabelText('key'), 'key');
screen.getByLabelText('Stream properties (JSON format)') userEvent.paste(screen.getByLabelText('value'), 'value');
).getByRole('textbox'), });
'{"some":"json"}' await act(() => {
);
userEvent.click(screen.getByRole('button', { name: 'Execute' })); userEvent.click(screen.getByRole('button', { name: 'Execute' }));
}); });
expect(mock.calls().length).toBe(1);
});
it('fetch on execute with streamParams', async () => {
renderComponent();
const mock = fetchMock.postOnce(`/api/clusters/${clusterName}/ksql/v2`, {
pipeId: 'testPipeID',
});
Object.defineProperty(window, 'EventSource', {
value: EventSourceMock,
});
await act(() => {
userEvent.paste(
within(screen.getByLabelText('KSQL')).getByRole('textbox'),
'show tables;'
);
userEvent.paste(
within(
screen.getByLabelText('Stream properties (JSON format)')
).getByRole('textbox'),
'{"some":"json"}'
);
userEvent.click(screen.getByRole('button', { name: 'Execute' }));
});
expect(mock.calls().length).toBe(1); expect(mock.calls().length).toBe(1);
}); });
}); });