live message tailing (#1672)

* live tailing

* addind test case

* fixing useffect array deps

* adding test cases for select

* adding test cases for filters

* deleting unused code

* adding test case for filter
This commit is contained in:
NelyDavtyan 2022-03-03 15:15:40 +04:00 committed by GitHub
parent e85e1aafa1
commit 39359bb9a9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 227 additions and 1510 deletions

View file

@ -1,5 +1,13 @@
import styled from 'styled-components'; import styled from 'styled-components';
interface MessageLoadingProps {
isLive: boolean;
}
interface MessageLoadingSpinnerProps {
isFetching: boolean;
}
export const FiltersWrapper = styled.div` export const FiltersWrapper = styled.div`
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -83,3 +91,36 @@ export const MetricsIcon = styled.div`
padding-right: 6px; padding-right: 6px;
height: 12px; height: 12px;
`; `;
export const MessageLoading = styled.div<MessageLoadingProps>`
color: ${({ theme }) => theme.heading.h3.color};
font-size: ${({ theme }) => theme.heading.h3.fontSize};
display: ${(props) => (props.isLive ? 'flex' : 'none')};
justify-content: space-around;
width: 250px;
`;
export const StopLoading = styled.div`
color: ${({ theme }) => theme.pageLoader.borderColor};
font-size: ${({ theme }) => theme.heading.h3.fontSize};
cursor: pointer;
`;
export const MessageLoadingSpinner = styled.div<MessageLoadingSpinnerProps>`
display: ${(props) => (props.isFetching ? 'block' : 'none')};
border: 3px solid ${({ theme }) => theme.pageLoader.borderColor};
border-bottom: 3px solid ${({ theme }) => theme.pageLoader.borderBottomColor};
border-radius: 50%;
width: 20px;
height: 20px;
animation: spin 1.3s linear infinite;
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
`;

View file

@ -53,8 +53,9 @@ const SeekTypeOptions = [
{ value: SeekType.TIMESTAMP, label: 'Timestamp' }, { value: SeekType.TIMESTAMP, label: 'Timestamp' },
]; ];
const SeekDirectionOptions = [ const SeekDirectionOptions = [
{ value: SeekDirection.FORWARD, label: 'Oldest First' }, { value: SeekDirection.FORWARD, label: 'Oldest First', isLive: false },
{ value: SeekDirection.BACKWARD, label: 'Newest First' }, { value: SeekDirection.BACKWARD, label: 'Newest First', isLive: false },
{ value: SeekDirection.TAILING, label: 'Live Mode', isLive: true },
]; ];
const Filters: React.FC<FiltersProps> = ({ const Filters: React.FC<FiltersProps> = ({
@ -100,7 +101,6 @@ const Filters: React.FC<FiltersProps> = ({
(searchParams.get('seekDirection') as SeekDirection) || (searchParams.get('seekDirection') as SeekDirection) ||
SeekDirection.FORWARD SeekDirection.FORWARD
); );
const isSeekTypeControlVisible = React.useMemo( const isSeekTypeControlVisible = React.useMemo(
() => selectedPartitions.length > 0, () => selectedPartitions.length > 0,
[selectedPartitions] [selectedPartitions]
@ -167,14 +167,21 @@ const Filters: React.FC<FiltersProps> = ({
search: `?${qs}`, search: `?${qs}`,
}); });
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, [seekDirection]);
const toggleSeekDirection = (val: string) => { const toggleSeekDirection = (val: string) => {
const nextSeekDirectionValue = switch (val) {
val === SeekDirection.FORWARD case SeekDirection.FORWARD:
? SeekDirection.FORWARD setSeekDirection(SeekDirection.FORWARD);
: SeekDirection.BACKWARD; break;
setSeekDirection(nextSeekDirectionValue); case SeekDirection.BACKWARD:
setSeekDirection(SeekDirection.BACKWARD);
break;
case SeekDirection.TAILING:
setSeekDirection(SeekDirection.TAILING);
break;
default:
}
}; };
const handleSSECancel = () => { const handleSSECancel = () => {
@ -228,6 +235,7 @@ const Filters: React.FC<FiltersProps> = ({
}, [ }, [
clusterName, clusterName,
topicName, topicName,
seekDirection,
location, location,
setIsFetching, setIsFetching,
resetMessages, resetMessages,
@ -268,6 +276,7 @@ const Filters: React.FC<FiltersProps> = ({
selectSize="M" selectSize="M"
minWidth="100px" minWidth="100px"
options={SeekTypeOptions} options={SeekTypeOptions}
disabled={seekDirection === SeekDirection.TAILING}
/> />
{currentSeekType === SeekType.OFFSET ? ( {currentSeekType === SeekType.OFFSET ? (
<Input <Input
@ -276,7 +285,9 @@ const Filters: React.FC<FiltersProps> = ({
inputSize="M" inputSize="M"
value={offset} value={offset}
className="offset-selector" className="offset-selector"
placeholder="Offset"
onChange={({ target: { value } }) => setOffset(value)} onChange={({ target: { value } }) => setOffset(value)}
disabled={seekDirection === SeekDirection.TAILING}
/> />
) : ( ) : (
<DatePicker <DatePicker
@ -287,6 +298,7 @@ const Filters: React.FC<FiltersProps> = ({
dateFormat="MMMM d, yyyy HH:mm" dateFormat="MMMM d, yyyy HH:mm"
className="date-picker" className="date-picker"
placeholderText="Select timestamp" placeholderText="Select timestamp"
disabled={seekDirection === SeekDirection.TAILING}
/> />
)} )}
</S.SeekTypeSelectorWrapper> </S.SeekTypeSelectorWrapper>
@ -331,10 +343,27 @@ const Filters: React.FC<FiltersProps> = ({
value={seekDirection} value={seekDirection}
minWidth="120px" minWidth="120px"
options={SeekDirectionOptions} options={SeekDirectionOptions}
isLive={seekDirection === SeekDirection.TAILING}
/> />
</div> </div>
<S.FiltersMetrics> <S.FiltersMetrics>
<p style={{ fontSize: 14 }}>{isFetching && phaseMessage}</p> <p style={{ fontSize: 14 }}>
{seekDirection !== SeekDirection.TAILING &&
isFetching &&
phaseMessage}
</p>
<S.MessageLoading isLive={seekDirection === SeekDirection.TAILING}>
<S.MessageLoadingSpinner isFetching={isFetching} />
Loading messages.
<S.StopLoading
onClick={() => {
setSeekDirection(SeekDirection.FORWARD);
setIsFetching(false);
}}
>
Stop loading
</S.StopLoading>
</S.MessageLoading>
<S.Metric title="Elapsed Time"> <S.Metric title="Elapsed Time">
<S.MetricsIcon> <S.MetricsIcon>
<i className="far fa-clock" /> <i className="far fa-clock" />

View file

@ -3,8 +3,11 @@ import Filters, {
FiltersProps, FiltersProps,
} from 'components/Topics/Topic/Details/Messages/Filters/Filters'; } from 'components/Topics/Topic/Details/Messages/Filters/Filters';
import { render } from 'lib/testHelpers'; import { render } from 'lib/testHelpers';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
const setupWrapper = (props?: Partial<FiltersProps>) => ( const setupWrapper = (props?: Partial<FiltersProps>) =>
render(
<Filters <Filters
clusterName="test-cluster" clusterName="test-cluster"
topicName="test-topic" topicName="test-topic"
@ -20,14 +23,83 @@ const setupWrapper = (props?: Partial<FiltersProps>) => (
/> />
); );
describe('Filters component', () => { describe('Filters component', () => {
it('matches the snapshot', () => { it('renders component', () => {
const component = render(setupWrapper()); setupWrapper();
expect(component.baseElement).toMatchSnapshot();
}); });
describe('when fetching', () => { describe('when fetching', () => {
it('matches the snapshot', () => { it('shows cancel button while fetching', () => {
const component = render(setupWrapper({ isFetching: true })); setupWrapper({ isFetching: true });
expect(component.baseElement).toMatchSnapshot(); expect(screen.getByText('Cancel')).toBeInTheDocument();
});
});
describe('when fetching is over', () => {
it('shows submit button while fetching is over', () => {
setupWrapper();
expect(screen.getByText('Submit')).toBeInTheDocument();
});
});
describe('Input elements', () => {
it('search input', () => {
setupWrapper();
const SearchInput = screen.getByPlaceholderText('Search');
expect(SearchInput).toBeInTheDocument();
expect(SearchInput).toHaveValue('');
userEvent.type(SearchInput, 'Hello World!');
expect(SearchInput).toHaveValue('Hello World!');
});
it('offset input', () => {
setupWrapper();
const OffsetInput = screen.getByPlaceholderText('Offset');
expect(OffsetInput).toBeInTheDocument();
expect(OffsetInput).toHaveValue('');
userEvent.type(OffsetInput, 'Hello World!');
expect(OffsetInput).toHaveValue('Hello World!');
});
it('timestamp input', () => {
setupWrapper();
const seekTypeSelect = screen.getAllByRole('listbox');
const option = screen.getAllByRole('option');
userEvent.click(seekTypeSelect[0]);
userEvent.selectOptions(seekTypeSelect[0], ['Timestamp']);
expect(option[0]).toHaveTextContent('Timestamp');
const TimestampInput = screen.getByPlaceholderText('Select timestamp');
expect(TimestampInput).toBeInTheDocument();
expect(TimestampInput).toHaveValue('');
userEvent.type(TimestampInput, 'Hello World!');
expect(TimestampInput).toHaveValue('Hello World!');
expect(screen.getByText('Submit')).toBeInTheDocument();
});
});
describe('Select elements', () => {
it('seekType select', () => {
setupWrapper();
const seekTypeSelect = screen.getAllByRole('listbox');
const option = screen.getAllByRole('option');
expect(option[0]).toHaveTextContent('Offset');
userEvent.click(seekTypeSelect[0]);
userEvent.selectOptions(seekTypeSelect[0], ['Timestamp']);
expect(option[0]).toHaveTextContent('Timestamp');
expect(screen.getByText('Submit')).toBeInTheDocument();
});
it('seekDirection select', () => {
setupWrapper();
const seekDirectionSelect = screen.getAllByRole('listbox');
const option = screen.getAllByRole('option');
userEvent.click(seekDirectionSelect[1]);
userEvent.selectOptions(seekDirectionSelect[1], ['Newest First']);
expect(option[1]).toHaveTextContent('Newest First');
});
});
describe('when live mode is active', () => {
it('stop loading', () => {
setupWrapper();
const StopLoading = screen.getByText('Stop loading');
expect(StopLoading).toBeInTheDocument();
userEvent.click(StopLoading);
const option = screen.getAllByRole('option');
expect(option[1]).toHaveTextContent('Oldest First');
expect(screen.getByText('Submit')).toBeInTheDocument();
}); });
}); });
}); });

View file

@ -5,10 +5,14 @@ interface Props {
className?: string; className?: string;
} }
const SVGWrapper = styled.i`
display: flex;
`;
const LiveIcon: React.FC<Props> = () => { const LiveIcon: React.FC<Props> = () => {
const theme = useTheme(); const theme = useTheme();
return ( return (
<i> <SVGWrapper data-testid="liveIcon">
<svg <svg
width="16" width="16"
height="16" height="16"
@ -19,7 +23,7 @@ const LiveIcon: React.FC<Props> = () => {
<circle cx="8" cy="8" r="7" fill={theme.icons.liveIcon.circleBig} /> <circle cx="8" cy="8" r="7" fill={theme.icons.liveIcon.circleBig} />
<circle cx="8" cy="8" r="4" fill={theme.icons.liveIcon.circleSmall} /> <circle cx="8" cy="8" r="4" fill={theme.icons.liveIcon.circleSmall} />
</svg> </svg>
</i> </SVGWrapper>
); );
}; };

View file

@ -15,6 +15,7 @@ export const Select = styled.ul<Props>`
position: relative; position: relative;
list-style: none; list-style: none;
display: flex; display: flex;
gap: ${(props) => (props.isLive ? '5px' : '0')};
align-items: center; align-items: center;
height: ${(props) => (props.selectSize === 'M' ? '32px' : '40px')}; height: ${(props) => (props.selectSize === 'M' ? '32px' : '40px')};
border: 1px border: 1px
@ -26,7 +27,7 @@ export const Select = styled.ul<Props>`
border-radius: 4px; border-radius: 4px;
font-size: 14px; font-size: 14px;
width: fit-content; width: fit-content;
padding-left: ${(props) => (props.isLive ? '36px' : '12px')}; padding-left: 16px;
padding-right: 16px; padding-right: 16px;
color: ${({ theme, disabled }) => color: ${({ theme, disabled }) =>
disabled ? theme.select.color.disabled : theme.select.color.normal}; disabled ? theme.select.color.disabled : theme.select.color.normal};
@ -38,8 +39,8 @@ export const Select = styled.ul<Props>`
background-repeat: no-repeat !important; background-repeat: no-repeat !important;
background-position-x: calc(100% - 8px) !important; background-position-x: calc(100% - 8px) !important;
background-position-y: 55% !important; background-position-y: 55% !important;
cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')};
&:hover { &:hover:enabled {
color: ${(props) => props.theme.select.color.hover}; color: ${(props) => props.theme.select.color.hover};
border-color: ${(props) => props.theme.select.borderColor.hover}; border-color: ${(props) => props.theme.select.borderColor.hover};
} }
@ -51,7 +52,6 @@ export const Select = styled.ul<Props>`
&:disabled { &:disabled {
color: ${(props) => props.theme.select.color.disabled}; color: ${(props) => props.theme.select.color.disabled};
border-color: ${(props) => props.theme.select.borderColor.disabled}; border-color: ${(props) => props.theme.select.borderColor.disabled};
cursor: not-allowed;
} }
`; `;
@ -71,7 +71,6 @@ export const OptionList = styled.ul`
z-index: 10; z-index: 10;
max-width: 300px; max-width: 300px;
min-width: 100%; min-width: 100%;
&::-webkit-scrollbar { &::-webkit-scrollbar {
-webkit-appearance: none; -webkit-appearance: none;
width: 7px; width: 7px;
@ -89,10 +88,12 @@ export const OptionList = styled.ul`
`; `;
export const Option = styled.li<OptionProps>` export const Option = styled.li<OptionProps>`
display: flex;
list-style: none; list-style: none;
padding: 10px 12px; padding: 10px 12px;
transition: all 0.2s ease-in-out; transition: all 0.2s ease-in-out;
cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')}; cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')};
gap: 5px;
&:hover { &:hover {
background-color: ${(props) => props.theme.select.backgroundColor.hover}; background-color: ${(props) => props.theme.select.backgroundColor.hover};

View file

@ -22,6 +22,7 @@ export interface SelectOption {
label: string | number; label: string | number;
value: string | number; value: string | number;
disabled?: boolean; disabled?: boolean;
isLive?: boolean;
} }
const Select: React.FC<SelectProps> = ({ const Select: React.FC<SelectProps> = ({
@ -53,10 +54,12 @@ const Select: React.FC<SelectProps> = ({
if (onChange) onChange(option.value); if (onChange) onChange(option.value);
setShowOptions(false); setShowOptions(false);
}; };
React.useEffect(() => {
setSelectedOption(value);
}, [isLive, value]);
return ( return (
<div ref={selectContainerRef}> <div ref={selectContainerRef}>
{isLive && <LiveIcon />}
<S.Select <S.Select
role="listbox" role="listbox"
selectSize={selectSize} selectSize={selectSize}
@ -66,6 +69,7 @@ const Select: React.FC<SelectProps> = ({
onKeyDown={showOptionsHandler} onKeyDown={showOptionsHandler}
{...props} {...props}
> >
{isLive && <LiveIcon />}
<S.SelectedOption role="option" tabIndex={0}> <S.SelectedOption role="option" tabIndex={0}>
{options.find( {options.find(
(option) => option.value === (defaultValue || selectedOption) (option) => option.value === (defaultValue || selectedOption)
@ -82,6 +86,7 @@ const Select: React.FC<SelectProps> = ({
tabIndex={0} tabIndex={0}
role="option" role="option"
> >
{option.isLive && <LiveIcon />}
{option.label} {option.label}
</S.Option> </S.Option>
))} ))}

View file

@ -1,6 +1,8 @@
import Select, { SelectProps } from 'components/common/Select/Select'; import Select, { SelectProps } from 'components/common/Select/Select';
import React from 'react'; import React from 'react';
import { render } from 'lib/testHelpers'; import { render } from 'lib/testHelpers';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
jest.mock('react-hook-form', () => ({ jest.mock('react-hook-form', () => ({
useFormContext: () => ({ useFormContext: () => ({
@ -8,26 +10,47 @@ jest.mock('react-hook-form', () => ({
}), }),
})); }));
const setupWrapper = (props?: Partial<SelectProps>) => ( const options = [
<Select name="test" {...props} /> { label: 'test-label1', value: 'test-value1' },
); { label: 'test-label2', value: 'test-value2' },
];
const renderComponent = (props?: Partial<SelectProps>) =>
render(<Select name="test" {...props} />);
describe('Custom Select', () => { describe('Custom Select', () => {
it('renders component', () => {
renderComponent();
expect(screen.getByRole('listbox')).toBeInTheDocument();
});
it('show select options when select is being clicked', () => {
renderComponent({
options,
});
expect(screen.getByRole('option')).toBeInTheDocument();
userEvent.click(screen.getByRole('listbox'));
expect(screen.getAllByRole('option')).toHaveLength(3);
});
it('checking select option change', () => {
renderComponent({
options,
});
userEvent.click(screen.getByRole('listbox'));
userEvent.selectOptions(screen.getByRole('listbox'), ['test-label1']);
expect(screen.getByRole('option')).toHaveTextContent('test-label1');
});
describe('when non-live', () => { describe('when non-live', () => {
it('matches the snapshot', () => { it('there is not live icon', () => {
const component = render(setupWrapper()); renderComponent({ isLive: false });
expect(component.baseElement).toMatchSnapshot(); expect(screen.queryByTestId('liveIcon')).not.toBeInTheDocument();
}); });
}); });
describe('when live', () => { describe('when live', () => {
it('matches the snapshot', () => { it('there is live icon', () => {
const component = render( renderComponent({ isLive: true });
setupWrapper({ expect(screen.getByTestId('liveIcon')).toBeInTheDocument();
isLive: true,
})
);
expect(component.baseElement).toMatchSnapshot();
}); });
}); });
}); });

View file

@ -1,169 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Custom Select when live matches the snapshot 1`] = `
<body>
.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: -webkit-fit-content;
width: -moz-fit-content;
width: fit-content;
padding-left: 36px;
padding-right: 16px;
color: #171A1C;
min-width: 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-repeat: no-repeat !important;
background-position-x: calc(100% - 8px) !important;
background-position-y: 55% !important;
}
.c0:hover {
color: #171A1C;
border-color: #73848C;
}
.c0:focus {
outline: none;
color: #171A1C;
border-color: #454F54;
}
.c0:disabled {
color: #ABB5BA;
border-color: #E3E6E8;
cursor: not-allowed;
}
.c1 {
padding-right: 16px;
list-style-position: inside;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
<div>
<div>
<i>
<svg
fill="none"
height="16"
viewBox="0 0 16 16"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<circle
cx="8"
cy="8"
fill="#FAD1D1"
r="7"
/>
<circle
cx="8"
cy="8"
fill="#E51A1A"
r="4"
/>
</svg>
</i>
<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`] = `
.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: -webkit-fit-content;
width: -moz-fit-content;
width: fit-content;
padding-left: 12px;
padding-right: 16px;
color: #171A1C;
min-width: 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-repeat: no-repeat !important;
background-position-x: calc(100% - 8px) !important;
background-position-y: 55% !important;
}
.c0:hover {
color: #171A1C;
border-color: #73848C;
}
.c0:focus {
outline: none;
color: #171A1C;
border-color: #454F54;
}
.c0:disabled {
color: #ABB5BA;
border-color: #E3E6E8;
cursor: not-allowed;
}
.c1 {
padding-right: 16px;
list-style-position: inside;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
<body>
<div>
<div>
<ul
class="c0"
name="test"
role="listbox"
>
<li
class="c1"
role="option"
tabindex="0"
/>
</ul>
</div>
</div>
</body>
`;