Changed the splitter for a EUI flyout (#26353)

* Changed the splitter for a EUI flyout
* Removed the splitter from the package.json and the HTML
* Removed all of the splitter specific panels
* Integrated the flyout into the project
* Added a absolute positioned button with vertical text called TIMELINE on the right side of the browser
* Used mouseover event to trigger the button (for now) ... clickable coming next
* Implemented a bit of a overlay to widen the mouseover target more than just the button would have
* Styled the button to look more like a tab
* Used react setState for toggling between the states of close and open (for now, redux in another PR will happen)
* Used older style React class but pulled all the methods I could out to make them as pure as I could
* Wrote unit tests which test the rendering, pure functions, and the two class functions
* https://github.com/elastic/ingest-dev/issues/108
This commit is contained in:
Frank Hassanabad 2018-11-29 10:11:58 -07:00 committed by Andrew Goldstein
parent afe82e2054
commit a7b36ad9f2
No known key found for this signature in database
GPG key ID: 42995DC9117D52CE
9 changed files with 338 additions and 128 deletions

View file

@ -12,7 +12,6 @@
"@types/lodash": "^4.14.110"
},
"dependencies": {
"lodash": "^4.17.10",
"react-split-pane": "^0.1.84"
"lodash": "^4.17.10"
}
}

View file

@ -0,0 +1,184 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { mount } from 'enzyme';
import * as React from 'react';
import { closeFlyout, Flyout, FlyoutButton, FlyoutPane, openFlyout, showFlyout } from './flyout';
describe('Flyout', () => {
describe('rendering', () => {
test('it renders the default flyout state as a button', () => {
const wrapper = mount(<Flyout />);
expect(
wrapper
.find('[data-test-subj="flyoutButton"]')
.first()
.text()
).toContain('T I M E L I N E');
});
test('it does NOT render the title element when the default flyout state is a button', () => {
const wrapper = mount(<Flyout />);
expect(wrapper.find('[data-test-subj="flyoutTitle"]').exists()).toEqual(false);
});
test('it renders the title element when its state is set to flyout is true', () => {
const wrapper = mount(<Flyout />).setState({ isFlyoutVisible: true });
expect(
wrapper
.find('[data-test-subj="flyoutTitle"]')
.first()
.text()
).toContain('Timeline');
});
test('it does NOT render the fly out button when its state is set to flyout is true', () => {
const wrapper = mount(<Flyout />).setState({ isFlyoutVisible: true });
expect(wrapper.find('[data-test-subj="flyoutButton"]').exists()).toEqual(false);
});
test('it renders children elements when its state is set to flyout is true', () => {
const wrapper = mount(
<Flyout>
<p>I am a child of flyout</p>
</Flyout>
).setState({ isFlyoutVisible: true });
expect(
wrapper
.find('[data-test-subj="flyoutChildren"]')
.first()
.text()
).toContain('I am a child of flyout');
});
test('should call the onOpen when the mouse is entered for rendering', () => {
const openMock = jest.fn();
const wrapper = mount<Flyout>(<Flyout />);
wrapper.instance().onOpen = openMock;
wrapper.instance().forceUpdate();
wrapper
.find('[data-test-subj="flyoutOverlay"]')
.first()
.simulate('mouseenter');
expect(openMock).toBeCalled();
});
test('should call the onClose when the close button is clicked', () => {
const closeMock = jest.fn();
const wrapper = mount<Flyout>(<Flyout />).setState({ isFlyoutVisible: true });
wrapper.instance().onClose = closeMock;
wrapper.instance().forceUpdate();
wrapper
.find('[data-test-subj="flyout"] button')
.first()
.simulate('click');
expect(closeMock).toBeCalled();
});
});
describe('showFlyout', () => {
test('should set a state to true when true is passed as an argument', () => {
const mockSetState = jest.fn();
showFlyout(true, mockSetState);
expect(mockSetState).toBeCalledWith({ isFlyoutVisible: true });
});
test('should set a state to false when false is passed as an argument', () => {
const mockSetState = jest.fn();
showFlyout(false, mockSetState);
expect(mockSetState).toBeCalledWith({ isFlyoutVisible: false });
});
});
describe('closeFlyout', () => {
test('should set a state to false when false is passed as an argument', () => {
const mockSetState = jest.fn();
closeFlyout(mockSetState);
expect(mockSetState).toBeCalledWith({ isFlyoutVisible: false });
});
});
describe('openFlyout', () => {
test('should set a state to true when true is passed as an argument', () => {
const mockSetState = jest.fn();
openFlyout(mockSetState);
expect(mockSetState).toBeCalledWith({ isFlyoutVisible: true });
});
});
describe('FlyoutPane', () => {
test('should return the flyout element with a title', () => {
const closeMock = jest.fn();
const wrapper = mount(
<FlyoutPane onClose={closeMock}>
<span>I am a child of flyout</span>,
</FlyoutPane>
);
expect(
wrapper
.find('[data-test-subj="flyoutTitle"]')
.first()
.text()
).toContain('Timeline');
});
test('should return the flyout element with children', () => {
const closeMock = jest.fn();
const wrapper = mount(
<FlyoutPane onClose={closeMock}>
<span>I am a mock child</span>,
</FlyoutPane>
);
expect(
wrapper
.find('[data-test-subj="flyoutChildren"]')
.first()
.text()
).toContain('I am a mock child');
});
test('should call the onClose when the close button is clicked', () => {
const closeMock = jest.fn();
const wrapper = mount(
<FlyoutPane onClose={closeMock}>
<span>I am a mock child</span>,
</FlyoutPane>
);
wrapper
.find('[data-test-subj="flyout"] button')
.first()
.simulate('click');
expect(closeMock).toBeCalled();
});
});
describe('showFlyoutButton', () => {
test('should return the flyout button with text', () => {
const openMock = jest.fn();
const wrapper = mount(<FlyoutButton onOpen={openMock} />);
expect(
wrapper
.find('[data-test-subj="flyoutButton"]')
.first()
.text()
).toContain('T I M E L I N E');
});
test('should call the onOpen when the mouse is entered', () => {
const openMock = jest.fn();
const wrapper = mount(<FlyoutButton onOpen={openMock} />);
wrapper
.find('[data-test-subj="flyoutOverlay"]')
.first()
.simulate('mouseenter');
expect(openMock).toBeCalled();
});
});
});

View file

@ -0,0 +1,124 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import {
EuiFlyout,
EuiFlyoutBody,
EuiFlyoutHeader,
EuiPanel,
EuiText,
EuiTitle,
} from '@elastic/eui';
import * as React from 'react';
import { pure } from 'recompose';
import styled from 'styled-components';
export const Overlay = styled.div`
position: absolute;
top: 15%;
right: 0%;
width: 30px;
z-index: 1;
height: 60%;
`;
export const Button = styled(EuiPanel)`
padding: 10px 0 10px 0;
display: flex;
position: absolute;
top: 30%;
right: 5%;
width: 100%;
z-index: 2;
justify-content: center;
text-align: center;
border-top: 1px solid #c5c5c5;
border-bottom: 1px solid #c5c5c5;
border-left: 1px solid #c5c5c5;
border-radius: 6px 0 0 6px;
box-shadow: 0 3px 3px -1px rgba(173, 173, 173, 0.5), 0 5px 7px -2px rgba(173, 173, 173, 0.5);
background-color: #fff;
`;
export const Text = styled(EuiText)`
width: 12px;
z-index: 3;
`;
interface Props {
children?: React.ReactNode;
isFlyoutVisible?: boolean;
}
interface State {
isFlyoutVisible: boolean;
}
type SetState = (opts: State) => void;
export const showFlyout = (isFlyoutVisible: boolean, setState: SetState) =>
setState({ isFlyoutVisible });
export const closeFlyout = (setState: SetState) => showFlyout(false, setState);
export const openFlyout = (setState: SetState) => showFlyout(true, setState);
interface FlyoutPaneProps {
onClose: () => void;
children: React.ReactNode;
}
export const FlyoutPane = pure(({ onClose, children }: FlyoutPaneProps) => (
<EuiFlyout onClose={onClose} aria-labelledby="flyoutTitle" data-test-subj="flyout">
<EuiFlyoutHeader hasBorder>
<EuiTitle size="m">
<h2 data-test-subj="flyoutTitle" id="flyoutTitle">
Timeline
</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody data-test-subj="flyoutChildren">{children}</EuiFlyoutBody>
</EuiFlyout>
));
interface FlyoutButtonProps {
onOpen: () => void;
}
export const FlyoutButton = pure(({ onOpen }: FlyoutButtonProps) => (
<Overlay data-test-subj="flyoutOverlay" onMouseEnter={onOpen}>
<Button>
<Text data-test-subj="flyoutButton">T I M E L I N E</Text>
</Button>
</Overlay>
));
export class Flyout extends React.PureComponent<Props, State> {
public readonly state = {
isFlyoutVisible: this.props.isFlyoutVisible ? this.props.isFlyoutVisible : false,
};
public render = () =>
this.state.isFlyoutVisible ? (
<FlyoutPane onClose={this.onClose}>{this.props.children}</FlyoutPane>
) : (
<FlyoutButton onOpen={this.onOpen} />
);
/**
* Provides stable instance reference for avoiding re-renders.
* setState.bind is required although this is a ES2017 function since setState
* is impure and calls other functions within this class.
*/
public onClose = () => closeFlyout(this.setState.bind(this));
/**
* Provides stable instance reference for avoiding re-renders.
* setState.bind is required although this is a ES2017 function since setState
* is impure and calls other functions within this class.
*/
public onOpen = () => openFlyout(this.setState.bind(this));
}

View file

@ -5,7 +5,6 @@
*/
import { EuiPage } from '@elastic/eui';
import * as React from 'react';
import styled from 'styled-components';
export const PageContainer = styled.div`
@ -65,17 +64,15 @@ export const PaneScrollContainer = styled.div`
overflow-y: scroll;
`;
export const Pane1 = styled.div`
export const Pane = styled.div`
height: 100%;
overflow: hidden;
user-select: none;
`;
/** For use with the `SplitPane` `pane1Style` prop */
export const Pane1Style: React.CSSProperties = {
height: '100%',
marginTop: '5px',
};
export const PaneHeader = styled.div`
display: flex;
`;
export const Pane1FlexContent = styled.div`
display: flex;
@ -83,32 +80,3 @@ export const Pane1FlexContent = styled.div`
flex-wrap: wrap;
height: 100%;
`;
export const Pane1Header = styled.div`
display: flex;
`;
export const Pane2 = styled.div`
height: 100%;
overflow-x: scroll;
overflow-y: hidden;
user-select: none;
`;
/** For use with the `SplitPane` `pane2Style` prop */
export const Pane2Style: React.CSSProperties = {
height: '100%',
};
export const Pane2TimelineContainer = styled.div`
height: 100%;
`;
/** For use with the `SplitPane` `resizerStyle` prop */
export const ResizerStyle: React.CSSProperties = {
border: '5px solid #909AA1',
backgroundClip: 'padding-box',
cursor: 'col-resize',
margin: '5px',
zIndex: 1,
};

View file

@ -14,24 +14,19 @@ import * as React from 'react';
import { Redirect, Route, Switch } from 'react-router-dom';
import { pure } from 'recompose';
import SplitPane from 'react-split-pane';
import { LinkToPage } from '../../components/link_to';
import {
PageContainer,
PageContent,
PageHeader,
Pane1,
Pane1Header,
Pane1Style,
Pane2,
Pane2Style,
Pane2TimelineContainer,
Pane,
PaneHeader,
PaneScrollContainer,
ResizerStyle,
SubHeader,
SubHeaderDatePicker,
} from '../../components/page';
import { DatePicker } from '../../components/page/date_picker';
import { Flyout } from '../../components/page/flyout';
import { Footer } from '../../components/page/footer';
import { Navigation } from '../../components/page/navigation';
import { StatefulTimeline } from '../../components/timeline';
@ -45,6 +40,9 @@ const maxTimelineWidth = 1125;
export const HomePage = pure(() => (
<PageContainer data-test-subj="pageContainer">
<Flyout>
<StatefulTimeline id="timeline" headers={headers} width={maxTimelineWidth} />
</Flyout>
<PageHeader data-test-subj="pageHeader">
<Navigation data-test-subj="navigation" />
</PageHeader>
@ -55,41 +53,21 @@ export const HomePage = pure(() => (
</SubHeaderDatePicker>
<EuiHorizontalRule margin="none" />
</SubHeader>
<SplitPane
data-test-subj="splitPane"
split="vertical"
defaultSize="75%"
primary="second"
pane1Style={Pane1Style}
pane2Style={{
...Pane2Style,
maxWidth: `${maxTimelineWidth}px`,
}}
resizerStyle={ResizerStyle}
>
<Pane1 data-test-subj="pane1">
<Pane1Header data-test-subj="pane1Header">
<EuiSearchBar onChange={noop} />
</Pane1Header>
<PaneScrollContainer data-test-subj="pane1ScrollContainer">
<Switch>
<Redirect from="/" exact={true} to="/overview" />
<Route path="/overview" component={Overview} />
<Route path="/hosts" component={Hosts} />
<Route path="/network" component={Network} />
<Route path="/link-to" component={LinkToPage} />
<Route component={NotFoundPage} />
</Switch>
</PaneScrollContainer>
</Pane1>
<Pane2 data-test-subj="pane2">
<Pane2TimelineContainer data-test-subj="pane2TimelineContainer">
<StatefulTimeline id="pane2-timeline" headers={headers} width={maxTimelineWidth} />
</Pane2TimelineContainer>
</Pane2>
</SplitPane>
<Pane data-test-subj="pane">
<PaneHeader data-test-subj="paneHeader">
<EuiSearchBar onChange={noop} />
</PaneHeader>
<PaneScrollContainer data-test-subj="pane1ScrollContainer">
<Switch>
<Redirect from="/" exact={true} to="/overview" />
<Route path="/overview" component={Overview} />
<Route path="/hosts" component={Hosts} />
<Route path="/network" component={Network} />
<Route path="/link-to" component={LinkToPage} />
<Route component={NotFoundPage} />
</Switch>
</PaneScrollContainer>
</Pane>
</PageContent>
<Footer />
</PageContainer>

View file

@ -68,7 +68,7 @@ export const Hosts = connect()(
title="Events"
/>
</VisualizationPlaceholder>
<Placeholders timelineId="pane2-timeline" count={8} myRoute="Hosts" />
<Placeholders timelineId="timeline" count={8} myRoute="Hosts" />
</Pane1FlexContent>
)}
</EventsQuery>
@ -98,7 +98,7 @@ const getEventsColumns = (dispatch: Dispatch) => [
onClick={() => {
dispatch(
timelineActions.addProvider({
id: 'pane2-timeline',
id: 'timeline',
provider: {
enabled: true,
id: `id-${hostName}`,

View file

@ -12,6 +12,6 @@ import { Placeholders } from '../../components/visualization_placeholder';
export const Network = pure(() => (
<Pane1FlexContent>
<Placeholders timelineId="pane2-timeline" count={10} myRoute="Network" />
<Placeholders timelineId="timeline" count={10} myRoute="Network" />
</Pane1FlexContent>
));

View file

@ -12,6 +12,6 @@ import { Placeholders } from '../../components/visualization_placeholder';
export const Overview = pure(() => (
<Pane1FlexContent>
<Placeholders timelineId="pane2-timeline" count={10} myRoute="Overview" />
<Placeholders timelineId="timeline" count={10} myRoute="Overview" />
</Pane1FlexContent>
));

View file

@ -4189,11 +4189,6 @@ bounce@1.x.x:
boom "7.x.x"
hoek "5.x.x"
bowser@^1.7.3:
version "1.9.4"
resolved "https://registry.yarnpkg.com/bowser/-/bowser-1.9.4.tgz#890c58a2813a9d3243704334fa81b96a5c150c9a"
integrity sha512-9IdMmj2KjigRq6oWhmwv1W36pDuA4STQZ8q6YO9um+x07xgYNCD3Oou+WP/3L1HNz7iqythGet3/p4wvc8AAwQ==
boxen@1.3.0, boxen@^1.2.1, boxen@^1.2.2:
version "1.3.0"
resolved "https://registry.yarnpkg.com/boxen/-/boxen-1.3.0.tgz#55c6c39a8ba58d9c61ad22cd877532deb665a20b"
@ -6206,14 +6201,6 @@ css-color-names@0.0.4:
resolved "https://registry.yarnpkg.com/css-color-names/-/css-color-names-0.0.4.tgz#808adc2e79cf84738069b646cb20ec27beb629e0"
integrity sha1-gIrcLnnPhHOAabZGyyDsJ762KeA=
css-in-js-utils@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/css-in-js-utils/-/css-in-js-utils-2.0.1.tgz#3b472b398787291b47cfe3e44fecfdd9e914ba99"
integrity sha512-PJF0SpJT+WdbVVt0AOYp9C8GnuruRlL/UFW7932nLWmFLQTaWEzTBQEx7/hn4BuV+WON75iAViSUJLiU3PKbpA==
dependencies:
hyphenate-style-name "^1.0.2"
isobject "^3.0.1"
css-loader@0.28.7:
version "0.28.7"
resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-0.28.7.tgz#5f2ee989dd32edd907717f953317656160999c1b"
@ -11023,11 +11010,6 @@ humps@2.0.1:
resolved "https://registry.yarnpkg.com/humps/-/humps-2.0.1.tgz#dd02ea6081bd0568dc5d073184463957ba9ef9aa"
integrity sha1-3QLqYIG9BWjcXQcxhEY5V7qe+ao=
hyphenate-style-name@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/hyphenate-style-name/-/hyphenate-style-name-1.0.2.tgz#31160a36930adaf1fc04c6074f7eb41465d4ec4b"
integrity sha1-MRYKNpMK2vH8BMYHT360FGXU7Es=
icalendar@0.7.1:
version "0.7.1"
resolved "https://registry.yarnpkg.com/icalendar/-/icalendar-0.7.1.tgz#d0d3486795f8f1c5cf4f8cafac081b4b4e7a32ae"
@ -11223,14 +11205,6 @@ ini@^1.3.4, ini@~1.3.0:
resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927"
integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==
inline-style-prefixer@^3.0.6:
version "3.0.8"
resolved "https://registry.yarnpkg.com/inline-style-prefixer/-/inline-style-prefixer-3.0.8.tgz#8551b8e5b4d573244e66a34b04f7d32076a2b534"
integrity sha1-hVG45bTVcyROZqNLBPfTIHaitTQ=
dependencies:
bowser "^1.7.3"
css-in-js-utils "^2.0.0"
inline-style@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/inline-style/-/inline-style-2.0.0.tgz#2fa9cf624596a8109355b925094e138bbd5ea29b"
@ -18017,16 +17991,6 @@ react-sizeme@^2.3.6:
invariant "^2.2.2"
lodash "^4.17.4"
react-split-pane@^0.1.84:
version "0.1.84"
resolved "https://registry.yarnpkg.com/react-split-pane/-/react-split-pane-0.1.84.tgz#b9c1499cbc40b09cf29953ee6f5ff1039d31906e"
integrity sha512-rso1dRAXX/WETyqF5C0fomIYzpF71Nothfr1R7pFkrJCPVJ20ok2e6wqF+JvUTyE/meiBvsbNPT1loZjyU+53w==
dependencies:
inline-style-prefixer "^3.0.6"
prop-types "^15.5.10"
react-lifecycles-compat "^3.0.4"
react-style-proptype "^3.0.0"
react-sticky@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/react-sticky/-/react-sticky-6.0.1.tgz#356988bdcc6fc8cd2d89746d2302edce67d86687"
@ -18035,13 +17999,6 @@ react-sticky@^6.0.1:
prop-types "^15.5.8"
raf "^3.3.0"
react-style-proptype@^3.0.0:
version "3.2.2"
resolved "https://registry.yarnpkg.com/react-style-proptype/-/react-style-proptype-3.2.2.tgz#d8e998e62ce79ec35b087252b90f19f1c33968a0"
integrity sha512-ywYLSjNkxKHiZOqNlso9PZByNEY+FTyh3C+7uuziK0xFXu9xzdyfHwg4S9iyiRRoPCR4k2LqaBBsWVmSBwCWYQ==
dependencies:
prop-types "^15.5.4"
react-syntax-highlighter@^5.7.0:
version "5.8.0"
resolved "https://registry.yarnpkg.com/react-syntax-highlighter/-/react-syntax-highlighter-5.8.0.tgz#a220c010fd0641751d93532509ba7159cc3a4383"