mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
parent
4c26ccca65
commit
87ca1a2ddc
932 changed files with 162245 additions and 10 deletions
11
.eslintrc.js
11
.eslintrc.js
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
1
x-pack/.gitignore
vendored
|
@ -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
|
||||
|
|
|
@ -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
6
x-pack/plugins/siem/.gitattributes
vendored
Normal 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
|
||||
|
7
x-pack/plugins/siem/common/graphql/root/index.ts
Normal file
7
x-pack/plugins/siem/common/graphql/root/index.ts
Normal 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';
|
18
x-pack/plugins/siem/common/graphql/root/schema.gql.ts
Normal file
18
x-pack/plugins/siem/common/graphql/root/schema.gql.ts
Normal 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
|
||||
`;
|
7
x-pack/plugins/siem/common/graphql/shared/index.ts
Normal file
7
x-pack/plugins/siem/common/graphql/shared/index.ts
Normal 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';
|
67
x-pack/plugins/siem/common/graphql/shared/schema.gql.ts
Normal file
67
x-pack/plugins/siem/common/graphql/shared/schema.gql.ts
Normal 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
|
||||
}
|
||||
`;
|
47
x-pack/plugins/siem/common/typed_json.ts
Normal file
47
x-pack/plugins/siem/common/typed_json.ts
Normal 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>;
|
||||
}
|
50
x-pack/plugins/siem/index.ts
Normal file
50
x-pack/plugins/siem/index.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
}
|
25
x-pack/plugins/siem/package.json
Normal file
25
x-pack/plugins/siem/package.json
Normal 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"
|
||||
}
|
||||
}
|
7
x-pack/plugins/siem/public/app.ts
Normal file
7
x-pack/plugins/siem/public/app.ts
Normal 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';
|
13
x-pack/plugins/siem/public/apps/kibana_app.ts
Normal file
13
x-pack/plugins/siem/public/apps/kibana_app.ts
Normal 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());
|
48
x-pack/plugins/siem/public/apps/start_app.tsx
Normal file
48
x-pack/plugins/siem/public/apps/start_app.tsx
Normal 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>
|
||||
);
|
||||
};
|
11
x-pack/plugins/siem/public/apps/testing_app.ts
Normal file
11
x-pack/plugins/siem/public/apps/testing_app.ts
Normal 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());
|
43
x-pack/plugins/siem/public/components/and_or_badge/index.tsx
Normal file
43
x-pack/plugins/siem/public/components/and_or_badge/index.tsx
Normal 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>
|
||||
));
|
|
@ -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',
|
||||
});
|
|
@ -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}
|
||||
/>
|
||||
`;
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
));
|
41
x-pack/plugins/siem/public/components/app_settings/index.tsx
Normal file
41
x-pack/plugins/siem/public/components/app_settings/index.tsx
Normal 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,
|
||||
});
|
||||
};
|
||||
}
|
|
@ -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',
|
||||
});
|
9
x-pack/plugins/siem/public/components/arrows/__snapshots__/index.test.tsx.snap
generated
Normal file
9
x-pack/plugins/siem/public/components/arrows/__snapshots__/index.test.tsx.snap
generated
Normal 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>
|
||||
`;
|
86
x-pack/plugins/siem/public/components/arrows/helpers.test.ts
Normal file
86
x-pack/plugins/siem/public/components/arrows/helpers.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
39
x-pack/plugins/siem/public/components/arrows/helpers.ts
Normal file
39
x-pack/plugins/siem/public/components/arrows/helpers.ts
Normal 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;
|
43
x-pack/plugins/siem/public/components/arrows/index.test.tsx
Normal file
43
x-pack/plugins/siem/public/components/arrows/index.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
26
x-pack/plugins/siem/public/components/arrows/index.tsx
Normal file
26
x-pack/plugins/siem/public/components/arrows/index.tsx
Normal 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} />
|
||||
));
|
182
x-pack/plugins/siem/public/components/auto_sizer/index.tsx
Normal file
182
x-pack/plugins/siem/public/components/auto_sizer/index.tsx
Normal 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;
|
||||
};
|
||||
}
|
25
x-pack/plugins/siem/public/components/autocomplete_field/__snapshots__/index.test.tsx.snap
generated
Normal file
25
x-pack/plugins/siem/public/components/autocomplete_field/__snapshots__/index.test.tsx.snap
generated
Normal 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>
|
||||
`;
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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};;
|
||||
`;
|
|
@ -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;
|
||||
}
|
||||
};
|
31
x-pack/plugins/siem/public/components/card_items/__snapshots__/index.test.tsx.snap
generated
Normal file
31
x-pack/plugins/siem/public/components/card_items/__snapshots__/index.test.tsx.snap
generated
Normal 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}
|
||||
/>
|
||||
`;
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
79
x-pack/plugins/siem/public/components/card_items/index.tsx
Normal file
79
x-pack/plugins/siem/public/components/card_items/index.tsx
Normal 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>
|
||||
)
|
||||
);
|
|
@ -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'
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
});
|
|
@ -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',
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
72
x-pack/plugins/siem/public/components/direction/index.tsx
Normal file
72
x-pack/plugins/siem/public/components/direction/index.tsx
Normal 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}
|
||||
/>
|
||||
));
|
|
@ -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))>
|
||||
`;
|
27
x-pack/plugins/siem/public/components/drag_and_drop/__snapshots__/draggable_wrapper.test.tsx.snap
generated
Normal file
27
x-pack/plugins/siem/public/components/drag_and_drop/__snapshots__/draggable_wrapper.test.tsx.snap
generated
Normal 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>
|
||||
`;
|
15
x-pack/plugins/siem/public/components/drag_and_drop/__snapshots__/droppable_wrapper.test.tsx.snap
generated
Normal file
15
x-pack/plugins/siem/public/components/drag_and_drop/__snapshots__/droppable_wrapper.test.tsx.snap
generated
Normal 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>
|
||||
`;
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
|
@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
));
|
|
@ -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?');
|
||||
});
|
||||
});
|
||||
});
|
121
x-pack/plugins/siem/public/components/drag_and_drop/helpers.ts
Normal file
121
x-pack/plugins/siem/public/components/drag_and_drop/helpers.ts
Normal 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 }));
|
||||
};
|
29
x-pack/plugins/siem/public/components/draggables/__snapshots__/index.test.tsx.snap
generated
Normal file
29
x-pack/plugins/siem/public/components/draggables/__snapshots__/index.test.tsx.snap
generated
Normal 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>
|
||||
`;
|
352
x-pack/plugins/siem/public/components/draggables/index.test.tsx
Normal file
352
x-pack/plugins/siem/public/components/draggables/index.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
173
x-pack/plugins/siem/public/components/draggables/index.tsx
Normal file
173
x-pack/plugins/siem/public/components/draggables/index.tsx
Normal 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
|
||||
);
|
|
@ -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');
|
||||
});
|
||||
});
|
34
x-pack/plugins/siem/public/components/duration/index.tsx
Normal file
34
x-pack/plugins/siem/public/components/duration/index.tsx
Normal 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>
|
||||
));
|
10
x-pack/plugins/siem/public/components/empty_page/__snapshots__/index.test.tsx.snap
generated
Normal file
10
x-pack/plugins/siem/public/components/empty_page/__snapshots__/index.test.tsx.snap
generated
Normal 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"
|
||||
/>
|
||||
`;
|
|
@ -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();
|
||||
});
|
37
x-pack/plugins/siem/public/components/empty_page/index.tsx
Normal file
37
x-pack/plugins/siem/public/components/empty_page/index.tsx
Normal 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;
|
||||
`;
|
7
x-pack/plugins/siem/public/components/empty_value/__snapshots__/empty_value.test.tsx.snap
generated
Normal file
7
x-pack/plugins/siem/public/components/empty_value/__snapshots__/empty_value.test.tsx.snap
generated
Normal file
|
@ -0,0 +1,7 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`EmptyValue it renders against snapshot 1`] = `
|
||||
<p>
|
||||
(Empty String)
|
||||
</p>
|
||||
`;
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
50
x-pack/plugins/siem/public/components/empty_value/index.tsx
Normal file
50
x-pack/plugins/siem/public/components/empty_value/index.tsx
Normal 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}</>;
|
||||
}
|
||||
};
|
|
@ -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',
|
||||
});
|
7
x-pack/plugins/siem/public/components/error_toast/__snapshots__/index.test.tsx.snap
generated
Normal file
7
x-pack/plugins/siem/public/components/error_toast/__snapshots__/index.test.tsx.snap
generated
Normal 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}
|
||||
/>
|
||||
`;
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
69
x-pack/plugins/siem/public/components/error_toast/index.tsx
Normal file
69
x-pack/plugins/siem/public/components/error_toast/index.tsx
Normal 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);
|
235
x-pack/plugins/siem/public/components/event_details/__snapshots__/event_details.test.tsx.snap
generated
Normal file
235
x-pack/plugins/siem/public/components/event_details/__snapshots__/event_details.test.tsx.snap
generated
Normal 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>
|
||||
`;
|
230
x-pack/plugins/siem/public/components/event_details/__snapshots__/json_view.test.tsx.snap
generated
Normal file
230
x-pack/plugins/siem/public/components/event_details/__snapshots__/json_view.test.tsx.snap
generated
Normal 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",
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
/>
|
||||
`;
|
|
@ -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%',
|
||||
},
|
||||
];
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
});
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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}
|
||||
/>
|
||||
));
|
|
@ -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';
|
|
@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
130
x-pack/plugins/siem/public/components/event_details/helpers.tsx
Normal file
130
x-pack/plugins/siem/public/components/event_details/helpers.tsx
Normal 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)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
),
|
||||
};
|
||||
}),
|
||||
}));
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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), {});
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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',
|
||||
});
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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} />
|
||||
)
|
||||
);
|
|
@ -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"
|
||||
/>
|
||||
`;
|
11
x-pack/plugins/siem/public/components/flow_controls/__snapshots__/flow_target_select.test.tsx.snap
generated
Normal file
11
x-pack/plugins/siem/public/components/flow_controls/__snapshots__/flow_target_select.test.tsx.snap
generated
Normal 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]}
|
||||
/>
|
||||
`;
|
|
@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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"
|
||||
/>
|
||||
));
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
);
|
|
@ -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',
|
||||
}
|
||||
);
|
16
x-pack/plugins/siem/public/components/flyout/__snapshots__/index.test.tsx.snap
generated
Normal file
16
x-pack/plugins/siem/public/components/flyout/__snapshots__/index.test.tsx.snap
generated
Normal 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>
|
||||
`;
|
|
@ -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
|
||||
);
|
|
@ -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',
|
||||
});
|
199
x-pack/plugins/siem/public/components/flyout/header/index.tsx
Normal file
199
x-pack/plugins/siem/public/components/flyout/header/index.tsx
Normal 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);
|
314
x-pack/plugins/siem/public/components/flyout/index.test.tsx
Normal file
314
x-pack/plugins/siem/public/components/flyout/index.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
122
x-pack/plugins/siem/public/components/flyout/index.tsx
Normal file
122
x-pack/plugins/siem/public/components/flyout/index.tsx
Normal 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);
|
23
x-pack/plugins/siem/public/components/flyout/pane/__snapshots__/index.test.tsx.snap
generated
Normal file
23
x-pack/plugins/siem/public/components/flyout/pane/__snapshots__/index.test.tsx.snap
generated
Normal 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>
|
||||
`;
|
225
x-pack/plugins/siem/public/components/flyout/pane/index.test.tsx
Normal file
225
x-pack/plugins/siem/public/components/flyout/pane/index.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
174
x-pack/plugins/siem/public/components/flyout/pane/index.tsx
Normal file
174
x-pack/plugins/siem/public/components/flyout/pane/index.tsx
Normal 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
Loading…
Add table
Add a link
Reference in a new issue