drag-n-drop part 1 (#25398)

* * redux wireup

* * sorting and removing providers

* * updating range state

* * added updateData

* * addProvider and updateProviders

* * added getQuery to the provider interface

* * fixed tests

* * click to add a provider!

* * passing timelineId to all Placeholders

* * added redux-thunk

* * added range to failing test

* * pr feedback
This commit is contained in:
Andrew Goldstein 2018-11-08 11:41:54 -07:00 committed by Andrew Goldstein
parent 022295f5a9
commit 34558a1e01
No known key found for this signature in database
GPG key ID: 42995DC9117D52CE
30 changed files with 716 additions and 143 deletions

View file

@ -7,6 +7,7 @@
import { createHashHistory } from 'history';
import React from 'react';
import { ApolloProvider } from 'react-apollo';
import { Provider as ReduxStoreProvider } from 'react-redux';
import { ThemeProvider } from 'styled-components';
// TODO use theme provided from parentApp when kibana supports it
@ -15,17 +16,22 @@ import * as euiVars from '@elastic/eui/dist/eui_theme_k6_light.json';
import { AppFrontendLibs } from '../lib/lib';
import { PageRouter } from '../routes';
import { createStore } from '../store';
export const startApp = async (libs: AppFrontendLibs) => {
const history = createHashHistory();
const store = createStore();
libs.framework.render(
<EuiErrorBoundary>
<ApolloProvider client={libs.apolloClient}>
<ThemeProvider theme={{ eui: euiVars }}>
<PageRouter history={history} />
</ThemeProvider>
</ApolloProvider>
<ReduxStoreProvider store={store}>
<ApolloProvider client={libs.apolloClient}>
<ThemeProvider theme={{ eui: euiVars }}>
<PageRouter history={history} />
</ThemeProvider>
</ApolloProvider>
</ReduxStoreProvider>
</EuiErrorBoundary>
);
};

View file

@ -33,6 +33,7 @@ describe('ColumnHeaders', () => {
onDataProviderRemoved={noop}
onFilterChange={noop}
onRangeSelected={noop}
range="1 Day"
rowRenderers={rowRenderers}
width={1000}
/>

View file

@ -20,7 +20,7 @@ describe('ColumnHeaders', () => {
test('it renders the other (data-driven) column headers', () => {
const wrapper = mount(
<ColumnHeaders columnHeaders={headers} sort={sort} onRangeSelected={noop} />
<ColumnHeaders columnHeaders={headers} range="1 Day" sort={sort} onRangeSelected={noop} />
);
headers.forEach(h => {

View file

@ -10,6 +10,7 @@ import { pure } from 'recompose';
import styled from 'styled-components';
import { OnColumnSorted, OnFilterChange, OnRangeSelected } from '../../events';
import { Range } from '../column_headers/range_picker/ranges';
import { Sort } from '../sort';
import { ColumnHeader } from './column_header';
import { Header } from './header';
@ -20,6 +21,7 @@ interface Props {
onColumnSorted?: OnColumnSorted;
onFilterChange?: OnFilterChange;
onRangeSelected: OnRangeSelected;
range: Range;
sort: Sort;
}
@ -31,15 +33,22 @@ const ColumnHeaderContainer = styled.div``;
const Flex = styled.div`
display: flex;
margin-left: 3px;
margin-left: 5px;
width: 100%;
`;
/** Renders the timeline header columns */
export const ColumnHeaders = pure<Props>(
({ columnHeaders, onColumnSorted = noop, onFilterChange = noop, onRangeSelected, sort }) => (
({
columnHeaders,
onColumnSorted = noop,
onFilterChange = noop,
onRangeSelected,
range,
sort,
}) => (
<ColumnHeadersSpan data-test-subj="columnHeaders">
<RangePicker selected={'1 Day'} onRangeSelected={onRangeSelected} />
<RangePicker selected={range} onRangeSelected={onRangeSelected} />
<Flex>
{columnHeaders.map(header => (
<ColumnHeaderContainer data-test-subj="columnHeaderContainer" key={header.id}>

View file

@ -17,12 +17,12 @@ interface Props {
onRangeSelected: OnRangeSelected;
}
export const RangePickerWidth = 110;
export const rangePickerWidth = 120;
// TODO: Upgrade Eui library and use EuiSuperSelect
const SelectContainer = styled.div`
cursor: pointer;
width: ${RangePickerWidth}px;
width: ${rangePickerWidth}px;
`;
/** Renders a time range picker for the MiniMap (e.g. 1 Day, 1 Week...) */

View file

@ -9,6 +9,7 @@ import * as React from 'react';
import { pure } from 'recompose';
import styled from 'styled-components';
import { Range } from '../body/column_headers/range_picker/ranges';
import { DataProvider } from '../data_providers/data_provider';
import { ECS } from '../ecs';
import { OnColumnSorted, OnDataProviderRemoved, OnFilterChange, OnRangeSelected } from '../events';
@ -27,6 +28,7 @@ interface Props {
onDataProviderRemoved: OnDataProviderRemoved;
onFilterChange: OnFilterChange;
onRangeSelected: OnRangeSelected;
range: Range;
rowRenderers: RowRenderer[];
sort: Sort;
width: number;
@ -79,6 +81,7 @@ const Pin = styled(EuiIcon)`
const DataDrivenColumns = styled.div`
display: flex;
margin-left: 5px;
width: 100%;
`;
@ -99,6 +102,7 @@ export const Body = pure<Props>(
onColumnSorted,
onFilterChange,
onRangeSelected,
range,
rowRenderers,
sort,
width,
@ -109,6 +113,7 @@ export const Body = pure<Props>(
onColumnSorted={onColumnSorted}
onFilterChange={onFilterChange}
onRangeSelected={onRangeSelected}
range={range}
sort={sort}
/>
<EuiHorizontalRule margin="xs" />

View file

@ -17,6 +17,10 @@ export interface DataProvider {
* the timeline. default: `true`
*/
enabled: boolean;
/**
* Returns a query that, when executed, returns the data for this provider
*/
getQuery: () => string;
/**
* When `true`, boolean logic is applied to the data provider to negate it.
* default: `false`

View file

@ -27,6 +27,7 @@ const mockSourceNameToEventCount: NameToEventCount<number> = {
'Provider 7': 533,
'Provider 8': 429,
'Provider 9': 706,
'Provider 10': 863,
};
/** Returns a collection of mock data provider names */
@ -48,6 +49,7 @@ const Text = styled(EuiText)`
export const mockDataProviders: DataProvider[] = Object.keys(mockSourceNameToEventCount).map(
name => ({
enabled: true,
getQuery: () => `query-for-provider-id-${name}`,
id: `id-${name}`,
name,
negated: false,

View file

@ -5,7 +5,7 @@
*/
import { mount } from 'enzyme';
import { noop, omit } from 'lodash/fp';
import { noop, pick } from 'lodash/fp';
import * as React from 'react';
import {
getEventCount,
@ -43,7 +43,10 @@ describe('Providers', () => {
.first()
.simulate('click');
const callbackParams = omit('render', mockOnDataProviderRemoved.mock.calls[0][0]);
const callbackParams = pick(
['enabled', 'id', 'name', 'negated'],
mockOnDataProviderRemoved.mock.calls[0][0]
);
expect(callbackParams).toEqual({
enabled: true,

View file

@ -4,82 +4,135 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { defaultTo, noop } from 'lodash/fp';
import * as React from 'react';
import { pure } from 'recompose';
import styled from 'styled-components';
import { ECS } from './ecs';
import { connect } from 'react-redux';
import { ActionCreator } from 'typescript-fsa';
import { Body } from './body';
import { timelineActions } from '../../store';
import { timelineDefaults } from '../../store/local/timeline/model';
import { State } from '../../store/reducer';
import { timelineByIdSelector } from '../../store/selectors';
import { ColumnHeader } from './body/column_headers/column_header';
import { ColumnRenderer } from './body/renderers';
import { RowRenderer } from './body/renderers';
import { Range } from './body/column_headers/range_picker/ranges';
import { columnRenderers, rowRenderers } from './body/renderers';
import { Sort } from './body/sort';
import { DataProvider } from './data_providers/data_provider';
import { OnColumnSorted, OnDataProviderRemoved, OnFilterChange, OnRangeSelected } from './events';
import { TimelineHeader } from './header/timeline_header';
import { ECS } from './ecs';
import { OnColumnSorted, OnDataProviderRemoved, OnRangeSelected } from './events';
import { Timeline } from './timeline';
interface Props {
columnHeaders: ColumnHeader[];
columnRenderers: ColumnRenderer[];
data: ECS[];
dataProviders: DataProvider[];
height?: string;
onColumnSorted: OnColumnSorted;
onDataProviderRemoved: OnDataProviderRemoved;
onFilterChange: OnFilterChange;
onRangeSelected: OnRangeSelected;
rowRenderers: RowRenderer[];
sort: Sort;
export interface OwnProps {
id: string;
headers: ColumnHeader[];
width: number;
}
const TimelineDiv = styled.div<{ width: string; height: string }>`
display: flex;
flex-direction: column;
min-height: 700px;
overflow: none;
user-select: none;
width: ${props => props.width};
height: ${props => props.height};
`;
interface StateProps {
dataProviders: DataProvider[];
data: ECS[];
range: Range;
sort: Sort;
}
const defaultHeight = '100%';
interface DispatchProps {
createTimeline: ActionCreator<{ id: string }>;
addProvider: ActionCreator<{
id: string;
provider: DataProvider;
}>;
updateData: ActionCreator<{
id: string;
data: ECS[];
}>;
updateProviders: ActionCreator<{
id: string;
providers: DataProvider[];
}>;
updateRange: ActionCreator<{
id: string;
range: Range;
}>;
updateSort: ActionCreator<{
id: string;
sort: Sort;
}>;
removeProvider: ActionCreator<{
id: string;
providerId: string;
}>;
}
/** The parent Timeline component */
export const Timeline = pure<Props>(
({
columnHeaders,
columnRenderers,
dataProviders,
data,
height = defaultHeight,
onColumnSorted,
onDataProviderRemoved,
onFilterChange,
onRangeSelected,
rowRenderers,
sort,
width,
}) => (
<TimelineDiv data-test-subj="timeline" width={`${width}px`} height={height}>
<TimelineHeader
dataProviders={dataProviders}
onDataProviderRemoved={onDataProviderRemoved}
width={width}
/>
<Body
columnHeaders={columnHeaders}
type Props = OwnProps & StateProps & DispatchProps;
class StatefulTimelineComponent extends React.PureComponent<Props> {
public componentDidMount() {
const { createTimeline, id } = this.props;
createTimeline({ id });
}
public render() {
const {
data,
dataProviders,
headers,
id,
range,
removeProvider,
sort,
updateRange,
updateSort,
width,
} = this.props;
const onColumnSorted: OnColumnSorted = sorted => {
updateSort({ id, sort: sorted });
};
const onDataProviderRemoved: OnDataProviderRemoved = dataProvider => {
removeProvider({ id, providerId: dataProvider.id });
};
const onRangeSelected: OnRangeSelected = selectedRange => {
updateRange({ id, range: selectedRange });
};
return (
<Timeline
columnHeaders={headers}
columnRenderers={columnRenderers}
dataProviders={dataProviders}
data={data}
onColumnSorted={onColumnSorted}
onDataProviderRemoved={onDataProviderRemoved}
onFilterChange={onFilterChange}
onFilterChange={noop} // TODO: this is the callback for column filters, which is out scope for this phase of delivery
onRangeSelected={onRangeSelected}
range={range}
rowRenderers={rowRenderers}
sort={sort}
width={width}
/>
</TimelineDiv>
)
);
);
}
}
const mapStateToProps = (state: State, { id }: OwnProps) => {
const timeline = timelineByIdSelector(state)[id];
const { dataProviders, data, sort } = timeline || timelineDefaults;
return defaultTo({ id, dataProviders, data, sort }, timeline);
};
export const StatefulTimeline = connect(
mapStateToProps,
{
addProvider: timelineActions.addProvider,
createTimeline: timelineActions.createTimeline,
updateData: timelineActions.updateData,
updateProviders: timelineActions.updateProviders,
updateRange: timelineActions.updateRange,
updateSort: timelineActions.updateSort,
removeProvider: timelineActions.removeProvider,
}
)(StatefulTimelineComponent);

View file

@ -5,16 +5,16 @@
*/
import { mount } from 'enzyme';
import { noop, omit } from 'lodash/fp';
import { noop, pick } from 'lodash/fp';
import * as React from 'react';
import { Timeline } from '.';
import { mockECSData } from '../../pages/mock/mock_ecs';
import { ColumnHeaderType } from './body/column_headers/column_header';
import { headers } from './body/column_headers/headers';
import { columnRenderers, rowRenderers } from './body/renderers';
import { Sort } from './body/sort';
import { mockDataProviders } from './data_providers/mock/mock_data_providers';
import { Timeline } from './timeline';
describe('Timeline', () => {
const sort: Sort = {
@ -34,6 +34,7 @@ describe('Timeline', () => {
onDataProviderRemoved={noop}
onFilterChange={noop}
onRangeSelected={noop}
range={'1 Day'}
rowRenderers={rowRenderers}
sort={sort}
width={1000}
@ -54,6 +55,7 @@ describe('Timeline', () => {
onDataProviderRemoved={noop}
onFilterChange={noop}
onRangeSelected={noop}
range={'1 Day'}
rowRenderers={rowRenderers}
sort={sort}
width={1000}
@ -79,6 +81,7 @@ describe('Timeline', () => {
onDataProviderRemoved={noop}
onFilterChange={noop}
onRangeSelected={noop}
range={'1 Day'}
rowRenderers={rowRenderers}
sort={sort}
width={1000}
@ -111,6 +114,7 @@ describe('Timeline', () => {
onDataProviderRemoved={mockOnDataProviderRemoved}
onFilterChange={noop}
onRangeSelected={noop}
range={'1 Day'}
rowRenderers={rowRenderers}
sort={sort}
width={1000}
@ -122,7 +126,10 @@ describe('Timeline', () => {
.first()
.simulate('click');
const callbackParams = omit('render', mockOnDataProviderRemoved.mock.calls[0][0]);
const callbackParams = pick(
['enabled', 'id', 'name', 'negated'],
mockOnDataProviderRemoved.mock.calls[0][0]
);
expect(callbackParams).toEqual({
enabled: true,
@ -154,6 +161,7 @@ describe('Timeline', () => {
onDataProviderRemoved={noop}
onFilterChange={mockOnFilterChange}
onRangeSelected={noop}
range={'1 Day'}
rowRenderers={rowRenderers}
sort={sort}
width={1000}
@ -187,6 +195,7 @@ describe('Timeline', () => {
onDataProviderRemoved={noop}
onFilterChange={noop}
onRangeSelected={mockOnRangeSelected}
range={'1 Day'}
rowRenderers={rowRenderers}
sort={sort}
width={1000}

View file

@ -0,0 +1,89 @@
/*
* 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 * as React from 'react';
import { pure } from 'recompose';
import styled from 'styled-components';
import { ECS } from './ecs';
import { Body } from './body';
import { ColumnHeader } from './body/column_headers/column_header';
import { Range } from './body/column_headers/range_picker/ranges';
import { ColumnRenderer } from './body/renderers';
import { RowRenderer } from './body/renderers';
import { Sort } from './body/sort';
import { DataProvider } from './data_providers/data_provider';
import { OnColumnSorted, OnDataProviderRemoved, OnFilterChange, OnRangeSelected } from './events';
import { TimelineHeader } from './header/timeline_header';
interface Props {
columnHeaders: ColumnHeader[];
columnRenderers: ColumnRenderer[];
data: ECS[];
dataProviders: DataProvider[];
height?: string;
onColumnSorted: OnColumnSorted;
onDataProviderRemoved: OnDataProviderRemoved;
onFilterChange: OnFilterChange;
onRangeSelected: OnRangeSelected;
range: Range;
rowRenderers: RowRenderer[];
sort: Sort;
width: number;
}
const TimelineDiv = styled.div<{ width: string; height: string }>`
display: flex;
flex-direction: column;
min-height: 700px;
overflow: none;
user-select: none;
width: ${props => props.width};
height: ${props => props.height};
`;
const defaultHeight = '100%';
/** The parent Timeline component */
export const Timeline = pure<Props>(
({
columnHeaders,
columnRenderers,
dataProviders,
data,
height = defaultHeight,
onColumnSorted,
onDataProviderRemoved,
onFilterChange,
onRangeSelected,
range,
rowRenderers,
sort,
width,
}) => (
<TimelineDiv data-test-subj="timeline" width={`${width}px`} height={height}>
<TimelineHeader
dataProviders={dataProviders}
onDataProviderRemoved={onDataProviderRemoved}
width={width}
/>
<Body
columnHeaders={columnHeaders}
columnRenderers={columnRenderers}
dataProviders={dataProviders}
data={data}
onColumnSorted={onColumnSorted}
onDataProviderRemoved={onDataProviderRemoved}
onFilterChange={onFilterChange}
onRangeSelected={onRangeSelected}
range={range}
rowRenderers={rowRenderers}
sort={sort}
width={width}
/>
</TimelineDiv>
)
);

View file

@ -7,10 +7,13 @@
import { EuiPanel } from '@elastic/eui';
import { range } from 'lodash/fp';
import * as React from 'react';
import { pure } from 'recompose';
import { connect } from 'react-redux';
import { Dispatch } from 'redux';
import styled from 'styled-components';
import { WhoAmI } from '../containers/who_am_i';
import { timelineActions } from '../store';
import { mockDataProviders } from './timeline/data_providers/mock/mock_data_providers';
export const VisualizationPlaceholder = styled(EuiPanel)`
&& {
@ -26,27 +29,52 @@ export const VisualizationPlaceholder = styled(EuiPanel)`
}
`;
export const ProviderContainer = styled.div`
margin: 5px;
user-select: none;
cursor: grab;
`;
interface Props {
timelineId: string;
count: number;
myRoute: string;
dispatch: Dispatch;
}
/** TODO: delete this stub */
export const Placeholders = pure<Props>(({ count, myRoute }) => (
<React.Fragment>
{range(0, count).map(p => (
<VisualizationPlaceholder
data-test-subj="visualizationPlaceholder"
key={`visualizationPlaceholder-${p}`}
>
<WhoAmI data-test-subj="whoAmI" sourceId="default">
{({ appName }) => (
<div>
{appName} {myRoute}
</div>
)}
</WhoAmI>
</VisualizationPlaceholder>
))}
</React.Fragment>
));
class PlaceholdersComponent extends React.PureComponent<Props> {
public render() {
const { count, dispatch, myRoute, timelineId } = this.props;
return (
<React.Fragment>
{range(0, count).map(i => (
<VisualizationPlaceholder
data-test-subj="visualizationPlaceholder"
key={`visualizationPlaceholder-${i}`}
>
<WhoAmI data-test-subj="whoAmI" sourceId="default">
{({ appName }) => (
<div>
{appName} {myRoute}
</div>
)}
</WhoAmI>
<ProviderContainer
onClick={() => {
dispatch(
timelineActions.addProvider({ id: timelineId, provider: mockDataProviders[i] })
);
}}
>
{mockDataProviders[i].render()}
</ProviderContainer>
</VisualizationPlaceholder>
))}
</React.Fragment>
);
}
}
export const Placeholders = connect()(PlaceholdersComponent);

View file

@ -35,46 +35,13 @@ import {
import { DatePicker } from '../../components/page/date_picker';
import { Footer } from '../../components/page/footer';
import { Navigation } from '../../components/page/navigation';
import { Timeline } from '../../components/timeline';
import { StatefulTimeline } from '../../components/timeline';
import { headers } from '../../components/timeline/body/column_headers/headers';
import { Sort } from '../../components/timeline/body/sort';
import { mockDataProviders } from '../../components/timeline/data_providers/mock/mock_data_providers';
import {
OnColumnSorted,
OnDataProviderRemoved,
OnFilterChange,
OnRangeSelected,
} from '../../components/timeline/events';
import { mockECSData } from '../mock/mock_ecs';
import { columnRenderers, rowRenderers } from '../../components/timeline/body/renderers';
import { NotFoundPage } from '../404';
import { Hosts } from '../hosts';
import { Network } from '../network';
import { Overview } from '../overview';
const onColumnSorted: OnColumnSorted = sorted => {
alert(`column sorted: ${JSON.stringify(sorted)}`);
};
const onDataProviderRemoved: OnDataProviderRemoved = dataProvider => {
alert(`data provider removed: ${JSON.stringify(dataProvider)}`);
};
const onRangeSelected: OnRangeSelected = range => {
alert(`range selected: ${range}`);
};
const onFilterChange: OnFilterChange = filter => {
alert(`filter changed: ${JSON.stringify(filter)}`);
};
const sort: Sort = {
columnId: 'timestamp',
sortDirection: 'descending',
};
const maxTimelineWidth = 1125;
export const HomePage = pure(() => (
@ -122,19 +89,7 @@ export const HomePage = pure(() => (
<Pane2 data-test-subj="pane2">
<Pane2TimelineContainer data-test-subj="pane2TimelineContainer">
<Timeline
columnHeaders={headers}
columnRenderers={columnRenderers}
dataProviders={mockDataProviders}
data={mockECSData}
onColumnSorted={onColumnSorted}
onDataProviderRemoved={onDataProviderRemoved}
onFilterChange={onFilterChange}
onRangeSelected={onRangeSelected}
rowRenderers={rowRenderers}
sort={sort}
width={maxTimelineWidth}
/>
<StatefulTimeline id="pane2-timeline" headers={headers} width={maxTimelineWidth} />
</Pane2TimelineContainer>
</Pane2>
</SplitPane>

View file

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

View file

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

View file

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

View file

@ -0,0 +1,7 @@
/*
* 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.
*/
export { timelineActions } from './local';

View file

@ -0,0 +1,9 @@
/*
* 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.
*/
export * from './actions';
export * from './reducer';
export { createStore } from './store';

View file

@ -0,0 +1,7 @@
/*
* 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.
*/
export { timelineActions } from './timeline';

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export * from './actions';
export * from './reducer';

View file

@ -0,0 +1,21 @@
/*
* 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 { combineReducers } from 'redux';
import { initialTimelineState, timelineReducer, TimelineState } from './timeline';
export interface LocalState {
timeline: TimelineState;
}
export const initialLocalState: LocalState = {
timeline: initialTimelineState,
};
export const localReducer = combineReducers<LocalState>({
timeline: timelineReducer,
});

View file

@ -0,0 +1,29 @@
/*
* 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 actionCreatorFactory from 'typescript-fsa';
import { Range } from '../../../components/timeline/body/column_headers/range_picker/ranges';
import { Sort } from '../../../components/timeline/body/sort';
import { DataProvider } from '../../../components/timeline/data_providers/data_provider';
import { ECS } from '../../../components/timeline/ecs';
const actionCreator = actionCreatorFactory('x-pack/secops/local/timeline');
export const addProvider = actionCreator<{ id: string; provider: DataProvider }>('ADD_PROVIDER');
export const createTimeline = actionCreator<{ id: string }>('CREATE_TIMELINE');
export const updateData = actionCreator<{ id: string; data: ECS[] }>('UPDATE_DATA');
export const updateProviders = actionCreator<{ id: string; providers: DataProvider[] }>(
'UPDATE_PROVIDERS'
);
export const updateRange = actionCreator<{ id: string; range: Range }>('UPDATE_RANGE');
export const updateSort = actionCreator<{ id: string; sort: Sort }>('UPDATE_SORT');
export const removeProvider = actionCreator<{ id: string; providerId: string }>('REMOVE_PROVIDER');

View file

@ -0,0 +1,10 @@
/*
* 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 * as timelineActions from './actions';
export { timelineActions };
export * from './reducer';

View file

@ -0,0 +1,31 @@
/*
* 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 { Range } from '../../../components/timeline/body/column_headers/range_picker/ranges';
import { Sort } from '../../../components/timeline/body/sort';
import { DataProvider } from '../../../components/timeline/data_providers/data_provider';
import { ECS } from '../../../components/timeline/ecs';
import { mockECSData } from '../../../pages/mock/mock_ecs';
export interface TimelineModel {
id: string;
dataProviders: DataProvider[];
data: ECS[];
range: Range;
sort: Sort;
}
export const timelineDefaults: Readonly<
Pick<TimelineModel, 'dataProviders' | 'data' | 'range' | 'sort'>
> = {
dataProviders: [],
data: mockECSData,
range: '1 Day',
sort: {
columnId: 'timestamp',
sortDirection: 'descending',
},
};

View file

@ -0,0 +1,214 @@
/*
* 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 { reducerWithInitialState } from 'typescript-fsa-reducers/dist';
import { filter } from 'lodash/fp';
import { Range } from '../../../components/timeline/body/column_headers/range_picker/ranges';
import { Sort } from '../../../components/timeline/body/sort';
import { DataProvider } from '../../../components/timeline/data_providers/data_provider';
import { ECS } from '../../../components/timeline/ecs';
import {
addProvider,
createTimeline,
removeProvider,
updateData,
updateProviders,
updateRange,
updateSort,
} from './actions';
import { timelineDefaults, TimelineModel } from './model';
/** A map of id to timeline */
export interface TimelineById {
[id: string]: TimelineModel;
}
/** The state of all timelines is stored here */
export interface TimelineState {
timelineById: TimelineById;
}
const EMPTY_TIMELINE_BY_ID: TimelineById = {}; // stable reference
export const initialTimelineState: TimelineState = {
timelineById: EMPTY_TIMELINE_BY_ID,
};
interface AddNewTimelineParams {
id: string;
timelineById: TimelineById;
}
/** Adds a new `Timeline` to the provided collection of `TimelineById` */
const addNewTimeline = ({ id, timelineById }: AddNewTimelineParams): TimelineById => ({
...timelineById,
[id]: {
id,
...timelineDefaults,
},
});
interface AddTimelineProviderParams {
id: string;
provider: DataProvider;
timelineById: TimelineById;
}
const addTimelineProvider = ({
id,
provider,
timelineById,
}: AddTimelineProviderParams): TimelineById => {
const timeline = timelineById[id];
const alreadyExistsAtIndex = timeline.dataProviders.findIndex(p => p.id === provider.id);
const dataProviders =
alreadyExistsAtIndex > -1
? [
...timeline.dataProviders.slice(0, alreadyExistsAtIndex),
provider,
...timeline.dataProviders.slice(alreadyExistsAtIndex + 1),
]
: [...timeline.dataProviders, provider];
return {
...timelineById,
[id]: {
...timeline,
dataProviders,
},
};
};
interface UpdateTimelineDataParams {
id: string;
data: ECS[];
timelineById: TimelineById;
}
const updateTimelineData = ({ id, data, timelineById }: UpdateTimelineDataParams): TimelineById => {
const timeline = timelineById[id];
return {
...timelineById,
[id]: {
...timeline,
data,
},
};
};
interface UpdateTimelineProvidersParams {
id: string;
providers: DataProvider[];
timelineById: TimelineById;
}
const updateTimelineProviders = ({
id,
providers,
timelineById,
}: UpdateTimelineProvidersParams): TimelineById => {
const timeline = timelineById[id];
return {
...timelineById,
[id]: {
...timeline,
dataProviders: providers,
},
};
};
interface UpdateTimelineRangeParams {
id: string;
range: Range;
timelineById: TimelineById;
}
const updateTimelineRange = ({
id,
range,
timelineById,
}: UpdateTimelineRangeParams): TimelineById => {
const timeline = timelineById[id];
return {
...timelineById,
[id]: {
...timeline,
range,
},
};
};
interface UpdateTimelineSortParams {
id: string;
sort: Sort;
timelineById: TimelineById;
}
const updateTimelineSort = ({ id, sort, timelineById }: UpdateTimelineSortParams): TimelineById => {
const timeline = timelineById[id];
return {
...timelineById,
[id]: {
...timeline,
sort,
},
};
};
interface RemoveTimelineProviderParams {
id: string;
providerId: string;
timelineById: TimelineById;
}
const removeTimelineProvider = ({
id,
providerId,
timelineById,
}: RemoveTimelineProviderParams): TimelineById => {
const timeline = timelineById[id];
return {
...timelineById,
[id]: {
...timeline,
dataProviders: filter(p => p.id !== providerId, timeline.dataProviders),
},
};
};
/** The reducer for all timeline actions */
export const timelineReducer = reducerWithInitialState(initialTimelineState)
.case(createTimeline, (state, { id }) => ({
...state,
timelineById: addNewTimeline({ id, timelineById: state.timelineById }),
}))
.case(addProvider, (state, { id, provider }) => ({
...state,
timelineById: addTimelineProvider({ id, provider, timelineById: state.timelineById }),
}))
.case(removeProvider, (state, { id, providerId }) => ({
...state,
timelineById: removeTimelineProvider({ id, providerId, timelineById: state.timelineById }),
}))
.case(updateData, (state, { id, data }) => ({
...state,
timelineById: updateTimelineData({ id, data, timelineById: state.timelineById }),
}))
.case(updateProviders, (state, { id, providers }) => ({
...state,
timelineById: updateTimelineProviders({ id, providers, timelineById: state.timelineById }),
}))
.case(updateRange, (state, { id, range }) => ({
...state,
timelineById: updateTimelineRange({ id, range, timelineById: state.timelineById }),
}))
.case(updateSort, (state, { id, sort }) => ({
...state,
timelineById: updateTimelineSort({ id, sort, timelineById: state.timelineById }),
}))
.build();

View file

@ -0,0 +1,17 @@
/*
* 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 { createSelector } from 'reselect';
import { State } from '../../reducer';
import { TimelineById } from './reducer';
const selectTimelineById = (state: State): TimelineById => state.local.timeline.timelineById;
export const timelineByIdSelector = createSelector(
selectTimelineById,
timelineById => timelineById
);

View file

@ -0,0 +1,21 @@
/*
* 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 { combineReducers } from 'redux';
import { initialLocalState, localReducer, LocalState } from './local';
export interface State {
local: LocalState;
}
export const initialState: State = {
local: initialLocalState,
};
export const reducer = combineReducers<State>({
local: localReducer,
});

View file

@ -0,0 +1,7 @@
/*
* 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.
*/
export { timelineByIdSelector } from './local/timeline/selectors';

View file

@ -0,0 +1,22 @@
/*
* 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 { AnyAction, applyMiddleware, compose, createStore as createReduxStore, Store } from 'redux';
import thunk from 'redux-thunk';
import { initialState, reducer, State } from '.';
declare global {
interface Window {
__REDUX_DEVTOOLS_EXTENSION_COMPOSE__: typeof compose;
}
}
export const createStore = (): Store<State, AnyAction> => {
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
return createReduxStore(reducer, initialState, composeEnhancers(applyMiddleware(thunk)));
};