Squash of feature-secops branch (#36044)

Backport of #35394
This commit is contained in:
Frank Hassanabad 2019-05-03 12:45:15 -06:00 committed by GitHub
parent 4c26ccca65
commit 87ca1a2ddc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
932 changed files with 162245 additions and 10 deletions

View file

@ -396,6 +396,17 @@ module.exports = {
},
},
/**
* SIEM overrides
*/
{
files: ['x-pack/plugins/siem/**/*.ts'],
rules: {
'@typescript-eslint/no-explicit-any': 'error',
'import/order': 'error',
},
},
/**
* disable jsx-a11y for kbn-ui-framework
*/

View file

@ -42,6 +42,7 @@
"xpack.reporting": "x-pack/plugins/reporting",
"xpack.rollupJobs": "x-pack/plugins/rollup",
"xpack.searchProfiler": "x-pack/plugins/searchprofiler",
"xpack.siem": "x-pack/plugins/siem",
"xpack.security": "x-pack/plugins/security",
"xpack.server": "x-pack/server",
"xpack.spaces": "x-pack/plugins/spaces",

View file

@ -35,10 +35,13 @@ module.exports = {
{
// Babel 7 don't support the namespace feature on typescript code.
// With namespaces only used for type declarations, we can securely
// strip them off for babel on x-pack infra plugin
// strip them off for babel on x-pack infra/siem plugins
//
// See https://github.com/babel/babel/issues/8244#issuecomment-466548733
test: /x-pack[\/\\]plugins[\/\\]infra[\/\\].*[\/\\]graphql/,
test: [
/x-pack[\/\\]plugins[\/\\]infra[\/\\].*[\/\\]graphql/,
/x-pack[\/\\]plugins[\/\\]siem[\/\\].*[\/\\]graphql/,
],
plugins: [[require.resolve('babel-plugin-typescript-strip-namespaces')]],
},
],

1
x-pack/.gitignore vendored
View file

@ -11,6 +11,7 @@
/.kibana-plugin-helpers.dev.*
!/plugins/infra/**/target
.cache
!/plugins/siem/**/target
# We don't want any yarn.lock files in here
/yarn.lock

View file

@ -32,6 +32,7 @@ import { canvas } from './plugins/canvas';
import { infra } from './plugins/infra';
import { taskManager } from './plugins/task_manager';
import { rollup } from './plugins/rollup';
import { siem } from './plugins/siem';
import { remoteClusters } from './plugins/remote_clusters';
import { crossClusterReplication } from './plugins/cross_cluster_replication';
import { translations } from './plugins/translations';
@ -70,6 +71,7 @@ module.exports = function (kibana) {
infra(kibana),
taskManager(kibana),
rollup(kibana),
siem(kibana),
remoteClusters(kibana),
crossClusterReplication(kibana),
translations(kibana),

6
x-pack/plugins/siem/.gitattributes vendored Normal file
View file

@ -0,0 +1,6 @@
# Auto-collapse generated files in GitHub
# https://help.github.com/en/articles/customizing-how-changed-files-appear-on-github
x-pack/plugins/siem/public/graphql/types.ts linguist-generated=true
x-pack/plugins/siem/server/graphql/types.ts linguist-generated=true
x-pack/plugins/siem/public/graphql/introspection.json linguist-generated=true

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 { rootSchema } from './schema.gql';

View file

@ -0,0 +1,18 @@
/*
* 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 gql from 'graphql-tag';
export const rootSchema = gql`
schema {
query: Query
#mutation: Mutation
}
type Query
#type Mutation
`;

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 * from './schema.gql';

View file

@ -0,0 +1,67 @@
/*
* 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 gql from 'graphql-tag';
export const sharedSchema = gql`
input TimerangeInput {
"The interval string to use for last bucket. The format is '{value}{unit}'. For example '5m' would return the metrics for the last 5 minutes of the timespan."
interval: String!
"The end of the timerange"
to: Float!
"The beginning of the timerange"
from: Float!
}
type CursorType {
value: String!
tiebreaker: String
}
input PaginationInput {
"The limit parameter allows you to configure the maximum amount of items to be returned"
limit: Float!
"The cursor parameter defines the next result you want to fetch"
cursor: String
"The tiebreaker parameter allow to be more precise to fetch the next item"
tiebreaker: String
}
enum Direction {
asc
desc
}
enum FlowTarget {
client
destination
server
source
}
enum FlowDirection {
uniDirectional
biDirectional
}
input SortField {
sortFieldId: String!
direction: Direction!
}
type PageInfo {
endCursor: CursorType
hasNextPage: Boolean
}
enum IndexType {
ANY
FILEBEAT
AUDITBEAT
PACKETBEAT
WINLOGBEAT
}
`;

View file

@ -0,0 +1,47 @@
/*
* 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 type JsonValue = null | boolean | number | string | JsonObject | JsonArray;
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface JsonArray extends Array<JsonValue> {}
export interface JsonObject {
[key: string]: JsonValue;
}
export type ESQuery = ESRangeQuery | ESQueryStringQuery | ESMatchQuery | ESTermQuery | JsonObject;
export interface ESRangeQuery {
range: {
[name: string]: {
gte: number;
lte: number;
format: string;
};
};
}
export interface ESMatchQuery {
match: {
[name: string]: {
query: string;
operator: string;
zero_terms_query: string;
};
};
}
export interface ESQueryStringQuery {
query_string: {
query: string;
analyze_wildcard: boolean;
};
}
export interface ESTermQuery {
term: Record<string, string>;
}

View file

@ -0,0 +1,50 @@
/*
* 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 JoiNamespace from 'joi';
import { resolve } from 'path';
import { getConfigSchema, initServerWithKibana, KbnServer } from './server/kibana.index';
const APP_ID = 'siem';
export const APP_NAME = 'SIEM';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function siem(kibana: any) {
return new kibana.Plugin({
id: APP_ID,
configPrefix: 'xpack.siem',
publicDir: resolve(__dirname, 'public'),
require: ['kibana', 'elasticsearch'],
uiExports: {
app: {
description: 'Explore your SIEM App',
main: 'plugins/siem/app',
euiIconType: 'securityAnalyticsApp',
title: APP_NAME,
listed: false,
url: `/app/${APP_ID}`,
},
home: ['plugins/siem/register_feature'],
links: [
{
description: 'Explore your SIEM App',
euiIconType: 'securityAnalyticsApp',
id: 'siem',
order: 9000,
title: APP_NAME,
url: `/app/${APP_ID}`,
},
],
},
config(Joi: typeof JoiNamespace) {
return getConfigSchema(Joi);
},
init(server: KbnServer) {
initServerWithKibana(server);
},
});
}

View file

@ -0,0 +1,25 @@
{
"author": "Elastic",
"name": "siem",
"version": "8.0.0",
"private": true,
"license": "Elastic-License",
"scripts": {
"build-graphql-types": "node scripts/generate_types_from_graphql.js"
},
"devDependencies": {
"@types/boom": "^7.2.0",
"@types/color": "^3.0.0",
"@types/lodash": "^4.14.110",
"@types/memoize-one": "^4.1.0",
"@types/react-beautiful-dnd": "^10.0.1"
},
"dependencies": {
"react-beautiful-dnd": "^10.0.1",
"react-markdown": "^4.0.6",
"lodash": "^4.17.10",
"memoize-one": "^5.0.0",
"apollo-link-error": "^1.1.7",
"suricata-sid-db": "^1.0.2"
}
}

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.
*/
import './apps/kibana_app';

View file

@ -0,0 +1,13 @@
/*
* 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 'uiExports/autocompleteProviders';
import { compose } from '../lib/compose/kibana_compose';
import { startApp } from './start_app';
startApp(compose());

View file

@ -0,0 +1,48 @@
/*
* 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 { createHashHistory } from 'history';
import React from 'react';
import { ApolloProvider } from 'react-apollo';
import { Provider as ReduxStoreProvider } from 'react-redux';
import { ThemeProvider } from 'styled-components';
import { EuiErrorBoundary } from '@elastic/eui';
import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json';
import euiLightVars from '@elastic/eui/dist/eui_theme_light.json';
import { I18nContext } from 'ui/i18n';
import { ErrorToast } from '../components/error_toast';
import { KibanaConfigContext } from '../components/formatted_date';
import { AppFrontendLibs } from '../lib/lib';
import { PageRouter } from '../routes';
import { store } from '../store';
export const startApp = async (libs: AppFrontendLibs) => {
const history = createHashHistory();
libs.framework.render(
<EuiErrorBoundary>
<I18nContext>
<ReduxStoreProvider store={store}>
<ApolloProvider client={libs.apolloClient}>
<ThemeProvider
theme={() => ({
eui: libs.framework.darkMode ? euiDarkVars : euiLightVars,
darkMode: libs.framework.darkMode,
})}
>
<KibanaConfigContext.Provider value={libs.framework}>
<PageRouter history={history} />
</KibanaConfigContext.Provider>
</ThemeProvider>
<ErrorToast />
</ApolloProvider>
</ReduxStoreProvider>
</I18nContext>
</EuiErrorBoundary>
);
};

View file

@ -0,0 +1,11 @@
/*
* 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 { compose } from '../lib/compose/testing_compose';
import { startApp } from './start_app';
startApp(compose());

View file

@ -0,0 +1,43 @@
/*
* 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 { EuiBadge } from '@elastic/eui';
import * as React from 'react';
import { pure } from 'recompose';
import styled from 'styled-components';
import * as i18n from './translations';
const RoundedBadge = styled(EuiBadge)`
align-items: center;
border-radius: 100%;
display: inline-flex;
font-size: 9px;
height: 19px;
justify-content: center;
margin: 0 5px 0 5px;
padding: 7px 6px 4px 6px;
user-select: none;
width: 19px;
.euiBadge__content {
position: relative;
top: -1px;
}
.euiBadge__text {
text-overflow: clip;
}
`;
export type AndOr = 'and' | 'or';
/** Displays AND / OR in a round badge */
export const AndOrBadge = pure<{ type: AndOr }>(({ type }) => (
<RoundedBadge data-test-subj="and-or-badge" color="hollow">
{type === 'and' ? i18n.AND : i18n.OR}
</RoundedBadge>
));

View file

@ -0,0 +1,15 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const AND = i18n.translate('xpack.siem.andOrBadge.and', {
defaultMessage: 'AND',
});
export const OR = i18n.translate('xpack.siem.andOrBadge.or', {
defaultMessage: 'OR',
});

View file

@ -0,0 +1,9 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AppSettingsPopover rendering it renders against snapshot 1`] = `
<Component
onClick={[Function]}
onClose={[Function]}
showPopover={false}
/>
`;

View file

@ -0,0 +1,49 @@
/*
* 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, shallow } from 'enzyme';
import toJson from 'enzyme-to-json';
import { noop } from 'lodash/fp';
import * as React from 'react';
import { AppSettingsPopover } from './app_settings_popover';
describe('AppSettingsPopover', () => {
describe('rendering', () => {
test('it renders against snapshot', () => {
const wrapper = shallow(
<AppSettingsPopover onClick={noop} onClose={noop} showPopover={false} />
);
expect(toJson(wrapper)).toMatchSnapshot();
});
test('it renders a settings gear icon', () => {
const wrapper = mount(
<AppSettingsPopover onClick={noop} onClose={noop} showPopover={false} />
);
expect(wrapper.find('[data-test-subj="gear"]').exists()).toEqual(true);
});
});
describe('onClick', () => {
test('it invokes onClick when clicked', () => {
const onClick = jest.fn();
const wrapper = mount(
<AppSettingsPopover onClick={onClick} onClose={noop} showPopover={false} />
);
wrapper
.find('[data-test-subj="gear"]')
.first()
.simulate('click');
expect(onClick).toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,45 @@
/*
* 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 { EuiButton, EuiFlexGroup, EuiFlexItem, EuiIcon, EuiPopover } from '@elastic/eui';
import * as React from 'react';
import { pure } from 'recompose';
import styled from 'styled-components';
import * as i18n from './translations';
interface Props {
showPopover: boolean;
onClick: () => void;
onClose: () => void;
}
const SettingsPopover = styled(EuiPopover)`
cursor: pointer;
`;
export const AppSettingsPopover = pure<Props>(({ showPopover, onClick, onClose }) => (
<SettingsPopover
anchorPosition="downRight"
button={<EuiIcon data-test-subj="gear" type="gear" size="l" onClick={onClick} />}
closePopover={onClose}
data-test-subj="app-settings-popover"
id="timelineSettingsPopover"
isOpen={showPopover}
>
<EuiFlexGroup direction="column" alignItems="center">
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="add-data"
href="kibana#home/tutorial_directory/security"
target="_blank"
>
{i18n.ADD_DATA}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</SettingsPopover>
));

View file

@ -0,0 +1,41 @@
/*
* 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 { AppSettingsPopover } from './app_settings_popover';
export interface State {
showPopover: boolean;
}
export class AppSettings extends React.PureComponent<{}, State> {
public readonly state = {
showPopover: false,
};
public render() {
return (
<AppSettingsPopover
onClick={this.onClick}
onClose={this.onClose}
showPopover={this.state.showPopover}
/>
);
}
private onClick = () => {
this.setState({
showPopover: !this.state.showPopover,
});
};
private onClose = () => {
this.setState({
showPopover: false,
});
};
}

View file

@ -0,0 +1,15 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const ADD_DATA = i18n.translate('xpack.siem.appSettings.addData', {
defaultMessage: 'Add Data',
});
export const THEME = i18n.translate('xpack.siem.appSettings.theme', {
defaultMessage: 'Theme',
});

View file

@ -0,0 +1,9 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`arrows ArrowBody renders correctly against snapshot 1`] = `
<Component>
<styled.span
height={3}
/>
</Component>
`;

View file

@ -0,0 +1,86 @@
/*
* 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 {
DEFAULT_ARROW_HEIGHT,
getArrowHeightFromPercent,
getPercent,
MAX_ARROW_HEIGHT,
} from './helpers';
describe('helpers', () => {
describe('#getArrowHeightFromPercent', () => {
test('it returns the default arrow height when the input is 0%', () => {
expect(getArrowHeightFromPercent(0)).toEqual(DEFAULT_ARROW_HEIGHT);
});
test('it returns undefined when we approach zero (Singularity issue)', () => {
expect(getPercent({ numerator: 1, denominator: 0.00000000000000009 })).toEqual(undefined);
});
test('it returns undefined when we approach zero with a negative number (Singularity issue)', () => {
expect(getPercent({ numerator: 1, denominator: -0.00000000000000009 })).toEqual(undefined);
});
test('it returns the max arrow height when the input is 100%', () => {
expect(getArrowHeightFromPercent(100)).toEqual(MAX_ARROW_HEIGHT);
});
test('it clamps to the default arrow height if the input is less than 0%', () => {
expect(getArrowHeightFromPercent(-50)).toEqual(DEFAULT_ARROW_HEIGHT);
});
test('it clamps to the max arrow height if the input is greater than 100%', () => {
expect(getArrowHeightFromPercent(150)).toEqual(MAX_ARROW_HEIGHT);
});
test('it returns the expected arrow height when the input is 24%', () => {
expect(getArrowHeightFromPercent(24)).toEqual(1.72);
});
test('it returns the expected arrow height when the input is 25%', () => {
expect(getArrowHeightFromPercent(25)).toEqual(1.75);
});
test('it returns the expected arrow height when the input is 50%', () => {
expect(getArrowHeightFromPercent(50)).toEqual(2.5);
});
test('it returns the expected arrow height when the input is 75%', () => {
expect(getArrowHeightFromPercent(75)).toEqual(3.25);
});
test('it returns the expected arrow height when the input is 99%', () => {
expect(getArrowHeightFromPercent(99)).toEqual(3.9699999999999998);
});
});
describe('#getPercent', () => {
test('it returns the expected percent when the input is 0 / 100', () => {
expect(getPercent({ numerator: 0, denominator: 100 })).toEqual(0);
});
test('it returns the expected percent when the input is 100 / 100', () => {
expect(getPercent({ numerator: 100, denominator: 100 })).toEqual(100);
});
test('it returns the expected percent when the input is 50 / 100', () => {
expect(getPercent({ numerator: 50, denominator: 100 })).toEqual(50);
});
test('it returns undefined when the denominator is 0', () => {
expect(getPercent({ numerator: 50, denominator: 0 })).toEqual(undefined);
});
test('it returns undefined when the numerator is not a number', () => {
expect(getPercent({ numerator: NaN, denominator: 100 })).toEqual(undefined);
});
test('it returns undefined when the denominator is not a number', () => {
expect(getPercent({ numerator: 50, denominator: NaN })).toEqual(undefined);
});
});
});

View file

@ -0,0 +1,39 @@
/*
* 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 { scaleLinear } from 'd3-scale';
export const DEFAULT_ARROW_HEIGHT = 1;
export const MAX_ARROW_HEIGHT = 4;
/** Returns the height of an arrow in pixels based on the specified percent (0-100) */
export const getArrowHeightFromPercent = scaleLinear()
.domain([0, 100])
.range([DEFAULT_ARROW_HEIGHT, MAX_ARROW_HEIGHT])
.clamp(true);
/** Returns a percent, or undefined if the percent cannot be calculated */
export const getPercent = ({
numerator,
denominator,
}: {
numerator: number;
denominator: number;
}): number | undefined => {
if (
Math.abs(denominator) < Number.EPSILON ||
!Number.isFinite(numerator) ||
!Number.isFinite(denominator)
) {
return undefined;
}
return (numerator / denominator) * 100;
};
/** Returns true if the input is an array that holds one value */
export const hasOneValue = <T>(array: T[] | null | undefined): boolean =>
Array.isArray(array) && array.length === 1;

View file

@ -0,0 +1,43 @@
/*
* 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, shallow } from 'enzyme';
import toJson from 'enzyme-to-json';
import * as React from 'react';
import { TestProviders } from '../../mock';
import { ArrowBody, ArrowHead } from '.';
describe('arrows', () => {
describe('ArrowBody', () => {
test('renders correctly against snapshot', () => {
const wrapper = shallow(
<TestProviders>
<ArrowBody height={3} />
</TestProviders>
);
expect(toJson(wrapper)).toMatchSnapshot();
});
});
describe('ArrowHead', () => {
test('it renders an arrow head icon', () => {
const wrapper = mount(
<TestProviders>
<ArrowHead direction={'arrowLeft'} />
</TestProviders>
);
expect(
wrapper
.find('[data-test-subj="arrow-icon"]')
.first()
.exists()
).toBe(true);
});
});
});

View file

@ -0,0 +1,26 @@
/*
* 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 { EuiIcon } from '@elastic/eui';
import * as React from 'react';
import { pure } from 'recompose';
import styled from 'styled-components';
/** Renders the body (non-pointy part) of an arrow */
export const ArrowBody = styled.span<{ height: number }>`
background-color: ${props => props.theme.eui.euiColorLightShade};
height: ${({ height }) => `${height}px`};
width: 25px;
`;
export type ArrowDirection = 'arrowLeft' | 'arrowRight';
/** Renders the head of an arrow */
export const ArrowHead = pure<{
direction: ArrowDirection;
}>(({ direction }) => (
<EuiIcon color="subdued" data-test-subj="arrow-icon" size="s" type={direction} />
));

View file

@ -0,0 +1,182 @@
/*
* 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 isEqual from 'lodash/fp/isEqual';
import React from 'react';
import ResizeObserver from 'resize-observer-polyfill';
interface Measurement {
width?: number;
height?: number;
}
interface Measurements {
bounds: Measurement;
content: Measurement;
windowMeasurement: Measurement;
}
interface AutoSizerProps {
detectAnyWindowResize?: boolean;
bounds?: boolean;
content?: boolean;
onResize?: (size: Measurements) => void;
children: (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
args: { measureRef: (instance: HTMLElement | null) => any } & Measurements
) => React.ReactNode;
}
interface AutoSizerState {
boundsMeasurement: Measurement;
contentMeasurement: Measurement;
windowMeasurement: Measurement;
}
/** A hard-fork of the `infra` `AutoSizer` ಠ_ಠ */
export class AutoSizer extends React.PureComponent<AutoSizerProps, AutoSizerState> {
public element: HTMLElement | null = null;
public resizeObserver: ResizeObserver | null = null;
public windowWidth: number = -1;
public readonly state = {
boundsMeasurement: {
height: void 0,
width: void 0,
},
contentMeasurement: {
height: void 0,
width: void 0,
},
windowMeasurement: {
height: void 0,
width: void 0,
},
};
constructor(props: AutoSizerProps) {
super(props);
if (this.props.detectAnyWindowResize) {
window.addEventListener('resize', this.updateMeasurement);
}
this.resizeObserver = new ResizeObserver(entries => {
entries.forEach(entry => {
if (entry.target === this.element) {
this.measure(entry);
}
});
});
}
public componentWillUnmount() {
if (this.resizeObserver) {
this.resizeObserver.disconnect();
this.resizeObserver = null;
}
if (this.props.detectAnyWindowResize) {
window.removeEventListener('resize', this.updateMeasurement);
}
}
public measure = (entry: ResizeObserverEntry | null) => {
if (!this.element) {
return;
}
const { content = true, bounds = false } = this.props;
const {
boundsMeasurement: previousBoundsMeasurement,
contentMeasurement: previousContentMeasurement,
windowMeasurement: previousWindowMeasurement,
} = this.state;
const boundsRect = bounds ? this.element.getBoundingClientRect() : null;
const boundsMeasurement = boundsRect
? {
height: this.element.getBoundingClientRect().height,
width: this.element.getBoundingClientRect().width,
}
: previousBoundsMeasurement;
const windowMeasurement: Measurement = {
width: window.innerWidth,
height: window.innerHeight,
};
if (
this.props.detectAnyWindowResize &&
boundsMeasurement &&
boundsMeasurement.width &&
this.windowWidth !== -1 &&
this.windowWidth > window.innerWidth
) {
const gap = this.windowWidth - window.innerWidth;
boundsMeasurement.width = boundsMeasurement.width - gap;
}
this.windowWidth = window.innerWidth;
const contentRect = content && entry ? entry.contentRect : null;
const contentMeasurement =
contentRect && entry
? {
height: entry.contentRect.height,
width: entry.contentRect.width,
}
: previousContentMeasurement;
if (
isEqual(boundsMeasurement, previousBoundsMeasurement) &&
isEqual(contentMeasurement, previousContentMeasurement) &&
isEqual(windowMeasurement, previousWindowMeasurement)
) {
return;
}
requestAnimationFrame(() => {
if (!this.resizeObserver) {
return;
}
this.setState({ boundsMeasurement, contentMeasurement, windowMeasurement });
if (this.props.onResize) {
this.props.onResize({
bounds: boundsMeasurement,
content: contentMeasurement,
windowMeasurement,
});
}
});
};
public render() {
const { children } = this.props;
const { boundsMeasurement, contentMeasurement, windowMeasurement } = this.state;
return children({
bounds: boundsMeasurement,
content: contentMeasurement,
windowMeasurement,
measureRef: this.storeRef,
});
}
private updateMeasurement = () => {
window.setTimeout(() => {
this.measure(null);
}, 0);
};
private storeRef = (element: HTMLElement | null) => {
if (this.element && this.resizeObserver) {
this.resizeObserver.unobserve(this.element);
}
if (element && this.resizeObserver) {
this.resizeObserver.observe(element);
}
this.element = element;
};
}

View file

@ -0,0 +1,25 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Autocomplete rendering it renders against snapshot 1`] = `
<EuiOutsideClickDetector
onOutsideClick={[Function]}
>
<styled.div>
<EuiFieldSearch
compressed={false}
fullWidth={true}
incremental={false}
inputRef={[Function]}
isInvalid={true}
isLoading={false}
onChange={[Function]}
onFocus={[Function]}
onKeyDown={[Function]}
onKeyUp={[Function]}
onSearch={[Function]}
placeholder="myPlaceholder"
value=""
/>
</styled.div>
</EuiOutsideClickDetector>
`;

View file

@ -0,0 +1,385 @@
/*
* 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 { EuiFieldSearch } from '@elastic/eui';
import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json';
import { mount, shallow } from 'enzyme';
import toJson from 'enzyme-to-json';
import { noop } from 'lodash/fp';
import * as React from 'react';
import { ThemeProvider } from 'styled-components';
import { AutocompleteSuggestion } from 'ui/autocomplete_providers';
import { TestProviders } from '../../mock';
import { AutocompleteField } from '.';
const mockAutoCompleteData: AutocompleteSuggestion[] = [
{
type: 'field',
text: 'agent.ephemeral_id ',
description:
'<p>Filter results that contain <span class="suggestionItem__callout">agent.ephemeral_id</span></p>',
start: 0,
end: 1,
},
{
type: 'field',
text: 'agent.hostname ',
description:
'<p>Filter results that contain <span class="suggestionItem__callout">agent.hostname</span></p>',
start: 0,
end: 1,
},
{
type: 'field',
text: 'agent.id ',
description:
'<p>Filter results that contain <span class="suggestionItem__callout">agent.id</span></p>',
start: 0,
end: 1,
},
{
type: 'field',
text: 'agent.name ',
description:
'<p>Filter results that contain <span class="suggestionItem__callout">agent.name</span></p>',
start: 0,
end: 1,
},
{
type: 'field',
text: 'agent.type ',
description:
'<p>Filter results that contain <span class="suggestionItem__callout">agent.type</span></p>',
start: 0,
end: 1,
},
{
type: 'field',
text: 'agent.version ',
description:
'<p>Filter results that contain <span class="suggestionItem__callout">agent.version</span></p>',
start: 0,
end: 1,
},
{
type: 'field',
text: 'agent.test1 ',
description:
'<p>Filter results that contain <span class="suggestionItem__callout">agent.test1</span></p>',
start: 0,
end: 1,
},
{
type: 'field',
text: 'agent.test2 ',
description:
'<p>Filter results that contain <span class="suggestionItem__callout">agent.test2</span></p>',
start: 0,
end: 1,
},
{
type: 'field',
text: 'agent.test3 ',
description:
'<p>Filter results that contain <span class="suggestionItem__callout">agent.test3</span></p>',
start: 0,
end: 1,
},
{
type: 'field',
text: 'agent.test4 ',
description:
'<p>Filter results that contain <span class="suggestionItem__callout">agent.test4</span></p>',
start: 0,
end: 1,
},
];
describe('Autocomplete', () => {
describe('rendering', () => {
test('it renders against snapshot', () => {
const placeholder = 'myPlaceholder';
const wrapper = shallow(
<AutocompleteField
isLoadingSuggestions={false}
isValid={false}
loadSuggestions={noop}
onChange={noop}
onSubmit={noop}
placeholder={placeholder}
suggestions={[]}
value={''}
/>
);
expect(toJson(wrapper)).toMatchSnapshot();
});
test('it is rendering with placeholder', () => {
const placeholder = 'myPlaceholder';
const wrapper = mount(
<AutocompleteField
isLoadingSuggestions={false}
isValid={false}
loadSuggestions={noop}
onChange={noop}
onSubmit={noop}
placeholder={placeholder}
suggestions={[]}
value={''}
/>
);
const input = wrapper.find('input[type="search"]');
expect(input.find('[placeholder]').props().placeholder).toEqual(placeholder);
});
test('Rendering suggested items', () => {
const wrapper = mount(
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>
<AutocompleteField
isLoadingSuggestions={false}
isValid={false}
loadSuggestions={noop}
onChange={noop}
onSubmit={noop}
placeholder=""
suggestions={mockAutoCompleteData}
value={''}
/>
</ThemeProvider>
);
const wrapperAutocompleteField = wrapper.find(AutocompleteField);
wrapperAutocompleteField.setState({ areSuggestionsVisible: true });
wrapper.update();
expect(wrapper.find('.euiPanel div[data-test-subj="suggestion-item"]').length).toEqual(10);
});
test('Should Not render suggested items if loading new suggestions', () => {
const wrapper = mount(
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>
<AutocompleteField
isLoadingSuggestions={true}
isValid={false}
loadSuggestions={noop}
onChange={noop}
onSubmit={noop}
placeholder=""
suggestions={mockAutoCompleteData}
value={''}
/>
</ThemeProvider>
);
const wrapperAutocompleteField = wrapper.find(AutocompleteField);
wrapperAutocompleteField.setState({ areSuggestionsVisible: true });
wrapper.update();
expect(wrapper.find('.euiPanel div[data-test-subj="suggestion-item"]').length).toEqual(0);
});
});
describe('events', () => {
test('OnChange should have been called', () => {
const onChange = jest.fn((value: string) => value);
const wrapper = mount(
<AutocompleteField
isLoadingSuggestions={false}
isValid={false}
loadSuggestions={noop}
onChange={onChange}
onSubmit={noop}
placeholder=""
suggestions={[]}
value={''}
/>
);
const wrapperFixedEuiFieldSearch = wrapper.find('input');
wrapperFixedEuiFieldSearch.simulate('change', { target: { value: 'test' } });
expect(onChange).toHaveBeenCalled();
});
});
test('OnSubmit should have been called by keying enter on the search input', () => {
const onSubmit = jest.fn((value: string) => value);
const wrapper = mount(
<AutocompleteField
isLoadingSuggestions={false}
isValid={true}
loadSuggestions={noop}
onChange={noop}
onSubmit={onSubmit}
placeholder=""
suggestions={mockAutoCompleteData}
value={'filter: query'}
/>
);
const wrapperAutocompleteField = wrapper.find(AutocompleteField);
wrapperAutocompleteField.setState({ selectedIndex: null });
const wrapperFixedEuiFieldSearch = wrapper.find('input');
wrapperFixedEuiFieldSearch.simulate('keydown', { key: 'Enter', preventDefault: noop });
expect(onSubmit).toHaveBeenCalled();
});
test('OnSubmit should have been called by onSearch event on the input', () => {
const onSubmit = jest.fn((value: string) => value);
const wrapper = mount(
<AutocompleteField
isLoadingSuggestions={false}
isValid={true}
loadSuggestions={noop}
onChange={noop}
onSubmit={onSubmit}
placeholder=""
suggestions={mockAutoCompleteData}
value={'filter: query'}
/>
);
const wrapperAutocompleteField = wrapper.find(AutocompleteField);
wrapperAutocompleteField.setState({ selectedIndex: null });
const wrapperFixedEuiFieldSearch = wrapper.find(EuiFieldSearch);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- TODO: FixedEuiFieldSearch fails to import
(wrapperFixedEuiFieldSearch as any).props().onSearch();
expect(onSubmit).toHaveBeenCalled();
});
test('OnChange should have been called if keying enter on a suggested item selected', () => {
const onChange = jest.fn((value: string) => value);
const wrapper = mount(
<AutocompleteField
isLoadingSuggestions={false}
isValid={false}
loadSuggestions={noop}
onChange={onChange}
onSubmit={noop}
placeholder=""
suggestions={mockAutoCompleteData}
value={''}
/>
);
const wrapperAutocompleteField = wrapper.find(AutocompleteField);
wrapperAutocompleteField.setState({ selectedIndex: 1 });
const wrapperFixedEuiFieldSearch = wrapper.find('input');
wrapperFixedEuiFieldSearch.simulate('keydown', { key: 'Enter', preventDefault: noop });
expect(onChange).toHaveBeenCalled();
});
test('OnChange should be called if tab is pressed when a suggested item is selected', () => {
const onChange = jest.fn((value: string) => value);
const wrapper = mount(
<AutocompleteField
isLoadingSuggestions={false}
isValid={false}
loadSuggestions={noop}
onChange={onChange}
onSubmit={noop}
placeholder=""
suggestions={mockAutoCompleteData}
value={''}
/>
);
const wrapperAutocompleteField = wrapper.find(AutocompleteField);
wrapperAutocompleteField.setState({ selectedIndex: 1 });
const wrapperFixedEuiFieldSearch = wrapper.find('input');
wrapperFixedEuiFieldSearch.simulate('keydown', { key: 'Tab', preventDefault: noop });
expect(onChange).toHaveBeenCalled();
});
test('OnChange should NOT be called if tab is pressed when more than one item is suggested, and no selection has been made', () => {
const onChange = jest.fn((value: string) => value);
const wrapper = mount(
<AutocompleteField
isLoadingSuggestions={false}
isValid={false}
loadSuggestions={noop}
onChange={onChange}
onSubmit={noop}
placeholder=""
suggestions={mockAutoCompleteData}
value={''}
/>
);
const wrapperFixedEuiFieldSearch = wrapper.find('input');
wrapperFixedEuiFieldSearch.simulate('keydown', { key: 'Tab', preventDefault: noop });
expect(onChange).not.toHaveBeenCalled();
});
test('OnChange should be called if tab is pressed when only one item is suggested, even though that item is NOT selected', () => {
const onChange = jest.fn((value: string) => value);
const onlyOneSuggestion = [mockAutoCompleteData[0]];
const wrapper = mount(
<TestProviders>
<AutocompleteField
isLoadingSuggestions={false}
isValid={false}
loadSuggestions={noop}
onChange={onChange}
onSubmit={noop}
placeholder=""
suggestions={onlyOneSuggestion}
value={''}
/>
</TestProviders>
);
const wrapperAutocompleteField = wrapper.find(AutocompleteField);
wrapperAutocompleteField.setState({ areSuggestionsVisible: true });
const wrapperFixedEuiFieldSearch = wrapper.find('input');
wrapperFixedEuiFieldSearch.simulate('keydown', { key: 'Tab', preventDefault: noop });
expect(onChange).toHaveBeenCalled();
});
test('OnChange should NOT be called if tab is pressed when 0 items are suggested', () => {
const onChange = jest.fn((value: string) => value);
const wrapper = mount(
<AutocompleteField
isLoadingSuggestions={false}
isValid={false}
loadSuggestions={noop}
onChange={onChange}
onSubmit={noop}
placeholder=""
suggestions={[]}
value={''}
/>
);
const wrapperFixedEuiFieldSearch = wrapper.find('input');
wrapperFixedEuiFieldSearch.simulate('keydown', { key: 'Tab', preventDefault: noop });
expect(onChange).not.toHaveBeenCalled();
});
test('Load more suggestions when arrowdown on the search bar', () => {
const loadSuggestions = jest.fn(noop);
const wrapper = mount(
<AutocompleteField
isLoadingSuggestions={false}
isValid={false}
loadSuggestions={loadSuggestions}
onChange={noop}
onSubmit={noop}
placeholder=""
suggestions={[]}
value={''}
/>
);
const wrapperFixedEuiFieldSearch = wrapper.find('input');
wrapperFixedEuiFieldSearch.simulate('keydown', { key: 'ArrowDown', preventDefault: noop });
expect(loadSuggestions).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,323 @@
/*
* 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 {
EuiFieldSearch,
EuiFieldSearchProps,
EuiOutsideClickDetector,
EuiPanel,
} from '@elastic/eui';
import React from 'react';
import { AutocompleteSuggestion } from 'ui/autocomplete_providers';
import euiStyled from '../../../../../common/eui_styled_components';
import { SuggestionItem } from './suggestion_item';
interface AutocompleteFieldProps {
isLoadingSuggestions: boolean;
isValid: boolean;
loadSuggestions: (value: string, cursorPosition: number, maxCount?: number) => void;
onSubmit?: (value: string) => void;
onChange?: (value: string) => void;
placeholder?: string;
suggestions: AutocompleteSuggestion[];
value: string;
}
interface AutocompleteFieldState {
areSuggestionsVisible: boolean;
isFocused: boolean;
selectedIndex: number | null;
}
export class AutocompleteField extends React.PureComponent<
AutocompleteFieldProps,
AutocompleteFieldState
> {
public readonly state: AutocompleteFieldState = {
areSuggestionsVisible: false,
isFocused: false,
selectedIndex: null,
};
private inputElement: HTMLInputElement | null = null;
public render() {
const { suggestions, isLoadingSuggestions, isValid, placeholder, value } = this.props;
const { areSuggestionsVisible, selectedIndex } = this.state;
return (
<EuiOutsideClickDetector onOutsideClick={this.handleBlur}>
<AutocompleteContainer>
<FixedEuiFieldSearch
fullWidth
inputRef={this.handleChangeInputRef}
isLoading={isLoadingSuggestions}
isInvalid={!isValid}
onChange={this.handleChange}
onFocus={this.handleFocus}
onKeyDown={this.handleKeyDown}
onKeyUp={this.handleKeyUp}
onSearch={this.submit}
placeholder={placeholder}
value={value}
/>
{areSuggestionsVisible && !isLoadingSuggestions && suggestions.length > 0 ? (
<SuggestionsPanel>
{suggestions.map((suggestion, suggestionIndex) => (
<SuggestionItem
key={suggestion.text}
suggestion={suggestion}
isSelected={suggestionIndex === selectedIndex}
onMouseEnter={this.selectSuggestionAt(suggestionIndex)}
onClick={this.applySuggestionAt(suggestionIndex)}
/>
))}
</SuggestionsPanel>
) : null}
</AutocompleteContainer>
</EuiOutsideClickDetector>
);
}
public componentDidUpdate(prevProps: AutocompleteFieldProps, prevState: AutocompleteFieldState) {
const hasNewValue = prevProps.value !== this.props.value;
const hasNewSuggestions = prevProps.suggestions !== this.props.suggestions;
if (hasNewValue) {
this.updateSuggestions();
}
if (hasNewSuggestions && this.state.isFocused) {
this.showSuggestions();
}
}
private handleChangeInputRef = (element: HTMLInputElement | null) => {
this.inputElement = element;
};
private handleChange = (evt: React.ChangeEvent<HTMLInputElement>) => {
this.changeValue(evt.currentTarget.value);
};
private handleKeyDown = (evt: React.KeyboardEvent<HTMLInputElement>) => {
const { suggestions } = this.props;
switch (evt.key) {
case 'ArrowUp':
evt.preventDefault();
if (suggestions.length > 0) {
this.setState(
composeStateUpdaters(withSuggestionsVisible, withPreviousSuggestionSelected)
);
}
break;
case 'ArrowDown':
evt.preventDefault();
if (suggestions.length > 0) {
this.setState(composeStateUpdaters(withSuggestionsVisible, withNextSuggestionSelected));
} else {
this.updateSuggestions();
}
break;
case 'Enter':
evt.preventDefault();
if (this.state.selectedIndex !== null) {
this.applySelectedSuggestion();
} else {
this.submit();
}
break;
case 'Tab':
evt.preventDefault();
if (this.state.areSuggestionsVisible && this.props.suggestions.length === 1) {
this.applySuggestionAt(0)();
} else if (this.state.selectedIndex !== null) {
this.applySelectedSuggestion();
}
break;
case 'Escape':
evt.preventDefault();
evt.stopPropagation();
this.setState(withSuggestionsHidden);
break;
}
};
private handleKeyUp = (evt: React.KeyboardEvent<HTMLInputElement>) => {
switch (evt.key) {
case 'ArrowLeft':
case 'ArrowRight':
case 'Home':
case 'End':
this.updateSuggestions();
break;
}
};
private handleFocus = () => {
this.setState(composeStateUpdaters(withSuggestionsVisible, withFocused));
};
private handleBlur = () => {
this.setState(composeStateUpdaters(withSuggestionsHidden, withUnfocused));
};
private selectSuggestionAt = (index: number) => () => {
this.setState(withSuggestionAtIndexSelected(index));
};
private applySelectedSuggestion = () => {
if (this.state.selectedIndex !== null) {
this.applySuggestionAt(this.state.selectedIndex)();
}
};
private applySuggestionAt = (index: number) => () => {
const { value, suggestions } = this.props;
const selectedSuggestion = suggestions[index];
if (!selectedSuggestion) {
return;
}
const newValue =
value.substr(0, selectedSuggestion.start) +
selectedSuggestion.text +
value.substr(selectedSuggestion.end);
this.setState(withSuggestionsHidden);
this.changeValue(newValue);
this.focusInputElement();
};
private changeValue = (value: string) => {
const { onChange } = this.props;
if (onChange) {
onChange(value);
}
};
private focusInputElement = () => {
if (this.inputElement) {
this.inputElement.focus();
}
};
private showSuggestions = () => {
this.setState(withSuggestionsVisible);
};
private submit = () => {
const { isValid, onSubmit, value } = this.props;
if (isValid && onSubmit) {
onSubmit(value);
}
this.setState(withSuggestionsHidden);
};
private updateSuggestions = () => {
const inputCursorPosition = this.inputElement ? this.inputElement.selectionStart || 0 : 0;
this.props.loadSuggestions(this.props.value, inputCursorPosition, 10);
};
}
type StateUpdater<State, Props = {}> = (
prevState: Readonly<State>,
prevProps: Readonly<Props>
) => State | null;
function composeStateUpdaters<State, Props>(...updaters: Array<StateUpdater<State, Props>>) {
return (state: State, props: Props) =>
updaters.reduce((currentState, updater) => updater(currentState, props) || currentState, state);
}
const withPreviousSuggestionSelected = (
state: AutocompleteFieldState,
props: AutocompleteFieldProps
): AutocompleteFieldState => ({
...state,
selectedIndex:
props.suggestions.length === 0
? null
: state.selectedIndex !== null
? (state.selectedIndex + props.suggestions.length - 1) % props.suggestions.length
: Math.max(props.suggestions.length - 1, 0),
});
const withNextSuggestionSelected = (
state: AutocompleteFieldState,
props: AutocompleteFieldProps
): AutocompleteFieldState => ({
...state,
selectedIndex:
props.suggestions.length === 0
? null
: state.selectedIndex !== null
? (state.selectedIndex + 1) % props.suggestions.length
: 0,
});
const withSuggestionAtIndexSelected = (suggestionIndex: number) => (
state: AutocompleteFieldState,
props: AutocompleteFieldProps
): AutocompleteFieldState => ({
...state,
selectedIndex:
props.suggestions.length === 0
? null
: suggestionIndex >= 0 && suggestionIndex < props.suggestions.length
? suggestionIndex
: 0,
});
const withSuggestionsVisible = (state: AutocompleteFieldState) => ({
...state,
areSuggestionsVisible: true,
});
const withSuggestionsHidden = (state: AutocompleteFieldState) => ({
...state,
areSuggestionsVisible: false,
selectedIndex: null,
});
const withFocused = (state: AutocompleteFieldState) => ({
...state,
isFocused: true,
});
const withUnfocused = (state: AutocompleteFieldState) => ({
...state,
isFocused: false,
});
export const FixedEuiFieldSearch: React.SFC<
React.InputHTMLAttributes<HTMLInputElement> &
EuiFieldSearchProps & {
inputRef?: (element: HTMLInputElement | null) => void;
onSearch: (value: string) => void;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
> = EuiFieldSearch as any;
const AutocompleteContainer = euiStyled.div`
position: relative;
`;
const SuggestionsPanel = euiStyled(EuiPanel).attrs({
paddingSize: 'none',
hasShadow: true,
})`
position: absolute;
width: 100%;
margin-top: 2px;
overflow: hidden;
z-index: ${props => props.theme.eui.euiZLevel1};;
`;

View file

@ -0,0 +1,125 @@
/*
* 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 { EuiIcon } from '@elastic/eui';
import { transparentize } from 'polished';
import React from 'react';
import { AutocompleteSuggestion } from 'ui/autocomplete_providers';
import euiStyled from '../../../../../common/eui_styled_components';
interface SuggestionItemProps {
isSelected?: boolean;
onClick?: React.MouseEventHandler<HTMLDivElement>;
onMouseEnter?: React.MouseEventHandler<HTMLDivElement>;
suggestion: AutocompleteSuggestion;
}
export class SuggestionItem extends React.PureComponent<SuggestionItemProps> {
public static defaultProps: Partial<SuggestionItemProps> = {
isSelected: false,
};
public render() {
const { isSelected, onClick, onMouseEnter, suggestion } = this.props;
return (
<SuggestionItemContainer
isSelected={isSelected}
onClick={onClick}
onMouseEnter={onMouseEnter}
data-test-subj="suggestion-item"
>
<SuggestionItemIconField suggestionType={suggestion.type}>
<EuiIcon type={getEuiIconType(suggestion.type)} />
</SuggestionItemIconField>
<SuggestionItemTextField>{suggestion.text}</SuggestionItemTextField>
<SuggestionItemDescriptionField>{suggestion.description}</SuggestionItemDescriptionField>
</SuggestionItemContainer>
);
}
}
const SuggestionItemContainer = euiStyled.div<{
isSelected?: boolean;
}>`
display: flex;
flex-direction: row;
font-size: ${props => props.theme.eui.euiFontSizeS};
height: ${props => props.theme.eui.euiSizeXL};
white-space: nowrap;
background-color: ${props =>
props.isSelected ? props.theme.eui.euiColorLightestShade : 'transparent'};
`;
const SuggestionItemField = euiStyled.div`
align-items: center;
cursor: pointer;
display: flex;
flex-direction: row;
height: ${props => props.theme.eui.euiSizeXL};
padding: ${props => props.theme.eui.euiSizeXS};
`;
const SuggestionItemIconField = SuggestionItemField.extend<{ suggestionType: string }>`
background-color: ${props =>
transparentize(0.9, getEuiIconColor(props.theme, props.suggestionType))};
color: ${props => getEuiIconColor(props.theme, props.suggestionType)};
flex: 0 0 auto;
justify-content: center;
width: ${props => props.theme.eui.euiSizeXL};
`;
const SuggestionItemTextField = SuggestionItemField.extend`
flex: 2 0 0;
font-family: ${props => props.theme.eui.euiCodeFontFamily};
`;
const SuggestionItemDescriptionField = SuggestionItemField.extend`
flex: 3 0 0;
p {
display: inline;
span {
font-family: ${props => props.theme.eui.euiCodeFontFamily};
}
}
`;
const getEuiIconType = (suggestionType: string) => {
switch (suggestionType) {
case 'field':
return 'kqlField';
case 'value':
return 'kqlValue';
case 'recentSearch':
return 'search';
case 'conjunction':
return 'kqlSelector';
case 'operator':
return 'kqlOperand';
default:
return 'empty';
}
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const getEuiIconColor = (theme: any, suggestionType: string): string => {
switch (suggestionType) {
case 'field':
return theme.eui.euiColorVis7;
case 'value':
return theme.eui.euiColorVis0;
case 'operator':
return theme.eui.euiColorVis1;
case 'conjunction':
return theme.eui.euiColorVis2;
case 'recentSearch':
default:
return theme.eui.euiColorMediumShade;
}
};

View file

@ -0,0 +1,31 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Card Items rendering it renders loading icons 1`] = `
<Component
fields={
Array [
Object {
"description": "NETWORK_EVENTS",
"key": "networkEvents",
"value": null,
},
]
}
isLoading={true}
/>
`;
exports[`Card Items rendering it renders the default widget 1`] = `
<Component
fields={
Array [
Object {
"description": "NETWORK_EVENTS",
"key": "networkEvents",
"value": null,
},
]
}
isLoading={false}
/>
`;

View file

@ -0,0 +1,73 @@
/*
* 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 {
// @ts-ignore
EuiCard,
} from '@elastic/eui';
import { mount, shallow } from 'enzyme';
import toJson from 'enzyme-to-json';
import * as React from 'react';
import { CardItemsComponent, CardItemsProps } from '.';
describe('Card Items', () => {
describe('rendering', () => {
test('it renders loading icons', () => {
const mockCardItemsData: CardItemsProps = {
fields: [
{
key: 'networkEvents',
description: 'NETWORK_EVENTS',
value: null,
},
],
isLoading: true,
key: 'mock-key',
};
const wrapper = shallow(<CardItemsComponent {...mockCardItemsData} />);
expect(toJson(wrapper)).toMatchSnapshot();
});
test('it renders the default widget', () => {
const mockCardItemsData: CardItemsProps = {
fields: [
{
key: 'networkEvents',
description: 'NETWORK_EVENTS',
value: null,
},
],
isLoading: false,
key: 'mock-key',
};
const wrapper = shallow(<CardItemsComponent {...mockCardItemsData} />);
expect(toJson(wrapper)).toMatchSnapshot();
});
it('should handle multiple titles', () => {
const mockCardItemsData: CardItemsProps = {
fields: [
{
key: 'uniqueSourcePrivateIps',
description: 'UNIQUE_SOURCE_PRIVATE_IPS',
value: null,
},
{
key: 'uniqueDestinationPrivateIps',
description: 'UNIQUE_DESTINATION_PRIVATE_IPS',
value: null,
},
],
description: 'UNIQUE_PRIVATE_IPS',
isLoading: false,
key: 'mock-keys',
};
const wrapper = mount(<CardItemsComponent {...mockCardItemsData} />);
expect(wrapper.find(EuiCard).prop('title')).toHaveLength(2);
});
});
});

View file

@ -0,0 +1,79 @@
/*
* 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 {
// @ts-ignore
EuiCard,
EuiFlexGroup,
EuiFlexItem,
EuiLoadingSpinner,
} from '@elastic/eui';
import numeral from '@elastic/numeral';
import React from 'react';
import { pure } from 'recompose';
import { getEmptyTagValue } from '../empty_value';
export interface CardItem {
key: string;
description: string;
value: number | undefined | null;
}
export interface CardItems {
fields: CardItem[];
description?: string;
}
export interface CardItemsProps extends CardItems {
isLoading: boolean;
key: string;
}
const CardTitle = pure<{ isLoading: boolean; value: number | null | undefined }>(
({ isLoading, value }) => (
<>
{isLoading ? (
<EuiLoadingSpinner size="m" />
) : value != null ? (
numeral(value).format('0,0')
) : (
getEmptyTagValue()
)}
</>
)
);
export const CardItemsComponent = pure<CardItemsProps>(
({ fields, description, isLoading, key }) => (
<EuiFlexItem key={`card-items-${key}`}>
{fields.length === 1 ? (
<EuiCard
title={<CardTitle isLoading={isLoading} value={fields[0].value} />}
description={fields[0].description}
/>
) : (
<EuiCard
title={fields.map(field => (
<EuiFlexGroup
key={`card-items-field-${field.key}`}
gutterSize="s"
justifyContent="spaceBetween"
>
<EuiFlexItem grow={false} component="span">
<CardTitle isLoading={isLoading} value={field.value} />
</EuiFlexItem>
<EuiFlexItem grow={false} component="span">
{field.description}
</EuiFlexItem>
</EuiFlexGroup>
))}
description={description}
/>
)}
</EuiFlexItem>
)
);

View file

@ -0,0 +1,77 @@
/*
* 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 { TestProviders } from '../../mock';
import { CertificateFingerprint } from '.';
describe('CertificateFingerprint', () => {
test('renders the expected label', () => {
const wrapper = mount(
<TestProviders>
<CertificateFingerprint
eventId="Tgwnt2kBqd-n62SwPZDP"
certificateType="client"
contextId="test"
fieldName="tls.client_certificate.fingerprint.sha1"
value="3f4c57934e089f02ae7511200aee2d7e7aabd272"
/>
</TestProviders>
);
expect(
wrapper
.find('[data-test-subj="fingerprint-label"]')
.first()
.text()
).toEqual('client cert');
});
test('renders the fingerprint as text', () => {
const wrapper = mount(
<TestProviders>
<CertificateFingerprint
eventId="Tgwnt2kBqd-n62SwPZDP"
certificateType="client"
contextId="test"
fieldName="tls.client_certificate.fingerprint.sha1"
value="3f4c57934e089f02ae7511200aee2d7e7aabd272"
/>
</TestProviders>
);
expect(
wrapper
.find('[data-test-subj="certificate-fingerprint-link"]')
.first()
.text()
).toEqual('3f4c57934e089f02ae7511200aee2d7e7aabd272');
});
test('it renders a hyperlink to an external site to compare the fingerprint against a known set of signatures', () => {
const wrapper = mount(
<TestProviders>
<CertificateFingerprint
eventId="Tgwnt2kBqd-n62SwPZDP"
certificateType="client"
contextId="test"
fieldName="tls.client_certificate.fingerprint.sha1"
value="3f4c57934e089f02ae7511200aee2d7e7aabd272"
/>
</TestProviders>
);
expect(
wrapper
.find('[data-test-subj="certificate-fingerprint-link"]')
.first()
.props().href
).toEqual(
'https://sslbl.abuse.ch/ssl-certificates/sha1/3f4c57934e089f02ae7511200aee2d7e7aabd272'
);
});
});

View file

@ -0,0 +1,65 @@
/*
* 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 { EuiText } from '@elastic/eui';
import * as React from 'react';
import { pure } from 'recompose';
import styled from 'styled-components';
import { DraggableBadge } from '../draggables';
import { ExternalLinkIcon } from '../external_link_icon';
import { CertificateFingerprintLink } from '../links';
import * as i18n from './translations';
export type CertificateType = 'client' | 'server';
export const TLS_CLIENT_CERTIFICATE_FINGERPRINT_SHA1_FIELD_NAME =
'tls.client_certificate.fingerprint.sha1';
export const TLS_SERVER_CERTIFICATE_FINGERPRINT_SHA1_FIELD_NAME =
'tls.server_certificate.fingerprint.sha1';
const FingerprintLabel = styled.span`
margin-right: 5px;
`;
/**
* Represents a field containing a certificate fingerprint (e.g. a sha1), with
* a link to an external site, which in-turn compares the fingerprint against a
* set of known fingerprints
* Examples:
* 'tls.client_certificate.fingerprint.sha1'
* 'tls.server_certificate.fingerprint.sha1'
*/
export const CertificateFingerprint = pure<{
eventId: string;
certificateType: CertificateType;
contextId: string;
fieldName: string;
value?: string | null;
}>(({ eventId, certificateType, contextId, fieldName, value }) => {
return (
<DraggableBadge
contextId={contextId}
data-test-subj={`${certificateType}-certificate-fingerprint`}
eventId={eventId}
field={fieldName}
iconType="snowflake"
tooltipContent={
<EuiText size="xs">
<span>{fieldName}</span>
</EuiText>
}
value={value}
>
<FingerprintLabel data-test-subj="fingerprint-label">
{certificateType === 'client' ? i18n.CLIENT_CERT : i18n.SERVER_CERT}
</FingerprintLabel>
<CertificateFingerprintLink certificateFingerprint={value || ''} />
<ExternalLinkIcon />
</DraggableBadge>
);
});

View file

@ -0,0 +1,15 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const CLIENT_CERT = i18n.translate('xpack.siem.certificate.fingerprint.clientCertLabel', {
defaultMessage: 'client cert',
});
export const SERVER_CERT = i18n.translate('xpack.siem.certificate.fingerprint.serverCertLabel', {
defaultMessage: 'server cert',
});

View file

@ -0,0 +1,47 @@
/*
* 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 {
DEFAULT_ICON,
EXTERNAL,
getDirectionIcon,
INBOUND,
INCOMING,
INTERNAL,
LISTENING,
OUTBOUND,
OUTGOING,
UNKNOWN,
} from '.';
describe('direction', () => {
describe('#getDirectionIcon', () => {
const knownDirections = [
{ direction: INBOUND, expected: 'arrowDown' },
{ direction: OUTBOUND, expected: 'arrowUp' },
{ direction: EXTERNAL, expected: 'globe' },
{ direction: INTERNAL, expected: 'bullseye' },
{ direction: INCOMING, expected: 'arrowDown' },
{ direction: OUTGOING, expected: 'arrowUp' },
{ direction: LISTENING, expected: 'arrowDown' },
{ direction: UNKNOWN, expected: DEFAULT_ICON },
];
test('returns the default icon when the direction is null', () => {
expect(getDirectionIcon(null)).toEqual(DEFAULT_ICON);
});
test('returns the default icon when the direction is an unexpected value', () => {
expect(getDirectionIcon('that was unexpected!')).toEqual(DEFAULT_ICON);
});
knownDirections.forEach(({ direction, expected }) => {
test(`returns ${expected} for known direction ${direction}`, () => {
expect(getDirectionIcon(direction)).toEqual(expected);
});
});
});
});

View file

@ -0,0 +1,72 @@
/*
* 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 { NetworkDirectionEcs } from '../../graphql/types';
import { DraggableBadge } from '../draggables';
import { NETWORK_DIRECTION_FIELD_NAME } from '../source_destination/network';
export const INBOUND = 'inbound';
export const OUTBOUND = 'outbound';
export const EXTERNAL = 'external';
export const INTERNAL = 'internal';
export const INCOMING = 'incoming';
export const OUTGOING = 'outgoing';
export const LISTENING = 'listening';
export const UNKNOWN = 'unknown';
export const DEFAULT_ICON = 'questionInCircle';
/** Returns an icon representing the value of `network.direction` */
export const getDirectionIcon = (
networkDirection?: string | null
): 'arrowUp' | 'arrowDown' | 'globe' | 'bullseye' | 'questionInCircle' => {
if (networkDirection == null) {
return DEFAULT_ICON;
}
const direction = `${networkDirection}`.toLowerCase();
switch (direction) {
case NetworkDirectionEcs.outbound:
case NetworkDirectionEcs.outgoing:
return 'arrowUp';
case NetworkDirectionEcs.inbound:
case NetworkDirectionEcs.incoming:
case NetworkDirectionEcs.listening:
return 'arrowDown';
case NetworkDirectionEcs.external:
return 'globe';
case NetworkDirectionEcs.internal:
return 'bullseye';
case NetworkDirectionEcs.unknown:
default:
return DEFAULT_ICON;
}
};
/**
* Renders a badge containing the value of `network.direction`
*/
export const DirectionBadge = pure<{
contextId: string;
direction?: string | null;
eventId: string;
}>(({ contextId, eventId, direction }) => (
<DraggableBadge
contextId={contextId}
data-test-subj="network-direction"
eventId={eventId}
field={NETWORK_DIRECTION_FIELD_NAME}
iconType={getDirectionIcon(direction)}
value={direction}
/>
));

View file

@ -0,0 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`DragDropContextWrapper rendering it renders against the snapshot 1`] = `
<Connect(pure(Component))>
Drag drop context wrapper children
</Connect(pure(Component))>
`;

View file

@ -0,0 +1,27 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`DraggableWrapper rendering it renders against the snapshot 1`] = `
<ThemeProvider
theme={[Function]}
>
<Connect(pure(Component))>
<Connect(DraggableWrapperComponent)
dataProvider={
Object {
"and": Array [],
"enabled": true,
"excluded": false,
"id": "id-Provider 1",
"kqlQuery": "",
"name": "Provider 1",
"queryMatch": Object {
"field": "name",
"value": "Provider 1",
},
}
}
render={[Function]}
/>
</Connect(pure(Component))>
</ThemeProvider>
`;

View file

@ -0,0 +1,15 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`DroppableWrapper rendering it renders against the snapshot 1`] = `
<ThemeProvider
theme={[Function]}
>
<Connect(pure(Component))>
<pure(Component)
droppableId="testing"
>
draggable wrapper content
</pure(Component)>
</Connect(pure(Component))>
</ThemeProvider>
`;

View file

@ -0,0 +1,43 @@
/*
* 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, shallow } from 'enzyme';
import toJson from 'enzyme-to-json';
import * as React from 'react';
import { Provider as ReduxStoreProvider } from 'react-redux';
import { createStore } from '../../store';
import { DragDropContextWrapper } from './drag_drop_context_wrapper';
describe('DragDropContextWrapper', () => {
describe('rendering', () => {
test('it renders against the snapshot', () => {
const message = 'Drag drop context wrapper children';
const store = createStore();
const wrapper = shallow(
<ReduxStoreProvider store={store}>
<DragDropContextWrapper>{message}</DragDropContextWrapper>
</ReduxStoreProvider>
);
expect(toJson(wrapper)).toMatchSnapshot();
});
test('it renders the children', () => {
const message = 'Drag drop context wrapper children';
const store = createStore();
const wrapper = mount(
<ReduxStoreProvider store={store}>
<DragDropContextWrapper>{message}</DragDropContextWrapper>
</ReduxStoreProvider>
);
expect(wrapper.text()).toEqual(message);
});
});
});

View file

@ -0,0 +1,84 @@
/*
* 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 { defaultTo, noop } from 'lodash/fp';
import * as React from 'react';
import { DragDropContext, DropResult } from 'react-beautiful-dnd';
import { connect } from 'react-redux';
import { pure } from 'recompose';
import { Dispatch } from 'redux';
import { dragAndDropModel, dragAndDropSelectors, State } from '../../store';
import {
addProviderToTimeline,
providerWasDroppedOnTimeline,
providerWasDroppedOnTimelineButton,
} from './helpers';
interface Props {
dataProviders?: dragAndDropModel.IdToDataProvider;
dispatch: Dispatch;
}
interface OnDragEndHandlerParams {
result: DropResult;
dataProviders: dragAndDropModel.IdToDataProvider;
dispatch: Dispatch;
}
const onDragEndHandler = ({ result, dataProviders, dispatch }: OnDragEndHandlerParams) => {
if (providerWasDroppedOnTimeline(result)) {
addProviderToTimeline({ dataProviders, result, dispatch });
} else if (providerWasDroppedOnTimelineButton(result)) {
addProviderToTimeline({ dataProviders, result, dispatch });
}
};
const DragDropContextWrapperComponent = pure<Props>(({ dataProviders, dispatch, children }) => (
<DragDropContext
onDragEnd={result => {
enableScrolling();
onDragEndHandler({
result,
dataProviders: dataProviders!,
dispatch,
});
}}
onDragStart={disableScrolling}
>
{children}
</DragDropContext>
));
const emptyDataProviders: dragAndDropModel.IdToDataProvider = {}; // stable reference
const mapStateToProps = (state: State) => {
const dataProviders = defaultTo(
emptyDataProviders,
dragAndDropSelectors.dataProvidersSelector(state)
);
return { dataProviders };
};
export const DragDropContextWrapper = connect(mapStateToProps)(DragDropContextWrapperComponent);
const disableScrolling = () => {
const x =
window.pageXOffset !== undefined
? window.pageXOffset
: (document.documentElement || document.body.parentNode || document.body).scrollLeft;
const y =
window.pageYOffset !== undefined
? window.pageYOffset
: (document.documentElement || document.body.parentNode || document.body).scrollTop;
window.onscroll = () => window.scrollTo(x, y);
};
const enableScrolling = () => (window.onscroll = () => noop);

View file

@ -0,0 +1,96 @@
/*
* 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 euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json';
import { mount, shallow } from 'enzyme';
import toJson from 'enzyme-to-json';
import * as React from 'react';
import { Provider as ReduxStoreProvider } from 'react-redux';
import { ThemeProvider } from 'styled-components';
import { mockGlobalState } from '../../mock/global_state';
import { createStore, State } from '../../store';
import { mockDataProviders } from '../timeline/data_providers/mock/mock_data_providers';
import { DragDropContextWrapper } from './drag_drop_context_wrapper';
import { DraggableWrapper } from './draggable_wrapper';
describe('DraggableWrapper', () => {
const dataProvider = mockDataProviders[0];
const message = 'draggable wrapper content';
const state: State = mockGlobalState;
let store = createStore(state);
beforeEach(() => {
store = createStore(state);
});
describe('rendering', () => {
test('it renders against the snapshot', () => {
const wrapper = shallow(
<ReduxStoreProvider store={store}>
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>
<DragDropContextWrapper>
<DraggableWrapper dataProvider={dataProvider} render={() => message} />
</DragDropContextWrapper>
</ThemeProvider>
</ReduxStoreProvider>
);
expect(toJson(wrapper)).toMatchSnapshot();
});
test('it renders the children passed to the render prop', () => {
const wrapper = mount(
<ReduxStoreProvider store={store}>
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>
<DragDropContextWrapper>
<DraggableWrapper dataProvider={dataProvider} render={() => message} />
</DragDropContextWrapper>
</ThemeProvider>
</ReduxStoreProvider>
);
expect(wrapper.text()).toEqual(message);
});
});
describe('text truncation styling', () => {
test('it applies text truncation styling when a width IS specified (implicit: and the user is not dragging)', () => {
const width = '100px';
const wrapper = mount(
<ReduxStoreProvider store={store}>
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>
<DragDropContextWrapper>
<DraggableWrapper dataProvider={dataProvider} width={width} render={() => message} />
</DragDropContextWrapper>
</ThemeProvider>
</ReduxStoreProvider>
);
expect(wrapper.find('[data-test-subj="draggable-truncatable-content"]').exists()).toEqual(
true
);
});
test('it does NOT apply text truncation styling when a width is NOT specified', () => {
const wrapper = mount(
<ReduxStoreProvider store={store}>
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>
<DragDropContextWrapper>
<DraggableWrapper dataProvider={dataProvider} render={() => message} />
</DragDropContextWrapper>
</ThemeProvider>
</ReduxStoreProvider>
);
expect(wrapper.find('[data-test-subj="draggable-truncatable-content"]').exists()).toEqual(
false
);
});
});
});

View file

@ -0,0 +1,140 @@
/*
* 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 { EuiText } from '@elastic/eui';
import { defaultTo } from 'lodash/fp';
import * as React from 'react';
import {
Draggable,
DraggableProvided,
DraggableStateSnapshot,
Droppable,
} from 'react-beautiful-dnd';
import { connect } from 'react-redux';
import styled from 'styled-components';
import { ActionCreator } from 'typescript-fsa';
import { State } from '../../store';
// This import needs to be directly link to drag_and_drop store or we will have a circular dependency
import {
dragAndDropActions,
dragAndDropModel,
dragAndDropSelectors,
} from '../../store/drag_and_drop';
import { DataProvider } from '../timeline/data_providers/data_provider';
import { TruncatableText } from '../truncatable_text';
import { getDraggableId, getDroppableId } from './helpers';
// As right now, we do not know what we want there, we will keep it as a placeholder
export const DragEffects = styled.div``;
const ProviderContainer = styled.div`
&:hover {
transition: background-color 0.7s ease;
background-color: ${props => props.theme.eui.euiColorLightShade};
}
`;
interface OwnProps {
dataProvider: DataProvider;
render: (
props: DataProvider,
provided: DraggableProvided,
state: DraggableStateSnapshot
) => React.ReactNode;
width?: string;
}
interface StateReduxProps {
dataProviders?: dragAndDropModel.IdToDataProvider;
}
interface DispatchProps {
registerProvider?: ActionCreator<{
provider: DataProvider;
}>;
unRegisterProvider?: ActionCreator<{
id: string;
}>;
}
type Props = OwnProps & StateReduxProps & DispatchProps;
class DraggableWrapperComponent extends React.PureComponent<Props> {
public componentDidMount() {
const { dataProvider, registerProvider } = this.props;
registerProvider!({ provider: dataProvider });
}
public componentWillUnmount() {
const { dataProvider, unRegisterProvider } = this.props;
unRegisterProvider!({ id: dataProvider.id });
}
public render() {
const { dataProvider, render, width } = this.props;
return (
<div data-test-subj="draggableWrapperDiv">
<Droppable isDropDisabled={true} droppableId={getDroppableId(dataProvider.id)}>
{droppableProvided => (
<div ref={droppableProvided.innerRef} {...droppableProvided.droppableProps}>
<Draggable
draggableId={getDraggableId(dataProvider.id)}
index={0}
key={dataProvider.id}
>
{(provided, snapshot) => (
<ProviderContainer
{...provided.draggableProps}
{...provided.dragHandleProps}
innerRef={provided.innerRef}
data-test-subj="providerContainer"
style={{
...provided.draggableProps.style,
zIndex: 9000, // EuiFlyout has a z-index of 8000
}}
>
{width != null && !snapshot.isDragging ? (
<TruncatableText
data-test-subj="draggable-truncatable-content"
size="s"
width={width}
>
{render(dataProvider, provided, snapshot)}
</TruncatableText>
) : (
<EuiText data-test-subj="draggable-content" size="s">
{render(dataProvider, provided, snapshot)}
</EuiText>
)}
</ProviderContainer>
)}
</Draggable>
{droppableProvided.placeholder}
</div>
)}
</Droppable>
</div>
);
}
}
const emptyDataProviders: dragAndDropModel.IdToDataProvider = {}; // stable reference
const mapStateToProps = (state: State) =>
defaultTo(emptyDataProviders, dragAndDropSelectors.dataProvidersSelector(state));
export const DraggableWrapper = connect(
mapStateToProps,
{
registerProvider: dragAndDropActions.registerProvider,
unRegisterProvider: dragAndDropActions.unRegisterProvider,
}
)(DraggableWrapperComponent);

View file

@ -0,0 +1,55 @@
/*
* 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 euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json';
import { mount, shallow } from 'enzyme';
import toJson from 'enzyme-to-json';
import * as React from 'react';
import { Provider as ReduxStoreProvider } from 'react-redux';
import { ThemeProvider } from 'styled-components';
import { createStore } from '../../store';
import { DragDropContextWrapper } from './drag_drop_context_wrapper';
import { DroppableWrapper } from './droppable_wrapper';
describe('DroppableWrapper', () => {
describe('rendering', () => {
test('it renders against the snapshot', () => {
const message = 'draggable wrapper content';
const store = createStore();
const wrapper = shallow(
<ReduxStoreProvider store={store}>
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>
<DragDropContextWrapper>
<DroppableWrapper droppableId="testing">{message}</DroppableWrapper>
</DragDropContextWrapper>
</ThemeProvider>
</ReduxStoreProvider>
);
expect(toJson(wrapper)).toMatchSnapshot();
});
test('it renders the children', () => {
const message = 'draggable wrapper content';
const store = createStore();
const wrapper = mount(
<ReduxStoreProvider store={store}>
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>
<DragDropContextWrapper>
<DroppableWrapper droppableId="testing">{message}</DroppableWrapper>
</DragDropContextWrapper>
</ThemeProvider>
</ReduxStoreProvider>
);
expect(wrapper.text()).toEqual(message);
});
});
});

View file

@ -0,0 +1,85 @@
/*
* 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 { Droppable } from 'react-beautiful-dnd';
import { pure } from 'recompose';
import styled from 'styled-components';
interface Props {
droppableId: string;
isDropDisabled?: boolean;
}
const ReactDndDropTarget = styled.div<{ isDraggingOver: boolean }>`
transition: background-color 0.7s ease;
width: 100%;
height: 100%;
.flyout-overlay {
.euiPanel {
background-color: ${props => props.theme.eui.euiFormBackgroundColor};
}
}
${props =>
props.isDraggingOver
? `
.drop-and-provider-timeline {
&:hover {
background-color: ${props.theme.eui.euiColorEmptyShade};
}
}
> div.timeline-drop-area-empty {
background-color: ${props.theme.eui.euiColorLightShade};
}
> div.timeline-drop-area {
background-color: ${props.theme.eui.euiColorLightShade};
.provider-item-filter-container div:first-child{
// Override dragNdrop beautiful so we do not have our droppable moving around for no good reason
transform: none !important;
}
.drop-and-provider-timeline {
display: block !important;
+ div {
display: none;
}
}
}
.flyout-overlay {
.euiPanel {
background-color: ${props.theme.eui.euiColorLightShade};
}
+ div {
// Override dragNdrop beautiful so we do not have our droppable moving around for no good reason
display: none !important;
}
}
`
: ''}
> div.timeline-drop-area {
.drop-and-provider-timeline {
display: none;
}
& + div {
// Override dragNdrop beautiful so we do not have our droppable moving around for no good reason
display: none !important;
}
}
`;
export const DroppableWrapper = pure<Props>(({ droppableId, isDropDisabled = false, children }) => (
<Droppable isDropDisabled={isDropDisabled} droppableId={droppableId} direction={'horizontal'}>
{(provided, snapshot) => (
<ReactDndDropTarget
innerRef={provided.innerRef}
{...provided.droppableProps}
isDraggingOver={snapshot.isDraggingOver}
>
{children}
{provided.placeholder}
</ReactDndDropTarget>
)}
</Droppable>
));

View file

@ -0,0 +1,460 @@
/*
* 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 {
destinationIsTimelineButton,
destinationIsTimelineProviders,
draggableContentPrefix,
draggableIdPrefix,
draggableIsContent,
droppableIdPrefix,
droppableTimelineFlyoutButtonPrefix,
droppableTimelineProvidersPrefix,
escapeDataProviderId,
getDraggableId,
getDroppableId,
getProviderIdFromDraggable,
getTimelineIdFromDestination,
providerWasDroppedOnTimeline,
reasonIsDrop,
sourceIsContent,
} from './helpers';
const DROPPABLE_ID_TIMELINE_PROVIDERS = `${droppableTimelineProvidersPrefix}timeline`;
describe('helpers', () => {
describe('#getDraggableId', () => {
test('it returns the expected id', () => {
const id = getDraggableId('dataProvider1234');
const expected = `${draggableContentPrefix}dataProvider1234`;
expect(id).toEqual(expected);
});
});
describe('#getDroppableId', () => {
test('it returns the expected id', () => {
const id = getDroppableId('a-visualization');
const expected = `${droppableIdPrefix}.content.a-visualization`;
expect(id).toEqual(expected);
});
});
describe('#sourceIsContent', () => {
test('it returns returns true when the source is content', () => {
expect(
sourceIsContent({
destination: { droppableId: `${droppableIdPrefix}.timelineProviders.timeline`, index: 0 },
draggableId: getDraggableId('2119990039033485'),
reason: 'DROP',
source: { index: 0, droppableId: getDroppableId('2119990039033485') },
type: 'DEFAULT',
mode: 'FLUID',
})
).toEqual(true);
});
test('it returns false when the source is NOT content', () => {
expect(
sourceIsContent({
destination: { droppableId: `${droppableIdPrefix}.timelineProviders.timeline`, index: 0 },
draggableId: `${draggableIdPrefix}.somethingElse.2119990039033485`,
reason: 'DROP',
source: { index: 0, droppableId: `${droppableIdPrefix}.somethingElse.2119990039033485` },
type: 'DEFAULT',
mode: 'FLUID',
})
).toEqual(false);
});
});
describe('#draggableIsContent', () => {
test('it returns returns true when the draggable is content', () => {
expect(
draggableIsContent({
destination: {
droppableId: DROPPABLE_ID_TIMELINE_PROVIDERS,
index: 0,
},
draggableId: getDraggableId('685260508808089'),
reason: 'DROP',
source: {
droppableId: getDroppableId('685260508808089'),
index: 0,
},
type: 'DEFAULT',
mode: 'FLUID',
})
).toEqual(true);
});
test('it returns false when the draggable is NOT content', () => {
expect(
draggableIsContent({
destination: null,
draggableId: `${draggableIdPrefix}.timeline.timeline.dataProvider.685260508808089`,
reason: 'DROP',
source: {
droppableId: getDroppableId('timeline'),
index: 0,
},
type: 'DEFAULT',
mode: 'FLUID',
})
).toEqual(false);
});
});
describe('#reasonIsDrop', () => {
test('it returns returns true when the reason is DROP', () => {
expect(
reasonIsDrop({
destination: {
droppableId: DROPPABLE_ID_TIMELINE_PROVIDERS,
index: 0,
},
draggableId: getDraggableId('685260508808089'),
reason: 'DROP',
source: {
droppableId: getDroppableId('685260508808089'),
index: 0,
},
type: 'DEFAULT',
mode: 'FLUID',
})
).toEqual(true);
});
test('it returns false when the reason is NOT DROP', () => {
expect(
reasonIsDrop({
destination: {
droppableId: DROPPABLE_ID_TIMELINE_PROVIDERS,
index: 0,
},
draggableId: getDraggableId('685260508808089'),
reason: 'CANCEL',
source: {
droppableId: getDroppableId('685260508808089'),
index: 0,
},
type: 'DEFAULT',
mode: 'FLUID',
})
).toEqual(false);
});
});
describe('#destinationIsTimelineProviders', () => {
test('it returns returns true when the destination is timelineProviders', () => {
expect(
destinationIsTimelineProviders({
destination: {
droppableId: DROPPABLE_ID_TIMELINE_PROVIDERS,
index: 0,
},
draggableId: getDraggableId('685260508808089'),
reason: 'DROP',
source: {
droppableId: getDroppableId('685260508808089'),
index: 0,
},
type: 'DEFAULT',
mode: 'FLUID',
})
).toEqual(true);
});
test('it returns false when the destination is null', () => {
expect(
destinationIsTimelineProviders({
destination: null,
draggableId: getDraggableId('685260508808089'),
reason: 'DROP',
source: {
droppableId: getDroppableId('timeline'),
index: 0,
},
type: 'DEFAULT',
mode: 'FLUID',
})
).toEqual(false);
});
test('it returns false when the destination is NOT timelineProviders', () => {
expect(
destinationIsTimelineProviders({
destination: {
droppableId: `${droppableIdPrefix}.somewhere.else`,
index: 0,
},
draggableId: getDraggableId('685260508808089'),
reason: 'DROP',
source: {
droppableId: getDroppableId('685260508808089'),
index: 0,
},
type: 'DEFAULT',
mode: 'FLUID',
})
).toEqual(false);
});
});
describe('#destinationIsTimelineButton', () => {
test('it returns returns true when the destination is a flyout button', () => {
expect(
destinationIsTimelineButton({
destination: {
droppableId: `${droppableTimelineFlyoutButtonPrefix}.timeline`,
index: 0,
},
draggableId: getDraggableId('685260508808089'),
reason: 'DROP',
source: {
droppableId: getDroppableId('685260508808089'),
index: 0,
},
type: 'DEFAULT',
mode: 'FLUID',
})
).toEqual(true);
});
test('it returns false when the destination is null', () => {
expect(
destinationIsTimelineButton({
destination: null,
draggableId: getDraggableId('685260508808089'),
reason: 'DROP',
source: {
droppableId: DROPPABLE_ID_TIMELINE_PROVIDERS,
index: 0,
},
type: 'DEFAULT',
mode: 'FLUID',
})
).toEqual(false);
});
test('it returns false when the destination is NOT a flyout button', () => {
expect(
destinationIsTimelineButton({
destination: {
droppableId: `${droppableIdPrefix}.somewhere.else`,
index: 0,
},
draggableId: getDraggableId('685260508808089'),
reason: 'DROP',
source: {
droppableId: getDroppableId('685260508808089'),
index: 0,
},
type: 'DEFAULT',
mode: 'FLUID',
})
).toEqual(false);
});
});
describe('#getTimelineIdFromDestination', () => {
test('it returns returns the timeline id from the destination when it is a provider', () => {
expect(
getTimelineIdFromDestination({
destination: {
droppableId: DROPPABLE_ID_TIMELINE_PROVIDERS,
index: 0,
},
draggableId: getDraggableId('685260508808089'),
reason: 'DROP',
source: {
droppableId: getDroppableId('685260508808089'),
index: 0,
},
type: 'DEFAULT',
mode: 'FLUID',
})
).toEqual('timeline');
});
test('it returns returns the timeline id from the destination when it is a button', () => {
expect(
getTimelineIdFromDestination({
destination: {
droppableId: `${droppableTimelineFlyoutButtonPrefix}timeline`,
index: 0,
},
draggableId: getDraggableId('685260508808089'),
reason: 'DROP',
source: {
droppableId: getDroppableId('685260508808089'),
index: 0,
},
type: 'DEFAULT',
mode: 'FLUID',
})
).toEqual('timeline');
});
test('it returns returns an empty string when the destination is null', () => {
expect(
getTimelineIdFromDestination({
destination: null,
draggableId: `${draggableIdPrefix}.timeline.timeline.dataProvider.685260508808089`,
reason: 'DROP',
source: {
droppableId: `${droppableIdPrefix}.timelineProviders.timeline`,
index: 0,
},
type: 'DEFAULT',
mode: 'FLUID',
})
).toEqual('');
});
test('it returns returns an empty string when the destination is not a timeline', () => {
expect(
getTimelineIdFromDestination({
destination: {
droppableId: `${droppableIdPrefix}.somewhere.else`,
index: 0,
},
draggableId: getDraggableId('685260508808089'),
reason: 'DROP',
source: {
droppableId: getDroppableId('685260508808089'),
index: 0,
},
type: 'DEFAULT',
mode: 'FLUID',
})
).toEqual('');
});
});
describe('#getProviderIdFromDraggable', () => {
test('it returns the expected id', () => {
const id = getProviderIdFromDraggable({
destination: {
droppableId: DROPPABLE_ID_TIMELINE_PROVIDERS,
index: 0,
},
draggableId: getDraggableId('2119990039033485'),
reason: 'DROP',
source: {
droppableId: getDroppableId('2119990039033485'),
index: 0,
},
type: 'DEFAULT',
mode: 'FLUID',
});
const expected = '2119990039033485';
expect(id).toEqual(expected);
});
});
describe('#providerWasDroppedOnTimeline', () => {
test('it returns returns true when a provider was dropped on the timeline', () => {
expect(
providerWasDroppedOnTimeline({
destination: {
droppableId: DROPPABLE_ID_TIMELINE_PROVIDERS,
index: 0,
},
draggableId: getDraggableId('2119990039033485'),
reason: 'DROP',
source: {
droppableId: getDroppableId('2119990039033485'),
index: 0,
},
type: 'DEFAULT',
mode: 'FLUID',
})
).toEqual(true);
});
test('it returns false when the reason is NOT DROP', () => {
expect(
providerWasDroppedOnTimeline({
destination: {
droppableId: DROPPABLE_ID_TIMELINE_PROVIDERS,
index: 0,
},
draggableId: getDraggableId('2119990039033485'),
reason: 'CANCEL',
source: {
droppableId: getDroppableId('2119990039033485'),
index: 0,
},
type: 'DEFAULT',
mode: 'FLUID',
})
).toEqual(false);
});
test('it returns false when the draggable is NOT content', () => {
expect(
providerWasDroppedOnTimeline({
destination: null,
draggableId: `${draggableIdPrefix}.timeline.timeline.dataProvider.685260508808089`,
reason: 'DROP',
source: {
droppableId: getDroppableId('timeline'),
index: 0,
},
type: 'DEFAULT',
mode: 'FLUID',
})
).toEqual(false);
});
test('it returns false when the the source is NOT content', () => {
expect(
providerWasDroppedOnTimeline({
destination: { droppableId: DROPPABLE_ID_TIMELINE_PROVIDERS, index: 0 },
draggableId: `${draggableIdPrefix}.somethingElse.2119990039033485`,
reason: 'DROP',
source: { index: 0, droppableId: `${droppableIdPrefix}.somethingElse.2119990039033485` },
type: 'DEFAULT',
mode: 'FLUID',
})
).toEqual(false);
});
test('it returns false when the the destination is NOT timeline providers', () => {
expect(
providerWasDroppedOnTimeline({
destination: {
droppableId: `${droppableIdPrefix}.somewhere.else`,
index: 0,
},
draggableId: getDraggableId('685260508808089'),
reason: 'DROP',
source: {
droppableId: getDroppableId('685260508808089'),
index: 0,
},
type: 'DEFAULT',
mode: 'FLUID',
})
).toEqual(false);
});
});
describe('#escapeDataProviderId', () => {
test('it should escape dotted notation', () => {
const escaped = escapeDataProviderId('hello.how.are.you');
expect(escaped).toEqual('hello_how_are_you');
});
test('it should not escape a string without dotted notation', () => {
const escaped = escapeDataProviderId('hello how are you?');
expect(escaped).toEqual('hello how are you?');
});
});
});

View file

@ -0,0 +1,121 @@
/*
* 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 { DropResult } from 'react-beautiful-dnd';
import { Dispatch } from 'redux';
import { ActionCreator } from 'typescript-fsa';
import { dragAndDropActions, dragAndDropModel, timelineActions } from '../../store';
import { DataProvider } from '../timeline/data_providers/data_provider';
export const draggableIdPrefix = 'draggableId';
export const droppableIdPrefix = 'droppableId';
export const draggableContentPrefix = `${draggableIdPrefix}.content.`;
export const droppableContentPrefix = `${droppableIdPrefix}.content.`;
export const droppableTimelineProvidersPrefix = `${droppableIdPrefix}.timelineProviders.`;
export const droppableTimelineFlyoutButtonPrefix = `${droppableIdPrefix}.flyoutButton.`;
export const getDraggableId = (dataProviderId: string): string =>
`${draggableContentPrefix}${dataProviderId}`;
export const getDroppableId = (visualizationPlaceholderId: string): string =>
`${droppableContentPrefix}${visualizationPlaceholderId}`;
export const sourceIsContent = (result: DropResult): boolean =>
result.source.droppableId.startsWith(droppableContentPrefix);
export const draggableIsContent = (result: DropResult): boolean =>
result.draggableId.startsWith(draggableContentPrefix);
export const reasonIsDrop = (result: DropResult): boolean => result.reason === 'DROP';
export const destinationIsTimelineProviders = (result: DropResult): boolean =>
result.destination != null &&
result.destination.droppableId.startsWith(droppableTimelineProvidersPrefix);
export const destinationIsTimelineButton = (result: DropResult): boolean =>
result.destination != null &&
result.destination.droppableId.startsWith(droppableTimelineFlyoutButtonPrefix);
export const getTimelineIdFromDestination = (result: DropResult): string =>
result.destination != null &&
(destinationIsTimelineProviders(result) || destinationIsTimelineButton(result))
? result.destination.droppableId.substring(result.destination.droppableId.lastIndexOf('.') + 1)
: '';
export const getProviderIdFromDraggable = (result: DropResult): string =>
result.draggableId.substring(result.draggableId.lastIndexOf('.') + 1);
export const escapeDataProviderId = (path: string) => path.replace(/\./g, '_');
export const providerWasDroppedOnTimeline = (result: DropResult): boolean =>
reasonIsDrop(result) &&
draggableIsContent(result) &&
sourceIsContent(result) &&
destinationIsTimelineProviders(result);
export const providerWasDroppedOnTimelineButton = (result: DropResult): boolean =>
reasonIsDrop(result) &&
draggableIsContent(result) &&
sourceIsContent(result) &&
destinationIsTimelineButton(result);
interface AddProviderToTimelineParams {
dataProviders: dragAndDropModel.IdToDataProvider;
result: DropResult;
dispatch: Dispatch;
addProvider?: ActionCreator<{
id: string;
provider: DataProvider;
}>;
noProviderFound?: ActionCreator<{
id: string;
}>;
}
export const addProviderToTimeline = ({
dataProviders,
result,
dispatch,
addProvider = timelineActions.addProvider,
noProviderFound = dragAndDropActions.noProviderFound,
}: AddProviderToTimelineParams): void => {
const timeline = getTimelineIdFromDestination(result);
const providerId = getProviderIdFromDraggable(result);
const provider = dataProviders[providerId];
if (provider) {
dispatch(addProvider({ id: timeline, provider }));
} else {
dispatch(noProviderFound({ id: providerId }));
}
};
interface ShowTimelineParams {
result: DropResult;
show: boolean;
dispatch: Dispatch;
showTimeline?: ActionCreator<{
id: string;
show: boolean;
}>;
}
export const updateShowTimeline = ({
result,
show,
dispatch,
showTimeline = timelineActions.showTimeline,
}: ShowTimelineParams): void => {
const timeline = getTimelineIdFromDestination(result);
dispatch(showTimeline({ id: timeline, show }));
};

View file

@ -0,0 +1,29 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`draggables rendering it renders the default Badge 1`] = `
<Component
contextId="context-id"
eventId="event-id"
field="some-field"
iconType="number"
queryValue="some-query-value"
value="some-value"
>
<span>
A child of this
</span>
</Component>
`;
exports[`draggables rendering it renders the default DefaultDraggable 1`] = `
<Component
field="some-field"
id="draggable-id"
queryValue="some-query-value"
value="some-value"
>
<span>
A child of this
</span>
</Component>
`;

View file

@ -0,0 +1,352 @@
/*
* 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 { shallow } from 'enzyme';
import toJson from 'enzyme-to-json';
import * as React from 'react';
import { mountWithIntl } from 'test_utils/enzyme_helpers';
import { TestProviders } from '../../mock';
import { getEmptyString } from '../empty_value';
import {
DefaultDraggable,
DraggableBadge,
getDefaultWhenTooltipIsUnspecified,
tooltipContentIsExplicitlyNull,
} from '.';
describe('draggables', () => {
describe('rendering', () => {
test('it renders the default DefaultDraggable', () => {
const wrapper = shallow(
<DefaultDraggable
id="draggable-id"
field="some-field"
value="some-value"
queryValue="some-query-value"
>
<span>A child of this</span>
</DefaultDraggable>
);
expect(toJson(wrapper)).toMatchSnapshot();
});
test('it renders the default Badge', () => {
const wrapper = shallow(
<DraggableBadge
contextId="context-id"
eventId="event-id"
field="some-field"
value="some-value"
queryValue="some-query-value"
iconType="number"
>
<span>A child of this</span>
</DraggableBadge>
);
expect(toJson(wrapper)).toMatchSnapshot();
});
});
describe('#tooltipContentIsExplicitlyNull', () => {
test('returns false if a string is provided for the tooltip', () => {
expect(tooltipContentIsExplicitlyNull('bob')).toBe(false);
});
test('returns false if the tooltip is undefined', () => {
expect(tooltipContentIsExplicitlyNull(undefined)).toBe(false);
});
test('returns false if the tooltip is a ReactNode', () => {
expect(tooltipContentIsExplicitlyNull(<span>be a good node</span>)).toBe(false);
});
test('returns true if the tooltip is null', () => {
expect(tooltipContentIsExplicitlyNull(null)).toBe(true);
});
});
describe('#getDefaultWhenTooltipIsUnspecified', () => {
test('it returns the field (as as string) when the tooltipContent is undefined', () => {
expect(getDefaultWhenTooltipIsUnspecified({ field: 'source.bytes' })).toEqual('source.bytes');
});
test('it returns the field (as as string) when the tooltipContent is null', () => {
expect(
getDefaultWhenTooltipIsUnspecified({ field: 'source.bytes', tooltipContent: null })
).toEqual('source.bytes');
});
test('it returns the tooltipContent when a string is provided as content', () => {
expect(
getDefaultWhenTooltipIsUnspecified({ field: 'source.bytes', tooltipContent: 'a string' })
).toEqual('a string');
});
test('it returns the tooltipContent when an element is provided as content', () => {
expect(
getDefaultWhenTooltipIsUnspecified({
field: 'source.bytes',
tooltipContent: <span>the universe</span>,
})
).toEqual(<span>the universe</span>);
});
});
describe('DefaultDraggable', () => {
test('it works with just an id, field, and value and is some value', () => {
const wrapper = mountWithIntl(
<TestProviders>
<DefaultDraggable id="draggable-id" field="some-field" value="some value" />
</TestProviders>
);
expect(wrapper.text()).toEqual('some value');
});
test('it returns null if value is undefined', () => {
const wrapper = mountWithIntl(
<TestProviders>
<DefaultDraggable id="draggable-id" field="some-field" value={undefined} />
</TestProviders>
);
expect(wrapper.text()).toBeNull();
});
test('it returns null if value is null', () => {
const wrapper = mountWithIntl(
<TestProviders>
<DefaultDraggable id="draggable-id" field="some-field" value={null} />
</TestProviders>
);
expect(wrapper.text()).toBeNull();
});
test('it renders a tooltip with the field name if a tooltip is not explicitly provided', () => {
const wrapper = mountWithIntl(
<TestProviders>
<DefaultDraggable id="draggable-id" field="source.bytes" value="a default draggable" />
</TestProviders>
);
expect(
wrapper
.find('[data-test-subj="source.bytes-tooltip"]')
.first()
.props().content
).toEqual('source.bytes');
});
test('it renders the tooltipContent when a string is provided as content', () => {
const wrapper = mountWithIntl(
<TestProviders>
<DefaultDraggable
id="draggable-id"
field="source.bytes"
tooltipContent="default draggable string tooltip"
value="a default draggable"
/>
</TestProviders>
);
expect(
wrapper
.find('[data-test-subj="source.bytes-tooltip"]')
.first()
.props().content
).toEqual('default draggable string tooltip');
});
test('it renders the tooltipContent when an element is provided as content', () => {
const wrapper = mountWithIntl(
<TestProviders>
<DefaultDraggable
id="draggable-id"
field="source.bytes"
tooltipContent={<span>default draggable tooltip</span>}
value="a default draggable"
/>
</TestProviders>
);
expect(
wrapper
.find('[data-test-subj="source.bytes-tooltip"]')
.first()
.props().content
).toEqual(<span>default draggable tooltip</span>);
});
test('it does NOT render a tooltip when tooltipContent is null', () => {
const wrapper = mountWithIntl(
<TestProviders>
<DefaultDraggable
id="draggable-id"
field="source.bytes"
tooltipContent={null}
value="a default draggable"
/>
</TestProviders>
);
expect(
wrapper
.find('[data-test-subj="source.bytes-tooltip"]')
.first()
.exists()
).toBe(false);
});
});
describe('DraggableBadge', () => {
test('it works with just an id, field, and value and is the default', () => {
const wrapper = mountWithIntl(
<TestProviders>
<DraggableBadge
contextId="context-id"
eventId="event-id"
field="some-field"
value="some value"
iconType="number"
/>
</TestProviders>
);
expect(wrapper.text()).toEqual('some value');
});
test('it returns null if value is undefined', () => {
const wrapper = mountWithIntl(
<TestProviders>
<DraggableBadge
contextId="context-id"
eventId="event-id"
field="some-field"
iconType="number"
value={undefined}
/>
</TestProviders>
);
expect(wrapper.text()).toBeNull();
});
test('it returns null if value is null', () => {
const wrapper = mountWithIntl(
<TestProviders>
<DraggableBadge
contextId="context-id"
eventId="event-id"
field="some-field"
value={null}
iconType="number"
/>
</TestProviders>
);
expect(wrapper.text()).toBeNull();
});
test('it returns Empty string text if value is an empty string', () => {
const wrapper = mountWithIntl(
<TestProviders>
<DraggableBadge
contextId="context-id"
eventId="event-id"
field="some-field"
value=""
iconType="document"
/>
</TestProviders>
);
expect(wrapper.text()).toEqual(getEmptyString());
});
test('it renders a tooltip with the field name if a tooltip is not explicitly provided', () => {
const wrapper = mountWithIntl(
<TestProviders>
<DraggableBadge
contextId="context-id"
eventId="event-id"
field="some-field"
value="some value"
iconType="number"
/>
</TestProviders>
);
expect(
wrapper
.find('[data-test-subj="some-field-tooltip"]')
.first()
.props().content
).toEqual('some-field');
});
test('it renders the tooltipContent when a string is provided as content', () => {
const wrapper = mountWithIntl(
<TestProviders>
<DraggableBadge
contextId="context-id"
eventId="event-id"
field="some-field"
value="some value"
iconType="number"
tooltipContent="draggable badge string tooltip"
/>
</TestProviders>
);
expect(
wrapper
.find('[data-test-subj="some-field-tooltip"]')
.first()
.props().content
).toEqual('draggable badge string tooltip');
});
test('it renders the tooltipContent when an element is provided as content', () => {
const wrapper = mountWithIntl(
<TestProviders>
<DraggableBadge
contextId="context-id"
eventId="event-id"
field="some-field"
value="some value"
iconType="number"
tooltipContent={<span>draggable badge tooltip</span>}
/>
</TestProviders>
);
expect(
wrapper
.find('[data-test-subj="some-field-tooltip"]')
.first()
.props().content
).toEqual(<span>draggable badge tooltip</span>);
});
test('it does NOT render a tooltip when tooltipContent is null', () => {
const wrapper = mountWithIntl(
<TestProviders>
<DraggableBadge
contextId="context-id"
eventId="event-id"
field="some-field"
value="some value"
iconType="number"
tooltipContent={null}
/>
</TestProviders>
);
expect(
wrapper
.find('[data-test-subj="some-field-tooltip"]')
.first()
.exists()
).toBe(false);
});
});
});

View file

@ -0,0 +1,173 @@
/*
* 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 { EuiBadge, EuiToolTip, IconType } from '@elastic/eui';
import * as React from 'react';
import { pure } from 'recompose';
import styled from 'styled-components';
import { escapeQueryValue } from '../../lib/keury';
import { DragEffects, DraggableWrapper } from '../drag_and_drop/draggable_wrapper';
import { escapeDataProviderId } from '../drag_and_drop/helpers';
import { getEmptyStringTag } from '../empty_value';
import { Provider } from '../timeline/data_providers/provider';
export interface DefaultDraggableType {
id: string;
field: string;
value?: string | null;
name?: string | null;
queryValue?: string | null;
children?: React.ReactNode;
tooltipContent?: React.ReactNode;
}
/**
* Only returns true if the specified tooltipContent is exactly `null`.
* Example input / output:
* `bob -> false`
* `undefined -> false`
* `<span>thing</span> -> false`
* `null -> true`
*/
export const tooltipContentIsExplicitlyNull = (tooltipContent?: React.ReactNode): boolean =>
tooltipContent === null; // an explicit / exact null check
/**
* Derives the tooltip content from the field name if no tooltip was specified
*/
export const getDefaultWhenTooltipIsUnspecified = ({
field,
tooltipContent,
}: {
field: string;
tooltipContent?: React.ReactNode;
}): React.ReactNode => (tooltipContent != null ? tooltipContent : field);
/**
* Renders the content of the draggable, wrapped in a tooltip
*/
const Content = pure<{
children?: React.ReactNode;
field: string;
tooltipContent?: React.ReactNode;
value?: string | null;
}>(({ children, field, tooltipContent, value }) =>
!tooltipContentIsExplicitlyNull(tooltipContent) ? (
<EuiToolTip
data-test-subj={`${field}-tooltip`}
content={getDefaultWhenTooltipIsUnspecified({ tooltipContent, field })}
>
<>{children ? children : value}</>
</EuiToolTip>
) : (
<>{children ? children : value}</>
)
);
/**
* Draggable text (or an arbitrary visualization specified by `children`)
* that's only displayed when the specified value is non-`null`.
*
* @param id - a unique draggable id, which typically follows the format `${contextId}-${eventId}-${field}-${value}`
* @param field - the name of the field, e.g. `network.transport`
* @param value - value of the field e.g. `tcp`
* @param name - defaulting to `field`, this optional human readable name is used by the `DataProvider` that represents the data
* @param children - defaults to displaying `value`, this allows an arbitrary visualization to be displayed in lieu of the default behavior
* @param tooltipContent - defaults to displaying `field`, pass `null` to
* prevent a tooltip from being displayed, or pass arbitrary content
* @param queryValue - defaults to `value`, this query overrides the `queryMatch.value` used by the `DataProvider` that represents the data
*/
export const DefaultDraggable = pure<DefaultDraggableType>(
({ id, field, value, name, children, tooltipContent, queryValue }) =>
value != null ? (
<DraggableWrapper
dataProvider={{
and: [],
enabled: true,
id: escapeDataProviderId(id),
name: name ? name : value,
excluded: false,
kqlQuery: '',
queryMatch: {
field,
value: escapeQueryValue(queryValue ? queryValue : value),
},
}}
render={(dataProvider, _, snapshot) =>
snapshot.isDragging ? (
<DragEffects>
<Provider dataProvider={dataProvider} />
</DragEffects>
) : (
<Content
children={children}
field={field}
tooltipContent={tooltipContent}
value={value}
/>
)
}
/>
) : null
);
const Badge = styled(EuiBadge)`
vertical-align: top;
`;
type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
export type BadgeDraggableType = Omit<DefaultDraggableType, 'id'> & {
contextId: string;
eventId: string;
iconType?: IconType;
color?: string;
};
/**
* A draggable badge that's only displayed when the specified value is non-`null`.
*
* @param contextId - used as part of the formula to derive a unique draggable id, this describes the context e.g. `event-fields-browser` in which the badge is displayed
* @param eventId - uniquely identifies an event, as specified in the `_id` field of the document
* @param field - the name of the field, e.g. `network.transport`
* @param value - value of the field e.g. `tcp`
* @param iconType -the (optional) type of icon e.g. `snowflake` to display on the badge
* @param name - defaulting to `field`, this optional human readable name is used by the `DataProvider` that represents the data
* @param color - defaults to `hollow`, optionally overwrite the color of the badge icon
* @param children - defaults to displaying `value`, this allows an arbitrary visualization to be displayed in lieu of the default behavior
* @param tooltipContent - defaults to displaying `field`, pass `null` to
* prevent a tooltip from being displayed, or pass arbitrary content
* @param queryValue - defaults to `value`, this query overrides the `queryMatch.value` used by the `DataProvider` that represents the data
*/
export const DraggableBadge = pure<BadgeDraggableType>(
({
contextId,
eventId,
field,
value,
iconType,
name,
color = 'hollow',
children,
tooltipContent,
queryValue,
}) =>
value != null ? (
<DefaultDraggable
id={`${contextId}-${eventId}-${field}-${value}`}
field={field}
name={name}
value={value}
tooltipContent={tooltipContent}
queryValue={queryValue}
>
<Badge iconType={iconType} color={color}>
{children ? children : value !== '' ? value : getEmptyStringTag()}
</Badge>
</DefaultDraggable>
) : null
);

View file

@ -0,0 +1,34 @@
/*
* 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 { TestProviders } from '../../mock';
import { ONE_MILLISECOND_AS_NANOSECONDS } from '../formatted_duration/helpers';
import { Duration } from '.';
describe('Duration', () => {
test('it renders the expected formatted duration', () => {
const wrapper = mount(
<TestProviders>
<Duration
contextId="test"
eventId="abc"
fieldName="event.duration"
value={`${ONE_MILLISECOND_AS_NANOSECONDS}`}
/>
</TestProviders>
);
expect(
wrapper
.find('[data-test-subj="formatted-duration"]')
.first()
.text()
).toEqual('1ms');
});
});

View file

@ -0,0 +1,34 @@
/*
* 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 { DefaultDraggable } from '../draggables';
import { FormattedDuration } from '../formatted_duration';
export const EVENT_DURATION_FIELD_NAME = 'event.duration';
/**
* Renders draggable text containing the value of a field representing a
* duration of time, (e.g. `event.duration`)
*/
export const Duration = pure<{
contextId: string;
eventId: string;
fieldName: string;
value?: string | null;
}>(({ contextId, eventId, fieldName, value }) => (
<DefaultDraggable
id={`${contextId}-${eventId}-${fieldName}-${value}`}
name={name}
field={fieldName}
tooltipContent={null}
value={value}
>
<FormattedDuration maybeDurationNanoseconds={value} tooltipTitle={fieldName} />
</DefaultDraggable>
));

View file

@ -0,0 +1,10 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders correctly 1`] = `
<Component
actionLabel="Do Something"
actionUrl="my/url/from/nowwhere"
message="My awesome message"
title="My Super Title"
/>
`;

View file

@ -0,0 +1,23 @@
/*
* 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 { shallow } from 'enzyme';
import toJson from 'enzyme-to-json';
import React from 'react';
import { EmptyPage } from './index';
test('renders correctly', () => {
const EmptyComponent = shallow(
<EmptyPage
title="My Super Title"
message="My awesome message"
actionLabel="Do Something"
actionUrl="my/url/from/nowwhere"
/>
);
expect(toJson(EmptyComponent)).toMatchSnapshot();
});

View file

@ -0,0 +1,37 @@
/*
* 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 { EuiButton, EuiEmptyPrompt } from '@elastic/eui';
import React from 'react';
import { pure } from 'recompose';
import styled from 'styled-components';
interface EmptyPageProps {
message: string;
title: string;
actionLabel: string;
actionUrl: string;
'data-test-subj'?: string;
}
export const EmptyPage = pure<EmptyPageProps>(
({ actionLabel, actionUrl, message, title, ...rest }) => (
<CenteredEmptyPrompt
title={<h2>{title}</h2>}
body={<p>{message}</p>}
actions={
<EuiButton href={actionUrl} color="primary" fill>
{actionLabel}
</EuiButton>
}
{...rest}
/>
)
);
const CenteredEmptyPrompt = styled(EuiEmptyPrompt)`
align-self: center;
`;

View file

@ -0,0 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`EmptyValue it renders against snapshot 1`] = `
<p>
(Empty String)
</p>
`;

View file

@ -0,0 +1,141 @@
/*
* 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 euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json';
import { mount, shallow } from 'enzyme';
import toJson from 'enzyme-to-json';
import React from 'react';
import { ThemeProvider } from 'styled-components';
import { mountWithIntl } from 'test_utils/enzyme_helpers';
import {
defaultToEmptyTag,
getEmptyString,
getEmptyStringTag,
getEmptyTagValue,
getEmptyValue,
getOrEmptyTag,
} from '.';
describe('EmptyValue', () => {
const theme = () => ({ eui: euiDarkVars, darkMode: true });
test('it renders against snapshot', () => {
const wrapper = shallow(<p>{getEmptyString()}</p>);
expect(toJson(wrapper)).toMatchSnapshot();
});
describe('#getEmptyValue', () =>
test('should return an empty value', () => expect(getEmptyValue()).toBe('--')));
describe('#getEmptyString', () => {
test('should turn into an empty string place holder', () => {
const wrapper = mountWithIntl(
<ThemeProvider theme={theme}>
<p>{getEmptyString()}</p>
</ThemeProvider>
);
expect(wrapper.text()).toBe('(Empty String)');
});
});
describe('#getEmptyTagValue', () => {
const wrapper = mount(<p>{getEmptyTagValue()}</p>);
test('should return an empty tag value', () => expect(wrapper.text()).toBe('--'));
});
describe('#getEmptyStringTag', () => {
test('should turn into an span that has length of 1', () => {
const wrapper = mountWithIntl(
<ThemeProvider theme={theme}>
<p>{getEmptyStringTag()}</p>
</ThemeProvider>
);
expect(wrapper.find('span')).toHaveLength(1);
});
test('should turn into an empty string tag place holder', () => {
const wrapper = mountWithIntl(
<ThemeProvider theme={theme}>
<p>{getEmptyStringTag()}</p>
</ThemeProvider>
);
expect(wrapper.text()).toBe(getEmptyString());
});
});
describe('#defaultToEmptyTag', () => {
test('should default to an empty value when a value is null', () => {
const wrapper = mount(<p>{defaultToEmptyTag(null)}</p>);
expect(wrapper.text()).toBe(getEmptyValue());
});
test('should default to an empty value when a value is undefined', () => {
const wrapper = mount(<p>{defaultToEmptyTag(undefined)}</p>);
expect(wrapper.text()).toBe(getEmptyValue());
});
test('should return a deep path value', () => {
const test = {
a: {
b: {
c: 1,
},
},
};
const wrapper = mount(<p>{defaultToEmptyTag(test.a.b.c)}</p>);
expect(wrapper.text()).toBe('1');
});
});
describe('#getOrEmptyTag', () => {
test('should default empty value when a deep rooted value is null', () => {
const test = {
a: {
b: {
c: null,
},
},
};
const wrapper = mount(<p>{getOrEmptyTag('a.b.c', test)}</p>);
expect(wrapper.text()).toBe(getEmptyValue());
});
test('should default empty value when a deep rooted value is undefined', () => {
const test = {
a: {
b: {
c: undefined,
},
},
};
const wrapper = mount(<p>{getOrEmptyTag('a.b.c', test)}</p>);
expect(wrapper.text()).toBe(getEmptyValue());
});
test('should default empty value when a deep rooted value is missing', () => {
const test = {
a: {
b: {},
},
};
const wrapper = mount(<p>{getOrEmptyTag('a.b.c', test)}</p>);
expect(wrapper.text()).toBe(getEmptyValue());
});
test('should return a deep path value', () => {
const test = {
a: {
b: {
c: 1,
},
},
};
const wrapper = mount(<p>{getOrEmptyTag('a.b.c', test)}</p>);
expect(wrapper.text()).toBe('1');
});
});
});

View file

@ -0,0 +1,50 @@
/*
* 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 { get, isString } from 'lodash/fp';
import React from 'react';
import styled from 'styled-components';
import * as i18n from './translations';
const EmptyString = styled.span`
color: ${({
theme: {
eui: { euiColorMediumShade },
},
}) => euiColorMediumShade};
`;
export const getEmptyValue = () => '--';
export const getEmptyString = () => `(${i18n.EMPTY_STRING})`;
export const getEmptyTagValue = () => <>{getEmptyValue()}</>;
export const getEmptyStringTag = () => <EmptyString>{getEmptyString()}</EmptyString>;
export const defaultToEmptyTag = <T extends unknown>(item: T): JSX.Element => {
if (item == null) {
return getEmptyTagValue();
} else if (isString(item) && item === '') {
return getEmptyStringTag();
} else {
return <>{item}</>;
}
};
export const getOrEmptyTag = (path: string, item: unknown): JSX.Element => {
const text = get(path, item);
return getOrEmptyTagFromValue(text);
};
export const getOrEmptyTagFromValue = (value: string | number | null | undefined): JSX.Element => {
if (value == null) {
return getEmptyTagValue();
} else if (value === '') {
return getEmptyStringTag();
} else {
return <>{value}</>;
}
};

View file

@ -0,0 +1,11 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const EMPTY_STRING = i18n.translate('xpack.siem.emptyString.emptyStringDescription', {
defaultMessage: 'Empty String',
});

View file

@ -0,0 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Error Toast rendering it renders the default Authentication table 1`] = `
<Connect(pure(Component))
toastLifeTimeMs={9999999999}
/>
`;

View file

@ -0,0 +1,35 @@
/*
* 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 { shallow } from 'enzyme';
import toJson from 'enzyme-to-json';
import * as React from 'react';
import { Provider } from 'react-redux';
import { mockGlobalState } from '../../mock';
import { createStore, State } from '../../store';
import { ErrorToast } from '.';
describe('Error Toast', () => {
const state: State = mockGlobalState;
let store = createStore(state);
beforeEach(() => {
store = createStore(state);
});
describe('rendering', () => {
test('it renders the default Authentication table', () => {
const wrapper = shallow(
<Provider store={store}>
<ErrorToast toastLifeTimeMs={9999999999} />
</Provider>
);
expect(toJson(wrapper)).toMatchSnapshot();
});
});
});

View file

@ -0,0 +1,69 @@
/*
* 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 { EuiGlobalToastList, Toast } from '@elastic/eui';
import React from 'react';
import { connect } from 'react-redux';
import { pure } from 'recompose';
import { ActionCreator } from 'typescript-fsa';
import { appActions, appModel, appSelectors, State } from '../../store';
interface OwnProps {
toastLifeTimeMs?: number;
}
interface ReduxProps {
errors?: appModel.Error[];
}
interface DispatchProps {
addError?: ActionCreator<{ id: string; title: string; message: string }>;
removeError?: ActionCreator<{ id: string }>;
}
type Props = OwnProps & ReduxProps & DispatchProps;
const ErrorToastComponent = pure<Props>(({ toastLifeTimeMs = 10000, errors = [], removeError }) =>
globalListFromToasts(errorsToToasts(errors), removeError!, toastLifeTimeMs)
);
export const globalListFromToasts = (
toasts: Toast[],
removeError: ActionCreator<{ id: string }>,
toastLifeTimeMs: number
) =>
toasts.length !== 0 ? (
<EuiGlobalToastList
toasts={toasts}
dismissToast={({ id }) => removeError({ id })}
toastLifeTimeMs={toastLifeTimeMs}
/>
) : null;
export const errorsToToasts = (errors: appModel.Error[]): Toast[] =>
errors.map(({ id, title, message }) => {
const toast: Toast = {
id,
title,
color: 'danger',
iconType: 'alert',
text: <p>{message}</p>,
};
return toast;
});
const makeMapStateToProps = () => {
const getErrorSelector = appSelectors.errorsSelector();
return (state: State) => getErrorSelector(state);
};
export const ErrorToast = connect(
makeMapStateToProps,
{
removeError: appActions.removeError,
}
)(ErrorToastComponent);

View file

@ -0,0 +1,235 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`EventDetails rendering should match snapshot 1`] = `
<Component>
<pure(Component)
data={
Array [
Object {
"category": "_id",
"description": "Each document has an _id that uniquely identifies it",
"example": "Y-6TfmcB0WOhS6qyMv3s",
"field": "_id",
"originalValue": "pEMaMmkBUV60JmNWmWVi",
"type": "keyword",
"values": Array [
"pEMaMmkBUV60JmNWmWVi",
],
},
Object {
"category": "_index",
"description": "An index is like a database in a relational database. It has a mapping which defines multiple types. An index is a logical namespace which maps to one or more primary shards and can have zero or more replica shards.",
"example": "auditbeat-8.0.0-2019.02.19-000001",
"field": "_index",
"originalValue": "filebeat-8.0.0-2019.02.19-000001",
"type": "keyword",
"values": Array [
"filebeat-8.0.0-2019.02.19-000001",
],
},
Object {
"category": "_type",
"description": null,
"example": null,
"field": "_type",
"originalValue": "_doc",
"type": "keyword",
"values": Array [
"_doc",
],
},
Object {
"category": "_score",
"description": null,
"example": null,
"field": "_score",
"originalValue": 1,
"type": "long",
"values": Array [
"1",
],
},
Object {
"category": "@timestamp",
"description": "Date/time when the event originated.For log events this is the date/time when the event was generated, and not when it was read.Required field for all events.",
"example": "2016-05-23T08:05:34.853Z",
"field": "@timestamp",
"originalValue": "2019-02-28T16:50:54.621Z",
"type": "date",
"values": Array [
"2019-02-28T16:50:54.621Z",
],
},
Object {
"category": "agent",
"description": "Ephemeral identifier of this agent (if one exists).This id normally changes across restarts, but \`agent.id\` does not.",
"example": "8a4f500f",
"field": "agent.ephemeral_id",
"originalValue": "9d391ef2-a734-4787-8891-67031178c641",
"type": "keyword",
"values": Array [
"9d391ef2-a734-4787-8891-67031178c641",
],
},
Object {
"category": "agent",
"description": null,
"example": null,
"field": "agent.hostname",
"originalValue": "siem-kibana",
"type": "keyword",
"values": Array [
"siem-kibana",
],
},
Object {
"category": "agent",
"description": "Unique identifier of this agent (if one exists).Example: For Beats this would be beat.id.",
"example": "8a4f500d",
"field": "agent.id",
"originalValue": "5de03d5f-52f3-482e-91d4-853c7de073c3",
"type": "keyword",
"values": Array [
"5de03d5f-52f3-482e-91d4-853c7de073c3",
],
},
Object {
"category": "agent",
"description": "Type of the agent.The agent type stays always the same and should be given by the agent used. In case of Filebeat the agent would always be Filebeat also if two Filebeat instances are run on the same machine.",
"example": "filebeat",
"field": "agent.type",
"originalValue": "filebeat",
"type": "keyword",
"values": Array [
"filebeat",
],
},
Object {
"category": "agent",
"description": "Version of the agent.",
"example": "6.0.0-rc2",
"field": "agent.version",
"originalValue": "8.0.0",
"type": "keyword",
"values": Array [
"8.0.0",
],
},
Object {
"category": "cloud",
"description": "Availability zone in which this host is running.",
"example": "us-east-1c",
"field": "cloud.availability_zone",
"originalValue": "projects/189716325846/zones/us-east1-b",
"type": "keyword",
"values": Array [
"projects/189716325846/zones/us-east1-b",
],
},
Object {
"category": "cloud",
"description": "Instance ID of the host machine.",
"example": "i-1234567890abcdef0",
"field": "cloud.instance.id",
"originalValue": "5412578377715150143",
"type": "keyword",
"values": Array [
"5412578377715150143",
],
},
Object {
"category": "cloud",
"description": "Instance name of the host machine.",
"example": null,
"field": "cloud.instance.name",
"originalValue": "siem-kibana",
"type": "keyword",
"values": Array [
"siem-kibana",
],
},
Object {
"category": "cloud",
"description": "Machine type of the host machine.",
"example": "t2.medium",
"field": "cloud.machine.type",
"originalValue": "projects/189716325846/machineTypes/n1-standard-1",
"type": "keyword",
"values": Array [
"projects/189716325846/machineTypes/n1-standard-1",
],
},
Object {
"category": "cloud",
"description": null,
"example": null,
"field": "cloud.project.id",
"originalValue": "elastic-beats",
"type": "keyword",
"values": Array [
"elastic-beats",
],
},
Object {
"category": "cloud",
"description": "Name of the cloud provider. Example values are ec2, gce, or digitalocean.",
"example": "ec2",
"field": "cloud.provider",
"originalValue": "gce",
"type": "keyword",
"values": Array [
"gce",
],
},
Object {
"category": "destination",
"description": "Bytes sent from the destination to the source.",
"example": "184",
"field": "destination.bytes",
"originalValue": 584,
"type": "long",
"values": Array [
"584",
],
},
Object {
"category": "destination",
"description": "IP address of the destination.Can be one or multiple IPv4 or IPv6 addresses.",
"example": null,
"field": "destination.ip",
"originalValue": "10.47.8.200",
"type": "ip",
"values": Array [
"10.47.8.200",
],
},
Object {
"category": "destination",
"description": "Packets sent from the destination to the source.",
"example": "12",
"field": "destination.packets",
"originalValue": 4,
"type": "long",
"values": Array [
"4",
],
},
Object {
"category": "destination",
"description": "Port of the destination.",
"example": null,
"field": "destination.port",
"originalValue": 902,
"type": "long",
"values": Array [
"902",
],
},
]
}
id="Y-6TfmcB0WOhS6qyMv3s"
onViewSelected={[MockFunction]}
view="table-view"
/>
</Component>
`;

View file

@ -0,0 +1,230 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`JSON View rendering should match snapshot 1`] = `
<Component
data={
Array [
Object {
"category": "_id",
"description": "Each document has an _id that uniquely identifies it",
"example": "Y-6TfmcB0WOhS6qyMv3s",
"field": "_id",
"originalValue": "pEMaMmkBUV60JmNWmWVi",
"type": "keyword",
"values": Array [
"pEMaMmkBUV60JmNWmWVi",
],
},
Object {
"category": "_index",
"description": "An index is like a database in a relational database. It has a mapping which defines multiple types. An index is a logical namespace which maps to one or more primary shards and can have zero or more replica shards.",
"example": "auditbeat-8.0.0-2019.02.19-000001",
"field": "_index",
"originalValue": "filebeat-8.0.0-2019.02.19-000001",
"type": "keyword",
"values": Array [
"filebeat-8.0.0-2019.02.19-000001",
],
},
Object {
"category": "_type",
"description": null,
"example": null,
"field": "_type",
"originalValue": "_doc",
"type": "keyword",
"values": Array [
"_doc",
],
},
Object {
"category": "_score",
"description": null,
"example": null,
"field": "_score",
"originalValue": 1,
"type": "long",
"values": Array [
"1",
],
},
Object {
"category": "@timestamp",
"description": "Date/time when the event originated.For log events this is the date/time when the event was generated, and not when it was read.Required field for all events.",
"example": "2016-05-23T08:05:34.853Z",
"field": "@timestamp",
"originalValue": "2019-02-28T16:50:54.621Z",
"type": "date",
"values": Array [
"2019-02-28T16:50:54.621Z",
],
},
Object {
"category": "agent",
"description": "Ephemeral identifier of this agent (if one exists).This id normally changes across restarts, but \`agent.id\` does not.",
"example": "8a4f500f",
"field": "agent.ephemeral_id",
"originalValue": "9d391ef2-a734-4787-8891-67031178c641",
"type": "keyword",
"values": Array [
"9d391ef2-a734-4787-8891-67031178c641",
],
},
Object {
"category": "agent",
"description": null,
"example": null,
"field": "agent.hostname",
"originalValue": "siem-kibana",
"type": "keyword",
"values": Array [
"siem-kibana",
],
},
Object {
"category": "agent",
"description": "Unique identifier of this agent (if one exists).Example: For Beats this would be beat.id.",
"example": "8a4f500d",
"field": "agent.id",
"originalValue": "5de03d5f-52f3-482e-91d4-853c7de073c3",
"type": "keyword",
"values": Array [
"5de03d5f-52f3-482e-91d4-853c7de073c3",
],
},
Object {
"category": "agent",
"description": "Type of the agent.The agent type stays always the same and should be given by the agent used. In case of Filebeat the agent would always be Filebeat also if two Filebeat instances are run on the same machine.",
"example": "filebeat",
"field": "agent.type",
"originalValue": "filebeat",
"type": "keyword",
"values": Array [
"filebeat",
],
},
Object {
"category": "agent",
"description": "Version of the agent.",
"example": "6.0.0-rc2",
"field": "agent.version",
"originalValue": "8.0.0",
"type": "keyword",
"values": Array [
"8.0.0",
],
},
Object {
"category": "cloud",
"description": "Availability zone in which this host is running.",
"example": "us-east-1c",
"field": "cloud.availability_zone",
"originalValue": "projects/189716325846/zones/us-east1-b",
"type": "keyword",
"values": Array [
"projects/189716325846/zones/us-east1-b",
],
},
Object {
"category": "cloud",
"description": "Instance ID of the host machine.",
"example": "i-1234567890abcdef0",
"field": "cloud.instance.id",
"originalValue": "5412578377715150143",
"type": "keyword",
"values": Array [
"5412578377715150143",
],
},
Object {
"category": "cloud",
"description": "Instance name of the host machine.",
"example": null,
"field": "cloud.instance.name",
"originalValue": "siem-kibana",
"type": "keyword",
"values": Array [
"siem-kibana",
],
},
Object {
"category": "cloud",
"description": "Machine type of the host machine.",
"example": "t2.medium",
"field": "cloud.machine.type",
"originalValue": "projects/189716325846/machineTypes/n1-standard-1",
"type": "keyword",
"values": Array [
"projects/189716325846/machineTypes/n1-standard-1",
],
},
Object {
"category": "cloud",
"description": null,
"example": null,
"field": "cloud.project.id",
"originalValue": "elastic-beats",
"type": "keyword",
"values": Array [
"elastic-beats",
],
},
Object {
"category": "cloud",
"description": "Name of the cloud provider. Example values are ec2, gce, or digitalocean.",
"example": "ec2",
"field": "cloud.provider",
"originalValue": "gce",
"type": "keyword",
"values": Array [
"gce",
],
},
Object {
"category": "destination",
"description": "Bytes sent from the destination to the source.",
"example": "184",
"field": "destination.bytes",
"originalValue": 584,
"type": "long",
"values": Array [
"584",
],
},
Object {
"category": "destination",
"description": "IP address of the destination.Can be one or multiple IPv4 or IPv6 addresses.",
"example": null,
"field": "destination.ip",
"originalValue": "10.47.8.200",
"type": "ip",
"values": Array [
"10.47.8.200",
],
},
Object {
"category": "destination",
"description": "Packets sent from the destination to the source.",
"example": "12",
"field": "destination.packets",
"originalValue": 4,
"type": "long",
"values": Array [
"4",
],
},
Object {
"category": "destination",
"description": "Port of the destination.",
"example": null,
"field": "destination.port",
"originalValue": 902,
"type": "long",
"values": Array [
"902",
],
},
]
}
/>
`;

View file

@ -0,0 +1,93 @@
/*
* 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 { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiPanel, EuiToolTip } from '@elastic/eui';
import * as React from 'react';
import styled from 'styled-components';
import { WithCopyToClipboard } from '../../lib/clipboard/with_copy_to_clipboard';
import { SelectableText } from '../selectable_text';
import { WithHoverActions } from '../with_hover_actions';
import { getIconFromType, ItemValues } from './helpers';
import * as i18n from './translations';
const HoverActionsContainer = styled(EuiPanel)`
align-items: center;
display: flex;
flex-direction: row;
height: 25px;
justify-content: center;
left: 5px;
position: absolute;
top: -10px;
width: 30px;
`;
export const getColumns = (id: string) => [
{
field: 'type',
name: '',
sortable: false,
truncateText: false,
width: '30px',
render: (type: string) => (
<EuiToolTip content={type}>
<EuiIcon type={getIconFromType(type)} />
</EuiToolTip>
),
},
{
field: 'field',
name: i18n.FIELD,
sortable: true,
truncateText: false,
render: (field: string) => (
<WithHoverActions
hoverContent={
<HoverActionsContainer data-test-subj="hover-actions-container">
<EuiToolTip content={i18n.COPY_TO_CLIPBOARD}>
<WithCopyToClipboard text={field} titleSummary={i18n.FIELD} />
</EuiToolTip>
</HoverActionsContainer>
}
render={() => <span>{field}</span>}
/>
),
},
{
field: 'values',
name: i18n.VALUE,
sortable: true,
truncateText: false,
render: (values: ItemValues[]) => (
<EuiFlexGroup direction="column" alignItems="flexStart" component="span" gutterSize="none">
{values.map(item => (
<EuiFlexItem grow={false} component="span" key={`${id}-value-${item.valueAsString}`}>
<WithHoverActions
hoverContent={
<HoverActionsContainer data-test-subj="hover-actions-container">
<EuiToolTip content={i18n.COPY_TO_CLIPBOARD}>
<WithCopyToClipboard text={item.valueAsString} titleSummary={i18n.VALUE} />
</EuiToolTip>
</HoverActionsContainer>
}
render={() => item.value}
/>
</EuiFlexItem>
))}
</EuiFlexGroup>
),
},
{
field: 'description',
name: i18n.DESCRIPTION,
render: (description: string) => <SelectableText>{description}</SelectableText>,
sortable: true,
truncateText: true,
width: '50%',
},
];

View file

@ -0,0 +1,77 @@
/*
* 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, shallow } from 'enzyme';
import toJson from 'enzyme-to-json';
import * as React from 'react';
import { mockDetailItemData, mockDetailItemDataId } from '../../mock/mock_detail_item';
import { TestProviders } from '../../mock/test_providers';
import { EventDetails } from './event_details';
describe('EventDetails', () => {
describe('rendering', () => {
test('should match snapshot', () => {
const wrapper = shallow(
<TestProviders>
<EventDetails
data={mockDetailItemData}
id={mockDetailItemDataId}
view="table-view"
onViewSelected={jest.fn()}
/>
</TestProviders>
);
expect(toJson(wrapper)).toMatchSnapshot();
});
});
describe('tabs', () => {
['Table', 'JSON View'].forEach(tab => {
test(`it renders the ${tab} tab`, () => {
const wrapper = mount(
<TestProviders>
<EventDetails
data={mockDetailItemData}
id={mockDetailItemDataId}
view="table-view"
onViewSelected={jest.fn()}
/>
</TestProviders>
);
expect(
wrapper
.find('[data-test-subj="eventDetails"]')
.find('[role="tablist"]')
.containsMatchingElement(<span>{tab}</span>)
).toBeTruthy();
});
});
test('the Table tab is selected by default', () => {
const wrapper = mount(
<TestProviders>
<EventDetails
data={mockDetailItemData}
id={mockDetailItemDataId}
view="table-view"
onViewSelected={jest.fn()}
/>
</TestProviders>
);
expect(
wrapper
.find('[data-test-subj="eventDetails"]')
.find('.euiTab-isSelected')
.first()
.text()
).toEqual('Table');
});
});
});

View file

@ -0,0 +1,55 @@
/*
* 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 { EuiTabbedContent, EuiTabbedContentTab } from '@elastic/eui';
import * as React from 'react';
import { pure } from 'recompose';
import styled from 'styled-components';
import { DetailItem } from '../../graphql/types';
import { EventFieldsBrowser } from './event_fields_browser';
import { JsonView } from './json_view';
import * as i18n from './translations';
export type View = 'table-view' | 'json-view';
interface Props {
data: DetailItem[];
id: string;
view: View;
onViewSelected: (selected: View) => void;
}
const Details = styled.div`
user-select: none;
width: 100%;
`;
export const EventDetails = pure<Props>(({ data, id, view, onViewSelected }) => {
const tabs: EuiTabbedContentTab[] = [
{
id: 'table-view',
name: i18n.TABLE,
content: <EventFieldsBrowser data={data} id={id} />,
},
{
id: 'json-view',
name: i18n.JSON_VIEW,
content: <JsonView data={data} />,
},
];
return (
<Details data-test-subj="eventDetails">
<EuiTabbedContent
tabs={tabs}
selectedTab={view === 'table-view' ? tabs[0] : tabs[1]}
onTabClick={e => onViewSelected(e.id as View)}
/>
</Details>
);
});

View file

@ -0,0 +1,113 @@
/*
* 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 { mountWithIntl } from 'test_utils/enzyme_helpers';
import { mockDetailItemData, mockDetailItemDataId } from '../../mock/mock_detail_item';
import { TestProviders } from '../../mock/test_providers';
import { EventFieldsBrowser } from './event_fields_browser';
describe('EventFieldsBrowser', () => {
describe('column headers', () => {
['Field', 'Value', 'Description'].forEach(header => {
test(`it renders the ${header} column header`, () => {
const wrapper = mountWithIntl(
<TestProviders>
<EventFieldsBrowser data={mockDetailItemData} id={mockDetailItemDataId} />
</TestProviders>
);
expect(wrapper.find('thead').containsMatchingElement(<span>{header}</span>)).toBeTruthy();
});
});
});
describe('filter input', () => {
test('it renders a filter input with the expected placeholder', () => {
const wrapper = mountWithIntl(
<TestProviders>
<EventFieldsBrowser data={mockDetailItemData} id={mockDetailItemDataId} />
</TestProviders>
);
expect(wrapper.find('input[type="search"]').props().placeholder).toEqual(
'Filter by Field, Value, or Description...'
);
});
});
describe('field type icon', () => {
test('it renders the expected icon type for the data provided', () => {
const wrapper = mountWithIntl(
<TestProviders>
<EventFieldsBrowser data={mockDetailItemData} id={mockDetailItemDataId} />
</TestProviders>
);
expect(
wrapper
.find('.euiTableRow')
.find('.euiTableRowCell')
.at(0)
.find('svg')
.exists()
).toEqual(true);
});
});
describe('field', () => {
test('it renders the field name for the data provided', () => {
const wrapper = mountWithIntl(
<TestProviders>
<EventFieldsBrowser data={mockDetailItemData} id={mockDetailItemDataId} />
</TestProviders>
);
expect(
wrapper
.find('.euiTableRow')
.find('.euiTableRowCell')
.at(1)
.containsMatchingElement(<span>_id</span>)
).toEqual(true);
});
});
describe('value', () => {
test('it renders the expected value for the data provided', () => {
const wrapper = mountWithIntl(
<TestProviders>
<EventFieldsBrowser data={mockDetailItemData} id={mockDetailItemDataId} />
</TestProviders>
);
expect(
wrapper
.find('[data-test-subj="draggable-content"]')
.at(0)
.text()
).toEqual('pEMaMmkBUV60JmNWmWVi');
});
});
describe('description', () => {
test('it renders the expected field description the data provided', () => {
const wrapper = mountWithIntl(
<TestProviders>
<EventFieldsBrowser data={mockDetailItemData} id={mockDetailItemDataId} />
</TestProviders>
);
expect(
wrapper
.find('.euiTableRow')
.find('.euiTableRowCell')
.at(3)
.text()
).toContain('Each document has an _id that uniquely identifies it');
});
});
});

View file

@ -0,0 +1,33 @@
/*
* 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 {
// @ts-ignore
EuiInMemoryTable,
} from '@elastic/eui';
import * as React from 'react';
import { pure } from 'recompose';
import { DetailItem } from '../../graphql/types';
import { getColumns } from './columns';
import { getItems, search } from './helpers';
interface Props {
data: DetailItem[];
id: string;
}
/** Renders a table view or JSON view of the `ECS` `data` */
export const EventFieldsBrowser = pure<Props>(({ data, id }) => (
<EuiInMemoryTable
items={getItems(data, id)}
columns={getColumns(id)}
pagination={false}
search={search}
sorting={true}
/>
));

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 const ID_FIELD_NAME = '_id';

View file

@ -0,0 +1,155 @@
/*
* 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 { TestProviders } from '../../mock';
import { mockDetailItemData, mockDetailItemDataId } from '../../mock/mock_detail_item';
import { getExampleText, getIconFromType, getItems } from './helpers';
const aField = mockDetailItemData[0];
describe('helpers', () => {
describe('getExampleText', () => {
test('it returns the expected example text when the field contains an example', () => {
expect(getExampleText(aField)).toEqual('Example: Y-6TfmcB0WOhS6qyMv3s');
});
test(`it returns an empty string when the field's example is an empty string`, () => {
const fieldWithEmptyExample = {
...aField,
example: '',
};
expect(getExampleText(fieldWithEmptyExample)).toEqual('');
});
});
describe('getIconFromType', () => {
[
{
type: 'keyword',
expected: 'string',
},
{
type: 'long',
expected: 'number',
},
{
type: 'date',
expected: 'clock',
},
{
type: 'ip',
expected: 'globe',
},
{
type: 'object',
expected: 'questionInCircle',
},
{
type: 'float',
expected: 'number',
},
{
type: 'anything else',
expected: 'questionInCircle',
},
].forEach(({ type, expected }) => {
test(`it returns a ${expected} icon for type ${type}`, () =>
expect(getIconFromType(type)).toEqual(expected));
});
});
describe('getItems', () => {
test('it returns the expected number of populated fields', () => {
expect(getItems(mockDetailItemData, mockDetailItemDataId).length).toEqual(20);
});
test('it includes the "cloud.instance.id" field', () => {
getItems(mockDetailItemData, mockDetailItemDataId).some(x => x.field === 'cloud.instance.id');
});
test('it returns the expected description', () => {
expect(
getItems(mockDetailItemData, mockDetailItemDataId).find(
x => x.field === 'cloud.instance.id'
)!.description
).toEqual('Instance ID of the host machine. Example: i-1234567890abcdef0');
});
test('it returns the expected type', () => {
expect(
getItems(mockDetailItemData, mockDetailItemDataId).find(
x => x.field === 'cloud.instance.id'
)!.type
).toEqual('keyword');
});
test('it returns the expected valueAsString', () => {
expect(
getItems(mockDetailItemData, mockDetailItemDataId).find(
x => x.field === 'cloud.instance.id'
)!.values[0].valueAsString
).toEqual('5412578377715150143');
});
test('it returns a draggable wrapper with the expected value.key', () => {
expect(
getItems(mockDetailItemData, mockDetailItemDataId).find(
x => x.field === 'cloud.instance.id'
)!.values[0].value.key
).toMatch(/^event-field-browser-value-for-cloud.instance.id-\S+$/);
});
describe('formatting data for display', () => {
const justDateFields = getItems(mockDetailItemData, mockDetailItemDataId).filter(
item => item.type === 'date'
);
const nonDateFields = getItems(mockDetailItemData, mockDetailItemDataId).filter(
item => item.type !== 'date'
);
test('it should have at least one date field (a sanity check for inputs to other tests)', () => {
expect(justDateFields.length > 0).toEqual(true); // to ensure the tests below run for at least one date field
});
test('it should have at least one NON-date field (a sanity check for inputs to other tests)', () => {
expect(nonDateFields.length > 0).toEqual(true); // to ensure the tests below run for at least one NON-date field
});
justDateFields.forEach(field => {
test(`it should render a tooltip for the ${field.field} (${field.type}) field`, () => {
const wrapper = mount(
<TestProviders>
<>{field.values[0].value}</>
</TestProviders>
);
expect(wrapper.find('[data-test-subj="localized-date-tool-tip"]').exists()).toEqual(true);
});
});
nonDateFields.forEach(field => {
test(`it should NOT render a tooltip for the NON-date ${field.field} (${
field.type
}) field`, () => {
const wrapper = mount(
<TestProviders>
<>{field.values[0].value}</>
</TestProviders>
);
expect(wrapper.find('[data-test-subj="localized-date-tool-tip"]').exists()).toEqual(
false
);
});
});
});
});
});

View file

@ -0,0 +1,130 @@
/*
* 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 { isEmpty } from 'lodash/fp';
import * as React from 'react';
import { DetailItem } from '../../graphql/types';
import { escapeQueryValue } from '../../lib/keury';
import { DragEffects, DraggableWrapper } from '../drag_and_drop/draggable_wrapper';
import { escapeDataProviderId } from '../drag_and_drop/helpers';
import { FormattedFieldValue } from '../timeline/body/renderers/formatted_field';
import { parseValue } from '../timeline/body/renderers/plain_column_renderer';
import { Provider } from '../timeline/data_providers/provider';
import * as i18n from './translations';
/**
* Defines the behavior of the search input that appears above the table of data
*/
export const search = {
box: {
incremental: true,
placeholder: i18n.PLACEHOLDER,
schema: {
field: {
type: 'string',
},
value: {
type: 'string',
},
description: {
type: 'string',
},
},
},
};
export interface ItemValues {
value: JSX.Element;
valueAsString: string;
}
/**
* An item rendered in the table
*/
export interface Item {
field: string;
description: string;
type: string;
values: ItemValues[];
}
/** Returns example text, or an empty string if the field does not have an example */
export const getExampleText = (field: DetailItem): string =>
!isEmpty(field.example) ? `Example: ${field.example}` : '';
export const getIconFromType = (type: string) => {
switch (type) {
case 'keyword':
return 'string';
case 'long':
return 'number';
case 'date':
return 'clock';
case 'ip':
return 'globe';
case 'object':
return 'questionInCircle';
case 'float':
return 'number';
default:
return 'questionInCircle';
}
};
/**
* Return a draggable value for the details item view in the timeline
*/
export const getItems = (data: DetailItem[], id: string): Item[] =>
data.map(item => ({
description: `${item.description || ''} ${getExampleText(item)}`,
field: item.field,
type: item.type,
values:
item.values == null
? []
: item.values.map((itemValue: string) => {
const itemDataProvider = {
enabled: true,
id: escapeDataProviderId(
`id-event-field-browser-value-for-${item.field}-${id}-${itemValue}`
),
name: item.field,
queryMatch: {
field: item.field,
value: escapeQueryValue(itemValue),
},
excluded: false,
kqlQuery: '',
and: [],
};
return {
valueAsString: itemValue,
value: (
<DraggableWrapper
key={`event-field-browser-value-for-${item.field}-${id}-${itemValue}`}
dataProvider={itemDataProvider}
render={(dataProvider, _, snapshot) =>
snapshot.isDragging ? (
<DragEffects>
<Provider dataProvider={dataProvider} />
</DragEffects>
) : (
<FormattedFieldValue
contextId="event-details"
eventId={id}
fieldName={item.field}
fieldType={item.type}
value={parseValue(itemValue)}
/>
)
}
/>
),
};
}),
}));

View file

@ -0,0 +1,62 @@
/*
* 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 { shallow } from 'enzyme';
import toJson from 'enzyme-to-json';
import * as React from 'react';
import { mockDetailItemData } from '../../mock';
import { buildJsonView, JsonView } from './json_view';
describe('JSON View', () => {
describe('rendering', () => {
test('should match snapshot', () => {
const wrapper = shallow(<JsonView data={mockDetailItemData} />);
expect(toJson(wrapper)).toMatchSnapshot();
});
});
describe('buildJsonView', () => {
test('should match a json', () => {
const expectedData = {
'@timestamp': '2019-02-28T16:50:54.621Z',
_id: 'pEMaMmkBUV60JmNWmWVi',
_index: 'filebeat-8.0.0-2019.02.19-000001',
_score: 1,
_type: '_doc',
agent: {
ephemeral_id: '9d391ef2-a734-4787-8891-67031178c641',
hostname: 'siem-kibana',
id: '5de03d5f-52f3-482e-91d4-853c7de073c3',
type: 'filebeat',
version: '8.0.0',
},
cloud: {
availability_zone: 'projects/189716325846/zones/us-east1-b',
instance: {
id: '5412578377715150143',
name: 'siem-kibana',
},
machine: {
type: 'projects/189716325846/machineTypes/n1-standard-1',
},
project: {
id: 'elastic-beats',
},
provider: 'gce',
},
destination: {
bytes: 584,
ip: '10.47.8.200',
packets: 4,
port: 902,
},
};
expect(buildJsonView(mockDetailItemData)).toEqual(expectedData);
});
});
});

View file

@ -0,0 +1,45 @@
/*
* 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 {
// @ts-ignore
EuiCodeEditor,
} from '@elastic/eui';
import { set } from 'lodash/fp';
import * as React from 'react';
import { pure } from 'recompose';
import styled from 'styled-components';
import { DetailItem } from '../../graphql/types';
import { omitTypenameAndEmpty } from '../timeline/body/helpers';
interface Props {
data: DetailItem[];
}
const JsonEditor = styled.div`
width: 100%;
`;
export const JsonView = pure<Props>(({ data }) => (
<JsonEditor data-test-subj="jsonView">
<EuiCodeEditor
isReadOnly
mode="javascript"
setOptions={{ fontSize: '12px' }}
value={JSON.stringify(
buildJsonView(data),
omitTypenameAndEmpty,
2 // indent level
)}
width="100%"
/>
}
</JsonEditor>
));
export const buildJsonView = (data: DetailItem[]) =>
data.reduce((accumulator, item) => set(item.field, item.originalValue, accumulator), {});

View file

@ -0,0 +1,45 @@
/*
* 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 { DetailItem } from '../../graphql/types';
import { EventDetails, View } from './event_details';
interface Props {
data: DetailItem[];
id: string;
}
interface State {
view: View;
}
export class StatefulEventDetails extends React.PureComponent<Props, State> {
constructor(props: Props) {
super(props);
this.state = { view: 'table-view' };
}
public onViewSelected = (view: View): void => {
this.setState({ view });
};
public render() {
const { data, id } = this.props;
return (
<EventDetails
data={data}
id={id}
view={this.state.view}
onViewSelected={this.onViewSelected}
/>
);
}
}

View file

@ -0,0 +1,35 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const TABLE = i18n.translate('xpack.siem.eventDetails.table', {
defaultMessage: 'Table',
});
export const JSON_VIEW = i18n.translate('xpack.siem.eventDetails.jsonView', {
defaultMessage: 'JSON View',
});
export const FIELD = i18n.translate('xpack.siem.eventDetails.field', {
defaultMessage: 'Field',
});
export const VALUE = i18n.translate('xpack.siem.eventDetails.value', {
defaultMessage: 'Value',
});
export const DESCRIPTION = i18n.translate('xpack.siem.eventDetails.description', {
defaultMessage: 'Description',
});
export const PLACEHOLDER = i18n.translate('xpack.siem.eventDetails.filter.placeholder', {
defaultMessage: 'Filter by Field, Value, or Description...',
});
export const COPY_TO_CLIPBOARD = i18n.translate('xpack.siem.eventDetails.copyToClipboard', {
defaultMessage: 'Copy to Clipboard',
});

View file

@ -0,0 +1,85 @@
/*
* 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 'jest-styled-components';
import * as React from 'react';
import { TestProviders } from '../../mock';
import { ExternalLinkIcon } from '.';
describe('Duration', () => {
test('it renders expected icon type when the leftMargin prop is not specified', () => {
const wrapper = mount(
<TestProviders>
<ExternalLinkIcon />
</TestProviders>
);
expect(
wrapper
.find('[data-test-subj="external-link-icon"]')
.first()
.props().type
).toEqual('popout');
});
test('it renders expected icon type when the leftMargin prop is true', () => {
const wrapper = mount(
<TestProviders>
<ExternalLinkIcon leftMargin={true} />
</TestProviders>
);
expect(
wrapper
.find('[data-test-subj="external-link-icon"]')
.first()
.props().type
).toEqual('popout');
});
test('it applies a margin-left style when the leftMargin prop is true', () => {
const wrapper = mount(
<TestProviders>
<ExternalLinkIcon leftMargin={true} />
</TestProviders>
);
expect(wrapper.find('[data-test-subj="external-link-icon"]').first()).toHaveStyleRule(
'margin-left',
'5px'
);
});
test('it does NOT apply a margin-left style when the leftMargin prop is false', () => {
const wrapper = mount(
<TestProviders>
<ExternalLinkIcon leftMargin={false} />
</TestProviders>
);
expect(wrapper.find('[data-test-subj="external-link-icon"]').first()).not.toHaveStyleRule(
'margin-left'
);
});
test('it renders expected icon type when the leftMargin prop is false', () => {
const wrapper = mount(
<TestProviders>
<ExternalLinkIcon leftMargin={true} />
</TestProviders>
);
expect(
wrapper
.find('[data-test-subj="external-link-icon"]')
.first()
.props().type
).toEqual('popout');
});
});

View file

@ -0,0 +1,42 @@
/*
* 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 { EuiIcon } from '@elastic/eui';
import * as React from 'react';
import { pure } from 'recompose';
import styled from 'styled-components';
const LinkIcon = styled(EuiIcon)`
position: relative;
top: -2px;
`;
const LinkIconWithMargin = styled(LinkIcon)`
margin-left: 5px;
`;
const color = 'subdued';
const iconSize = 's';
const iconType = 'popout';
/**
* Renders an icon that indicates following the hyperlink will navigate to
* content external to the app
*/
export const ExternalLinkIcon = pure<{
leftMargin?: boolean;
}>(({ leftMargin = true }) =>
leftMargin ? (
<LinkIconWithMargin
color={color}
data-test-subj="external-link-icon"
size={iconSize}
type={iconType}
/>
) : (
<LinkIcon color={color} data-test-subj="external-link-icon" size={iconSize} type={iconType} />
)
);

View file

@ -0,0 +1,9 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Select Flow Direction rendering it renders the basic group button for uni-direction and bi-direction 1`] = `
<Component
id="TestFlowDirectionId"
onChangeDirection={[MockFunction]}
selectedDirection="uniDirectional"
/>
`;

View file

@ -0,0 +1,11 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`FlowTargetSelect Component rendering it renders the FlowTargetSelect 1`] = `
<Component
id="TestFlowTargetId"
isLoading={false}
selectedDirection="uniDirectional"
selectedTarget="source"
updateFlowTargetAction={[MockFunction]}
/>
`;

View file

@ -0,0 +1,61 @@
/*
* 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, shallow } from 'enzyme';
import toJson from 'enzyme-to-json';
import * as React from 'react';
import { FlowDirection } from '../../graphql/types';
import { FlowDirectionSelect } from './flow_direction_select';
describe('Select Flow Direction', () => {
const TestFlowDirectionId = 'TestFlowDirectionId';
const mockOnChange = jest.fn();
describe('rendering', () => {
test('it renders the basic group button for uni-direction and bi-direction', () => {
const wrapper = shallow(
<FlowDirectionSelect
id={TestFlowDirectionId}
selectedDirection={FlowDirection.uniDirectional}
onChangeDirection={mockOnChange}
/>
);
expect(toJson(wrapper)).toMatchSnapshot();
});
});
describe('Functionality work as expected', () => {
test('when you click on bi-directional, you trigger onChange function', () => {
const event = {
target: {
name: `${TestFlowDirectionId}-select-flow-direction`,
value: FlowDirection.biDirectional,
},
};
const wrapper = mount(
<FlowDirectionSelect
id={TestFlowDirectionId}
selectedDirection={FlowDirection.uniDirectional}
onChangeDirection={mockOnChange}
/>
);
wrapper
.find('input[value="biDirectional"]')
.first()
.simulate('change', event);
wrapper.update();
expect(mockOnChange.mock.calls[0]).toEqual([
`${TestFlowDirectionId}-select-flow-direction-biDirectional`,
'biDirectional',
]);
});
});
});

View file

@ -0,0 +1,52 @@
/*
* 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 { EuiButtonGroup, EuiButtonGroupProps } from '@elastic/eui';
import React from 'react';
import { pure } from 'recompose';
import { FlowDirection } from '../../graphql/types';
import * as i18n from './translations';
type MyEuiButtonGroupProps = Pick<
EuiButtonGroupProps,
'options' | 'idSelected' | 'onChange' | 'color' | 'type'
> & {
name?: string;
};
const MyEuiButtonGroup: React.FC<MyEuiButtonGroupProps> = EuiButtonGroup;
const getToggleButtonDirection = (id: string) => [
{
id: `${id}-select-flow-direction-${FlowDirection.uniDirectional}`,
label: i18n.UNIDIRECTIONAL,
value: FlowDirection.uniDirectional,
},
{
id: `${id}-select-flow-direction-${FlowDirection.biDirectional}`,
label: i18n.BIDIRECTIONAL,
value: FlowDirection.biDirectional,
},
];
interface Props {
id: string;
selectedDirection: FlowDirection;
onChangeDirection: (id: string, value: FlowDirection) => void;
}
export const FlowDirectionSelect = pure<Props>(({ id, onChangeDirection, selectedDirection }) => (
<MyEuiButtonGroup
name={`${id}-${selectedDirection}`}
options={getToggleButtonDirection(id)}
idSelected={`${id}-select-flow-direction-${selectedDirection}`}
onChange={onChangeDirection}
color="primary"
type="single"
/>
));

View file

@ -0,0 +1,111 @@
/*
* 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, shallow } from 'enzyme';
import toJson from 'enzyme-to-json';
import { clone } from 'lodash/fp';
import * as React from 'react';
import { ActionCreator } from 'typescript-fsa';
import { FlowDirection, FlowTarget } from '../../graphql/types';
import { FlowTargetSelect } from './flow_target_select';
describe('FlowTargetSelect Component', () => {
const TestFlowTargetId = 'TestFlowTargetId';
const mockProps = {
id: TestFlowTargetId,
selectedDirection: FlowDirection.uniDirectional,
isLoading: false,
selectedTarget: FlowTarget.source,
updateFlowTargetAction: (jest.fn() as unknown) as ActionCreator<{
flowTarget: FlowTarget;
}>,
};
describe('rendering', () => {
test('it renders the FlowTargetSelect', () => {
const wrapper = shallow(<FlowTargetSelect {...mockProps} />);
expect(toJson(wrapper)).toMatchSnapshot();
});
});
test('selecting destination from the type drop down', () => {
const wrapper = mount(<FlowTargetSelect {...mockProps} />);
wrapper
.find('button')
.first()
.simulate('click');
wrapper.update();
wrapper
.find(`button#${TestFlowTargetId}-select-flow-target-destination`)
.first()
.simulate('click');
wrapper.update();
// @ts-ignore property mock does not exists
expect(mockProps.updateFlowTargetAction.mock.calls[0][0]).toEqual({
flowTarget: 'destination',
});
});
test('when selectedDirection=unidirectional only source/destination are options', () => {
const wrapper = mount(<FlowTargetSelect {...mockProps} />);
wrapper
.find('button')
.first()
.simulate('click');
wrapper.update();
expect(
wrapper.find(`button#${TestFlowTargetId}-select-flow-target-source`).exists()
).toBeTruthy();
expect(
wrapper.find(`button#${TestFlowTargetId}-select-flow-target-destination`).exists()
).toBeTruthy();
expect(
wrapper.find(`button#${TestFlowTargetId}-select-flow-target-client`).exists()
).toBeFalsy();
expect(
wrapper.find(`button#${TestFlowTargetId}-select-flow-target-server`).exists()
).toBeFalsy();
});
test('when selectedDirection=bidirectional source/destination/client/server are options', () => {
const bidirectionalMock = clone(mockProps);
bidirectionalMock.selectedDirection = FlowDirection.biDirectional;
const wrapper = mount(<FlowTargetSelect {...bidirectionalMock} />);
wrapper
.find('button')
.first()
.simulate('click');
wrapper.update();
expect(
wrapper.find(`button#${TestFlowTargetId}-select-flow-target-source`).exists()
).toBeTruthy();
expect(
wrapper.find(`button#${TestFlowTargetId}-select-flow-target-destination`).exists()
).toBeTruthy();
expect(
wrapper.find(`button#${TestFlowTargetId}-select-flow-target-client`).exists()
).toBeTruthy();
expect(
wrapper.find(`button#${TestFlowTargetId}-select-flow-target-server`).exists()
).toBeTruthy();
});
});

View file

@ -0,0 +1,88 @@
/*
* 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 {
// @ts-ignore
EuiSuperSelect,
} from '@elastic/eui';
import React from 'react';
import { pure } from 'recompose';
import { ActionCreator } from 'typescript-fsa';
import { FlowDirection, FlowTarget } from '../../graphql/types';
import * as i18n from './translations';
const toggleTargetOptions = (id: string, displayText: string[]) => [
{
id: `${id}-select-flow-target-${FlowTarget.source}`,
value: FlowTarget.source,
inputDisplay: displayText[0] || i18n.SOURCE,
directions: [FlowDirection.uniDirectional, FlowDirection.biDirectional],
},
{
id: `${id}-select-flow-target-${FlowTarget.destination}`,
value: FlowTarget.destination,
inputDisplay: displayText[1] || i18n.DESTINATION,
directions: [FlowDirection.uniDirectional, FlowDirection.biDirectional],
},
{
id: `${id}-select-flow-target-${FlowTarget.client}`,
value: FlowTarget.client,
inputDisplay: displayText[2] || i18n.CLIENT,
directions: [FlowDirection.biDirectional],
},
{
id: `${id}-select-flow-target-${FlowTarget.server}`,
value: FlowTarget.server,
inputDisplay: displayText[3] || i18n.SERVER,
directions: [FlowDirection.biDirectional],
},
];
interface OwnProps {
id: string;
isLoading: boolean;
selectedTarget: FlowTarget;
displayTextOverride?: string[];
selectedDirection?: FlowDirection;
updateFlowTargetAction: ActionCreator<{ flowTarget: FlowTarget }>;
}
const onChangeTarget = (
flowTarget: FlowTarget,
updateFlowTargetSelectAction: ActionCreator<{ flowTarget: FlowTarget }>
) => {
updateFlowTargetSelectAction({ flowTarget });
};
export type FlowTargetSelectProps = OwnProps;
export const FlowTargetSelect = pure<FlowTargetSelectProps>(
({
id,
isLoading = false,
selectedDirection,
selectedTarget,
displayTextOverride = [],
updateFlowTargetAction,
}) => (
<EuiSuperSelect
options={
selectedDirection
? toggleTargetOptions(id, displayTextOverride).filter(option =>
option.directions.includes(selectedDirection)
)
: toggleTargetOptions(id, displayTextOverride)
}
valueOfSelected={selectedTarget}
onChange={(newFlowTarget: FlowTarget) =>
onChangeTarget(newFlowTarget, updateFlowTargetAction)
}
isLoading={isLoading}
/>
)
);

View file

@ -0,0 +1,49 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const SOURCE = i18n.translate(
'xpack.siem.components.flowControls.selectFlowTarget.sourceDropDownOptionLabel',
{
defaultMessage: 'Source',
}
);
export const DESTINATION = i18n.translate(
'xpack.siem.components.flowControls.selectFlowTarget.destinationDropDownOptionLabel',
{
defaultMessage: 'Destination',
}
);
export const CLIENT = i18n.translate(
'xpack.siem.components.flowControls.selectFlowTarget.clientDropDownOptionLabel',
{
defaultMessage: 'CLIENT',
}
);
export const SERVER = i18n.translate(
'xpack.siem.components.flowControls.selectFlowTarget.serverDropDownOptionLabel',
{
defaultMessage: 'SERVER',
}
);
export const UNIDIRECTIONAL = i18n.translate(
'xpack.siem.components.flowControls.selectFlowDirection.unidirectionalButtonLabel',
{
defaultMessage: 'Unidirectional',
}
);
export const BIDIRECTIONAL = i18n.translate(
'xpack.siem.components.flowControls.selectFlowDirection.bidirectionalButtonLabel',
{
defaultMessage: 'Bidirectional',
}
);

View file

@ -0,0 +1,16 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Flyout rendering it renders correctly against snapshot 1`] = `
<Component>
<Connect(pure(Component))
flyoutHeight={980}
headerHeight={48}
timelineId="test"
usersViewing={
Array [
"elastic",
]
}
/>
</Component>
`;

View file

@ -0,0 +1,99 @@
/*
* 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 { EuiBadge, EuiPanel, EuiText } from '@elastic/eui';
import * as React from 'react';
import { pure } from 'recompose';
import styled from 'styled-components';
import { DroppableWrapper } from '../../drag_and_drop/droppable_wrapper';
import { droppableTimelineFlyoutButtonPrefix } from '../../drag_and_drop/helpers';
import { DataProvider } from '../../timeline/data_providers/data_provider';
import * as i18n from './translations';
const Container = styled.div`
overflow-x: auto;
overflow-y: hidden;
position: fixed;
top: 40%;
right: -3px;
min-width: 50px;
max-width: 80px;
z-index: 9;
height: 240px;
max-height: 240px;
`;
const BadgeButtonContainer = styled.div`
align-items: center;
display: flex;
flex-direction: column;
`;
export const Button = styled(EuiPanel)`
display: flex;
z-index: 9;
justify-content: center;
text-align: center;
border-top: 1px solid ${({ theme }) => theme.eui.euiColorLightShade};
border-bottom: 1px solid ${({ theme }) => theme.eui.euiColorLightShade};
border-left: 1px solid ${({ theme }) => theme.eui.euiColorLightShade};
border-radius: 6px 0 0 6px;
box-shadow: ${({ theme }) =>
`0 3px 3px -1px ${theme.eui.euiColorLightestShade}, 0 5px 7px -2px ${
theme.eui.euiColorLightestShade
}`};
background-color: inherit;
cursor: pointer;
`;
export const Text = styled(EuiText)`
width: 12px;
z-index: 10;
user-select: none;
`;
export const Badge = styled(EuiBadge)`
border-radius: 5px;
min-width: 25px;
padding: 0px;
transform: translateY(10px);
z-index: 10;
`;
interface FlyoutButtonProps {
dataProviders: DataProvider[];
onOpen: () => void;
show: boolean;
timelineId: string;
}
export const FlyoutButton = pure<FlyoutButtonProps>(({ onOpen, show, dataProviders, timelineId }) =>
show ? (
<Container>
<DroppableWrapper droppableId={`${droppableTimelineFlyoutButtonPrefix}${timelineId}`}>
<BadgeButtonContainer
className="flyout-overlay"
data-test-subj="flyoutOverlay"
onClick={onOpen}
>
{dataProviders.length !== 0 && (
<Badge data-test-subj="badge" color="primary">
{dataProviders.length}
</Badge>
)}
<Button>
<Text data-test-subj="flyoutButton" size="s">
{i18n.TIMELINE.toLocaleUpperCase()
.split('')
.join(' ')}
</Text>
</Button>
</BadgeButtonContainer>
</DroppableWrapper>
</Container>
) : null
);

View file

@ -0,0 +1,11 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const TIMELINE = i18n.translate('xpack.siem.flyout.button.timeline', {
defaultMessage: 'timeline',
});

View file

@ -0,0 +1,199 @@
/*
* 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 { connect } from 'react-redux';
import { pure } from 'recompose';
import { Dispatch } from 'redux';
import { ActionCreator } from 'typescript-fsa';
import { History } from '../../../lib/history';
import { Note } from '../../../lib/note';
import {
appActions,
appSelectors,
inputsActions,
inputsModel,
inputsSelectors,
State,
timelineActions,
timelineModel,
timelineSelectors,
} from '../../../store';
import { UpdateNote } from '../../notes/helpers';
import { DEFAULT_TIMELINE_WIDTH } from '../../timeline/body';
import { defaultHeaders } from '../../timeline/body/column_headers/default_headers';
import { Properties } from '../../timeline/properties';
interface OwnProps {
timelineId: string;
usersViewing: string[];
}
interface StateReduxProps {
description: string;
getNotesByIds: (noteIds: string[]) => Note[];
isFavorite: boolean;
isDatepickerLocked: boolean;
noteIds: string[];
title: string;
width: number;
}
interface DispatchProps {
associateNote: (noteId: string) => void;
applyDeltaToWidth?: (
{
id,
delta,
bodyClientWidthPixels,
maxWidthPercent,
minWidthPixels,
}: {
id: string;
delta: number;
bodyClientWidthPixels: number;
maxWidthPercent: number;
minWidthPixels: number;
}
) => void;
createTimeline: ActionCreator<{ id: string; show?: boolean }>;
toggleLock: ActionCreator<{ linkToId: inputsModel.InputsModelId }>;
updateDescription: ActionCreator<{ id: string; description: string }>;
updateIsFavorite: ActionCreator<{ id: string; isFavorite: boolean }>;
updateNote: UpdateNote;
updateTitle: ActionCreator<{ id: string; title: string }>;
}
type Props = OwnProps & StateReduxProps & DispatchProps;
const statefulFlyoutHeader = pure<Props>(
({
associateNote,
createTimeline,
description,
getNotesByIds,
isFavorite,
isDatepickerLocked,
title,
width = DEFAULT_TIMELINE_WIDTH,
noteIds,
timelineId,
toggleLock,
updateDescription,
updateIsFavorite,
updateNote,
updateTitle,
usersViewing,
}) => (
<Properties
associateNote={associateNote}
createTimeline={createTimeline}
description={description}
getNotesByIds={getNotesByIds}
isDatepickerLocked={isDatepickerLocked}
isFavorite={isFavorite}
title={title}
noteIds={noteIds}
timelineId={timelineId}
toggleLock={toggleLock}
updateDescription={updateDescription}
updateIsFavorite={updateIsFavorite}
updateTitle={updateTitle}
updateNote={updateNote}
usersViewing={usersViewing}
width={width}
/>
)
);
const emptyHistory: History[] = []; // stable reference
const makeMapStateToProps = () => {
const getTimeline = timelineSelectors.getTimelineByIdSelector();
const getNotesByIds = appSelectors.notesByIdsSelector();
const getGlobalInput = inputsSelectors.globalSelector();
const mapStateToProps = (state: State, { timelineId }: OwnProps) => {
const timeline: timelineModel.TimelineModel = getTimeline(state, timelineId);
const globalInput: inputsModel.InputsRange = getGlobalInput(state);
const {
description = '',
isFavorite = false,
title = '',
noteIds = [],
width = DEFAULT_TIMELINE_WIDTH,
} = timeline;
const history = emptyHistory; // TODO: get history from store via selector
return {
description,
getNotesByIds: getNotesByIds(state),
history,
isFavorite,
isDatepickerLocked: globalInput.linkTo.includes('timeline'),
noteIds,
title,
width,
};
};
return mapStateToProps;
};
const mapDispatchToProps = (dispatch: Dispatch, { timelineId }: OwnProps) => ({
associateNote: (noteId: string) => {
dispatch(timelineActions.addNote({ id: timelineId, noteId }));
},
applyDeltaToWidth: ({
id,
delta,
bodyClientWidthPixels,
maxWidthPercent,
minWidthPixels,
}: {
id: string;
delta: number;
bodyClientWidthPixels: number;
maxWidthPercent: number;
minWidthPixels: number;
}) => {
dispatch(
timelineActions.applyDeltaToWidth({
id,
delta,
bodyClientWidthPixels,
maxWidthPercent,
minWidthPixels,
})
);
},
createTimeline: ({ id, show }: { id: string; show?: boolean }) => {
dispatch(timelineActions.createTimeline({ id, columns: defaultHeaders, show }));
},
updateDescription: ({ id, description }: { id: string; description: string }) => {
dispatch(timelineActions.updateDescription({ id, description }));
},
updateIsFavorite: ({ id, isFavorite }: { id: string; isFavorite: boolean }) => {
dispatch(timelineActions.updateIsFavorite({ id, isFavorite }));
},
updateIsLive: ({ id, isLive }: { id: string; isLive: boolean }) => {
dispatch(timelineActions.updateIsLive({ id, isLive }));
},
updateNote: (note: Note) => {
dispatch(appActions.updateNote({ note }));
},
updateTitle: ({ id, title }: { id: string; title: string }) => {
dispatch(timelineActions.updateTitle({ id, title }));
},
toggleLock: ({ linkToId }: { linkToId: inputsModel.InputsModelId }) => {
dispatch(inputsActions.toggleTimelineLinkTo({ linkToId }));
},
});
export const FlyoutHeader = connect(
makeMapStateToProps,
mapDispatchToProps
)(statefulFlyoutHeader);

View file

@ -0,0 +1,314 @@
/*
* 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, shallow } from 'enzyme';
import toJson from 'enzyme-to-json';
import { set } from 'lodash/fp';
import * as React from 'react';
import { ActionCreator } from 'typescript-fsa';
import { mockGlobalState, TestProviders } from '../../mock';
import { createStore, State } from '../../store';
import { mockDataProviders } from '../timeline/data_providers/mock/mock_data_providers';
import { Flyout, FlyoutComponent, flyoutHeaderHeight } from '.';
import { FlyoutButton } from './button';
const testFlyoutHeight = 980;
const usersViewing = ['elastic'];
describe('Flyout', () => {
const state: State = mockGlobalState;
describe('rendering', () => {
test('it renders correctly against snapshot', () => {
const wrapper = shallow(
<TestProviders>
<Flyout
flyoutHeight={testFlyoutHeight}
headerHeight={flyoutHeaderHeight}
timelineId="test"
usersViewing={usersViewing}
/>
</TestProviders>
);
expect(toJson(wrapper)).toMatchSnapshot();
});
test('it renders the default flyout state as a button', () => {
const wrapper = mount(
<TestProviders>
<Flyout
flyoutHeight={testFlyoutHeight}
headerHeight={flyoutHeaderHeight}
timelineId="test"
usersViewing={usersViewing}
/>
</TestProviders>
);
expect(
wrapper
.find('[data-test-subj="flyoutButton"]')
.first()
.text()
).toContain('T I M E L I N E');
});
test('it renders the title field when its state is set to flyout is true', () => {
const stateShowIsTrue = set('timeline.timelineById.test.show', true, state);
const storeShowIsTrue = createStore(stateShowIsTrue);
const wrapper = mount(
<TestProviders store={storeShowIsTrue}>
<Flyout
flyoutHeight={testFlyoutHeight}
headerHeight={flyoutHeaderHeight}
timelineId="test"
usersViewing={usersViewing}
/>
</TestProviders>
);
expect(
wrapper
.find('[data-test-subj="timeline-title"]')
.first()
.props().placeholder
).toContain('Untitled Timeline');
});
test('it does NOT render the fly out button when its state is set to flyout is true', () => {
const stateShowIsTrue = set('timeline.timelineById.test.show', true, state);
const storeShowIsTrue = createStore(stateShowIsTrue);
const wrapper = mount(
<TestProviders store={storeShowIsTrue}>
<Flyout
flyoutHeight={testFlyoutHeight}
headerHeight={flyoutHeaderHeight}
timelineId="test"
usersViewing={usersViewing}
/>
</TestProviders>
);
expect(wrapper.find('[data-test-subj="flyoutButton"]').exists()).toEqual(false);
});
test('it renders the flyout body', () => {
const stateShowIsTrue = set('timeline.timelineById.test.show', true, state);
const storeShowIsTrue = createStore(stateShowIsTrue);
const wrapper = mount(
<TestProviders store={storeShowIsTrue}>
<Flyout
flyoutHeight={testFlyoutHeight}
headerHeight={flyoutHeaderHeight}
timelineId="test"
usersViewing={usersViewing}
>
<p>Fake flyout body</p>
</Flyout>
</TestProviders>
);
expect(
wrapper
.find('[data-test-subj="eui-flyout-body"]')
.first()
.text()
).toContain('Fake flyout body');
});
test('it does render the data providers badge when the number is greater than 0', () => {
const stateWithDataProviders = set(
'timeline.timelineById.test.dataProviders',
mockDataProviders,
state
);
const storeWithDataProviders = createStore(stateWithDataProviders);
const wrapper = mount(
<TestProviders store={storeWithDataProviders}>
<Flyout
flyoutHeight={testFlyoutHeight}
headerHeight={flyoutHeaderHeight}
timelineId="test"
usersViewing={usersViewing}
/>
</TestProviders>
);
expect(wrapper.find('[data-test-subj="badge"]').exists()).toEqual(true);
});
test('it renders the correct number of data providers badge when the number is greater than 0', () => {
const stateWithDataProviders = set(
'timeline.timelineById.test.dataProviders',
mockDataProviders,
state
);
const storeWithDataProviders = createStore(stateWithDataProviders);
const wrapper = mount(
<TestProviders store={storeWithDataProviders}>
<Flyout
flyoutHeight={testFlyoutHeight}
headerHeight={flyoutHeaderHeight}
timelineId="test"
usersViewing={usersViewing}
/>
</TestProviders>
);
expect(
wrapper
.find('[data-test-subj="badge"]')
.first()
.text()
).toContain('10');
});
test('it does NOT render the data providers badge when the number is equal to 0', () => {
const wrapper = mount(
<TestProviders>
<Flyout
flyoutHeight={testFlyoutHeight}
headerHeight={flyoutHeaderHeight}
timelineId="test"
usersViewing={usersViewing}
/>
</TestProviders>
);
expect(wrapper.find('[data-test-subj="badge"]').exists()).toEqual(false);
});
test('should call the onOpen when the mouse is clicked for rendering', () => {
const showTimeline = (jest.fn() as unknown) as ActionCreator<{ id: string; show: boolean }>;
const wrapper = mount(
<TestProviders>
<FlyoutComponent
dataProviders={mockDataProviders}
flyoutHeight={testFlyoutHeight}
headerHeight={flyoutHeaderHeight}
show={false}
showTimeline={showTimeline}
timelineId="test"
usersViewing={usersViewing}
/>
</TestProviders>
);
wrapper
.find('[data-test-subj="flyoutOverlay"]')
.first()
.simulate('click');
expect(showTimeline).toBeCalled();
});
test('should call the onClose when the close button is clicked', () => {
const stateShowIsTrue = set('timeline.timelineById.test.show', true, state);
const storeShowIsTrue = createStore(stateShowIsTrue);
const showTimeline = (jest.fn() as unknown) as ActionCreator<{ id: string; show: boolean }>;
const wrapper = mount(
<TestProviders store={storeShowIsTrue}>
<FlyoutComponent
dataProviders={mockDataProviders}
flyoutHeight={testFlyoutHeight}
headerHeight={flyoutHeaderHeight}
show={true}
showTimeline={showTimeline}
timelineId="test"
usersViewing={usersViewing}
/>
</TestProviders>
);
wrapper
.find('[data-test-subj="close-timeline"] button')
.first()
.simulate('click');
expect(showTimeline).toBeCalled();
});
});
describe('showFlyoutButton', () => {
test('should show the flyout button when show is true', () => {
const openMock = jest.fn();
const wrapper = mount(
<TestProviders>
<FlyoutButton
dataProviders={mockDataProviders}
show={true}
timelineId="test"
onOpen={openMock}
/>
</TestProviders>
);
expect(wrapper.find('[data-test-subj="flyoutButton"]').exists()).toEqual(true);
});
test('should NOT show the flyout button when show is false', () => {
const openMock = jest.fn();
const wrapper = mount(
<TestProviders>
<FlyoutButton
dataProviders={mockDataProviders}
show={false}
timelineId="test"
onOpen={openMock}
/>
</TestProviders>
);
expect(wrapper.find('[data-test-subj="flyoutButton"]').exists()).toEqual(false);
});
test('should return the flyout button with text', () => {
const openMock = jest.fn();
const wrapper = mount(
<TestProviders>
<FlyoutButton
dataProviders={mockDataProviders}
show={true}
timelineId="test"
onOpen={openMock}
/>
</TestProviders>
);
expect(
wrapper
.find('[data-test-subj="flyoutButton"]')
.first()
.text()
).toContain('T I M E L I N E');
});
test('should call the onOpen when it is clicked', () => {
const openMock = jest.fn();
const wrapper = mount(
<TestProviders>
<FlyoutButton
dataProviders={mockDataProviders}
show={true}
timelineId="test"
onOpen={openMock}
/>
</TestProviders>
);
wrapper
.find('[data-test-subj="flyoutOverlay"]')
.first()
.simulate('click');
expect(openMock).toBeCalled();
});
});
});

View file

@ -0,0 +1,122 @@
/*
* 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 { EuiBadge } from '@elastic/eui';
import { defaultTo, getOr } from 'lodash/fp';
import * as React from 'react';
import { connect } from 'react-redux';
import { pure } from 'recompose';
import styled from 'styled-components';
import { ActionCreator } from 'typescript-fsa';
import { State, timelineActions, timelineSelectors } from '../../store';
import { DEFAULT_TIMELINE_WIDTH } from '../timeline/body';
import { DataProvider } from '../timeline/data_providers/data_provider';
import { FlyoutButton } from './button';
import { Pane } from './pane';
/** The height in pixels of the flyout header, exported for use in height calculations */
export const flyoutHeaderHeight: number = 48;
export const Badge = styled(EuiBadge)`
position: absolute;
padding-left: 4px;
padding-right: 4px;
right: 0%;
top: 0%;
border-bottom-left-radius: 5px;
`;
const Visible = styled.div<{ show: boolean }>`
visibility: ${({ show }) => (show ? 'visible' : 'hidden')};
`;
interface OwnProps {
children?: React.ReactNode;
flyoutHeight: number;
headerHeight: number;
timelineId: string;
usersViewing: string[];
}
interface DispatchProps {
showTimeline?: ActionCreator<{ id: string; show: boolean }>;
applyDeltaToWidth?: (
{
id,
delta,
bodyClientWidthPixels,
maxWidthPercent,
minWidthPixels,
}: {
id: string;
delta: number;
bodyClientWidthPixels: number;
maxWidthPercent: number;
minWidthPixels: number;
}
) => void;
}
interface StateReduxProps {
dataProviders?: DataProvider[];
show?: boolean;
width?: number;
}
type Props = OwnProps & DispatchProps & StateReduxProps;
export const FlyoutComponent = pure<Props>(
({
children,
dataProviders,
flyoutHeight,
headerHeight,
show,
showTimeline,
timelineId,
usersViewing,
width,
}) => (
<>
<Visible show={show!}>
<Pane
flyoutHeight={flyoutHeight}
headerHeight={headerHeight}
onClose={() => showTimeline!({ id: timelineId, show: false })}
timelineId={timelineId}
usersViewing={usersViewing}
width={width!}
>
{children}
</Pane>
</Visible>
<FlyoutButton
dataProviders={dataProviders!}
show={!show}
timelineId={timelineId}
onOpen={() => showTimeline!({ id: timelineId, show: true })}
/>
</>
)
);
const mapStateToProps = (state: State, { timelineId }: OwnProps) => {
const timelineById = defaultTo({}, timelineSelectors.timelineByIdSelector(state));
const dataProviders = getOr([], `${timelineId}.dataProviders`, timelineById);
const show = getOr('false', `${timelineId}.show`, timelineById);
const width = getOr(DEFAULT_TIMELINE_WIDTH, `${timelineId}.width`, timelineById);
return { dataProviders, show, width };
};
export const Flyout = connect(
mapStateToProps,
{
showTimeline: timelineActions.showTimeline,
}
)(FlyoutComponent);

View file

@ -0,0 +1,23 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Pane renders correctly against snapshot 1`] = `
<Component>
<Connect(FlyoutPaneComponent)
flyoutHeight={980}
headerHeight={48}
onClose={[MockFunction]}
timelineId="test"
usersViewing={
Array [
"elastic",
]
}
width={640}
>
<span>
I am a child of flyout
</span>
,
</Connect(FlyoutPaneComponent)>
</Component>
`;

View file

@ -0,0 +1,225 @@
/*
* 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, shallow } from 'enzyme';
import toJson from 'enzyme-to-json';
import 'jest-styled-components';
import * as React from 'react';
import { flyoutHeaderHeight } from '../';
import { TestProviders } from '../../../mock';
import { Pane } from '.';
const testFlyoutHeight = 980;
const testWidth = 640;
const usersViewing = ['elastic'];
describe('Pane', () => {
test('renders correctly against snapshot', () => {
const EmptyComponent = shallow(
<TestProviders>
<Pane
flyoutHeight={testFlyoutHeight}
headerHeight={flyoutHeaderHeight}
onClose={jest.fn()}
timelineId={'test'}
usersViewing={usersViewing}
width={testWidth}
>
<span>I am a child of flyout</span>,
</Pane>
</TestProviders>
);
expect(toJson(EmptyComponent)).toMatchSnapshot();
});
test('it should NOT let the flyout expand to take up the full width of the element that contains it', () => {
const wrapper = mount(
<TestProviders>
<Pane
flyoutHeight={testFlyoutHeight}
headerHeight={flyoutHeaderHeight}
onClose={jest.fn()}
timelineId={'test'}
usersViewing={usersViewing}
width={testWidth}
>
<span>I am a child of flyout</span>,
</Pane>
</TestProviders>
);
expect(wrapper.find('[data-test-subj="eui-flyout"]').get(0).props.maxWidth).toEqual('95%');
});
test('it applies timeline styles to the EuiFlyout', () => {
const wrapper = mount(
<TestProviders>
<Pane
flyoutHeight={testFlyoutHeight}
headerHeight={flyoutHeaderHeight}
onClose={jest.fn()}
timelineId={'test'}
usersViewing={usersViewing}
width={testWidth}
>
<span>I am a child of flyout</span>,
</Pane>
</TestProviders>
);
expect(
wrapper
.find('[data-test-subj="eui-flyout"]')
.first()
.hasClass('timeline-flyout')
).toEqual(true);
});
test('it applies timeline styles to the EuiFlyoutHeader', () => {
const wrapper = mount(
<TestProviders>
<Pane
flyoutHeight={testFlyoutHeight}
headerHeight={flyoutHeaderHeight}
onClose={jest.fn()}
timelineId={'test'}
usersViewing={usersViewing}
width={testWidth}
>
<span>I am a child of flyout</span>,
</Pane>
</TestProviders>
);
expect(
wrapper
.find('[data-test-subj="eui-flyout-header"]')
.first()
.hasClass('timeline-flyout-header')
).toEqual(true);
});
test('it applies timeline styles to the EuiFlyoutBody', () => {
const wrapper = mount(
<TestProviders>
<Pane
flyoutHeight={testFlyoutHeight}
headerHeight={flyoutHeaderHeight}
onClose={jest.fn()}
timelineId={'test'}
usersViewing={usersViewing}
width={testWidth}
>
<span>I am a child of flyout</span>,
</Pane>
</TestProviders>
);
expect(
wrapper
.find('[data-test-subj="eui-flyout-body"]')
.first()
.hasClass('timeline-flyout-body')
).toEqual(true);
});
test('it should render a resize handle', () => {
const wrapper = mount(
<TestProviders>
<Pane
flyoutHeight={testFlyoutHeight}
headerHeight={flyoutHeaderHeight}
onClose={jest.fn()}
timelineId={'test'}
usersViewing={usersViewing}
width={testWidth}
>
<span>I am a child of flyout</span>,
</Pane>
</TestProviders>
);
expect(
wrapper
.find('[data-test-subj="flyout-resize-handle"]')
.first()
.exists()
).toEqual(true);
});
test('it should render an empty title', () => {
const wrapper = mount(
<TestProviders>
<Pane
flyoutHeight={testFlyoutHeight}
headerHeight={flyoutHeaderHeight}
onClose={jest.fn()}
timelineId={'test'}
usersViewing={usersViewing}
width={testWidth}
>
<span>I am a child of flyout</span>,
</Pane>
</TestProviders>
);
expect(
wrapper
.find('[data-test-subj="timeline-title"]')
.first()
.text()
).toContain('');
});
test('it should render the flyout body', () => {
const wrapper = mount(
<TestProviders>
<Pane
flyoutHeight={testFlyoutHeight}
headerHeight={flyoutHeaderHeight}
onClose={jest.fn()}
timelineId={'test'}
usersViewing={usersViewing}
width={testWidth}
>
<span>I am a mock body</span>,
</Pane>
</TestProviders>
);
expect(
wrapper
.find('[data-test-subj="eui-flyout-body"]')
.first()
.text()
).toContain('I am a mock body');
});
test('it should invoke onClose when the close button is clicked', () => {
const closeMock = jest.fn();
const wrapper = mount(
<TestProviders>
<Pane
flyoutHeight={testFlyoutHeight}
headerHeight={flyoutHeaderHeight}
onClose={closeMock}
timelineId={'test'}
usersViewing={usersViewing}
width={testWidth}
>
<span>I am a mock child</span>,
</Pane>
</TestProviders>
);
wrapper
.find('[data-test-subj="close-timeline"] button')
.first()
.simulate('click');
expect(closeMock).toBeCalled();
});
});

View file

@ -0,0 +1,174 @@
/*
* 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 { EuiButtonIcon, EuiFlyout, EuiFlyoutBody, EuiFlyoutHeader, EuiToolTip } from '@elastic/eui';
import * as React from 'react';
import { connect } from 'react-redux';
import { pure } from 'recompose';
import styled from 'styled-components';
import { ActionCreator } from 'typescript-fsa';
import { timelineActions } from '../../../store';
import { OnResize, Resizeable } from '../../resize_handle';
import { TimelineResizeHandle } from '../../resize_handle/styled_handles';
import { FlyoutHeader } from '../header';
import * as i18n from './translations';
const minWidthPixels = 550; // do not allow the flyout to shrink below this width (pixels)
const maxWidthPercent = 95; // do not allow the flyout to grow past this percentage of the view
interface OwnProps {
children: React.ReactNode;
flyoutHeight: number;
headerHeight: number;
onClose: () => void;
timelineId: string;
usersViewing: string[];
width: number;
}
interface DispatchProps {
applyDeltaToWidth: ActionCreator<{
id: string;
delta: number;
bodyClientWidthPixels: number;
maxWidthPercent: number;
minWidthPixels: number;
}>;
}
type Props = OwnProps & DispatchProps;
const EuiFlyoutContainer = styled.div<{ headerHeight: number; width: number }>`
.timeline-flyout {
min-width: 150px;
width: ${({ width }) => `${width}px`};
}
.timeline-flyout-header {
align-items: center;
box-shadow: none;
display: flex;
flex-direction: row;
height: ${({ headerHeight }) => `${headerHeight}px`};
max-height: ${({ headerHeight }) => `${headerHeight}px`};
overflow: hidden;
padding: 5px 0 0 10px;
}
.timeline-flyout-body {
overflow-y: hidden;
padding: 0;
.euiFlyoutBody__overflow {
padding: 0;
}
}
`;
const FlyoutHeaderContainer = styled.div`
align-items: center;
display: flex;
flex-direction: row;
justify-content: space-between;
width: 100%;
`;
// manually wrap the close button because EuiButtonIcon can't be a wrapped `styled`
const WrappedCloseButton = styled.div`
margin-right: 5px;
`;
const FlyoutHeaderWithCloseButton = pure<{
onClose: () => void;
timelineId: string;
usersViewing: string[];
}>(({ onClose, timelineId, usersViewing }) => (
<FlyoutHeaderContainer>
<WrappedCloseButton>
<EuiToolTip content={i18n.CLOSE_TIMELINE}>
<EuiButtonIcon
aria-label={i18n.CLOSE_TIMELINE}
data-test-subj="close-timeline"
iconType="cross"
onClick={onClose}
/>
</EuiToolTip>
</WrappedCloseButton>
<FlyoutHeader timelineId={timelineId} usersViewing={usersViewing} />
</FlyoutHeaderContainer>
));
class FlyoutPaneComponent extends React.PureComponent<Props> {
public render() {
const {
children,
flyoutHeight,
headerHeight,
onClose,
timelineId,
usersViewing,
width,
} = this.props;
return (
<EuiFlyoutContainer headerHeight={headerHeight} data-test-subj="flyout-pane" width={width}>
<EuiFlyout
aria-label={i18n.TIMELINE_DESCRIPTION}
className="timeline-flyout"
data-test-subj="eui-flyout"
hideCloseButton={true}
maxWidth={`${maxWidthPercent}%`}
onClose={onClose}
size="l"
>
<Resizeable
handle={
<TimelineResizeHandle data-test-subj="flyout-resize-handle" height={flyoutHeight} />
}
id={timelineId}
onResize={this.onResize}
render={this.renderFlyout}
/>
<EuiFlyoutHeader
className="timeline-flyout-header"
data-test-subj="eui-flyout-header"
hasBorder={false}
>
<FlyoutHeaderWithCloseButton
onClose={onClose}
timelineId={timelineId}
usersViewing={usersViewing}
/>
</EuiFlyoutHeader>
<EuiFlyoutBody data-test-subj="eui-flyout-body" className="timeline-flyout-body">
{children}
</EuiFlyoutBody>
</EuiFlyout>
</EuiFlyoutContainer>
);
}
private renderFlyout = () => <></>;
private onResize: OnResize = ({ delta, id }) => {
const { applyDeltaToWidth } = this.props;
const bodyClientWidthPixels = document.body.clientWidth;
applyDeltaToWidth({
bodyClientWidthPixels,
delta,
id,
maxWidthPercent,
minWidthPixels,
});
};
}
export const Pane = connect(
null,
{
applyDeltaToWidth: timelineActions.applyDeltaToWidth,
}
)(FlyoutPaneComponent);

Some files were not shown because too many files have changed in this diff Show more