From 6f09ecc0d9f189a805ca2e875916167b748ea449 Mon Sep 17 00:00:00 2001 From: Greg Thompson Date: Wed, 2 Oct 2019 11:58:22 -0500 Subject: [PATCH 01/19] Upgrade EUI to 14.4.0 (#46949) * eui to 14.4.0 * euicard ts updates * snaps --- package.json | 2 +- .../__snapshots__/dashboard_listing.test.js.snap | 6 ------ .../embeddables/contact_card/contact_card.tsx | 15 +++++++-------- .../__snapshots__/data_view.test.tsx.snap | 1 - .../plugins/kbn_tp_run_pipeline/package.json | 2 +- .../kbn_tp_custom_visualizations/package.json | 2 +- .../kbn_tp_embeddable_explorer/package.json | 2 +- .../kbn_tp_sample_panel_action/package.json | 2 +- .../kbn_tp_visualize_embedding/package.json | 2 +- typings/@elastic/eui/index.d.ts | 1 - .../__snapshots__/NoServicesMessage.test.tsx.snap | 4 ---- .../components/element_card/element_card.tsx | 8 ++------ .../__snapshots__/upgrade_failure.test.js.snap | 4 ---- .../__snapshots__/transform_list.test.tsx.snap | 1 - .../explorer_no_influencers_found.test.js.snap | 1 - .../explorer_no_jobs_found.test.js.snap | 1 - .../explorer_no_results_found.test.js.snap | 1 - .../__snapshots__/roles_grid_page.test.tsx.snap | 1 - .../index_patterns_missing_prompt.test.tsx.snap | 1 - .../__snapshots__/checkup_tab.test.tsx.snap | 1 - .../__snapshots__/data_missing.test.tsx.snap | 1 - .../__snapshots__/empty_state.test.tsx.snap | 4 ---- x-pack/package.json | 2 +- x-pack/typings/@elastic/eui/index.d.ts | 1 - yarn.lock | 8 ++++---- 25 files changed, 20 insertions(+), 54 deletions(-) diff --git a/package.json b/package.json index 6f54c8683410a..be43e242ce569 100644 --- a/package.json +++ b/package.json @@ -107,7 +107,7 @@ "@babel/register": "^7.5.5", "@elastic/charts": "^12.0.2", "@elastic/datemath": "5.0.2", - "@elastic/eui": "14.3.0", + "@elastic/eui": "14.4.0", "@elastic/filesaver": "1.1.2", "@elastic/good": "8.1.1-kibana2", "@elastic/numeral": "2.3.3", diff --git a/src/legacy/core_plugins/kibana/public/dashboard/listing/__snapshots__/dashboard_listing.test.js.snap b/src/legacy/core_plugins/kibana/public/dashboard/listing/__snapshots__/dashboard_listing.test.js.snap index 9b819443808c9..89b8e2ac83ec1 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/listing/__snapshots__/dashboard_listing.test.js.snap +++ b/src/legacy/core_plugins/kibana/public/dashboard/listing/__snapshots__/dashboard_listing.test.js.snap @@ -13,7 +13,6 @@ exports[`after fetch hideWriteControls 1`] = ` noItemsFragment={
@@ -106,7 +105,6 @@ exports[`after fetch initialFilter 1`] = `

} - iconColor="subdued" iconType="dashboardApp" title={

@@ -199,7 +197,6 @@ exports[`after fetch renders call to action when no dashboards exist 1`] = `

} - iconColor="subdued" iconType="dashboardApp" title={

@@ -292,7 +289,6 @@ exports[`after fetch renders table rows 1`] = `

} - iconColor="subdued" iconType="dashboardApp" title={

@@ -385,7 +381,6 @@ exports[`after fetch renders warning when listingLimit is exceeded 1`] = `

} - iconColor="subdued" iconType="dashboardApp" title={

@@ -478,7 +473,6 @@ exports[`renders empty page in before initial fetch to avoid flickering 1`] = `

} - iconColor="subdued" iconType="dashboardApp" title={

diff --git a/src/plugins/embeddable/public/lib/test_samples/embeddables/contact_card/contact_card.tsx b/src/plugins/embeddable/public/lib/test_samples/embeddables/contact_card/contact_card.tsx index a83364f22021a..51640749bc2b4 100644 --- a/src/plugins/embeddable/public/lib/test_samples/embeddables/contact_card/contact_card.tsx +++ b/src/plugins/embeddable/public/lib/test_samples/embeddables/contact_card/contact_card.tsx @@ -17,13 +17,7 @@ * under the License. */ import React from 'react'; -import { - // @ts-ignore - EuiCard, - EuiFlexItem, - EuiFlexGroup, - EuiFormRow, -} from '@elastic/eui'; +import { EuiCard, EuiFlexItem, EuiFlexGroup, EuiFormRow } from '@elastic/eui'; import { Subscription } from 'rxjs'; import { EuiButton } from '@elastic/eui'; @@ -96,7 +90,12 @@ export class ContactCardEmbeddableComponent extends React.Component + ); } } diff --git a/src/plugins/inspector/public/views/data/components/__snapshots__/data_view.test.tsx.snap b/src/plugins/inspector/public/views/data/components/__snapshots__/data_view.test.tsx.snap index 37c77c97fe39d..adea7831d6b80 100644 --- a/src/plugins/inspector/public/views/data/components/__snapshots__/data_view.test.tsx.snap +++ b/src/plugins/inspector/public/views/data/components/__snapshots__/data_view.test.tsx.snap @@ -250,7 +250,6 @@ exports[`Inspector Data View component should render empty state 1`] = `

} - iconColor="subdued" title={

; export const EuiCodeEditor: React.SFC; export const Query: any; - export const EuiCard: any; export interface EuiTableCriteria { page: { index: number; size: number }; diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/__test__/__snapshots__/NoServicesMessage.test.tsx.snap b/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/__test__/__snapshots__/NoServicesMessage.test.tsx.snap index de8e109e62324..209b88f73b9e2 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/__test__/__snapshots__/NoServicesMessage.test.tsx.snap +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/__test__/__snapshots__/NoServicesMessage.test.tsx.snap @@ -33,7 +33,6 @@ exports[`NoServicesMessage status: pending and historicalDataFound: false 1`] =

} - iconColor="subdued" title={
Looks like you don't have any APM services installed. Let's add some! @@ -45,7 +44,6 @@ exports[`NoServicesMessage status: pending and historicalDataFound: false 1`] = exports[`NoServicesMessage status: pending and historicalDataFound: true 1`] = ` No services found @@ -80,7 +78,6 @@ exports[`NoServicesMessage status: success and historicalDataFound: false 1`] =

} - iconColor="subdued" title={
Looks like you don't have any APM services installed. Let's add some! @@ -92,7 +89,6 @@ exports[`NoServicesMessage status: success and historicalDataFound: false 1`] = exports[`NoServicesMessage status: success and historicalDataFound: true 1`] = ` No services found diff --git a/x-pack/legacy/plugins/canvas/public/components/element_card/element_card.tsx b/x-pack/legacy/plugins/canvas/public/components/element_card/element_card.tsx index 9262a67cf393c..819282d588129 100644 --- a/x-pack/legacy/plugins/canvas/public/components/element_card/element_card.tsx +++ b/x-pack/legacy/plugins/canvas/public/components/element_card/element_card.tsx @@ -5,11 +5,7 @@ */ import React from 'react'; import PropTypes from 'prop-types'; -import { - // @ts-ignore unconverted EUI component - EuiCard, - EuiIcon, -} from '@elastic/eui'; +import { EuiCard, EuiIcon } from '@elastic/eui'; import { TagList } from '../tag_list/'; export interface Props { @@ -45,7 +41,7 @@ export const ElementCard = ({ title, description, image, tags = [], onClick, ... description={description} footer={} image={image} - icon={image ? null : } + icon={image ? undefined : } onClick={onClick} {...rest} /> diff --git a/x-pack/legacy/plugins/logstash/public/components/upgrade_failure/__snapshots__/upgrade_failure.test.js.snap b/x-pack/legacy/plugins/logstash/public/components/upgrade_failure/__snapshots__/upgrade_failure.test.js.snap index b31bae263fa8f..4d752888d3df4 100644 --- a/x-pack/legacy/plugins/logstash/public/components/upgrade_failure/__snapshots__/upgrade_failure.test.js.snap +++ b/x-pack/legacy/plugins/logstash/public/components/upgrade_failure/__snapshots__/upgrade_failure.test.js.snap @@ -146,7 +146,6 @@ exports[`UpgradeFailure component passes expected text for new pipeline 1`] = ` Before you can add a pipeline, we need to upgrade your configuration.

} - iconColor="subdued" title={ } - iconColor="subdued" title={ } - iconColor="subdued" title={ } - iconColor="subdued" title={ Minimal initializ ] } data-test-subj="mlNoDataFrameTransformsFound" - iconColor="subdued" title={

No data frame transforms found diff --git a/x-pack/legacy/plugins/ml/public/explorer/components/explorer_no_influencers_found/__snapshots__/explorer_no_influencers_found.test.js.snap b/x-pack/legacy/plugins/ml/public/explorer/components/explorer_no_influencers_found/__snapshots__/explorer_no_influencers_found.test.js.snap index f6e083e31984a..77821663783cf 100644 --- a/x-pack/legacy/plugins/ml/public/explorer/components/explorer_no_influencers_found/__snapshots__/explorer_no_influencers_found.test.js.snap +++ b/x-pack/legacy/plugins/ml/public/explorer/components/explorer_no_influencers_found/__snapshots__/explorer_no_influencers_found.test.js.snap @@ -2,7 +2,6 @@ exports[`ExplorerNoInfluencersFound snapshot 1`] = ` } data-test-subj="mlNoJobsFound" - iconColor="subdued" iconType="alert" title={

diff --git a/x-pack/legacy/plugins/ml/public/explorer/components/explorer_no_results_found/__snapshots__/explorer_no_results_found.test.js.snap b/x-pack/legacy/plugins/ml/public/explorer/components/explorer_no_results_found/__snapshots__/explorer_no_results_found.test.js.snap index a7eb6d8db8a59..dc7e567380fdf 100644 --- a/x-pack/legacy/plugins/ml/public/explorer/components/explorer_no_results_found/__snapshots__/explorer_no_results_found.test.js.snap +++ b/x-pack/legacy/plugins/ml/public/explorer/components/explorer_no_results_found/__snapshots__/explorer_no_results_found.test.js.snap @@ -13,7 +13,6 @@ exports[`ExplorerNoInfluencersFound snapshot 1`] = `

} - iconColor="subdued" iconType="iInCircle" title={

diff --git a/x-pack/legacy/plugins/security/public/views/management/roles_grid/components/__snapshots__/roles_grid_page.test.tsx.snap b/x-pack/legacy/plugins/security/public/views/management/roles_grid/components/__snapshots__/roles_grid_page.test.tsx.snap index 5e3625a1f0fc4..048fa74a72818 100644 --- a/x-pack/legacy/plugins/security/public/views/management/roles_grid/components/__snapshots__/roles_grid_page.test.tsx.snap +++ b/x-pack/legacy/plugins/security/public/views/management/roles_grid/components/__snapshots__/roles_grid_page.test.tsx.snap @@ -31,7 +31,6 @@ exports[` renders permission denied if required 1`] = ` />

} - iconColor="subdued" iconType="securityApp" title={

diff --git a/x-pack/legacy/plugins/siem/public/components/embeddables/__snapshots__/index_patterns_missing_prompt.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/embeddables/__snapshots__/index_patterns_missing_prompt.test.tsx.snap index 937ba229b8883..f482a864bed6d 100644 --- a/x-pack/legacy/plugins/siem/public/components/embeddables/__snapshots__/index_patterns_missing_prompt.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/components/embeddables/__snapshots__/index_patterns_missing_prompt.test.tsx.snap @@ -56,7 +56,6 @@ exports[`IndexPatternsMissingPrompt renders correctly against snapshot 1`] = `

} - iconColor="subdued" iconType="gisApp" title={

diff --git a/x-pack/legacy/plugins/upgrade_assistant/public/components/tabs/checkup/__snapshots__/checkup_tab.test.tsx.snap b/x-pack/legacy/plugins/upgrade_assistant/public/components/tabs/checkup/__snapshots__/checkup_tab.test.tsx.snap index 5f826ba30262c..6f92d475ae6c5 100644 --- a/x-pack/legacy/plugins/upgrade_assistant/public/components/tabs/checkup/__snapshots__/checkup_tab.test.tsx.snap +++ b/x-pack/legacy/plugins/upgrade_assistant/public/components/tabs/checkup/__snapshots__/checkup_tab.test.tsx.snap @@ -457,7 +457,6 @@ exports[`CheckupTab render without deprecations 1`] = `

} - iconColor="subdued" iconType="faceHappy" title={

diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/__tests__/__snapshots__/data_missing.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/__tests__/__snapshots__/data_missing.test.tsx.snap index 30b2e7204e404..b17d28f19335b 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/__tests__/__snapshots__/data_missing.test.tsx.snap +++ b/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/__tests__/__snapshots__/data_missing.test.tsx.snap @@ -36,7 +36,6 @@ exports[`DataMissing component renders basePath and headingMessage 1`] = ` />

} - iconColor="subdued" iconType="uptimeApp" title={

} - iconColor="subdued" iconType="uptimeApp" title={ } - iconColor="subdued" >

} - iconColor="subdued" iconType="uptimeApp" title={ } - iconColor="subdued" >
; export const EuiCodeEditor: React.SFC; export const Query: any; - export const EuiCard: any; } declare module '@elastic/eui/lib/services' { diff --git a/yarn.lock b/yarn.lock index 824618215a8be..9d73d21a044bf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1145,10 +1145,10 @@ tabbable "^1.1.0" uuid "^3.1.0" -"@elastic/eui@14.3.0": - version "14.3.0" - resolved "https://registry.yarnpkg.com/@elastic/eui/-/eui-14.3.0.tgz#256e1af8f6b15717904f8959742a23b3495ff0bb" - integrity sha512-gAbPNezBmndInYqqw6EvRYLn2VMYQgYuPQYA5UZ7TyHzwvoBiMpUw5nFzYhS2A/Xcmq/ON5Mu8RY3LGRAVBOvQ== +"@elastic/eui@14.4.0": + version "14.4.0" + resolved "https://registry.yarnpkg.com/@elastic/eui/-/eui-14.4.0.tgz#ac09a476798dcdb1005616cccc149eda23ea2a90" + integrity sha512-dR7lYwUaIRXZjlUrJBq8GcGLPh6QfM3waQxUFI8lOnMVayJe3OOMNADCn8Oty6wNYIOrBWzZbW6w4bzInWF6oA== dependencies: "@types/lodash" "^4.14.116" "@types/numeral" "^0.0.25" From e6ace31c0e719067b689b9c83e850fc68210c015 Mon Sep 17 00:00:00 2001 From: Chris Davies Date: Wed, 2 Oct 2019 13:21:39 -0400 Subject: [PATCH 02/19] [Lens] Make horizontal bar chart a first-class chart (#47062) --- .../xy_visualization.test.ts.snap | 3 -- .../xy_visualization_plugin/state_helpers.ts | 26 +++++++++ .../xy_visualization_plugin/to_expression.ts | 1 - .../public/xy_visualization_plugin/types.ts | 27 ++++++++-- .../xy_config_panel.test.tsx | 51 +++++++----------- .../xy_config_panel.tsx | 51 ++++-------------- .../xy_expression.test.tsx | 6 +-- .../xy_visualization_plugin/xy_expression.tsx | 20 +++---- .../xy_suggestions.test.ts | 13 +---- .../xy_visualization_plugin/xy_suggestions.ts | 30 +++-------- .../xy_visualization.test.ts | 49 +++++++++++++++-- .../xy_visualization.tsx | 17 ++++-- .../es_archives/lens/reporting/data.json.gz | Bin 4356 -> 4359 bytes 13 files changed, 161 insertions(+), 133 deletions(-) create mode 100644 x-pack/legacy/plugins/lens/public/xy_visualization_plugin/state_helpers.ts diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/__snapshots__/xy_visualization.test.ts.snap b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/__snapshots__/xy_visualization.test.ts.snap index 12902f548e45b..76af8328673ad 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/__snapshots__/xy_visualization.test.ts.snap +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/__snapshots__/xy_visualization.test.ts.snap @@ -5,9 +5,6 @@ Object { "chain": Array [ Object { "arguments": Object { - "isHorizontal": Array [ - false, - ], "layers": Array [ Object { "chain": Array [ diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/state_helpers.ts b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/state_helpers.ts new file mode 100644 index 0000000000000..eb7fd688bab5a --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/state_helpers.ts @@ -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 { EuiIconType } from '@elastic/eui/src/components/icon/icon'; +import { SeriesType, visualizationTypes } from './types'; + +export function isHorizontalSeries(seriesType: SeriesType) { + return seriesType === 'bar_horizontal' || seriesType === 'bar_horizontal_stacked'; +} + +export function isHorizontalChart(layers: Array<{ seriesType: SeriesType }>) { + return layers.every(l => isHorizontalSeries(l.seriesType)); +} + +export function getIconForSeries(type: SeriesType): EuiIconType { + const definition = visualizationTypes.find(t => t.id === type); + + if (!definition) { + throw new Error(`Unknown series type ${type}`); + } + + return (definition.icon as EuiIconType) || 'empty'; +} diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/to_expression.ts b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/to_expression.ts index ff5f7eb08f2db..f0e932d14f281 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/to_expression.ts +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/to_expression.ts @@ -131,7 +131,6 @@ export const buildExpression = ( arguments: { xTitle: [xTitle], yTitle: [yTitle], - isHorizontal: [state.isHorizontal], legend: [ { type: 'expression', diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/types.ts b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/types.ts index 742cc36be4ea6..28f72f60c3a2d 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/types.ts +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/types.ts @@ -175,7 +175,14 @@ export const layerConfig: ExpressionFunction< }, }; -export type SeriesType = 'bar' | 'line' | 'area' | 'bar_stacked' | 'area_stacked'; +export type SeriesType = + | 'bar' + | 'bar_horizontal' + | 'line' + | 'area' + | 'bar_stacked' + | 'bar_horizontal_stacked' + | 'area_stacked'; export interface LayerConfig { hide?: boolean; @@ -199,7 +206,6 @@ export interface XYArgs { yTitle: string; legend: LegendConfig; layers: LayerArgs[]; - isHorizontal: boolean; } // Persisted parts of the state @@ -207,7 +213,6 @@ export interface XYState { preferredSeriesType: SeriesType; legend: LegendConfig; layers: LayerConfig[]; - isHorizontal: boolean; } export type State = XYState; @@ -221,13 +226,27 @@ export const visualizationTypes: VisualizationType[] = [ defaultMessage: 'Bar', }), }, + { + id: 'bar_horizontal', + icon: 'visBarHorizontal', + label: i18n.translate('xpack.lens.xyVisualization.barHorizontalLabel', { + defaultMessage: 'Horizontal Bar', + }), + }, { id: 'bar_stacked', icon: 'visBarVertical', - label: i18n.translate('xpack.lens.xyVisualization.stackedBarLabel', { + label: i18n.translate('xpack.lens.xyVisualization.stackedBar', { defaultMessage: 'Stacked Bar', }), }, + { + id: 'bar_horizontal_stacked', + icon: 'visBarHorizontal', + label: i18n.translate('xpack.lens.xyVisualization.stackedBarHorizontalLabel', { + defaultMessage: 'Stacked Horizontal Bar', + }), + }, { id: 'line', icon: 'visLine', diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_config_panel.test.tsx b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_config_panel.test.tsx index ad08b8949f3b9..5cdf1031a22b0 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_config_panel.test.tsx +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_config_panel.test.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FormEvent } from 'react'; +import React from 'react'; import { ReactWrapper } from 'enzyme'; import { mountWithIntl as mount } from 'test_utils/enzyme_helpers'; import { EuiButtonGroupProps } from '@elastic/eui'; @@ -15,7 +15,6 @@ import { Position } from '@elastic/charts'; import { NativeRendererProps } from '../native_renderer'; import { generateId } from '../id_generator'; import { createMockFramePublicAPI, createMockDatasource } from '../editor_frame_plugin/mocks'; -import { act } from 'react-test-renderer'; jest.mock('../id_generator'); @@ -28,7 +27,6 @@ describe('XYConfigPanel', () => { return { legend: { isVisible: true, position: Position.Right }, preferredSeriesType: 'bar', - isHorizontal: false, layers: [ { seriesType: 'bar', @@ -64,50 +62,48 @@ describe('XYConfigPanel', () => { }; }); - test.skip('toggles axis position when going from horizontal bar to any other type', () => {}); test.skip('allows toggling of legend visibility', () => {}); test.skip('allows changing legend position', () => {}); test.skip('allows toggling the y axis gridlines', () => {}); test.skip('allows toggling the x axis gridlines', () => {}); - test('puts the horizontal toggle in a popover', () => { + test('enables stacked chart types even when there is no split series', () => { const state = testState(); - const setState = jest.fn(); const component = mount( ); - component - .find(`[data-test-subj="lnsXY_chart_settings"]`) + openComponentPopover(component, 'first'); + + const options = component + .find('[data-test-subj="lnsXY_seriesType"]') .first() - .simulate('click'); + .prop('options') as EuiButtonGroupProps['options']; - act(() => { - component - .find('[data-test-subj="lnsXY_chart_horizontal"]') - .first() - .prop('onChange')!({} as FormEvent); - }); + expect(options!.map(({ id }) => id)).toEqual([ + 'bar', + 'bar_stacked', + 'line', + 'area', + 'area_stacked', + ]); - expect(setState).toHaveBeenCalledWith({ - ...state, - isHorizontal: true, - }); + expect(options!.filter(({ isDisabled }) => isDisabled).map(({ id }) => id)).toEqual([]); }); - test('enables stacked chart types even when there is no split series', () => { + test('shows only horizontal bar options when in horizontal mode', () => { const state = testState(); const component = mount( ); @@ -118,14 +114,7 @@ describe('XYConfigPanel', () => { .first() .prop('options') as EuiButtonGroupProps['options']; - expect(options!.map(({ id }) => id)).toEqual([ - 'bar', - 'bar_stacked', - 'line', - 'area', - 'area_stacked', - ]); - + expect(options!.map(({ id }) => id)).toEqual(['bar_horizontal', 'bar_horizontal_stacked']); expect(options!.filter(({ isDisabled }) => isDisabled).map(({ id }) => id)).toEqual([]); }); diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_config_panel.tsx b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_config_panel.tsx index 7170a41a16880..e268c099ddc24 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_config_panel.tsx +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_config_panel.tsx @@ -17,7 +17,6 @@ import { EuiPanel, EuiButtonIcon, EuiPopover, - EuiSwitch, EuiSpacer, EuiButtonEmpty, EuiPopoverFooter, @@ -27,6 +26,7 @@ import { VisualizationProps, OperationMetadata } from '../types'; import { NativeRenderer } from '../native_renderer'; import { MultiColumnEditor } from '../multi_column_editor'; import { generateId } from '../id_generator'; +import { isHorizontalChart, isHorizontalSeries } from './state_helpers'; const isNumericMetric = (op: OperationMetadata) => !op.isBucketed && op.dataType === 'number'; const isBucketed = (op: OperationMetadata) => op.isBucketed; @@ -55,10 +55,12 @@ function newLayerState(seriesType: SeriesType, layerId: string): LayerConfig { function LayerSettings({ layer, + horizontalOnly, setSeriesType, removeLayer, }: { layer: LayerConfig; + horizontalOnly: boolean; setSeriesType: (seriesType: SeriesType) => void; removeLayer: () => void; }) { @@ -96,10 +98,12 @@ function LayerSettings({ name="chartType" className="eui-displayInlineBlock" data-test-subj="lnsXY_seriesType" - options={visualizationTypes.map(t => ({ - ...t, - iconType: t.icon || 'empty', - }))} + options={visualizationTypes + .filter(t => isHorizontalSeries(t.id as SeriesType) === horizontalOnly) + .map(t => ({ + ...t, + iconType: t.icon || 'empty', + }))} idSelected={layer.seriesType} onChange={seriesType => setSeriesType(seriesType as SeriesType)} isIconOnly @@ -124,44 +128,10 @@ function LayerSettings({ export function XYConfigPanel(props: VisualizationProps) { const { state, setState, frame } = props; - const [isChartOptionsOpen, setIsChartOptionsOpen] = useState(false); + const horizontalOnly = isHorizontalChart(state.layers); return ( - setIsChartOptionsOpen(false)} - button={ - setIsChartOptionsOpen(!isChartOptionsOpen)} - aria-label={i18n.translate('xpack.lens.xyChart.chartSettings', { - defaultMessage: 'Chart Settings', - })} - title={i18n.translate('xpack.lens.xyChart.chartSettings', { - defaultMessage: 'Chart Settings', - })} - /> - } - > - { - setState({ - ...state, - isHorizontal: !state.isHorizontal, - }); - }} - data-test-subj="lnsXY_chart_horizontal" - /> - - {state.layers.map((layer, index) => ( ) { setState(updateLayer(state, { ...layer, seriesType }, index)) } diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_expression.test.tsx b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_expression.test.tsx index 0ac286c7bb83c..8770ee5b5e1c9 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_expression.test.tsx +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_expression.test.tsx @@ -35,7 +35,6 @@ function sampleArgs() { const args: XYArgs = { xTitle: '', yTitle: '', - isHorizontal: false, legend: { isVisible: false, position: Position.Top, @@ -161,7 +160,7 @@ describe('xy_expression', () => { const component = shallow( @@ -208,8 +207,7 @@ describe('xy_expression', () => { data={data} args={{ ...args, - isHorizontal: true, - layers: [{ ...args.layers[0], seriesType: 'bar_stacked' }], + layers: [{ ...args.layers[0], seriesType: 'bar_horizontal_stacked' }], }} formatFactory={getFormatSpy} timeZone="UTC" diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_expression.tsx b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_expression.tsx index e559cdd514bc6..43452ff432767 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_expression.tsx +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_expression.tsx @@ -27,6 +27,7 @@ import { IInterpreterRenderFunction } from '../../../../../../src/legacy/core_pl import { LensMultiTable } from '../types'; import { XYArgs, SeriesType, visualizationTypes } from './types'; import { VisualizationContainer } from '../visualization_container'; +import { isHorizontalChart } from './state_helpers'; export interface XYChartProps { data: LensMultiTable; @@ -75,10 +76,6 @@ export const xyChart: ExpressionFunction<'lens_xy_chart', LensMultiTable, XYArgs help: 'Layers of visual series', multi: true, }, - isHorizontal: { - types: ['boolean'], - help: 'Render horizontally', - }, }, context: { types: ['lens_multitable'], @@ -140,7 +137,7 @@ export function XYChartReportable(props: XYChartRenderProps) { } export function XYChart({ data, args, formatFactory, timeZone }: XYChartRenderProps) { - const { legend, layers, isHorizontal } = args; + const { legend, layers } = args; if (Object.values(data.tables).every(table => table.rows.length === 0)) { const icon: IconType = layers.length > 0 ? getIconForSeriesType(layers[0].seriesType) : 'bar'; @@ -176,18 +173,20 @@ export function XYChart({ data, args, formatFactory, timeZone }: XYChartRenderPr } } + const shouldRotate = isHorizontalChart(layers); + return ( - ) : seriesType === 'bar' || seriesType === 'bar_stacked' ? ( + ) : seriesType === 'bar' || + seriesType === 'bar_stacked' || + seriesType === 'bar_horizontal' || + seriesType === 'bar_horizontal_stacked' ? ( ) : ( diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_suggestions.test.ts b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_suggestions.test.ts index ed44c74123316..a205fe433106a 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_suggestions.test.ts +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_suggestions.test.ts @@ -199,7 +199,6 @@ describe('xy_suggestions', () => { changeType: 'reduced', }, state: { - isHorizontal: false, legend: { isVisible: true, position: 'bottom' }, preferredSeriesType: 'bar', layers: [ @@ -235,7 +234,6 @@ describe('xy_suggestions', () => { test('only makes a seriesType suggestion for unchanged table without split', () => { (generateId as jest.Mock).mockReturnValueOnce('dummyCol'); const currentState: XYState = { - isHorizontal: false, legend: { isVisible: true, position: 'bottom' }, preferredSeriesType: 'bar', layers: [ @@ -270,7 +268,6 @@ describe('xy_suggestions', () => { test('suggests seriesType and stacking when there is a split', () => { const currentState: XYState = { - isHorizontal: false, legend: { isVisible: true, position: 'bottom' }, preferredSeriesType: 'bar', layers: [ @@ -311,7 +308,6 @@ describe('xy_suggestions', () => { test('suggests a flipped chart for unchanged table and existing bar chart on ordinal x axis', () => { (generateId as jest.Mock).mockReturnValueOnce('dummyCol'); const currentState: XYState = { - isHorizontal: false, legend: { isVisible: true, position: 'bottom' }, preferredSeriesType: 'bar', layers: [ @@ -335,16 +331,13 @@ describe('xy_suggestions', () => { }); expect(rest).toHaveLength(0); - expect(suggestion.state).toEqual({ - ...currentState, - isHorizontal: true, - }); + expect(suggestion.state.preferredSeriesType).toEqual('bar_horizontal'); + expect(suggestion.state.layers.every(l => l.seriesType === 'bar_horizontal')).toBeTruthy(); expect(suggestion.title).toEqual('Flip'); }); test('suggests stacking for unchanged table that has a split', () => { const currentState: XYState = { - isHorizontal: false, legend: { isVisible: true, position: 'bottom' }, preferredSeriesType: 'bar', layers: [ @@ -379,7 +372,6 @@ describe('xy_suggestions', () => { test('keeps column to dimension mappings on extended tables', () => { const currentState: XYState = { - isHorizontal: false, legend: { isVisible: true, position: 'bottom' }, preferredSeriesType: 'bar', layers: [ @@ -418,7 +410,6 @@ describe('xy_suggestions', () => { test('overwrites column to dimension mappings if a date dimension is added', () => { (generateId as jest.Mock).mockReturnValueOnce('dummyCol'); const currentState: XYState = { - isHorizontal: false, legend: { isVisible: true, position: 'bottom' }, preferredSeriesType: 'bar', layers: [ diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_suggestions.ts b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_suggestions.ts index 2f28e20ebd274..7c7e9caddd31b 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_suggestions.ts +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_suggestions.ts @@ -7,7 +7,6 @@ import { i18n } from '@kbn/i18n'; import { partition } from 'lodash'; import { Position } from '@elastic/charts'; -import { EuiIconType } from '@elastic/eui/src/components/icon/icon'; import { SuggestionRequest, VisualizationSuggestion, @@ -17,6 +16,7 @@ import { } from '../types'; import { State, SeriesType, XYState } from './types'; import { generateId } from '../id_generator'; +import { getIconForSeries } from './state_helpers'; const columnSortOrder = { date: 0, @@ -26,21 +26,6 @@ const columnSortOrder = { number: 4, }; -function getIconForSeries(type: SeriesType): EuiIconType { - switch (type) { - case 'area': - case 'area_stacked': - return 'visArea'; - case 'bar': - case 'bar_stacked': - return 'visBarVertical'; - case 'line': - return 'visLine'; - default: - throw new Error('unknown series type'); - } -} - /** * Generate suggestions for the xy chart. * @@ -163,10 +148,8 @@ function getSuggestionsForLayer( ): VisualizationSuggestion | Array> { const title = getSuggestionTitle(yValues, xValue, tableLabel); const seriesType: SeriesType = getSeriesType(currentState, layerId, xValue, changeType); - const isHorizontal = currentState ? currentState.isHorizontal : false; const options = { - isHorizontal, currentState, seriesType, layerId, @@ -186,14 +169,18 @@ function getSuggestionsForLayer( const sameStateSuggestions: Array> = []; // if current state is using the same data, suggest same chart with different presentational configuration - if (seriesType !== 'line' && xValue.operation.scale === 'ordinal') { // flip between horizontal/vertical for ordinal scales sameStateSuggestions.push( buildSuggestion({ ...options, title: i18n.translate('xpack.lens.xySuggestions.flipTitle', { defaultMessage: 'Flip' }), - isHorizontal: !options.isHorizontal, + seriesType: + seriesType === 'bar_horizontal' + ? 'bar' + : seriesType === 'bar_horizontal_stacked' + ? 'bar_stacked' + : 'bar_horizontal', }) ); } else { @@ -328,7 +315,6 @@ function getSuggestionTitle( } function buildSuggestion({ - isHorizontal, currentState, seriesType, layerId, @@ -339,7 +325,6 @@ function buildSuggestion({ xValue, }: { currentState: XYState | undefined; - isHorizontal: boolean; seriesType: SeriesType; title: string; yValues: TableSuggestionColumn[]; @@ -358,7 +343,6 @@ function buildSuggestion({ }; const state: State = { - isHorizontal, legend: currentState ? currentState.legend : { isVisible: true, position: Position.Right }, preferredSeriesType: seriesType, layers: [ diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_visualization.test.ts b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_visualization.test.ts index 8bc7b0c9116f7..5cd0791ae3da9 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_visualization.test.ts +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_visualization.test.ts @@ -7,7 +7,7 @@ import { xyVisualization } from './xy_visualization'; import { Position } from '@elastic/charts'; import { Operation } from '../types'; -import { State } from './types'; +import { State, SeriesType } from './types'; import { createMockDatasource, createMockFramePublicAPI } from '../editor_frame_plugin/mocks'; import { generateId } from '../id_generator'; import { Ast } from '@kbn/interpreter/target/common'; @@ -16,7 +16,6 @@ jest.mock('../id_generator'); function exampleState(): State { return { - isHorizontal: false, legend: { position: Position.Bottom, isVisible: true }, preferredSeriesType: 'bar', layers: [ @@ -32,6 +31,51 @@ function exampleState(): State { } describe('xy_visualization', () => { + describe('getDescription', () => { + function mixedState(...types: SeriesType[]) { + const state = exampleState(); + return { + ...state, + layers: types.map((t, i) => ({ + ...state.layers[0], + layerId: `layer_${i}`, + seriesType: t, + })), + }; + } + + it('should show mixed xy chart when multilple series types', () => { + const desc = xyVisualization.getDescription(mixedState('bar', 'line')); + + expect(desc.label).toEqual('Mixed XY Chart'); + }); + + it('should show mixed horizontal bar chart when multiple horizontal bar types', () => { + const desc = xyVisualization.getDescription( + mixedState('bar_horizontal', 'bar_horizontal_stacked') + ); + + expect(desc.label).toEqual('Mixed Horizontal Bar Chart'); + }); + + it('should show bar chart when bar only', () => { + const desc = xyVisualization.getDescription(mixedState('bar_horizontal', 'bar_horizontal')); + + expect(desc.label).toEqual('Horizontal Bar Chart'); + }); + + it('should show the chart description if not mixed', () => { + expect(xyVisualization.getDescription(mixedState('area')).label).toEqual('Area Chart'); + expect(xyVisualization.getDescription(mixedState('line')).label).toEqual('Line Chart'); + expect(xyVisualization.getDescription(mixedState('area_stacked')).label).toEqual( + 'Stacked Area Chart' + ); + expect(xyVisualization.getDescription(mixedState('bar_horizontal_stacked')).label).toEqual( + 'Stacked Horizontal Bar Chart' + ); + }); + }); + describe('#initialize', () => { it('loads default state', () => { (generateId as jest.Mock) @@ -48,7 +92,6 @@ describe('xy_visualization', () => { expect(initialState).toMatchInlineSnapshot(` Object { - "isHorizontal": false, "layers": Array [ Object { "accessors": Array [ diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx index 69cb93bb1903d..29c5e5d5e4297 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx @@ -16,6 +16,7 @@ import { Visualization } from '../types'; import { State, PersistableState, SeriesType, visualizationTypes } from './types'; import { toExpression, toPreviewExpression } from './to_expression'; import { generateId } from '../id_generator'; +import { isHorizontalChart } from './state_helpers'; const defaultIcon = 'visBarVertical'; const defaultSeriesType = 'bar_stacked'; @@ -25,7 +26,7 @@ function getDescription(state?: State) { return { icon: defaultIcon, label: i18n.translate('xpack.lens.xyVisualization.xyLabel', { - defaultMessage: 'XY Chart', + defaultMessage: 'XY', }), }; } @@ -42,8 +43,12 @@ function getDescription(state?: State) { label: seriesTypes.length === 1 ? visualizationType.label + : isHorizontalChart(state.layers) + ? i18n.translate('xpack.lens.xyVisualization.mixedBarHorizontalLabel', { + defaultMessage: 'Mixed Horizontal Bar', + }) : i18n.translate('xpack.lens.xyVisualization.mixedLabel', { - defaultMessage: 'Mixed XY Chart', + defaultMessage: 'Mixed XY', }), }; } @@ -55,9 +60,14 @@ export const xyVisualization: Visualization = { getDescription(state) { const { icon, label } = getDescription(state); + const chartLabel = i18n.translate('xpack.lens.xyVisualization.chartLabel', { + defaultMessage: '{label} Chart', + values: { label }, + }); + return { icon: icon || defaultIcon, - label, + label: chartLabel, }; }, @@ -75,7 +85,6 @@ export const xyVisualization: Visualization = { return ( state || { title: 'Empty XY Chart', - isHorizontal: false, legend: { isVisible: true, position: Position.Right }, preferredSeriesType: defaultSeriesType, layers: [ diff --git a/x-pack/test/functional/es_archives/lens/reporting/data.json.gz b/x-pack/test/functional/es_archives/lens/reporting/data.json.gz index b59717330488afadc06fd319e810fdcfe6363a60..93ceaf3d8f6f5daa843f9fd8fb1e43a0eff23805 100644 GIT binary patch literal 4359 zcmd6pRYMaDpg>`S0|Z8Q!;lUU{OA#qBL+xFONWFsBc!B6TBMX7NJxzyA>a@RsS#3= zqZ!@z-hXk=(|I}Xha(Ay_kRFr_7&W{7Fx5HZ-kl#!!2Tn`3&_rd(QO&140g)*mN7C z7i-9(xdmReG^|r?NV#Gj!nCw~d0*+R%QF-6aC+LK{H*20cE*=&m#UQMlke>nT8%lzXr9a@@Wt%+0#DP>*l2zJ^Jer5`+>!k8l?|W(kv8 zf`iW=3AJ8M?>ybv*w8+hD(KT%tXFV%Vi0yB8V0V}k5{yxoz!-GUb67979UO20T+oS zk;`>pi<2|B%|Fr6W+XZGkslk0em3NBK@dGSP84Q(vz8^_|MqSZko9ijq#}6Q3CroM zboGzaQ9UBvHmw=8!dObGN_pCN+H_qj=IbPK*?t&Kr!&vlB(t_h5k3TP#QD$cOi1gQ zY4Z_*zo5ow`x>>L=iS~U`lK-Y`!T1BUu_X=&O4{loCeRxDyB;87NQpyR2+Q1p(`z6 znOaub;9@MF2R*Vk{7xjgJLBZiD}L98Hudtwz2_zLx<)#OfuLl!XCy^cuWlmKtwP1K zf_k5Erx9w1UGqk{^I6w)fDA1gwj!Mi$G5nkl#QEiMmq!){K54ti;W|K_5(vJQXeR3 zHiOFs+d<7NU+_ywDWK26ens#?(O(+xN-}T>wK{_w>R&nV8Pma0@xcT7mt2 zv~Z$@32B?r`TLC)CEaH|e5D`!@)s+i=&zVkVapAv1ezCHl4O+{035J%7n%u%Ur{S> zBxhQbQM`F?#4i*=I6*DeR8byj_*#22>AOD#LPVm&b_Y7#-JlvoUpJ-T^C#2qGYlfjWQb(c ztDfQ$1u;+7-R7cYN>!rBszm4(lu>vVD8iqek7gK@&AY5lDKf<(b0>z!3v;N^49YrP zRz(z<%tl7eMzsZO4{v|8zJwqFnqthy{)Ij@=Z-{add4s@oVy>pWp4>hY~dl>tg2ZP zEG-w?57m;Ag|`1=_7ueb{q*h5+YVMOU#zov+zColrd=R!n}X&(aqrDEfQ^=S(K@%; z=8=?7#i)XIQ!b$Vz_;UI#6p{?U=2pm@D)oi3CIq{w~OBcg%~lmKHRiU!yJAL!=Ke@ zFAPpp*88~$7clEo{D07E?F!zqfaS>PTJ7AfFzOmMR0 zpS*dax3H($Lz-85s38L_tOn8DvQl`EQjNi0_4ruX zV*I`#G9XQUbOukE#%v~*DF#V5zd02ZRnu{N({vqvD)(oerHEm9)ME&#aJs>*@(9?X zi+VwYyw8VDmFxQ;XA$uhsBcIvVVa0H>Kgf_WpE7F?der@IOZsjzS}EvNMMSA42f81 zX|ZAH=Bxg&2fi{R%{?%fMaqc<0$d9Hc@0J?4Qopz@T)93zn#ta|6pba8W8GZ^|RL& z!^xX$kjcerVCy`3B||1NtH!!FxV25@*s=fiM^V)G;4x`Occ~OJ3#Bx%lR6Qi(7Bl` zwMA09uGa0b`m*Y-ER!BaBkmXA&T+RH< zu+##D1TxF5+vN%DRop)EAT?TXQP^J4*t{H-Q%AJBWMt2`F{Vn%CV6jg^>{GF$D(ZW~ zK<5LM8Q1R5m$h!$>-U`tG7A;oTBm<2|HS}5N;@=hm5?)e zqFq$OlgSNbIe&NGzU^AMsHbQj{!!d}=8E$teDT%LnF*KgNdB9&}4TF`#n z8Qj3{9O^w%=6HZF{XL1?+YPhLOv&I!lilK4=r_->s|a10{H^R{ik zbSaosrhPeU=*edTOvF#Fy4_4EW~RlFxAG2$n{^m(vow4hH(!od;H8N3uvt8ByK`N* zIhzVY>_4vbg-%7LjMYcC&(RkVt+Ks5{-Ja)DzhC{mvGfcpkDXsAO2*Ac-J_jXhY9) zEMjlh4kxTfU!)Xk6vUx!G;-*c`wPKmm=50XH#;1|m^Z)kepO}c0|_+_sF`d~)?VC$gy@Dj1dAgjBo2Qlwgr6(-gC3kJTQ4#Y+;vu z_rK~PkL<0jJSW>@3O;wTMVRy_zA2N~v^>fXTvZqsgoYN{N89swi8Nfv9S z5UV;bV~HeFiJv}o$N>OVHu~Ac#mMpW$+K@TE%N6oldK#gB(NlTm%5s;T0SErmd| zNFG7}wE>80&|}*+Nj&07Y#F~)D#|MGkTE!x-f2)QA)bk$FJDXWqR@Ov-HikTpA&uy z|0lv_uH#Co@T60cuJ6q+xUa+Z`J1JKZ027}^te0u@MoYD?K6%^EM-EMEEu6=a?~a> zINu0u(Qw+I_3%AC{%M4)$>9~tTvk-eIE17g+2FXZz@0XHr!h z9p7-bQ_i=X<3048(FsCo?JzSi;&8U(B;EanBe3`j6r6)p4vO5e>Pu<6 z9Vz=EJvhYdkxE%~56@#x%pi(xVfJke4fkO!nU65gi$XF^eYM8jJ&%NCbV)~kV9D-O z>2hISbsLb~^lBqwf^tp-k_1<%t29$+SL{gDisH>kSE{23bogSvD)zS3c&|*;Z=89h zMs^kpdsYrvxkdxnOZ#YTNn^L=7-!4(?ef~w_E%^S?a^!f$%6^5Nkt9b?JMXrsCcFz zf7+4yEV(?tev{T@AhS#vdWF5}l;euURdt)gki(5!i( zTi&yPO?aSMyGciW36~LiR0Oe<+=Hjca|WR@XXPsCh@JEO)+rY3(4B~ zn(5(6z&9&a!_{dYs?pflMzh})gCC-~dV2;eQ;lOpnzH=TNu5C~{4e;j7}{h7JxX!B z!B{(65w_RbyH`dQs-?FOff_EBRZYDAG)LJb*rb6|&QLz#pT>RT#(k=4J4`gb@8@xZ zn}UH3-mo{iz4FhAP7ow1km9*vQbxNmdI^Q^@m~1rm!d%UPa>3SeGfN9vzec7)az8o zZS!I_%%`jC16Ivp4J=#d@9ul&Gy^Q#@fr(O+>tx#?`(jj>1OZ~<$gO(ZV7-Wq}*K{ zom6uJs*#ArA9g27uo8;5f1%CCnD+aZ3`Ie^~Dyc{HYaoeU zUoz;VDti#W?7?fa6mBe~wKcV6@6e0!(S6dyT0qSUS;>(;V*={B3^|kwAZ64dgHZ;t z{k-g?dcck2jeq7gP{zbjt=8o`x4vDSRaN2HG*cR41&t%w3g4QbjPkYroq9EYf^%GS zaXVMkaeUg`*PjX{7*uzX-qBp_(pAyV?UIOyF`gNSWYF!c-*`6bK}r_`tdrk$EWpiw zEi<^tRnH3erB8s&V>khDjq&{jN=)D@rdNhD`OPX40`!5p^c`Rl)}JzA1^JTtnynk+ zl>KHgCBR2OLUbaYBmtFZ`xMpcc95I@P9+-bca;Ck(49;)p9bEA zSG-hpT=na%)T@buZSYbD$U9-B5uRnzW*^+b?3;EM(!O3>?Jq#Kb!Cl+A4CMKzTmD1q=IK9zUP-`NldOI=5DbLSsmfefBhKb!3J`JsW$K55(yPjn99jG8H*%!E zBT_Aj8bCdYbh-TTOyy6a=u5lIDDr}2vp1vbJu z`0IB}y?A-848wLb`adVA_2pVej{;{b`RljdFCpe^VrQO`B1e;O3u!kldYy~kYhF*4 zX&-U5926W$1}oIH=~QMP-@B)0HXKfZ6*%*~Ef>h5uDn5dntJC^w&Z|SglnfAR{UoB XeTrPHwRcO{Z3JU_-i12~5AS~fbnSYJ delta 4260 zcmV;V5L@quB7`D;ABzY8000000u${$X>;2+mf!O$xT^iMWvF<7m#SPfna=ihW;#h% z$J@2DQA!08pu`D9stL-mlhuFU2k`L^1PQSzfNy);fdw90Z!<$8* zWgRbbQp8#M8_PNTYV~Q=@?s@t>P+=X)v-IPXXh+pIZH#vVbG_l^>fo*Pj9Wb$Qft( zB@@sW`IepCbWz5`Jl+%~6qAcp%r*E|te93IsL2KYZPK0;a}p$s{>VtN%{y?kAc27? zSxZdbRJ1yOz@;X53G}O0XVbM1%z&odtKF+t--mNaG6Zc%QLsFnCE1b}gs;$l+=pSE zwYs#8VwO-rfyt-ubBXYLGMi8SvSyruBYC(IRE9>$v-yN9mpNOKqH3*&=8VuE^KAWpCJTQPgy41G?yL`@X->XAAyids zA5fxe52k`Fxk4lo;HX8P7`RrD^`;9EB?Ws9cos2z9@6wMYautKI^ggy%lEc>x?Kk> zKN+05@nOQeDAiaOqITmmfK`HpY#L@+PUDmuh!S)zHd$Qlz-NFAJl{ekO7T2Y3>=jx zw1QFaXoZ}`8X}c;+$!S=Su_ESO4QkRUZ2mJJ+MBGL^^DpD#ZYgJUxnys_^S{-PA3NnQSb7eRt0LocHcCvlWY%t3r8FR&c%qihhl0u~MI)5=^z}$>V}8)bhe_0a9&R7IBd%O-Bi!;xv5P z_p1*lA%LYe1-E&kG64ZRh3Qv(jl~J6Sx9oKEEU^8z5rUj1joDw))zoowO#ZMlrMmq z!pl&em;%TbKufhq{u`4xH} zT*9Lgg`V#W>>(^lsYu0i;* z5AUZXik%huxzvo<9c615$>VJGk!~Qm zTd-{peX$7W0C3C1F^XC1&-p}d`h=v*t&FI}S+#A*QuuQR9Z{SV;;h5$)9175tl_UL zdS5G(Q>YIMwy4{;w_j_&#H@|%HvU)uO&b(;?as?a0FASb1j;!T%2hN5pvuL&T5nS| zY)DSlyd=y)C!zEr%Mw|>&?r1ge}866meQN1M1KAygD6#IdxVSEy!(pzn=IWHt}6Y5%AveiBm=p6W^81g4~~*eY1PIid)4D z#C?tMXhTLDGTM;QhKx4ke~+;tQU}bkFaO*u`F*T~oPVq0`tU`ob7fouxzpfL8mJV; z+Z(ymFr6N#g zM`0)hy$81zuO`5O$zJ#FV5M4qHmjU)b_hr{{XUAOoZrjIzL+8uO%kLt8TSI?a zN^2m4D(r6M5d6azhBw3R%*0dIF&>#i32eBT>qH?2c|>7^e{re00^hPwL_KV}dSsE% zxDWfp;$7ykt4%JrS6Yd3g?ajaTD4XvUe;X|nkHrUd=8G>yyc_Lg@J@0a({_G&cu)3 z>rb*z{J)z|G_k69{$kDYC0oceC)W~k&GNUD!_uK+5RXIwB8F!p91(_y?qC!JmWQ3l z39)U+bu_JM^YzzTHanXcoqfS6X~ zHZ-Zdrg`Hx%`4BTUT#gUn$ea2)O3}uS>0Q?QFG}?f6Z%Gsvcb4SuvOH3Xq{9I!Hf6 z3c$}~yH0^(^c~sM?1V<7qoL@Wg!@kXyJ5UQV#%~(}~LZoD-o-NVywCRP=`Q_#Kt+Be*10$}eRUy6+1e`a^d|$ag2-`widcS4XUQ z3m@rbe;s-nrB>*>t*ch#+Z;rqXrub}E*vIuvmSA4hV0&B9z-dXAB1<`L+)zO5K)-) zO#OUz{E_?!L_;5UY<9(>i%R2{L0zwJR5FW~F~@PQw#5PnOT?P{`{wxS*@l&=q#7L1 zG{;Btkd#s?=iD+*il2PvB%N&uL+{{S{~8l&e=)}-E@ci{j3CGKfx=dRkstaL1(-2Qw}}}tZzzR@Cp3`0U3c#oc|i*^ z?WY)G#wD0DLFL|emUod_z_nP_%N(D0OGwsInxF^4E0ZDYw#X(&hVJg>ZL`!+_O_7k zf9LV5fpCl4}zHg zl;!a^@P4H<^k|Gp<+;kZVTkSPn?BIHsIlQktm80M+AA!k!y;EFJ_PICheiPK&&4W5-mt?>&?5k9Xu*#NbUSek6WLg&k?RuTI@Sr1 zch`kD7KGO?GKNLrt!r!R30uzIe+xWe?=bqG{B7kSbODRot>TC9Lz*RMo^2Kt70X9g z?=D1=AWntkFUprYqR%Mt7dc!CdntepS^>P1FLt_AWkz7&zyPUtL1AOVprF5*C#Z@e zAG?g&>;$SdaGaRYI5BN8R2EJb9I?KEhEnAu5_ZxeSJzcav2B_ z5{qu&`&J02$ey%amui=lmgxL@{kZaTxxuu?7?nG(Ha+LfThpD~_@%$nT0?xUmB?@% zvWh9IplZzT`9Q`1<{yQBEsV~&ybO$q+mf`ac58WFRZyX(Ux`=8YdD9Uz#^DWwHu!U zCJT$X05U@;LtBX`p@aspe|Dmkl6sNJ*N3&Fo+Lv(OsynlOmjVdnrn!dKPb&LWG5U6 zDhJ8gBc{j(fjXuaUPgN1AtLpRNxDOAc>;5#i!wM-H>PDCoR%4yR(Y(v$RO$M<(6%m zk!64~(*rTncd^j(9{d$rp%)p15qxr9WRUUbS)!c|D6-A4)Jg5ffAo%CWncd*kEw8> z`0>qlm}>FEfZDDn$P!V6bx`>pu??gXBh-D*HNC*NGu1N4ZoIryOGEJCDVLwYnb!X2 zA4AeBFzP8&E2ccuV0q$DgJrr;ky`Od0hHlX_7IHiUWDM3{ii z_kEyhL@WY!21kKS)l01mm4(v&xa4a=Cz00{t8H(;MdBTTc#CTfH+qmpO(LFoXj$Lf8x)dy>oif#KT!cgL}$n~s0R~Q;uQDjkCM?BpFZIs}Ob!*~W#gAw_S7^UyDF7}+yxoQ z=`1ubi-PdcowMc%gKzg(#f2c7{`ePubZ+=4-8gY8LHiz%z570n+}4pvz$4M`w%7B7+VN4k0Oux9&&WUrY>VfWSaGj7XZiG@`{qd z6UChrp!F#g9!2R>)^}#e_1d?j$cBY3p7tOlWlI-JSI3|PLK_7%6ak^*A&(k1q7nSZ z%)m26sbUY7!zP}NrSV`aYgWpJ63c)_p~pgue_Rt&ggp`=&od}Q-mV|GPGn#c_hLD0 z;^|ll$vL9 zuUL}GzE7LkY7v{|^Rl174@T G0ssJeX)LM$ From f8810d12ac100b88fdf1aa1c49c88192d4ff02d0 Mon Sep 17 00:00:00 2001 From: Steph Milovic Date: Wed, 2 Oct 2019 11:46:55 -0600 Subject: [PATCH 03/19] [SIEM] Start of deprecated lifecycle refactor (#46293) --- .../timeline/data_providers.spec.ts | 6 +- .../timeline/flyout_button.spec.ts | 2 +- .../autocomplete_field/suggestion_item.tsx | 13 +- .../drag_drop_context_wrapper.tsx | 60 ++- .../drag_and_drop/draggable_wrapper.tsx | 42 +- .../components/edit_data_provider/index.tsx | 196 ++++---- .../components/embeddables/embedded_map.tsx | 2 +- .../event_details/stateful_event_details.tsx | 37 +- .../events_viewer/events_viewer.test.tsx | 11 +- .../components/events_viewer/index.test.tsx | 10 + .../public/components/events_viewer/index.tsx | 2 +- .../fields_browser/field_browser.tsx | 163 ++++--- .../components/fields_browser/index.tsx | 251 +++++------ .../public/components/flyout/pane/index.tsx | 58 ++- .../public/components/help_menu/help_menu.tsx | 52 ++- .../components/lazy_accordion/index.tsx | 77 ++-- .../__snapshots__/index.test.tsx.snap | 104 ----- .../components/load_more_table/index.mock.tsx | 118 ----- .../components/load_more_table/index.test.tsx | 360 --------------- .../components/load_more_table/index.tsx | 320 ------------- .../load_more_table/translations.ts | 23 - .../permissions/ml_capabilities_provider.tsx | 2 +- .../get_anomalies_host_table_columns.test.tsx | 2 +- .../get_anomalies_host_table_columns.tsx | 2 +- ...t_anomalies_network_table_columns.test.tsx | 2 +- .../get_anomalies_network_table_columns.tsx | 2 +- .../components/navigation/index.test.tsx | 4 +- .../public/components/navigation/index.tsx | 124 ++--- .../navigation/tab_navigation/index.test.tsx | 10 +- .../navigation/tab_navigation/index.tsx | 103 ++--- .../siem/public/components/notes/index.tsx | 28 +- .../components/notes/note_cards/index.tsx | 55 +-- .../delete_timeline_modal.test.tsx | 22 +- .../delete_timeline_modal.tsx | 6 +- .../delete_timeline_modal/index.test.tsx | 30 -- .../delete_timeline_modal/index.tsx | 53 +-- .../components/open_timeline/index.test.tsx | 190 ++++---- .../public/components/open_timeline/index.tsx | 424 ++++++++---------- .../open_timeline_modal/index.test.tsx | 23 +- .../open_timeline_modal/index.tsx | 124 ++--- .../components/page/add_to_kql/index.tsx | 28 +- .../page/hosts/hosts_table/index.tsx | 124 +++-- .../page/network/domains_table/columns.tsx | 2 +- .../network/network_dns_table/columns.tsx | 2 +- .../network_top_n_flow_table/columns.tsx | 2 +- .../network_top_n_flow_table/index.tsx | 83 ++-- .../page/network/tls_table/columns.tsx | 2 +- .../page/network/users_table/columns.tsx | 2 +- .../components/paginated_table/index.tsx | 12 +- .../public/components/resize_handle/index.tsx | 147 +++--- .../components/super_date_picker/index.tsx | 264 +++++------ .../body/column_headers/header/index.tsx | 77 ++-- .../body/data_driven_columns/index.tsx | 10 +- .../timeline/body/events/stateful_event.tsx | 192 ++++---- .../timeline/body/stateful_body.tsx | 138 +++--- .../data_providers/provider_item_badge.tsx | 92 ++-- .../components/timeline/footer/index.test.tsx | 60 ++- .../components/timeline/footer/index.tsx | 190 +++----- .../timeline/footer/last_updated.tsx | 71 ++- .../siem/public/components/timeline/index.tsx | 276 ++++++------ .../components/timeline/properties/index.tsx | 150 +++---- .../timeline/search_or_filter/index.tsx | 31 +- .../components/url_state/use_url_state.tsx | 2 +- .../components/with_hover_actions/index.tsx | 40 +- .../siem/public/containers/hosts/filter.tsx | 115 +++-- .../containers/hosts/first_last_seen/index.ts | 6 +- .../containers/kuery_autocompletion/index.tsx | 117 +++-- .../siem/public/containers/network/filter.tsx | 124 +++-- .../siem/public/containers/source/index.tsx | 81 ++-- .../public/containers/timeline/all/index.tsx | 90 ++-- .../containers/timeline/details/index.tsx | 22 +- .../public/pages/timelines/timelines_page.tsx | 34 +- .../translations/translations/ja-JP.json | 4 - .../translations/translations/zh-CN.json | 4 - 74 files changed, 2091 insertions(+), 3616 deletions(-) delete mode 100644 x-pack/legacy/plugins/siem/public/components/load_more_table/__snapshots__/index.test.tsx.snap delete mode 100644 x-pack/legacy/plugins/siem/public/components/load_more_table/index.mock.tsx delete mode 100644 x-pack/legacy/plugins/siem/public/components/load_more_table/index.test.tsx delete mode 100644 x-pack/legacy/plugins/siem/public/components/load_more_table/index.tsx delete mode 100644 x-pack/legacy/plugins/siem/public/components/load_more_table/translations.ts diff --git a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/data_providers.spec.ts b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/data_providers.spec.ts index 7c9a0edebe53e..236d5a53481b7 100644 --- a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/data_providers.spec.ts +++ b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/data_providers.spec.ts @@ -61,7 +61,7 @@ describe('timeline data providers', () => { cy.get(TIMELINE_DATA_PROVIDERS).should( 'have.css', 'background', - 'rgba(125, 226, 209, 0.1) none repeat scroll 0% 0% / auto padding-box border-box' + 'rgba(1, 125, 115, 0.1) none repeat scroll 0% 0% / auto padding-box border-box' ); }); @@ -81,7 +81,7 @@ describe('timeline data providers', () => { cy.get(TIMELINE_DATA_PROVIDERS_EMPTY).should( 'have.css', 'background', - 'rgba(125, 226, 209, 0.2) none repeat scroll 0% 0% / auto padding-box border-box' + 'rgba(1, 125, 115, 0.2) none repeat scroll 0% 0% / auto padding-box border-box' ); }); @@ -101,7 +101,7 @@ describe('timeline data providers', () => { cy.get(TIMELINE_DATA_PROVIDERS).should( 'have.css', 'border', - '3.1875px dashed rgb(125, 226, 209)' + '3.1875px dashed rgb(1, 125, 115)' ); }); }); diff --git a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/flyout_button.spec.ts b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/flyout_button.spec.ts index 811c529b8bec5..c1c35e497d081 100644 --- a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/flyout_button.spec.ts +++ b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/flyout_button.spec.ts @@ -41,7 +41,7 @@ describe('timeline flyout button', () => { cy.get(TIMELINE_NOT_READY_TO_DROP_BUTTON).should( 'have.css', 'background', - 'rgba(125, 226, 209, 0.1) none repeat scroll 0% 0% / auto padding-box border-box' + 'rgba(1, 125, 115, 0.1) none repeat scroll 0% 0% / auto padding-box border-box' ); }); }); diff --git a/x-pack/legacy/plugins/siem/public/components/autocomplete_field/suggestion_item.tsx b/x-pack/legacy/plugins/siem/public/components/autocomplete_field/suggestion_item.tsx index 997a19b0e8a2e..aaf7be2f7f5a6 100644 --- a/x-pack/legacy/plugins/siem/public/components/autocomplete_field/suggestion_item.tsx +++ b/x-pack/legacy/plugins/siem/public/components/autocomplete_field/suggestion_item.tsx @@ -18,13 +18,8 @@ interface SuggestionItemProps { suggestion: AutocompleteSuggestion; } -export class SuggestionItem extends React.PureComponent { - public static defaultProps: Partial = { - isSelected: false, - }; - - public render() { - const { isSelected, onClick, onMouseEnter, suggestion } = this.props; +export const SuggestionItem = React.memo( + ({ isSelected = false, onClick, onMouseEnter, suggestion }) => { return ( { ); } -} +); + +SuggestionItem.displayName = 'SuggestionItem'; const SuggestionItemContainer = euiStyled.div<{ isSelected?: boolean; diff --git a/x-pack/legacy/plugins/siem/public/components/drag_and_drop/drag_drop_context_wrapper.tsx b/x-pack/legacy/plugins/siem/public/components/drag_and_drop/drag_drop_context_wrapper.tsx index aab83ec7908fe..11b604571378b 100644 --- a/x-pack/legacy/plugins/siem/public/components/drag_and_drop/drag_drop_context_wrapper.tsx +++ b/x-pack/legacy/plugins/siem/public/components/drag_and_drop/drag_drop_context_wrapper.tsx @@ -6,7 +6,7 @@ import { defaultTo, noop } from 'lodash/fp'; import * as React from 'react'; -import { DragDropContext, DropResult, ResponderProvided, DragStart } from 'react-beautiful-dnd'; +import { DragDropContext, DropResult, DragStart } from 'react-beautiful-dnd'; import { connect } from 'react-redux'; import { Dispatch } from 'redux'; @@ -57,43 +57,39 @@ const onDragEndHandler = ({ /** * DragDropContextWrapperComponent handles all drag end events */ -export class DragDropContextWrapperComponent extends React.Component { - public shouldComponentUpdate = ({ children, dataProviders }: Props) => - children === this.props.children && dataProviders !== this.props.dataProviders // prevent re-renders when data providers are added or removed, but all other props are the same - ? false - : true; - - public render() { - const { children } = this.props; - +export const DragDropContextWrapperComponent = React.memo( + ({ browserFields, children, dataProviders, dispatch }) => { + function onDragEnd(result: DropResult) { + enableScrolling(); + + if (dataProviders != null) { + onDragEndHandler({ + browserFields, + result, + dataProviders, + dispatch, + }); + } + + if (!draggableIsField(result)) { + document.body.classList.remove(IS_DRAGGING_CLASS_NAME); + } + } return ( - + {children} ); + }, + (prevProps, nextProps) => { + return ( + prevProps.children === nextProps.children && + prevProps.dataProviders === nextProps.dataProviders + ); // prevent re-renders when data providers are added or removed, but all other props are the same } +); - private onDragEnd: (result: DropResult, provided: ResponderProvided) => void = ( - result: DropResult - ) => { - const { browserFields, dataProviders, dispatch } = this.props; - - enableScrolling(); - - if (dataProviders != null) { - onDragEndHandler({ - browserFields, - result, - dataProviders, - dispatch, - }); - } - - if (!draggableIsField(result)) { - document.body.classList.remove(IS_DRAGGING_CLASS_NAME); - } - }; -} +DragDropContextWrapperComponent.displayName = 'DragDropContextWrapperComponent'; const emptyDataProviders: dragAndDropModel.IdToDataProvider = {}; // stable reference diff --git a/x-pack/legacy/plugins/siem/public/components/drag_and_drop/draggable_wrapper.tsx b/x-pack/legacy/plugins/siem/public/components/drag_and_drop/draggable_wrapper.tsx index 0755ef0e5592c..8a12a5035fc3a 100644 --- a/x-pack/legacy/plugins/siem/public/components/drag_and_drop/draggable_wrapper.tsx +++ b/x-pack/legacy/plugins/siem/public/components/drag_and_drop/draggable_wrapper.tsx @@ -5,7 +5,7 @@ */ import { isEqual } from 'lodash/fp'; -import * as React from 'react'; +import React, { useEffect } from 'react'; import { Draggable, DraggableProvided, @@ -161,28 +161,15 @@ type Props = OwnProps & DispatchProps; * Wraps a draggable component to handle registration / unregistration of the * data provider associated with the item being dropped */ -class DraggableWrapperComponent extends React.Component { - public shouldComponentUpdate = ({ dataProvider, render, truncate }: Props) => - isEqual(dataProvider, this.props.dataProvider) && - render !== this.props.render && - truncate === this.props.truncate - ? false - : true; - - 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, truncate } = this.props; +const DraggableWrapperComponent = React.memo( + ({ dataProvider, registerProvider, render, truncate, unRegisterProvider }) => { + useEffect(() => { + registerProvider!({ provider: dataProvider }); + return () => { + unRegisterProvider!({ id: dataProvider.id }); + }; + }, []); return ( @@ -223,8 +210,17 @@ class DraggableWrapperComponent extends React.Component { ); + }, + (prevProps, nextProps) => { + return ( + isEqual(prevProps.dataProvider, nextProps.dataProvider) && + prevProps.render !== nextProps.render && + prevProps.truncate === nextProps.truncate + ); } -} +); + +DraggableWrapperComponent.displayName = 'DraggableWrapperComponent'; export const DraggableWrapper = connect( null, diff --git a/x-pack/legacy/plugins/siem/public/components/edit_data_provider/index.tsx b/x-pack/legacy/plugins/siem/public/components/edit_data_provider/index.tsx index 10b4340b6a88d..dc7f2185c26b7 100644 --- a/x-pack/legacy/plugins/siem/public/components/edit_data_provider/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/edit_data_provider/index.tsx @@ -9,15 +9,15 @@ import { EuiButton, EuiComboBox, EuiComboBoxOptionProps, + EuiFieldText, EuiFlexGroup, EuiFlexItem, - EuiFieldText, EuiFormRow, EuiPanel, EuiSpacer, EuiToolTip, } from '@elastic/eui'; -import * as React from 'react'; +import React, { useEffect, useState } from 'react'; import styled, { injectGlobal } from 'styled-components'; import { BrowserFields } from '../../containers/source'; @@ -37,8 +37,8 @@ import * as i18n from './translations'; const EDIT_DATA_PROVIDER_WIDTH = 400; const FIELD_COMBO_BOX_WIDTH = 195; const OPERATOR_COMBO_BOX_WIDTH = 160; -const VALUE_INPUT_CLASS_NAME = 'edit-data-provider-value'; const SAVE_CLASS_NAME = 'edit-data-provider-save'; +const VALUE_INPUT_CLASS_NAME = 'edit-data-provider-value'; export const HeaderContainer = styled.div` width: ${EDIT_DATA_PROVIDER_WIDTH}; @@ -68,12 +68,6 @@ interface Props { value: string | number; } -interface State { - updatedField: EuiComboBoxOptionProps[]; - updatedOperator: EuiComboBoxOptionProps[]; - updatedValue: string | number; -} - const sanatizeValue = (value: string | number): string => Array.isArray(value) ? `${value[0]}` : `${value}`; // fun fact: value should never be an array @@ -88,37 +82,80 @@ export const getInitialOperatorLabel = ( } }; -export class StatefulEditDataProvider extends React.PureComponent { - constructor(props: Props) { - super(props); +export const StatefulEditDataProvider = React.memo( + ({ + andProviderId, + browserFields, + field, + isExcluded, + onDataProviderEdited, + operator, + providerId, + timelineId, + value, + }) => { + const [updatedField, setUpdatedField] = useState([{ label: field }]); + const [updatedOperator, setUpdatedOperator] = useState( + getInitialOperatorLabel(isExcluded, operator) + ); + const [updatedValue, setUpdatedValue] = useState(value); - const { field, isExcluded, operator, value } = props; + /** Focuses the Value input if it is visible, falling back to the Save button if it's not */ + function focusInput() { + const elements = document.getElementsByClassName(VALUE_INPUT_CLASS_NAME); - this.state = { - updatedField: [{ label: field }], - updatedOperator: getInitialOperatorLabel(isExcluded, operator), - updatedValue: value, - }; - } + if (elements.length > 0) { + (elements[0] as HTMLElement).focus(); // this cast is required because focus() does not exist on every `Element` returned by `getElementsByClassName` + } else { + const saveElements = document.getElementsByClassName(SAVE_CLASS_NAME); - public componentDidMount() { - this.disableScrolling(); - this.focusInput(); - } + if (saveElements.length > 0) { + (saveElements[0] as HTMLElement).focus(); + } + } + } - public componentWillUnmount() { - this.enableScrolling(); - } + function onFieldSelected(selectedField: EuiComboBoxOptionProps[]) { + setUpdatedField(selectedField); + + focusInput(); + } + + function onOperatorSelected(operatorSelected: EuiComboBoxOptionProps[]) { + setUpdatedOperator(operatorSelected); + + focusInput(); + } + + function onValueChange(e: React.ChangeEvent) { + setUpdatedValue(e.target.value); + } + + function disableScrolling() { + const x = + window.pageXOffset !== undefined + ? window.pageXOffset + : (document.documentElement || document.body.parentNode || document.body).scrollLeft; - public render() { - const { - andProviderId, - browserFields, - onDataProviderEdited, - providerId, - timelineId, - } = this.props; - const { updatedField, updatedOperator, updatedValue } = this.state; + const y = + window.pageYOffset !== undefined + ? window.pageYOffset + : (document.documentElement || document.body.parentNode || document.body).scrollTop; + + window.onscroll = () => window.scrollTo(x, y); + } + + function enableScrolling() { + window.onscroll = () => noop; + } + + useEffect(() => { + disableScrolling(); + focusInput(); + return () => { + enableScrolling(); + }; + }, []); return ( @@ -127,18 +164,14 @@ export class StatefulEditDataProvider extends React.PureComponent - 0 ? this.state.updatedField[0].label : null - } - > + 0 ? updatedField[0].label : null}> @@ -151,10 +184,10 @@ export class StatefulEditDataProvider extends React.PureComponent @@ -167,17 +200,17 @@ export class StatefulEditDataProvider extends React.PureComponent - {this.state.updatedOperator.length > 0 && - this.state.updatedOperator[0].label !== i18n.EXISTS && - this.state.updatedOperator[0].label !== i18n.DOES_NOT_EXIST ? ( + {updatedOperator.length > 0 && + updatedOperator[0].label !== i18n.EXISTS && + updatedOperator[0].label !== i18n.DOES_NOT_EXIST ? ( @@ -196,6 +229,13 @@ export class StatefulEditDataProvider extends React.PureComponent color="primary" data-test-subj="save" fill={true} + isDisabled={ + !selectionsAreValid({ + browserFields, + selectedField: updatedField, + selectedOperator: updatedOperator, + }) + } onClick={() => { onDataProviderEdited({ andProviderId, @@ -207,13 +247,6 @@ export class StatefulEditDataProvider extends React.PureComponent value: updatedValue, }); }} - isDisabled={ - !selectionsAreValid({ - browserFields: this.props.browserFields, - selectedField: updatedField, - selectedOperator: updatedOperator, - }) - } size="s" > {i18n.SAVE} @@ -225,53 +258,6 @@ export class StatefulEditDataProvider extends React.PureComponent ); } +); - /** Focuses the Value input if it is visible, falling back to the Save button if it's not */ - private focusInput = () => { - const elements = document.getElementsByClassName(VALUE_INPUT_CLASS_NAME); - - if (elements.length > 0) { - (elements[0] as HTMLElement).focus(); // this cast is required because focus() does not exist on every `Element` returned by `getElementsByClassName` - } else { - const saveElements = document.getElementsByClassName(SAVE_CLASS_NAME); - - if (saveElements.length > 0) { - (saveElements[0] as HTMLElement).focus(); - } - } - }; - - private onFieldSelected = (selectedField: EuiComboBoxOptionProps[]) => { - this.setState({ updatedField: selectedField }); - - this.focusInput(); - }; - - private onOperatorSelected = (updatedOperator: EuiComboBoxOptionProps[]) => { - this.setState({ updatedOperator }); - - this.focusInput(); - }; - - private onValueChange = (e: React.ChangeEvent) => { - this.setState({ - updatedValue: e.target.value, - }); - }; - - private 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); - }; - - private enableScrolling = () => (window.onscroll = () => noop); -} +StatefulEditDataProvider.displayName = 'StatefulEditDataProvider'; diff --git a/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map.tsx b/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map.tsx index 86696503dbda3..18040a35a5280 100644 --- a/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map.tsx +++ b/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map.tsx @@ -43,7 +43,7 @@ export interface EmbeddedMapProps { } export const EmbeddedMap = React.memo( - ({ applyFilterQueryFromKueryExpression, queryExpression, startDate, endDate, setQuery }) => { + ({ applyFilterQueryFromKueryExpression, endDate, queryExpression, setQuery, startDate }) => { const [embeddable, setEmbeddable] = React.useState(null); const [isLoading, setIsLoading] = useState(true); const [isError, setIsError] = useState(false); diff --git a/x-pack/legacy/plugins/siem/public/components/event_details/stateful_event_details.tsx b/x-pack/legacy/plugins/siem/public/components/event_details/stateful_event_details.tsx index ec76d8f90c3de..cb67736829878 100644 --- a/x-pack/legacy/plugins/siem/public/components/event_details/stateful_event_details.tsx +++ b/x-pack/legacy/plugins/siem/public/components/event_details/stateful_event_details.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import * as React from 'react'; +import React, { useState } from 'react'; import { BrowserFields } from '../../containers/source'; import { ColumnHeader } from '../timeline/body/column_headers/column_header'; @@ -23,43 +23,24 @@ interface Props { toggleColumn: (column: ColumnHeader) => void; } -interface State { - view: View; -} - -export class StatefulEventDetails extends React.PureComponent { - constructor(props: Props) { - super(props); - - this.state = { view: 'table-view' }; - } +export const StatefulEventDetails = React.memo( + ({ browserFields, columnHeaders, data, id, onUpdateColumns, timelineId, toggleColumn }) => { + const [view, setView] = useState('table-view'); - public onViewSelected = (view: View): void => { - this.setState({ view }); - }; - - public render() { - const { - browserFields, - columnHeaders, - data, - id, - onUpdateColumns, - timelineId, - toggleColumn, - } = this.props; return ( setView(newView)} timelineId={timelineId} toggleColumn={toggleColumn} + view={view} /> ); } -} +); + +StatefulEventDetails.displayName = 'StatefulEventDetails'; diff --git a/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.test.tsx b/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.test.tsx index 03fb37760bc35..d85231b564da8 100644 --- a/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.test.tsx @@ -20,8 +20,17 @@ jest.mock('../../lib/settings/use_kibana_ui_setting'); const from = 1566943856794; const to = 1566857456791; - +// Suppress warnings about "act" until async/await syntax is supported: https://github.com/facebook/react/issues/14769 +/* eslint-disable no-console */ +const originalError = console.error; describe('EventsViewer', () => { + beforeAll(() => { + console.error = jest.fn(); + }); + + afterAll(() => { + console.error = originalError; + }); test('it renders the "Showing..." subtitle with the expected event count', async () => { const wrapper = mount( diff --git a/x-pack/legacy/plugins/siem/public/components/events_viewer/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/events_viewer/index.test.tsx index bef5e66faecd1..dc0e1288f40f8 100644 --- a/x-pack/legacy/plugins/siem/public/components/events_viewer/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/events_viewer/index.test.tsx @@ -20,7 +20,17 @@ jest.mock('../../lib/settings/use_kibana_ui_setting'); const from = 1566943856794; const to = 1566857456791; +// Suppress warnings about "act" until async/await syntax is supported: https://github.com/facebook/react/issues/14769 +/* eslint-disable no-console */ +const originalError = console.error; describe('StatefulEventsViewer', () => { + beforeAll(() => { + console.error = jest.fn(); + }); + + afterAll(() => { + console.error = originalError; + }); test('it renders the events viewer', async () => { const wrapper = mount( diff --git a/x-pack/legacy/plugins/siem/public/components/events_viewer/index.tsx b/x-pack/legacy/plugins/siem/public/components/events_viewer/index.tsx index 52b724525f5a9..d572d6dd4913b 100644 --- a/x-pack/legacy/plugins/siem/public/components/events_viewer/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/events_viewer/index.tsx @@ -87,7 +87,7 @@ const StatefulEventsViewerComponent = React.memo( updateItemsPerPage, upsertColumn, }) => { - const [showInspect, setShowInspect] = useState(false); + const [showInspect, setShowInspect] = useState(false); useEffect(() => { if (createTimeline != null) { diff --git a/x-pack/legacy/plugins/siem/public/components/fields_browser/field_browser.tsx b/x-pack/legacy/plugins/siem/public/components/fields_browser/field_browser.tsx index 17785ff582a3c..fb47672512de5 100644 --- a/x-pack/legacy/plugins/siem/public/components/fields_browser/field_browser.tsx +++ b/x-pack/legacy/plugins/siem/public/components/fields_browser/field_browser.tsx @@ -5,8 +5,8 @@ */ import { EuiFlexGroup, EuiFlexItem, EuiOutsideClickDetector } from '@elastic/eui'; +import React, { useEffect } from 'react'; import { noop } from 'lodash/fp'; -import * as React from 'react'; import styled, { css } from 'styled-components'; import { BrowserFields } from '../../containers/source'; @@ -22,7 +22,7 @@ import { getFieldBrowserSearchInputClassName, PANES_FLEX_GROUP_WIDTH, } from './helpers'; -import { FieldBrowserProps, OnFieldSelected, OnHideFieldBrowser } from './types'; +import { FieldBrowserProps, OnHideFieldBrowser } from './types'; const FieldsBrowserContainer = styled.div<{ width: number }>` ${({ theme, width }) => css` @@ -102,34 +102,80 @@ type Props = Pick< * This component has no internal state, but it uses lifecycle methods to * set focus to the search input, scroll to the selected category, etc */ -export class FieldsBrowser extends React.PureComponent { - public componentDidMount() { - this.scrollViews(); - this.focusInput(); - } +export const FieldsBrowser = React.memo( + ({ + browserFields, + columnHeaders, + filteredBrowserFields, + isEventViewer, + isSearching, + onCategorySelected, + onFieldSelected, + onHideFieldBrowser, + onSearchInputChange, + onOutsideClick, + onUpdateColumns, + searchInput, + selectedCategoryId, + timelineId, + toggleColumn, + width, + }) => { + /** Focuses the input that filters the field browser */ + function focusInput() { + const elements = document.getElementsByClassName( + getFieldBrowserSearchInputClassName(timelineId) + ); - public componentDidUpdate() { - this.scrollViews(); - this.focusInput(); // always re-focus the input to enable additional filtering - } + if (elements.length > 0) { + (elements[0] as HTMLElement).focus(); // this cast is required because focus() does not exist on every `Element` returned by `getElementsByClassName` + } + } + + /** Invoked when the user types in the input to filter the field browser */ + function onInputChange(event: React.ChangeEvent) { + onSearchInputChange(event.target.value); + } + + function selectFieldAndHide(fieldId: string) { + if (onFieldSelected != null) { + onFieldSelected(fieldId); + } - public render() { - const { - columnHeaders, - browserFields, - filteredBrowserFields, - searchInput, - isEventViewer, - isSearching, - onCategorySelected, - onFieldSelected, - onOutsideClick, - onUpdateColumns, - selectedCategoryId, - timelineId, - toggleColumn, - width, - } = this.props; + onHideFieldBrowser(); + } + + function scrollViews() { + if (selectedCategoryId !== '') { + const categoryPaneTitles = document.getElementsByClassName( + getCategoryPaneCategoryClassName({ + categoryId: selectedCategoryId, + timelineId, + }) + ); + + if (categoryPaneTitles.length > 0) { + categoryPaneTitles[0].scrollIntoView(); + } + + const fieldPaneTitles = document.getElementsByClassName( + getFieldBrowserCategoryTitleClassName({ + categoryId: selectedCategoryId, + timelineId, + }) + ); + + if (fieldPaneTitles.length > 0) { + fieldPaneTitles[0].scrollIntoView(); + } + } + + focusInput(); // always re-focus the input to enable additional filtering + } + + useEffect(() => { + scrollViews(); + }, [selectedCategoryId, timelineId]); return ( { isEventViewer={isEventViewer} isSearching={isSearching} onOutsideClick={onOutsideClick} - onSearchInputChange={this.onInputChange} + onSearchInputChange={onInputChange} onUpdateColumns={onUpdateColumns} searchInput={searchInput} timelineId={timelineId} @@ -170,7 +216,7 @@ export class FieldsBrowser extends React.PureComponent { data-test-subj="fields-pane" filteredBrowserFields={filteredBrowserFields} onCategorySelected={onCategorySelected} - onFieldSelected={this.selectFieldAndHide} + onFieldSelected={selectFieldAndHide} onUpdateColumns={onUpdateColumns} searchInput={searchInput} selectedCategoryId={selectedCategoryId} @@ -184,59 +230,4 @@ export class FieldsBrowser extends React.PureComponent { ); } - - /** Focuses the input that filters the field browser */ - private focusInput = () => { - const elements = document.getElementsByClassName( - getFieldBrowserSearchInputClassName(this.props.timelineId) - ); - - if (elements.length > 0) { - (elements[0] as HTMLElement).focus(); // this cast is required because focus() does not exist on every `Element` returned by `getElementsByClassName` - } - }; - - /** Invoked when the user types in the input to filter the field browser */ - private onInputChange = (event: React.ChangeEvent) => - this.props.onSearchInputChange(event.target.value); - - private selectFieldAndHide: OnFieldSelected = (fieldId: string) => { - const { onFieldSelected, onHideFieldBrowser } = this.props; - - if (onFieldSelected != null) { - onFieldSelected(fieldId); - } - - onHideFieldBrowser(); - }; - - private scrollViews = () => { - const { selectedCategoryId, timelineId } = this.props; - - if (this.props.selectedCategoryId !== '') { - const categoryPaneTitles = document.getElementsByClassName( - getCategoryPaneCategoryClassName({ - categoryId: selectedCategoryId, - timelineId, - }) - ); - - if (categoryPaneTitles.length > 0) { - categoryPaneTitles[0].scrollIntoView(); - } - - const fieldPaneTitles = document.getElementsByClassName( - getFieldBrowserCategoryTitleClassName({ - categoryId: selectedCategoryId, - timelineId, - }) - ); - - if (fieldPaneTitles.length > 0) { - fieldPaneTitles[0].scrollIntoView(); - } - } - - this.focusInput(); // always re-focus the input to enable additional filtering - }; -} +); diff --git a/x-pack/legacy/plugins/siem/public/components/fields_browser/index.tsx b/x-pack/legacy/plugins/siem/public/components/fields_browser/index.tsx index 69720c76cab80..7d21e1f44d04b 100644 --- a/x-pack/legacy/plugins/siem/public/components/fields_browser/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/fields_browser/index.tsx @@ -6,7 +6,7 @@ import { EuiButtonEmpty, EuiButtonIcon, EuiToolTip } from '@elastic/eui'; import { noop } from 'lodash/fp'; -import * as React from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { connect } from 'react-redux'; import styled from 'styled-components'; import { ActionCreator } from 'typescript-fsa'; @@ -15,7 +15,6 @@ import { BrowserFields } from '../../containers/source'; import { timelineActions } from '../../store/actions'; import { ColumnHeader } from '../timeline/body/column_headers/column_header'; import { DEFAULT_CATEGORY_NAME } from '../timeline/body/column_headers/default_headers'; -import { OnUpdateColumns } from '../timeline/events'; import { FieldsBrowser } from './field_browser'; import { filterBrowserFieldsByFieldName, mergeBrowserFieldsWithDefaultCategory } from './helpers'; import * as i18n from './translations'; @@ -26,19 +25,6 @@ const fieldsButtonClassName = 'fields-button'; /** wait this many ms after the user completes typing before applying the filter input */ const INPUT_TIMEOUT = 250; -interface State { - /** all field names shown in the field browser must contain this string (when specified) */ - filterInput: string; - /** all fields in this collection have field names that match the filterInput */ - filteredBrowserFields: BrowserFields | null; - /** when true, show a spinner in the input to indicate the field browser is searching for matching field names */ - isSearching: boolean; - /** this category will be displayed in the right-hand pane of the field browser */ - selectedCategoryId: string; - /** show the field browser */ - show: boolean; -} - const FieldsBrowserButtonContainer = styled.div` position: relative; `; @@ -60,52 +46,110 @@ interface DispatchProps { /** * Manages the state of the field browser */ -export class StatefulFieldsBrowserComponent extends React.PureComponent< - FieldBrowserProps & DispatchProps, - State -> { - /** tracks the latest timeout id from `setTimeout`*/ - private inputTimeoutId: number = 0; - - constructor(props: FieldBrowserProps) { - super(props); - - this.state = { - filterInput: '', - filteredBrowserFields: null, - isSearching: false, - selectedCategoryId: DEFAULT_CATEGORY_NAME, - show: false, - }; - } +export const StatefulFieldsBrowserComponent = React.memo( + ({ + columnHeaders, + browserFields, + height, + isEventViewer = false, + onFieldSelected, + onUpdateColumns, + timelineId, + toggleColumn, + width, + }) => { + /** tracks the latest timeout id from `setTimeout`*/ + const inputTimeoutId = useRef(0); + + /** all field names shown in the field browser must contain this string (when specified) */ + const [filterInput, setFilterInput] = useState(''); + /** all fields in this collection have field names that match the filterInput */ + const [filteredBrowserFields, setFilteredBrowserFields] = useState(null); + /** when true, show a spinner in the input to indicate the field browser is searching for matching field names */ + const [isSearching, setIsSearching] = useState(false); + /** this category will be displayed in the right-hand pane of the field browser */ + const [selectedCategoryId, setSelectedCategoryId] = useState(DEFAULT_CATEGORY_NAME); + /** show the field browser */ + const [show, setShow] = useState(false); + useEffect(() => { + return () => { + if (inputTimeoutId.current !== 0) { + // ⚠️ mutation: cancel any remaining timers and zero-out the timer id: + clearTimeout(inputTimeoutId.current); + inputTimeoutId.current = 0; + } + }; + }, []); + + /** Shows / hides the field browser */ + function toggleShow() { + setShow(!show); + } + + /** Invoked when the user types in the filter input */ + function updateFilter(newFilterInput: string) { + setFilterInput(newFilterInput); + setIsSearching(true); + + if (inputTimeoutId.current !== 0) { + clearTimeout(inputTimeoutId.current); // ⚠️ mutation: cancel any previous timers + } + + // ⚠️ mutation: schedule a new timer that will apply the filter when it fires: + inputTimeoutId.current = window.setTimeout(() => { + const newFilteredBrowserFields = filterBrowserFieldsByFieldName({ + browserFields: mergeBrowserFieldsWithDefaultCategory(browserFields), + substring: filterInput, + }); + + setFilteredBrowserFields(newFilteredBrowserFields); + setIsSearching(false); + + const newSelectedCategoryId = + filterInput === '' || Object.keys(newFilteredBrowserFields).length === 0 + ? DEFAULT_CATEGORY_NAME + : Object.keys(newFilteredBrowserFields) + .sort() + .reduce( + (selected, category) => + newFilteredBrowserFields[category].fields != null && + newFilteredBrowserFields[selected].fields != null && + newFilteredBrowserFields[category].fields!.length > + newFilteredBrowserFields[selected].fields!.length + ? category + : selected, + Object.keys(newFilteredBrowserFields)[0] + ); + setSelectedCategoryId(newSelectedCategoryId); + }, INPUT_TIMEOUT); + } - public componentWillUnmount() { - if (this.inputTimeoutId !== 0) { - // ⚠️ mutation: cancel any remaining timers and zero-out the timer id: - clearTimeout(this.inputTimeoutId); - this.inputTimeoutId = 0; + /** + * Invoked when the user clicks a category name in the left-hand side of + * the field browser + */ + function updateSelectedCategoryId(categoryId: string) { + setSelectedCategoryId(categoryId); } - } - public render() { - const { - columnHeaders, - browserFields, - height, - isEventViewer = false, - onFieldSelected, - timelineId, - toggleColumn, - width, - } = this.props; - const { - filterInput, - filteredBrowserFields, - isSearching, - selectedCategoryId, - show, - } = this.state; + /** + * Invoked when the user clicks on the context menu to view a category's + * columns in the timeline, this function dispatches the action that + * causes the timeline display those columns. + */ + function updateColumnsAndSelectCategoryId(columns: ColumnHeader[]) { + onUpdateColumns(columns); // show the category columns in the timeline + } + /** Invoked when the field browser should be hidden */ + function hideFieldBrowser() { + setFilterInput(''); + setFilterInput(''); + setFilteredBrowserFields(null); + setIsSearching(false); + setSelectedCategoryId(DEFAULT_CATEGORY_NAME); + setShow(false); + } // only merge in the default category if the field browser is visible const browserFieldsWithDefaultCategory = show ? mergeBrowserFieldsWithDefaultCategory(browserFields) @@ -121,14 +165,14 @@ export class StatefulFieldsBrowserComponent extends React.PureComponent< className={fieldsButtonClassName} data-test-subj="show-field-browser-gear" iconType="list" - onClick={this.toggleShow} + onClick={toggleShow} /> ) : ( {i18n.FIELDS} @@ -148,12 +192,12 @@ export class StatefulFieldsBrowserComponent extends React.PureComponent< height={height} isEventViewer={isEventViewer} isSearching={isSearching} - onCategorySelected={this.updateSelectedCategoryId} + onCategorySelected={updateSelectedCategoryId} onFieldSelected={onFieldSelected} - onHideFieldBrowser={this.hideFieldBrowser} - onOutsideClick={show ? this.hideFieldBrowser : noop} - onSearchInputChange={this.updateFilter} - onUpdateColumns={this.updateColumnsAndSelectCategoryId} + onHideFieldBrowser={hideFieldBrowser} + onOutsideClick={show ? hideFieldBrowser : noop} + onSearchInputChange={updateFilter} + onUpdateColumns={updateColumnsAndSelectCategoryId} searchInput={filterInput} selectedCategoryId={selectedCategoryId} timelineId={timelineId} @@ -165,84 +209,9 @@ export class StatefulFieldsBrowserComponent extends React.PureComponent< ); } +); - /** Shows / hides the field browser */ - private toggleShow = () => { - this.setState(({ show }) => ({ - show: !show, - })); - }; - - /** Invoked when the user types in the filter input */ - private updateFilter = (filterInput: string): void => { - this.setState({ - filterInput, - isSearching: true, - }); - - if (this.inputTimeoutId !== 0) { - clearTimeout(this.inputTimeoutId); // ⚠️ mutation: cancel any previous timers - } - - // ⚠️ mutation: schedule a new timer that will apply the filter when it fires: - this.inputTimeoutId = window.setTimeout(() => { - const filteredBrowserFields = filterBrowserFieldsByFieldName({ - browserFields: mergeBrowserFieldsWithDefaultCategory(this.props.browserFields), - substring: this.state.filterInput, - }); - - this.setState(currentState => ({ - filteredBrowserFields, - isSearching: false, - selectedCategoryId: - currentState.filterInput === '' || Object.keys(filteredBrowserFields).length === 0 - ? DEFAULT_CATEGORY_NAME - : Object.keys(filteredBrowserFields) - .sort() - .reduce( - (selected, category) => - filteredBrowserFields[category].fields != null && - filteredBrowserFields[selected].fields != null && - filteredBrowserFields[category].fields!.length > - filteredBrowserFields[selected].fields!.length - ? category - : selected, - Object.keys(filteredBrowserFields)[0] - ), - })); - }, INPUT_TIMEOUT); - }; - - /** - * Invoked when the user clicks a category name in the left-hand side of - * the field browser - */ - private updateSelectedCategoryId = (categoryId: string): void => { - this.setState({ - selectedCategoryId: categoryId, - }); - }; - - /** - * Invoked when the user clicks on the context menu to view a category's - * columns in the timeline, this function dispatches the action that - * causes the timeline display those columns. - */ - private updateColumnsAndSelectCategoryId: OnUpdateColumns = (columns: ColumnHeader[]): void => { - this.props.onUpdateColumns(columns); // show the category columns in the timeline - }; - - /** Invoked when the field browser should be hidden */ - private hideFieldBrowser = () => { - this.setState({ - filterInput: '', - filteredBrowserFields: null, - isSearching: false, - selectedCategoryId: DEFAULT_CATEGORY_NAME, - show: false, - }); - }; -} +StatefulFieldsBrowserComponent.displayName = 'StatefulFieldsBrowserComponent'; export const StatefulFieldsBrowser = connect( null, diff --git a/x-pack/legacy/plugins/siem/public/components/flyout/pane/index.tsx b/x-pack/legacy/plugins/siem/public/components/flyout/pane/index.tsx index 15ce42c6a16b6..ceaff289f776c 100644 --- a/x-pack/legacy/plugins/siem/public/components/flyout/pane/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/flyout/pane/index.tsx @@ -111,18 +111,30 @@ const FlyoutHeaderWithCloseButton = React.memo<{ FlyoutHeaderWithCloseButton.displayName = 'FlyoutHeaderWithCloseButton'; -class FlyoutPaneComponent extends React.PureComponent { - public render() { - const { - children, - flyoutHeight, - headerHeight, - onClose, - timelineId, - usersViewing, - width, - } = this.props; - +const FlyoutPaneComponent = React.memo( + ({ + applyDeltaToWidth, + children, + flyoutHeight, + headerHeight, + onClose, + timelineId, + usersViewing, + width, + }) => { + const renderFlyout = () => <>; + + const onResize: OnResize = ({ delta, id }) => { + const bodyClientWidthPixels = document.body.clientWidth; + + applyDeltaToWidth({ + bodyClientWidthPixels, + delta, + id, + maxWidthPercent, + minWidthPixels, + }); + }; return ( { } id={timelineId} - onResize={this.onResize} - render={this.renderFlyout} + onResize={onResize} + render={renderFlyout} /> { ); } +); - private renderFlyout = () => <>; - - private onResize: OnResize = ({ delta, id }) => { - const { applyDeltaToWidth } = this.props; - - const bodyClientWidthPixels = document.body.clientWidth; - - applyDeltaToWidth({ - bodyClientWidthPixels, - delta, - id, - maxWidthPercent, - minWidthPixels, - }); - }; -} +FlyoutPaneComponent.displayName = 'FlyoutPaneComponent'; export const Pane = connect( null, diff --git a/x-pack/legacy/plugins/siem/public/components/help_menu/help_menu.tsx b/x-pack/legacy/plugins/siem/public/components/help_menu/help_menu.tsx index 90af0d56c1582..b59753e8add6a 100644 --- a/x-pack/legacy/plugins/siem/public/components/help_menu/help_menu.tsx +++ b/x-pack/legacy/plugins/siem/public/components/help_menu/help_menu.tsx @@ -16,30 +16,28 @@ export const Icon = styled(EuiIcon)` Icon.displayName = 'Icon'; -export class HelpMenuComponent extends React.PureComponent { - public render() { - return ( - <> - - - - - -
- - -
-
- - - - - - - - ); - } -} +export const HelpMenuComponent = React.memo(() => ( + <> + + + + + +
+ + +
+
+ + + + + + + +)); + +HelpMenuComponent.displayName = 'HelpMenuComponent'; diff --git a/x-pack/legacy/plugins/siem/public/components/lazy_accordion/index.tsx b/x-pack/legacy/plugins/siem/public/components/lazy_accordion/index.tsx index 5ed9a3b623c1c..da2e7334756e4 100644 --- a/x-pack/legacy/plugins/siem/public/components/lazy_accordion/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/lazy_accordion/index.tsx @@ -5,7 +5,7 @@ */ import { EuiAccordion, EuiAccordionProps } from '@elastic/eui'; -import * as React from 'react'; +import React, { useState } from 'react'; type Props = Pick> & { forceExpand?: boolean; @@ -14,10 +14,6 @@ type Props = Pick React.ReactNode; }; -interface State { - expanded: boolean; -} - /** * An accordion that doesn't render it's content unless it's expanded. * This component was created because `EuiAccordion`'s eager rendering of @@ -33,29 +29,36 @@ interface State { * TODO: animate the expansion and collapse of content rendered "below" * the real `EuiAccordion`. */ -export class LazyAccordion extends React.PureComponent { - constructor(props: Props) { - super(props); - - this.state = { - expanded: false, +export const LazyAccordion = React.memo( + ({ + buttonContent, + buttonContentClassName, + extraAction, + forceExpand, + id, + onCollapse, + onExpand, + paddingSize, + renderExpandedContent, + }) => { + const [expanded, setExpanded] = useState(false); + const onCollapsedClick = () => { + setExpanded(true); + if (onExpand != null) { + onExpand(); + } }; - } - public render() { - const { - id, - buttonContentClassName, - buttonContent, - forceExpand, - extraAction, - renderExpandedContent, - paddingSize, - } = this.props; + const onExpandedClick = () => { + setExpanded(false); + if (onCollapse != null) { + onCollapse(); + } + }; return ( <> - {forceExpand || this.state.expanded ? ( + {forceExpand || expanded ? ( <> { extraAction={extraAction} id={id} initialIsOpen={true} - onClick={this.onExpandedClick} + onClick={onExpandedClick} paddingSize={paddingSize} > <> - {renderExpandedContent(this.state.expanded)} + {renderExpandedContent(expanded)} ) : ( { data-test-subj="lazy-accordion-placeholder" extraAction={extraAction} id={id} - onClick={this.onCollapsedClick} + onClick={onCollapsedClick} paddingSize={paddingSize} /> )} ); } +); - private onCollapsedClick = () => { - const { onExpand } = this.props; - - this.setState({ expanded: true }); - - if (onExpand != null) { - onExpand(); - } - }; - - private onExpandedClick = () => { - const { onCollapse } = this.props; - - this.setState({ expanded: false }); - - if (onCollapse != null) { - onCollapse(); - } - }; -} +LazyAccordion.displayName = 'LazyAccordion'; diff --git a/x-pack/legacy/plugins/siem/public/components/load_more_table/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/load_more_table/__snapshots__/index.test.tsx.snap deleted file mode 100644 index 4bf3f647502e2..0000000000000 --- a/x-pack/legacy/plugins/siem/public/components/load_more_table/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,104 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Load More Table Component rendering it renders the default load more table 1`] = ` - - My test supplement. -

- } - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={ - Array [ - Object { - "numberOfRow": 2, - "text": "2 rows", - }, - Object { - "numberOfRow": 5, - "text": "5 rows", - }, - Object { - "numberOfRow": 10, - "text": "10 rows", - }, - Object { - "numberOfRow": 20, - "text": "20 rows", - }, - Object { - "numberOfRow": 50, - "text": "50 rows", - }, - ] - } - limit={1} - loadMore={[Function]} - loading={false} - pageOfItems={ - Array [ - Object { - "cursor": Object { - "value": "98966fa2013c396155c460d35c0902be", - }, - "host": Object { - "_id": "cPsuhGcB0WOhS6qyTKC0", - "firstSeen": "2018-12-06T15:40:53.319Z", - "name": "elrond.elstc.co", - "os": "Ubuntu", - "version": "18.04.1 LTS (Bionic Beaver)", - }, - }, - Object { - "cursor": Object { - "value": "aa7ca589f1b8220002f2fc61c64cfbf1", - }, - "host": Object { - "_id": "KwQDiWcB0WOhS6qyXmrW", - "firstSeen": "2018-12-07T14:12:38.560Z", - "name": "siem-kibana", - "os": "Debian GNU/Linux", - "version": "9 (stretch)", - }, - }, - ] - } - updateLimitPagination={[Function]} -/> -`; diff --git a/x-pack/legacy/plugins/siem/public/components/load_more_table/index.mock.tsx b/x-pack/legacy/plugins/siem/public/components/load_more_table/index.mock.tsx deleted file mode 100644 index 02ec00a78bc91..0000000000000 --- a/x-pack/legacy/plugins/siem/public/components/load_more_table/index.mock.tsx +++ /dev/null @@ -1,118 +0,0 @@ -/* - * 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 { getOrEmptyTagFromValue } from '../empty_value'; - -import { Columns, ItemsPerRow } from './index'; - -export const mockData = { - Hosts: { - totalCount: 4, - edges: [ - { - host: { - _id: 'cPsuhGcB0WOhS6qyTKC0', - name: 'elrond.elstc.co', - os: 'Ubuntu', - version: '18.04.1 LTS (Bionic Beaver)', - firstSeen: '2018-12-06T15:40:53.319Z', - }, - cursor: { - value: '98966fa2013c396155c460d35c0902be', - }, - }, - { - host: { - _id: 'KwQDiWcB0WOhS6qyXmrW', - name: 'siem-kibana', - os: 'Debian GNU/Linux', - version: '9 (stretch)', - firstSeen: '2018-12-07T14:12:38.560Z', - }, - cursor: { - value: 'aa7ca589f1b8220002f2fc61c64cfbf1', - }, - }, - ], - pageInfo: { - endCursor: { - value: 'aa7ca589f1b8220002f2fc61c64cfbf1', - }, - hasNextPage: true, - }, - }, -}; - -export const getHostsColumns = (): [ - Columns, - Columns, - Columns, - Columns -] => [ - { - field: 'node.host.name', - name: 'Host', - truncateText: false, - hideForMobile: false, - render: (name: string) => getOrEmptyTagFromValue(name), - }, - { - field: 'node.host.firstSeen', - name: 'First seen', - truncateText: false, - hideForMobile: false, - render: (firstSeen: string) => getOrEmptyTagFromValue(firstSeen), - }, - { - field: 'node.host.os', - name: 'OS', - truncateText: false, - hideForMobile: false, - render: (os: string) => getOrEmptyTagFromValue(os), - }, - { - field: 'node.host.version', - name: 'Version', - truncateText: false, - hideForMobile: false, - render: (version: string) => getOrEmptyTagFromValue(version), - }, -]; - -export const sortedHosts: [ - Columns, - Columns, - Columns, - Columns -] = getHostsColumns().map(h => ({ ...h, sortable: true })) as [ - Columns, - Columns, - Columns, - Columns -]; - -export const rowItems: ItemsPerRow[] = [ - { - text: '2 rows', - numberOfRow: 2, - }, - { - text: '5 rows', - numberOfRow: 5, - }, - { - text: '10 rows', - numberOfRow: 10, - }, - { - text: '20 rows', - numberOfRow: 20, - }, - { - text: '50 rows', - numberOfRow: 50, - }, -]; diff --git a/x-pack/legacy/plugins/siem/public/components/load_more_table/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/load_more_table/index.test.tsx deleted file mode 100644 index 3c42d3d2acfe3..0000000000000 --- a/x-pack/legacy/plugins/siem/public/components/load_more_table/index.test.tsx +++ /dev/null @@ -1,360 +0,0 @@ -/* - * 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 { Direction } from '../../graphql/types'; - -import { LoadMoreTable } from './index'; -import { getHostsColumns, mockData, rowItems, sortedHosts } from './index.mock'; -import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; -import { ThemeProvider } from 'styled-components'; - -describe('Load More Table Component', () => { - const theme = () => ({ eui: euiDarkVars, darkMode: true }); - const loadMore = jest.fn(); - const updateLimitPagination = jest.fn(); - describe('rendering', () => { - test('it renders the default load more table', () => { - const wrapper = shallow( - - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={1} - loading={false} - loadMore={() => loadMore(mockData.Hosts.pageInfo.endCursor)} - pageOfItems={mockData.Hosts.edges} - updateLimitPagination={newlimit => updateLimitPagination({ limit: newlimit })} - /> -
- ); - - expect(toJson(wrapper)).toMatchSnapshot(); - }); - - test('it renders the loading panel at the beginning ', () => { - const wrapper = mount( - - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={1} - loading={true} - loadMore={() => loadMore(mockData.Hosts.pageInfo.endCursor)} - pageOfItems={[]} - updateLimitPagination={newlimit => updateLimitPagination({ limit: newlimit })} - /> -
- ); - - expect( - wrapper.find('[data-test-subj="initialLoadingPanelLoadMoreTable"]').exists() - ).toBeTruthy(); - }); - - test('it renders the over loading panel after data has been in the table ', () => { - const wrapper = mount( - - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={1} - loading={true} - loadMore={() => loadMore(mockData.Hosts.pageInfo.endCursor)} - pageOfItems={mockData.Hosts.edges} - updateLimitPagination={newlimit => updateLimitPagination({ limit: newlimit })} - /> -
- ); - - expect(wrapper.find('[data-test-subj="loadingPanelLoadMoreTable"]').exists()).toBeTruthy(); - }); - - test('it renders the loadMore button if need to fetch more', () => { - const wrapper = mount( - - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={1} - loading={false} - loadMore={() => loadMore(mockData.Hosts.pageInfo.endCursor)} - pageOfItems={mockData.Hosts.edges} - updateLimitPagination={newlimit => updateLimitPagination({ limit: newlimit })} - /> -
- ); - - expect( - wrapper - .find('[data-test-subj="loadingMoreButton"]') - .first() - .text() - ).toContain('Load more'); - }); - - test('it renders the Loading... in the more load button when fetching new data', () => { - const wrapper = mount( - - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={1} - loading={true} - loadMore={() => loadMore(mockData.Hosts.pageInfo.endCursor)} - pageOfItems={mockData.Hosts.edges} - updateLimitPagination={newlimit => updateLimitPagination({ limit: newlimit })} - /> -
- ); - - expect( - wrapper.find('[data-test-subj="initialLoadingPanelLoadMoreTable"]').exists() - ).toBeFalsy(); - expect( - wrapper - .find('[data-test-subj="loadingMoreButton"]') - .first() - .text() - ).toContain('Loading…'); - }); - - test('it does NOT render the loadMore button because there is nothing else to fetch', () => { - const wrapper = mount( - - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={2} - loading={false} - loadMore={() => loadMore(mockData.Hosts.pageInfo.endCursor)} - pageOfItems={mockData.Hosts.edges} - updateLimitPagination={newlimit => updateLimitPagination({ limit: newlimit })} - /> -
- ); - - expect(wrapper.find('[data-test-subj="loadingMoreButton"]').exists()).toBeFalsy(); - }); - - test('it render popover to select new limit in table', () => { - const wrapper = mount( - - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={2} - loading={false} - loadMore={() => loadMore(mockData.Hosts.pageInfo.endCursor)} - pageOfItems={mockData.Hosts.edges} - updateLimitPagination={newlimit => updateLimitPagination({ limit: newlimit })} - /> -
- ); - - wrapper - .find('[data-test-subj="loadingMoreSizeRowPopover"] button') - .first() - .simulate('click'); - expect(wrapper.find('[data-test-subj="loadingMorePickSizeRow"]').exists()).toBeTruthy(); - }); - - test('it will NOT render popover to select new limit in table if props itemsPerRow is empty', () => { - const wrapper = mount( - - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={[]} - limit={2} - loading={false} - loadMore={() => loadMore(mockData.Hosts.pageInfo.endCursor)} - pageOfItems={mockData.Hosts.edges} - updateLimitPagination={newlimit => updateLimitPagination({ limit: newlimit })} - /> -
- ); - - expect(wrapper.find('[data-test-subj="loadingMoreSizeRowPopover"]').exists()).toBeFalsy(); - }); - - test('It should render a sort icon if sorting is defined', () => { - const mockOnChange = jest.fn(); - const wrapper = mount( - - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={2} - loading={false} - loadMore={jest.fn()} - onChange={mockOnChange} - pageOfItems={mockData.Hosts.edges} - sorting={{ direction: Direction.asc, field: 'node.host.name' }} - updateLimitPagination={newlimit => updateLimitPagination({ limit: newlimit })} - /> -
- ); - - expect(wrapper.find('.euiTable thead tr th button svg')).toBeTruthy(); - }); - }); - - describe('Events', () => { - test('should call loadmore when clicking on the button load more', () => { - const wrapper = mount( - - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={1} - loading={false} - loadMore={() => loadMore(mockData.Hosts.pageInfo.endCursor)} - pageOfItems={mockData.Hosts.edges} - updateLimitPagination={newlimit => updateLimitPagination({ limit: newlimit })} - /> -
- ); - - wrapper - .find('[data-test-subj="loadingMoreButton"]') - .first() - .simulate('click'); - - expect(loadMore).toBeCalled(); - }); - - test('Should call updateLimitPagination when you pick a new limit', () => { - const wrapper = mount( - - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={2} - loading={false} - loadMore={() => loadMore(mockData.Hosts.pageInfo.endCursor)} - pageOfItems={mockData.Hosts.edges} - updateLimitPagination={newlimit => updateLimitPagination({ limit: newlimit })} - /> -
- ); - - wrapper - .find('[data-test-subj="loadingMoreSizeRowPopover"] button') - .first() - .simulate('click'); - - wrapper - .find('[data-test-subj="loadingMorePickSizeRow"] button') - .first() - .simulate('click'); - expect(updateLimitPagination).toBeCalled(); - }); - - test('Should call onChange when you choose a new sort in the table', () => { - const mockOnChange = jest.fn(); - const wrapper = mount( - - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={2} - loading={false} - loadMore={jest.fn()} - onChange={mockOnChange} - pageOfItems={mockData.Hosts.edges} - sorting={{ direction: Direction.asc, field: 'node.host.name' }} - updateLimitPagination={newlimit => updateLimitPagination({ limit: newlimit })} - /> -
- ); - - wrapper - .find('.euiTable thead tr th button') - .first() - .simulate('click'); - - expect(mockOnChange).toBeCalled(); - expect(mockOnChange.mock.calls[0]).toEqual([ - { page: undefined, sort: { direction: 'desc', field: 'node.host.name' } }, - ]); - }); - }); -}); diff --git a/x-pack/legacy/plugins/siem/public/components/load_more_table/index.tsx b/x-pack/legacy/plugins/siem/public/components/load_more_table/index.tsx deleted file mode 100644 index 0663246039cb8..0000000000000 --- a/x-pack/legacy/plugins/siem/public/components/load_more_table/index.tsx +++ /dev/null @@ -1,320 +0,0 @@ -/* - * 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 { - EuiBasicTable, - EuiButton, - EuiButtonEmpty, - EuiContextMenuItem, - EuiContextMenuPanel, - EuiFlexGroup, - EuiFlexItem, - EuiLoadingContent, - EuiPopover, -} from '@elastic/eui'; -import { isEmpty, noop } from 'lodash/fp'; -import React from 'react'; -import styled from 'styled-components'; - -import { Direction } from '../../graphql/types'; -import { HeaderPanel } from '../header_panel'; -import { Loader } from '../loader'; - -import * as i18n from './translations'; -import { Panel } from '../panel'; - -const DEFAULT_DATA_TEST_SUBJ = 'load-more-table'; - -export interface ItemsPerRow { - text: string; - numberOfRow: number; -} - -export interface SortingBasicTable { - field: string; - direction: Direction; - allowNeutralSort?: boolean; -} - -export interface Criteria { - page?: { index: number; size: number }; - sort?: SortingBasicTable; -} - -// Using telescoping templates to remove 'any' that was polluting downstream column type checks -interface BasicTableProps { - columns: - | [Columns] - | [Columns, Columns] - | [Columns, Columns, Columns] - | [Columns, Columns, Columns, Columns] - | [Columns, Columns, Columns, Columns, Columns] - | [Columns, Columns, Columns, Columns, Columns, Columns] - | [Columns, Columns, Columns, Columns, Columns, Columns, Columns] - | [ - Columns, - Columns, - Columns, - Columns, - Columns, - Columns, - Columns, - Columns - ] - | [ - Columns, - Columns, - Columns, - Columns, - Columns, - Columns, - Columns, - Columns, - Columns - ]; - hasNextPage: boolean; - dataTestSubj?: string; - headerCount: number; - headerSupplement?: React.ReactElement; - headerTitle: string | React.ReactElement; - headerTooltip?: string; - headerUnit: string | React.ReactElement; - id?: string; - itemsPerRow?: ItemsPerRow[]; - limit: number; - loading: boolean; - loadMore: () => void; - onChange?: (criteria: Criteria) => void; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - pageOfItems: any[]; - sorting?: SortingBasicTable; - updateLimitPagination: (limit: number) => void; -} - -interface BasicTableState { - loadingInitial: boolean; - isPopoverOpen: boolean; - showInspect: boolean; -} - -type Func = (arg: T) => string | number; - -export interface Columns { - field?: string; - align?: string; - name: string | React.ReactNode; - isMobileHeader?: boolean; - sortable?: boolean | Func; - truncateText?: boolean; - hideForMobile?: boolean; - render?: (item: T, node: U) => React.ReactNode; - width?: string; -} - -export class LoadMoreTable extends React.PureComponent< - BasicTableProps, - BasicTableState -> { - public readonly state = { - loadingInitial: this.props.headerCount === -1, - isPopoverOpen: false, - showInspect: false, - }; - - static getDerivedStateFromProps( - props: BasicTableProps, - state: BasicTableState - ) { - if (state.loadingInitial && props.headerCount >= 0) { - return { - ...state, - loadingInitial: false, - }; - } - return null; - } - - public render() { - const { - columns, - dataTestSubj = DEFAULT_DATA_TEST_SUBJ, - hasNextPage, - headerCount, - headerSupplement, - headerTitle, - headerTooltip, - headerUnit, - id, - itemsPerRow, - limit, - loading, - onChange = noop, - pageOfItems, - sorting = null, - updateLimitPagination, - } = this.props; - const { loadingInitial } = this.state; - - const button = ( - - {`${i18n.ROWS}: ${limit}`} - - ); - - const rowItems = - itemsPerRow && - itemsPerRow.map(item => ( - { - this.closePopover(); - updateLimitPagination(item.numberOfRow); - }} - > - {item.text} - - )); - - return ( - - = 0 ? headerCount.toLocaleString() : 0} ${headerUnit}` - } - title={headerTitle} - tooltip={headerTooltip} - > - {!loadingInitial && headerSupplement} - - - {loadingInitial ? ( - - ) : ( - <> - - - {hasNextPage && ( - - - {!isEmpty(itemsPerRow) && ( - - - - )} - - - - - {loading ? `${i18n.LOADING}` : i18n.LOAD_MORE} - - - - )} - - {loading && } - - )} - - ); - } - - private mouseEnter = () => { - this.setState(prevState => ({ - ...prevState, - showInspect: true, - })); - }; - - private mouseLeave = () => { - this.setState(prevState => ({ - ...prevState, - showInspect: false, - })); - }; - - private onButtonClick = () => { - this.setState(prevState => ({ - ...prevState, - isPopoverOpen: !prevState.isPopoverOpen, - })); - }; - - private closePopover = () => { - this.setState(prevState => ({ - ...prevState, - isPopoverOpen: false, - })); - }; -} - -const BasicTable = styled(EuiBasicTable)` - tbody { - th, - td { - vertical-align: top; - } - - .euiTableCellContent { - display: block; - } - } -`; - -BasicTable.displayName = 'BasicTable'; - -const FooterAction = styled(EuiFlexGroup).attrs({ - alignItems: 'center', - responsive: false, -})` - margin-top: ${props => props.theme.eui.euiSizeXS}; -`; - -FooterAction.displayName = 'FooterAction'; diff --git a/x-pack/legacy/plugins/siem/public/components/load_more_table/translations.ts b/x-pack/legacy/plugins/siem/public/components/load_more_table/translations.ts deleted file mode 100644 index ec093f9721624..0000000000000 --- a/x-pack/legacy/plugins/siem/public/components/load_more_table/translations.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* - * 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 LOADING = i18n.translate('xpack.siem.loadMoreTable.loadingButtonLabel', { - defaultMessage: 'Loading…', -}); - -export const LOAD_MORE = i18n.translate('xpack.siem.loadMoreTable.loadMoreButtonLabel', { - defaultMessage: 'Load more', -}); - -export const SHOWING = i18n.translate('xpack.siem.loadMoreTable.showingSubtitle', { - defaultMessage: 'Showing', -}); - -export const ROWS = i18n.translate('xpack.siem.loadMoreTable.rowsButtonLabel', { - defaultMessage: 'Rows per page', -}); diff --git a/x-pack/legacy/plugins/siem/public/components/ml/permissions/ml_capabilities_provider.tsx b/x-pack/legacy/plugins/siem/public/components/ml/permissions/ml_capabilities_provider.tsx index b2470bc0f5abd..0956e93829e5a 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml/permissions/ml_capabilities_provider.tsx +++ b/x-pack/legacy/plugins/siem/public/components/ml/permissions/ml_capabilities_provider.tsx @@ -21,7 +21,7 @@ export const MlCapabilitiesContext = React.createContext(emptyMl MlCapabilitiesContext.displayName = 'MlCapabilitiesContext'; export const MlCapabilitiesProvider = React.memo<{ children: JSX.Element }>(({ children }) => { - const [capabilities, setCapabilities] = useState(emptyMlCapabilities); + const [capabilities, setCapabilities] = useState(emptyMlCapabilities); const [, dispatchToaster] = useStateToaster(); const [kbnVersion] = useKibanaUiSetting(DEFAULT_KBN_VERSION); diff --git a/x-pack/legacy/plugins/siem/public/components/ml/tables/get_anomalies_host_table_columns.test.tsx b/x-pack/legacy/plugins/siem/public/components/ml/tables/get_anomalies_host_table_columns.test.tsx index 3a1fcbb653efe..04fed8e4fff3f 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml/tables/get_anomalies_host_table_columns.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/ml/tables/get_anomalies_host_table_columns.test.tsx @@ -8,7 +8,7 @@ import { getAnomaliesHostTableColumnsCurated } from './get_anomalies_host_table_ import { HostsType } from '../../../store/hosts/model'; import * as i18n from './translations'; import { AnomaliesByHost, Anomaly } from '../types'; -import { Columns } from '../../load_more_table'; +import { Columns } from '../../paginated_table'; import { TestProviders } from '../../../mock'; import { mount } from 'enzyme'; import React from 'react'; diff --git a/x-pack/legacy/plugins/siem/public/components/ml/tables/get_anomalies_host_table_columns.tsx b/x-pack/legacy/plugins/siem/public/components/ml/tables/get_anomalies_host_table_columns.tsx index f0cf2e5a6e662..6650449dd8200 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml/tables/get_anomalies_host_table_columns.tsx +++ b/x-pack/legacy/plugins/siem/public/components/ml/tables/get_anomalies_host_table_columns.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiLink } from '@elastic/eui'; import moment from 'moment'; -import { Columns } from '../../load_more_table'; +import { Columns } from '../../paginated_table'; import { AnomaliesByHost, Anomaly, NarrowDateRange } from '../types'; import { getRowItemDraggable } from '../../tables/helpers'; import { EntityDraggable } from '../entity_draggable'; diff --git a/x-pack/legacy/plugins/siem/public/components/ml/tables/get_anomalies_network_table_columns.test.tsx b/x-pack/legacy/plugins/siem/public/components/ml/tables/get_anomalies_network_table_columns.test.tsx index d063ed023bca6..768c7af8f4b2c 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml/tables/get_anomalies_network_table_columns.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/ml/tables/get_anomalies_network_table_columns.test.tsx @@ -8,7 +8,7 @@ import { getAnomaliesNetworkTableColumnsCurated } from './get_anomalies_network_ import { NetworkType } from '../../../store/network/model'; import * as i18n from './translations'; import { AnomaliesByNetwork, Anomaly } from '../types'; -import { Columns } from '../../load_more_table'; +import { Columns } from '../../paginated_table'; import { mount } from 'enzyme'; import React from 'react'; import { TestProviders } from '../../../mock'; diff --git a/x-pack/legacy/plugins/siem/public/components/ml/tables/get_anomalies_network_table_columns.tsx b/x-pack/legacy/plugins/siem/public/components/ml/tables/get_anomalies_network_table_columns.tsx index fb43175686e3d..1e1628fb077dd 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml/tables/get_anomalies_network_table_columns.tsx +++ b/x-pack/legacy/plugins/siem/public/components/ml/tables/get_anomalies_network_table_columns.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiLink } from '@elastic/eui'; import moment from 'moment'; -import { Columns } from '../../load_more_table'; +import { Columns } from '../../paginated_table'; import { Anomaly, NarrowDateRange, AnomaliesByNetwork } from '../types'; import { getRowItemDraggable } from '../../tables/helpers'; import { EntityDraggable } from '../entity_draggable'; diff --git a/x-pack/legacy/plugins/siem/public/components/navigation/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/navigation/index.test.tsx index 25ebb8ad89ecd..96cb85b246a49 100644 --- a/x-pack/legacy/plugins/siem/public/components/navigation/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/navigation/index.test.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { shallow } from 'enzyme'; +import { mount } from 'enzyme'; import * as React from 'react'; import { CONSTANTS } from '../url_state/constants'; @@ -63,7 +63,7 @@ describe('SIEM Navigation', () => { }, [CONSTANTS.timelineId]: '', }; - const wrapper = shallow(); + const wrapper = mount(); test('it calls setBreadcrumbs with correct path on mount', () => { expect(setBreadcrumbs).toHaveBeenNthCalledWith(1, { detailName: undefined, diff --git a/x-pack/legacy/plugins/siem/public/components/navigation/index.tsx b/x-pack/legacy/plugins/siem/public/components/navigation/index.tsx index 6c5cac1464e79..06f7a2ffb0566 100644 --- a/x-pack/legacy/plugins/siem/public/components/navigation/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/navigation/index.tsx @@ -5,7 +5,7 @@ */ import { isEqual } from 'lodash/fp'; -import React from 'react'; +import React, { useEffect } from 'react'; import { compose } from 'redux'; import { connect } from 'react-redux'; @@ -27,78 +27,23 @@ import { TabNavigation } from './tab_navigation'; import { TabNavigationProps } from './tab_navigation/types'; import { SiemNavigationComponentProps } from './types'; -export class SiemNavigationComponent extends React.Component { - public shouldComponentUpdate(nextProps: Readonly): boolean { - if ( - this.props.pathName === nextProps.pathName && - this.props.search === nextProps.search && - isEqual(this.props.hosts, nextProps.hosts) && - isEqual(this.props.hostDetails, nextProps.hostDetails) && - isEqual(this.props.network, nextProps.network) && - isEqual(this.props.navTabs, nextProps.navTabs) && - isEqual(this.props.timerange, nextProps.timerange) && - isEqual(this.props.timelineId, nextProps.timelineId) - ) { - return false; - } - return true; - } - - public componentWillMount(): void { - const { - detailName, - hosts, - hostDetails, - navTabs, - network, - pageName, - pathName, - search, - tabName, - timerange, - timelineId, - } = this.props; - if (pathName) { - setBreadcrumbs({ - detailName, - hosts, - hostDetails, - navTabs, - network, - pageName, - pathName, - search, - tabName, - timerange, - timelineId, - }); - } - } - - public componentWillReceiveProps(nextProps: Readonly): void { - if ( - this.props.pathName !== nextProps.pathName || - this.props.search !== nextProps.search || - !isEqual(this.props.hosts, nextProps.hosts) || - !isEqual(this.props.hostDetails, nextProps.hostDetails) || - !isEqual(this.props.network, nextProps.network) || - !isEqual(this.props.navTabs, nextProps.navTabs) || - !isEqual(this.props.timerange, nextProps.timerange) || - !isEqual(this.props.timelineId, nextProps.timelineId) - ) { - const { - detailName, - hosts, - hostDetails, - navTabs, - network, - pageName, - pathName, - search, - tabName, - timelineId, - timerange, - } = nextProps; +export const SiemNavigationComponent = React.memo( + ({ + detailName, + display, + hostDetails, + hosts, + navTabs, + network, + pageName, + pathName, + search, + showBorder, + tabName, + timelineId, + timerange, + }) => { + useEffect(() => { if (pathName) { setBreadcrumbs({ detailName, @@ -114,23 +59,8 @@ export class SiemNavigationComponent extends React.Component ); + }, + (prevProps, nextProps) => { + return ( + prevProps.pathName === nextProps.pathName && + prevProps.search === nextProps.search && + isEqual(prevProps.hosts, nextProps.hosts) && + isEqual(prevProps.hostDetails, nextProps.hostDetails) && + isEqual(prevProps.network, nextProps.network) && + isEqual(prevProps.navTabs, nextProps.navTabs) && + isEqual(prevProps.timerange, nextProps.timerange) && + isEqual(prevProps.timelineId, nextProps.timelineId) + ); } -} +); + +SiemNavigationComponent.displayName = 'SiemNavigationComponent'; const makeMapStateToProps = () => { const getInputsSelector = inputsSelectors.inputsSelector(); diff --git a/x-pack/legacy/plugins/siem/public/components/navigation/tab_navigation/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/navigation/tab_navigation/index.test.tsx index b6ec9e5ee0e02..61a0ec9c06c2d 100644 --- a/x-pack/legacy/plugins/siem/public/components/navigation/tab_navigation/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/navigation/tab_navigation/index.test.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { shallow } from 'enzyme'; +import { mount, shallow } from 'enzyme'; import * as React from 'react'; import { TabNavigation } from './'; @@ -74,8 +74,8 @@ describe('Tab Navigation', () => { expect(hostsTab.prop('isSelected')).toBeTruthy(); }); test('it changes active tab when nav changes by props', () => { - const wrapper = shallow(); - const networkTab = () => wrapper.find('[data-test-subj="navigation-network"]'); + const wrapper = mount(); + const networkTab = () => wrapper.find('[data-test-subj="navigation-network"]').first(); expect(networkTab().prop('isSelected')).toBeFalsy(); wrapper.setProps({ pageName: 'network', @@ -151,9 +151,9 @@ describe('Tab Navigation', () => { expect(tableNavigationTab.prop('isSelected')).toBeTruthy(); }); test('it changes active tab when nav changes by props', () => { - const wrapper = shallow(); + const wrapper = mount(); const tableNavigationTab = () => - wrapper.find(`[data-test-subj="navigation-${HostsTableType.events}"]`); + wrapper.find(`[data-test-subj="navigation-${HostsTableType.events}"]`).first(); expect(tableNavigationTab().prop('isSelected')).toBeFalsy(); wrapper.setProps({ pageName: SiemPageName.hosts, diff --git a/x-pack/legacy/plugins/siem/public/components/navigation/tab_navigation/index.tsx b/x-pack/legacy/plugins/siem/public/components/navigation/tab_navigation/index.tsx index 9a409b9f53d8c..c62335ea1c06d 100644 --- a/x-pack/legacy/plugins/siem/public/components/navigation/tab_navigation/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/navigation/tab_navigation/index.tsx @@ -4,14 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ import { EuiTab, EuiTabs, EuiLink } from '@elastic/eui'; -import { get, getOr } from 'lodash/fp'; +import { getOr } from 'lodash/fp'; -import * as React from 'react'; +import React, { useEffect, useState } from 'react'; import styled from 'styled-components'; import classnames from 'classnames'; import { trackUiAction as track, METRIC_TYPE, TELEMETRY_EVENT } from '../../../lib/track_usage'; -import { HostsTableType } from '../../../store/hosts/model'; import { getSearch } from '../helpers'; import { TabNavigationProps } from './types'; @@ -36,71 +35,51 @@ const TabContainer = styled.div` TabContainer.displayName = 'TabContainer'; -interface TabNavigationState { - selectedTabId: string; -} - -export class TabNavigation extends React.PureComponent { - constructor(props: TabNavigationProps) { - super(props); - const selectedTabId = this.mapLocationToTab(props.pageName, props.tabName); - this.state = { selectedTabId }; - } - public componentWillReceiveProps(nextProps: TabNavigationProps): void { - const selectedTabId = this.mapLocationToTab(nextProps.pageName, nextProps.tabName); - - if (this.state.selectedTabId !== selectedTabId) { - this.setState(prevState => ({ - ...prevState, - selectedTabId, - })); - } - } - public render() { - const { display = 'condensed' } = this.props; - return ( - - {this.renderTabs()} - - ); - } - - public mapLocationToTab = (pageName: string, tabName?: HostsTableType): string => { - const { navTabs } = this.props; +export const TabNavigation = React.memo(props => { + const { display = 'condensed', navTabs, pageName, showBorder, tabName } = props; + const mapLocationToTab = (): string => { return getOr( '', 'id', Object.values(navTabs).find(item => tabName === item.id || pageName === item.id) ); }; + const [selectedTabId, setSelectedTabId] = useState(mapLocationToTab()); + useEffect(() => { + const currentTabSelected = mapLocationToTab(); - private renderTabs = (): JSX.Element[] => { - const { navTabs } = this.props; - return Object.keys(navTabs).map(tabName => { - const tab = get(tabName, navTabs); - return ( - + Object.values(navTabs).map(tab => ( + + - { + track(METRIC_TYPE.CLICK, `${TELEMETRY_EVENT.TAB_CLICKED}${tab.id}`); + }} > - { - track(METRIC_TYPE.CLICK, `${TELEMETRY_EVENT.TAB_CLICKED}${tab.id}`); - }} - > - {tab.name} - - - - ); - }); - }; -} + {tab.name} + + + + )); + return ( + + {renderTabs()} + + ); +}); diff --git a/x-pack/legacy/plugins/siem/public/components/notes/index.tsx b/x-pack/legacy/plugins/siem/public/components/notes/index.tsx index 8eaf368058631..29f7686ade88b 100644 --- a/x-pack/legacy/plugins/siem/public/components/notes/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/notes/index.tsx @@ -5,7 +5,7 @@ */ import { EuiInMemoryTable, EuiModalBody, EuiModalHeader, EuiPanel, EuiSpacer } from '@elastic/eui'; -import * as React from 'react'; +import React, { useState } from 'react'; import styled from 'styled-components'; import { Note } from '../../lib/note'; @@ -23,10 +23,6 @@ interface Props { updateNote: UpdateNote; } -interface State { - newNote: string; -} - const NotesPanel = styled(EuiPanel)` height: ${NOTES_PANEL_HEIGHT}px; width: ${NOTES_PANEL_WIDTH}px; @@ -47,15 +43,9 @@ const InMemoryTable = styled(EuiInMemoryTable)` InMemoryTable.displayName = 'InMemoryTable'; /** A view for entering and reviewing notes */ -export class Notes extends React.PureComponent { - constructor(props: Props) { - super(props); - - this.state = { newNote: '' }; - } - - public render() { - const { associateNote, getNotesByIds, getNewNoteId, noteIds, updateNote } = this.props; +export const Notes = React.memo( + ({ associateNote, getNotesByIds, getNewNoteId, noteIds, updateNote }) => { + const [newNote, setNewNote] = useState(''); return ( @@ -67,8 +57,8 @@ export class Notes extends React.PureComponent { @@ -84,8 +74,6 @@ export class Notes extends React.PureComponent { ); } +); - private updateNewNote = (newNote: string): void => { - this.setState({ newNote }); - }; -} +Notes.displayName = 'Notes'; diff --git a/x-pack/legacy/plugins/siem/public/components/notes/note_cards/index.tsx b/x-pack/legacy/plugins/siem/public/components/notes/note_cards/index.tsx index 51992e00313a4..aa9415aadeda1 100644 --- a/x-pack/legacy/plugins/siem/public/components/notes/note_cards/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/notes/note_cards/index.tsx @@ -5,7 +5,7 @@ */ import { EuiFlexGroup, EuiPanel } from '@elastic/eui'; -import * as React from 'react'; +import React, { useState } from 'react'; import styled from 'styled-components'; import { Note } from '../../../lib/note'; @@ -53,27 +53,23 @@ interface Props { updateNote: UpdateNote; } -interface State { - newNote: string; -} - /** A view for entering and reviewing notes */ -export class NoteCards extends React.PureComponent { - constructor(props: Props) { - super(props); - - this.state = { newNote: '' }; - } - - public render() { - const { - getNotesByIds, - getNewNoteId, - noteIds, - showAddNote, - toggleShowAddNote, - updateNote, - } = this.props; +export const NoteCards = React.memo( + ({ + associateNote, + getNotesByIds, + getNewNoteId, + noteIds, + showAddNote, + toggleShowAddNote, + updateNote, + }) => { + const [newNote, setNewNote] = useState(''); + + const associateNoteAndToggleShow = (noteId: string) => { + associateNote(noteId); + toggleShowAddNote(); + }; return ( @@ -90,11 +86,11 @@ export class NoteCards extends React.PureComponent { {showAddNote ? ( @@ -102,13 +98,6 @@ export class NoteCards extends React.PureComponent { ); } +); - private associateNoteAndToggleShow = (noteId: string) => { - this.props.associateNote(noteId); - this.props.toggleShowAddNote(); - }; - - private updateNewNote = (newNote: string): void => { - this.setState({ newNote }); - }; -} +NoteCards.displayName = 'NoteCards'; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.test.tsx index e91feed536f93..917ec3f1bf0b8 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.test.tsx @@ -17,7 +17,7 @@ describe('DeleteTimelineModal', () => { ); @@ -34,7 +34,7 @@ describe('DeleteTimelineModal', () => { ); @@ -48,7 +48,7 @@ describe('DeleteTimelineModal', () => { test('it displays `Untitled Timeline` in the title when title is undefined', () => { const wrapper = mountWithIntl( - + ); expect( @@ -61,7 +61,7 @@ describe('DeleteTimelineModal', () => { test('it displays `Untitled Timeline` in the title when title is null', () => { const wrapper = mountWithIntl( - + ); expect( @@ -74,7 +74,7 @@ describe('DeleteTimelineModal', () => { test('it displays `Untitled Timeline` in the title when title is just whitespace', () => { const wrapper = mountWithIntl( - + ); expect( @@ -90,7 +90,7 @@ describe('DeleteTimelineModal', () => { ); @@ -102,14 +102,14 @@ describe('DeleteTimelineModal', () => { ).toEqual(i18n.DELETE_WARNING); }); - test('it invokes toggleShowModal when the Cancel button is clicked', () => { - const toggleShowModal = jest.fn(); + test('it invokes closeModal when the Cancel button is clicked', () => { + const closeModal = jest.fn(); const wrapper = mountWithIntl( ); @@ -118,7 +118,7 @@ describe('DeleteTimelineModal', () => { .first() .simulate('click'); - expect(toggleShowModal).toBeCalled(); + expect(closeModal).toBeCalled(); }); test('it invokes onDelete when the Delete button is clicked', () => { @@ -128,7 +128,7 @@ describe('DeleteTimelineModal', () => { ); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx index 9c416419066e6..e9e438a8c5e2e 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx @@ -14,7 +14,7 @@ import * as i18n from '../translations'; interface Props { title?: string | null; onDelete: () => void; - toggleShowModal: () => void; + closeModal: () => void; } export const DELETE_TIMELINE_MODAL_WIDTH = 600; // px @@ -22,7 +22,7 @@ export const DELETE_TIMELINE_MODAL_WIDTH = 600; // px /** * Renders a modal that confirms deletion of a timeline */ -export const DeleteTimelineModal = pure(({ title, toggleShowModal, onDelete }) => ( +export const DeleteTimelineModal = pure(({ title, closeModal, onDelete }) => ( (({ title, toggleShowModal, onDele }} /> } - onCancel={toggleShowModal} + onCancel={closeModal} onConfirm={onDelete} cancelButtonText={i18n.CANCEL} confirmButtonText={i18n.DELETE} diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.test.tsx index 1700e86f57c84..561eac000bbf7 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.test.tsx @@ -5,7 +5,6 @@ */ import { EuiButtonIconProps } from '@elastic/eui'; -import { get } from 'lodash/fp'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; import * as React from 'react'; @@ -79,35 +78,6 @@ describe('DeleteTimelineModal', () => { expect(props.isDisabled).toBe(false); }); - test('it defaults showModal to false until the trash button is clicked', () => { - const wrapper = mountWithIntl( - - ); - - expect(get('showModal', wrapper.state())).toBe(false); - }); - - test('it sets showModal to true when the trash button is clicked', () => { - const wrapper = mountWithIntl( - - ); - - wrapper - .find('[data-test-subj="delete-timeline"]') - .first() - .simulate('click'); - - expect(get('showModal', wrapper.state())).toBe(true); - }); - test('it does NOT render the modal when showModal is false', () => { const wrapper = mountWithIntl( { - constructor(props: Props) { - super(props); +export const DeleteTimelineModalButton = React.memo( + ({ deleteTimelines, savedObjectId, title }) => { + const [showModal, setShowModal] = useState(false); - this.state = { showModal: false }; - } + const openModal = () => setShowModal(true); + const closeModal = () => setShowModal(false); - public render() { - const { deleteTimelines, savedObjectId, title } = this.props; + const onDelete = () => { + if (deleteTimelines != null && savedObjectId != null) { + deleteTimelines([savedObjectId]); + } + closeModal(); + }; return ( <> @@ -44,19 +43,19 @@ export class DeleteTimelineModalButton extends React.PureComponent iconSize="s" iconType="trash" isDisabled={deleteTimelines == null || savedObjectId == null || savedObjectId === ''} - onClick={this.toggleShowModal} + onClick={openModal} size="s" /> - {this.state.showModal ? ( + {showModal ? ( - + @@ -64,20 +63,6 @@ export class DeleteTimelineModalButton extends React.PureComponent ); } +); - private toggleShowModal = () => { - this.setState(state => ({ - showModal: !state.showModal, - })); - }; - - private onDelete = () => { - const { deleteTimelines, savedObjectId } = this.props; - - if (deleteTimelines != null && savedObjectId != null) { - deleteTimelines([savedObjectId]); - } - - this.toggleShowModal(); - }; -} +DeleteTimelineModalButton.displayName = 'DeleteTimelineModalButton'; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/index.test.tsx index 62de2ea30542a..7a0caf14af302 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/index.test.tsx @@ -5,8 +5,7 @@ */ import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; -import { mount, ReactWrapper } from 'enzyme'; -import { get } from 'lodash/fp'; +import { mount } from 'enzyme'; import { MockedProvider } from 'react-apollo/test-utils'; import * as React from 'react'; import { ThemeProvider } from 'styled-components'; @@ -22,21 +21,11 @@ import { OPEN_TIMELINE_CLASS_NAME } from './helpers'; jest.mock('../../lib/settings/use_kibana_ui_setting'); -const getStateChildComponent = ( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - wrapper: ReactWrapper, React.Component<{}, {}, any>> -): // eslint-disable-next-line @typescript-eslint/no-explicit-any -React.Component<{}, {}, any> => - wrapper - .find('[data-test-subj="stateful-timeline"]') - .last() - .instance(); - describe('StatefulOpenTimeline', () => { const theme = () => ({ eui: euiDarkVars, darkMode: true }); const title = 'All Timelines / Open Timelines'; - test('it has the expected initial state', async () => { + test('it has the expected initial state', () => { const wrapper = mount( @@ -53,15 +42,18 @@ describe('StatefulOpenTimeline', () => { ); - await wait(); - wrapper.update(); + const componentProps = wrapper + .find('[data-test-subj="open-timeline"]') + .last() + .props(); - expect(getStateChildComponent(wrapper).state).toEqual({ + expect(componentProps).toEqual({ + ...componentProps, itemIdToExpandedNotesRowMap: {}, onlyFavorites: false, pageIndex: 0, pageSize: 10, - search: '', + query: '', selectedItems: [], sortDirection: 'desc', sortField: 'updated', @@ -69,7 +61,7 @@ describe('StatefulOpenTimeline', () => { }); describe('#onQueryChange', () => { - test('it updates the query state with the expected trimmed value when the user enters a query', async () => { + test('it updates the query state with the expected trimmed value when the user enters a query', () => { const wrapper = mount( @@ -85,26 +77,15 @@ describe('StatefulOpenTimeline', () => { ); - - await wait(); - wrapper.update(); - wrapper .find('[data-test-subj="search-bar"] input') .simulate('keyup', { keyCode: 13, target: { value: ' abcd ' } }); - - wrapper.update(); - - expect(getStateChildComponent(wrapper).state).toEqual({ - itemIdToExpandedNotesRowMap: {}, - onlyFavorites: false, - pageIndex: 0, - pageSize: 10, - search: 'abcd', - selectedItems: [], - sortDirection: 'desc', - sortField: 'updated', - }); + expect( + wrapper + .find('[data-test-subj="search-row"]') + .first() + .prop('query') + ).toEqual('abcd'); }); test('it appends the word "with" to the Showing in Timelines message when the user enters a query', async () => { @@ -129,8 +110,6 @@ describe('StatefulOpenTimeline', () => { .find('[data-test-subj="search-bar"] input') .simulate('keyup', { keyCode: 13, target: { value: ' abcd ' } }); - wrapper.update(); - expect( wrapper .find('[data-test-subj="query-message"]') @@ -161,8 +140,6 @@ describe('StatefulOpenTimeline', () => { .find('[data-test-subj="search-bar"] input') .simulate('keyup', { keyCode: 13, target: { value: ' abcd ' } }); - wrapper.update(); - expect( wrapper .find('[data-test-subj="selectable-query-text"]') @@ -226,7 +203,6 @@ describe('StatefulOpenTimeline', () => { .find('.euiCheckbox__input') .first() .simulate('change', { target: { checked: true } }); - wrapper.update(); wrapper .find('[data-test-subj="favorite-selected"]') @@ -273,7 +249,6 @@ describe('StatefulOpenTimeline', () => { .find('.euiCheckbox__input') .first() .simulate('change', { target: { checked: true } }); - wrapper.update(); wrapper .find('[data-test-subj="delete-selected"]') @@ -319,14 +294,17 @@ describe('StatefulOpenTimeline', () => { .first() .simulate('change', { target: { checked: true } }); - wrapper.update(); + const selectedItems: [] = wrapper + .find('[data-test-subj="open-timeline"]') + .last() + .prop('selectedItems'); - expect(get('selectedItems', getStateChildComponent(wrapper).state).length).toEqual(13); // 13 because we did mock 13 timelines in the query + expect(selectedItems.length).toEqual(13); // 13 because we did mock 13 timelines in the query }); }); describe('#onTableChange', () => { - test('it updates the sort state when the user clicks on a column to sort it', async () => { + test('it updates the sort state when the user clicks on a column to sort it', () => { const wrapper = mount( @@ -343,32 +321,29 @@ describe('StatefulOpenTimeline', () => { ); - await wait(); - - wrapper.update(); + expect( + wrapper + .find('[data-test-subj="open-timeline"]') + .last() + .prop('sortDirection') + ).toEqual('desc'); wrapper .find('thead tr th button') .at(0) .simulate('click'); - wrapper.update(); - - expect(getStateChildComponent(wrapper).state).toEqual({ - itemIdToExpandedNotesRowMap: {}, - onlyFavorites: false, - pageIndex: 0, - pageSize: 10, - search: '', - selectedItems: [], - sortDirection: 'asc', - sortField: 'updated', - }); + expect( + wrapper + .find('[data-test-subj="open-timeline"]') + .last() + .prop('sortDirection') + ).toEqual('asc'); }); }); describe('#onToggleOnlyFavorites', () => { - test('it updates the onlyFavorites state when the user clicks the Only Favorites button', async () => { + test('it updates the onlyFavorites state when the user clicks the Only Favorites button', () => { const wrapper = mount( @@ -385,25 +360,24 @@ describe('StatefulOpenTimeline', () => { ); - await wait(); + expect( + wrapper + .find('[data-test-subj="open-timeline"]') + .last() + .prop('onlyFavorites') + ).toEqual(false); wrapper .find('[data-test-subj="only-favorites-toggle"]') .first() .simulate('click'); - wrapper.update(); - - expect(getStateChildComponent(wrapper).state).toEqual({ - itemIdToExpandedNotesRowMap: {}, - onlyFavorites: true, - pageIndex: 0, - pageSize: 10, - search: '', - selectedItems: [], - sortDirection: 'desc', - sortField: 'updated', - }); + expect( + wrapper + .find('[data-test-subj="open-timeline"]') + .last() + .prop('onlyFavorites') + ).toEqual(true); }); }); @@ -426,38 +400,38 @@ describe('StatefulOpenTimeline', () => { ); await wait(); - wrapper.update(); + expect( + wrapper + .find('[data-test-subj="open-timeline"]') + .last() + .prop('itemIdToExpandedNotesRowMap') + ).toEqual({}); + wrapper .find('[data-test-subj="expand-notes"]') .first() .simulate('click'); - wrapper.update(); - expect(getStateChildComponent(wrapper).state).toEqual({ - itemIdToExpandedNotesRowMap: { - '10849df0-7b44-11e9-a608-ab3d811609': ( - ({ ...note, savedObjectId: note.noteId }) - ) - : [] - } - /> - ), - }, - onlyFavorites: false, - pageIndex: 0, - pageSize: 10, - search: '', - selectedItems: [], - sortDirection: 'desc', - sortField: 'updated', + expect( + wrapper + .find('[data-test-subj="open-timeline"]') + .last() + .prop('itemIdToExpandedNotesRowMap') + ).toEqual({ + '10849df0-7b44-11e9-a608-ab3d811609': ( + ({ ...note, savedObjectId: note.noteId }) + ) + : [] + } + /> + ), }); }); @@ -487,8 +461,6 @@ describe('StatefulOpenTimeline', () => { .first() .simulate('click'); - wrapper.update(); - expect( wrapper .find('[data-test-subj="note-previews-container"]') @@ -543,25 +515,23 @@ describe('StatefulOpenTimeline', () => { ); - + const getSelectedItem = (): [] => + wrapper + .find('[data-test-subj="open-timeline"]') + .last() + .prop('selectedItems'); await wait(); - - wrapper.update(); - + expect(getSelectedItem().length).toEqual(0); wrapper .find('.euiCheckbox__input') .first() .simulate('change', { target: { checked: true } }); - wrapper.update(); - + expect(getSelectedItem().length).toEqual(13); wrapper .find('[data-test-subj="delete-selected"]') .first() .simulate('click'); - - wrapper.update(); - - expect(get('selectedItems', getStateChildComponent(wrapper).state).length).toEqual(0); + expect(getSelectedItem().length).toEqual(0); }); }); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/index.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/index.tsx index c686228ed31e8..d101d1f4d39f4 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/index.tsx @@ -5,7 +5,7 @@ */ import ApolloClient from 'apollo-client'; -import * as React from 'react'; +import React, { useEffect, useState } from 'react'; import { connect } from 'react-redux'; import { Dispatch } from 'redux'; @@ -43,25 +43,6 @@ import { import { DEFAULT_SORT_FIELD, DEFAULT_SORT_DIRECTION } from './constants'; import { ColumnHeader } from '../timeline/body/column_headers/column_header'; -export interface OpenTimelineState { - /** Required by EuiTable for expandable rows: a map of `TimelineResult.savedObjectId` to rendered notes */ - itemIdToExpandedNotesRowMap: Record; - /** Only query for favorite timelines when true */ - onlyFavorites: boolean; - /** The requested page of results */ - pageIndex: number; - /** The requested size of each page of search results */ - pageSize: number; - /** The current search criteria */ - search: string; - /** The currently-selected timelines in the table */ - selectedItems: OpenTimelineResult[]; - /** The requested sort direction of the query results */ - sortDirection: 'asc' | 'desc'; - /** The requested field to sort on */ - sortField: string; -} - interface OwnProps { apolloClient: ApolloClient; /** Displays open timeline in modal */ @@ -85,70 +66,208 @@ export const getSelectedTimelineIds = (selectedItems: OpenTimelineResult[]): str ); /** Manages the state (e.g table selection) of the (pure) `OpenTimeline` component */ -export class StatefulOpenTimelineComponent extends React.PureComponent< - OpenTimelineOwnProps, - OpenTimelineState -> { - constructor(props: OpenTimelineOwnProps) { - super(props); +export const StatefulOpenTimelineComponent = React.memo( + ({ + defaultPageSize, + isModal = false, + title, + apolloClient, + closeModalTimeline, + updateTimeline, + updateIsLoading, + timeline, + createNewTimeline, + }) => { + /** Required by EuiTable for expandable rows: a map of `TimelineResult.savedObjectId` to rendered notes */ + const [itemIdToExpandedNotesRowMap, setItemIdToExpandedNotesRowMap] = useState< + Record + >({}); + /** Only query for favorite timelines when true */ + const [onlyFavorites, setOnlyFavorites] = useState(false); + /** The requested page of results */ + const [pageIndex, setPageIndex] = useState(0); + /** The requested size of each page of search results */ + const [pageSize, setPageSize] = useState(defaultPageSize); + /** The current search criteria */ + const [search, setSearch] = useState(''); + /** The currently-selected timelines in the table */ + const [selectedItems, setSelectedItems] = useState([]); + /** The requested sort direction of the query results */ + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>(DEFAULT_SORT_DIRECTION); + /** The requested field to sort on */ + const [sortField, setSortField] = useState(DEFAULT_SORT_FIELD); + + /** Invoked when the user presses enters to submit the text in the search input */ + const onQueryChange: OnQueryChange = (query: EuiSearchBarQuery) => { + setSearch(query.queryText.trim()); + }; + + /** Focuses the input that filters the field browser */ + const focusInput = () => { + const elements = document.querySelector(`.${OPEN_TIMELINE_CLASS_NAME} input`); - this.state = { - itemIdToExpandedNotesRowMap: {}, - onlyFavorites: false, - search: '', - pageIndex: 0, - pageSize: props.defaultPageSize, - sortField: DEFAULT_SORT_FIELD, - sortDirection: DEFAULT_SORT_DIRECTION, - selectedItems: [], + if (elements != null) { + elements.focus(); + } }; - } - public componentDidMount() { - this.focusInput(); - } + /* This feature will be implemented in the near future, so we are keeping it to know what to do */ + + /** Invoked when the user clicks the action to add the selected timelines to favorites */ + // const onAddTimelinesToFavorites: OnAddTimelinesToFavorites = () => { + // const { addTimelinesToFavorites } = this.props; + // const { selectedItems } = this.state; + // if (addTimelinesToFavorites != null) { + // addTimelinesToFavorites(getSelectedTimelineIds(selectedItems)); + // TODO: it's not possible to clear the selection state of the newly-favorited + // items, because we can't pass the selection state as props to the table. + // See: https://github.com/elastic/eui/issues/1077 + // TODO: the query must re-execute to show the results of the mutation + // } + // }; + + const onDeleteOneTimeline: OnDeleteOneTimeline = (timelineIds: string[]) => { + deleteTimelines(timelineIds, { + search, + pageInfo: { + pageIndex: pageIndex + 1, + pageSize, + }, + sort: { + sortField: sortField as SortFieldTimeline, + sortOrder: sortDirection as Direction, + }, + onlyUserFavorite: onlyFavorites, + }); + }; + + /** Invoked when the user clicks the action to delete the selected timelines */ + const onDeleteSelected: OnDeleteSelected = () => { + deleteTimelines(getSelectedTimelineIds(selectedItems), { + search, + pageInfo: { + pageIndex: pageIndex + 1, + pageSize, + }, + sort: { + sortField: sortField as SortFieldTimeline, + sortOrder: sortDirection as Direction, + }, + onlyUserFavorite: onlyFavorites, + }); + + // NOTE: we clear the selection state below, but if the server fails to + // delete a timeline, it will remain selected in the table: + resetSelectionState(); + + // TODO: the query must re-execute to show the results of the deletion + }; + + /** Invoked when the user selects (or de-selects) timelines */ + const onSelectionChange: OnSelectionChange = (newSelectedItems: OpenTimelineResult[]) => { + setSelectedItems(newSelectedItems); // <-- this is NOT passed down as props to the table: https://github.com/elastic/eui/issues/1077 + }; + + /** Invoked by the EUI table implementation when the user interacts with the table (i.e. to update sorting) */ + const onTableChange: OnTableChange = ({ page, sort }: OnTableChangeParams) => { + const { index, size } = page; + const { field, direction } = sort; + setPageIndex(index); + setPageSize(size); + setSortDirection(direction); + setSortField(field); + }; + + /** Invoked when the user toggles the option to only view favorite timelines */ + const onToggleOnlyFavorites: OnToggleOnlyFavorites = () => { + setOnlyFavorites(!onlyFavorites); + }; + + /** Invoked when the user toggles the expansion or collapse of inline notes in a table row */ + const onToggleShowNotes: OnToggleShowNotes = ( + newItemIdToExpandedNotesRowMap: Record + ) => { + setItemIdToExpandedNotesRowMap(newItemIdToExpandedNotesRowMap); + }; + + /** Resets the selection state such that all timelines are unselected */ + const resetSelectionState = () => { + setSelectedItems([]); + }; + + const openTimeline: OnOpenTimeline = ({ + duplicate, + timelineId, + }: { + duplicate: boolean; + timelineId: string; + }) => { + if (isModal && closeModalTimeline != null) { + closeModalTimeline(); + } + + queryTimelineById({ + apolloClient, + duplicate, + timelineId, + updateIsLoading, + updateTimeline, + }); + }; + + const deleteTimelines: DeleteTimelines = ( + timelineIds: string[], + variables?: AllTimelinesVariables + ) => { + if (timelineIds.includes(timeline.savedObjectId || '')) { + createNewTimeline({ id: 'timeline-1', columns: defaultHeaders, show: false }); + } + apolloClient.mutate({ + mutation: deleteTimelineMutation, + fetchPolicy: 'no-cache', + variables: { id: timelineIds }, + refetchQueries: [ + { + query: allTimelinesQuery, + variables, + }, + ], + }); + }; + useEffect(() => { + focusInput(); + }, []); - public render() { - const { defaultPageSize, isModal = false, title } = this.props; - const { - itemIdToExpandedNotesRowMap, - onlyFavorites, - pageIndex, - pageSize, - search: query, - selectedItems, - sortDirection, - sortField, - } = this.state; return ( {({ timelines, loading, totalCount }) => { return !isModal ? ( ) : ( ); } - - /** Invoked when the user presses enters to submit the text in the search input */ - private onQueryChange: OnQueryChange = (query: EuiSearchBarQuery) => { - this.setState({ - search: query.queryText.trim(), - }); - }; - - /** Focuses the input that filters the field browser */ - private focusInput = () => { - const elements = document.querySelector(`.${OPEN_TIMELINE_CLASS_NAME} input`); - - if (elements != null) { - elements.focus(); - } - }; - - /* This feature will be implemented in the near future, so we are keeping it to know what to do */ - - /** Invoked when the user clicks the action to add the selected timelines to favorites */ - // private onAddTimelinesToFavorites: OnAddTimelinesToFavorites = () => { - // const { addTimelinesToFavorites } = this.props; - // const { selectedItems } = this.state; - // if (addTimelinesToFavorites != null) { - // addTimelinesToFavorites(getSelectedTimelineIds(selectedItems)); - // TODO: it's not possible to clear the selection state of the newly-favorited - // items, because we can't pass the selection state as props to the table. - // See: https://github.com/elastic/eui/issues/1077 - // TODO: the query must re-execute to show the results of the mutation - // } - // }; - - private onDeleteOneTimeline: OnDeleteOneTimeline = (timelineIds: string[]) => { - const { onlyFavorites, pageIndex, pageSize, search, sortDirection, sortField } = this.state; - - this.deleteTimelines(timelineIds, { - search, - pageInfo: { - pageIndex: pageIndex + 1, - pageSize, - }, - sort: { - sortField: sortField as SortFieldTimeline, - sortOrder: sortDirection as Direction, - }, - onlyUserFavorite: onlyFavorites, - }); - }; - - /** Invoked when the user clicks the action to delete the selected timelines */ - private onDeleteSelected: OnDeleteSelected = () => { - const { selectedItems, onlyFavorites } = this.state; - - this.deleteTimelines(getSelectedTimelineIds(selectedItems), { - search: this.state.search, - pageInfo: { - pageIndex: this.state.pageIndex + 1, - pageSize: this.state.pageSize, - }, - sort: { - sortField: this.state.sortField as SortFieldTimeline, - sortOrder: this.state.sortDirection as Direction, - }, - onlyUserFavorite: onlyFavorites, - }); - - // NOTE: we clear the selection state below, but if the server fails to - // delete a timeline, it will remain selected in the table: - this.resetSelectionState(); - - // TODO: the query must re-execute to show the results of the deletion - }; - - /** Invoked when the user selects (or de-selects) timelines */ - private onSelectionChange: OnSelectionChange = (selectedItems: OpenTimelineResult[]) => { - this.setState({ selectedItems }); // <-- this is NOT passed down as props to the table: https://github.com/elastic/eui/issues/1077 - }; - - /** Invoked by the EUI table implementation when the user interacts with the table (i.e. to update sorting) */ - private onTableChange: OnTableChange = ({ page, sort }: OnTableChangeParams) => { - const { index: pageIndex, size: pageSize } = page; - const { field: sortField, direction: sortDirection } = sort; - - this.setState({ - pageIndex, - pageSize, - sortDirection, - sortField, - }); - }; - - /** Invoked when the user toggles the option to only view favorite timelines */ - private onToggleOnlyFavorites: OnToggleOnlyFavorites = () => { - this.setState(state => ({ - onlyFavorites: !state.onlyFavorites, - })); - }; - - /** Invoked when the user toggles the expansion or collapse of inline notes in a table row */ - private onToggleShowNotes: OnToggleShowNotes = ( - itemIdToExpandedNotesRowMap: Record - ) => { - this.setState(() => ({ - itemIdToExpandedNotesRowMap, - })); - }; - - /** Resets the selection state such that all timelines are unselected */ - private resetSelectionState = () => { - this.setState({ - selectedItems: [], - }); - }; - - private openTimeline: OnOpenTimeline = ({ - duplicate, - timelineId, - }: { - duplicate: boolean; - timelineId: string; - }) => { - const { - apolloClient, - closeModalTimeline, - isModal, - updateTimeline, - updateIsLoading, - } = this.props; - - if (isModal && closeModalTimeline != null) { - closeModalTimeline(); - } - - queryTimelineById({ - apolloClient, - duplicate, - timelineId, - updateIsLoading, - updateTimeline, - }); - }; - - private deleteTimelines: DeleteTimelines = ( - timelineIds: string[], - variables?: AllTimelinesVariables - ) => { - if (timelineIds.includes(this.props.timeline.savedObjectId || '')) { - this.props.createNewTimeline({ id: 'timeline-1', columns: defaultHeaders, show: false }); - } - this.props.apolloClient.mutate< - DeleteTimelineMutation.Mutation, - DeleteTimelineMutation.Variables - >({ - mutation: deleteTimelineMutation, - fetchPolicy: 'no-cache', - variables: { id: timelineIds }, - refetchQueries: [ - { - query: allTimelinesQuery, - variables, - }, - ], - }); - }; -} +); const makeMapStateToProps = () => { const getTimeline = timelineSelectors.getTimelineByIdSelector(); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/index.test.tsx index bcafed20a50ff..146afa85e10a7 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/index.test.tsx @@ -5,8 +5,7 @@ */ import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; -import { get } from 'lodash/fp'; -import { mount, ReactWrapper } from 'enzyme'; +import { mount } from 'enzyme'; import * as React from 'react'; import { MockedProvider } from 'react-apollo/test-utils'; import { ThemeProvider } from 'styled-components'; @@ -20,12 +19,6 @@ import { OpenTimelineModalButton } from '.'; jest.mock('../../../lib/settings/use_kibana_ui_setting'); -const getStateChildComponent = ( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - wrapper: ReactWrapper, React.Component<{}, {}, any>> -): // eslint-disable-next-line @typescript-eslint/no-explicit-any -React.Component<{}, {}, any> => wrapper.find('[data-test-subj="state-child-component"]').instance(); - describe('OpenTimelineModalButton', () => { const theme = () => ({ eui: euiDarkVars, darkMode: true }); @@ -56,10 +49,7 @@ describe('OpenTimelineModalButton', () => { - + @@ -69,7 +59,7 @@ describe('OpenTimelineModalButton', () => { wrapper.update(); - expect(get('showModal', getStateChildComponent(wrapper).state)).toEqual(false); + expect(wrapper.find('div[data-test-subj="open-timeline-modal"].euiModal').length).toEqual(0); }); test('it sets showModal to true when the button is clicked', async () => { @@ -151,10 +141,7 @@ describe('OpenTimelineModalButton', () => { - + @@ -169,7 +156,7 @@ describe('OpenTimelineModalButton', () => { wrapper.update(); - expect(get('showModal', getStateChildComponent(wrapper).state)).toEqual(true); + expect(wrapper.find('div[data-test-subj="open-timeline-modal"].euiModal').length).toEqual(1); }); test('it invokes the optional onToggle function provided as a prop when the open timeline button is clicked', async () => { diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/index.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/index.tsx index 79fa747aee081..41907e07d5c1b 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/index.tsx @@ -5,8 +5,7 @@ */ import { EuiButtonEmpty, EuiModal, EuiOverlayMask } from '@elastic/eui'; -import * as React from 'react'; -import styled from 'styled-components'; +import React, { useState } from 'react'; import { ApolloConsumer } from 'react-apollo'; import * as i18n from '../translations'; @@ -20,90 +19,61 @@ export interface OpenTimelineModalButtonProps { onToggle?: () => void; } -export interface OpenTimelineModalButtonState { - showModal: boolean; -} - const DEFAULT_SEARCH_RESULTS_PER_PAGE = 10; const OPEN_TIMELINE_MODAL_WIDTH = 1000; // px -// TODO: this container can be removed when -// the following EUI PR is available (in Kibana): -// https://github.com/elastic/eui/pull/1902/files#diff-d662c14c5dcd7e4b41028bf60b9bc77b -const ModalContainer = styled.div` - .euiModalBody { - display: flex; - flex-direction: column; - } -`; - -ModalContainer.displayName = 'ModalContainer'; - /** * Renders a button that when clicked, displays the `Open Timelines` modal */ -export class OpenTimelineModalButton extends React.PureComponent< - OpenTimelineModalButtonProps, - OpenTimelineModalButtonState -> { - constructor(props: OpenTimelineModalButtonProps) { - super(props); +export const OpenTimelineModalButton = React.memo(({ onToggle }) => { + const [showModal, setShowModal] = useState(false); - this.state = { showModal: false }; + /** shows or hides the `Open Timeline` modal */ + function toggleShowModal() { + if (onToggle != null) { + onToggle(); + } + setShowModal(!showModal); } - public render() { - return ( - - {client => ( - <> - - {i18n.OPEN_TIMELINE} - - - {this.state.showModal && ( - - - - - - - - )} - - )} - - ); + function closeModalTimeline() { + toggleShowModal(); } + return ( + + {client => ( + <> + + {i18n.OPEN_TIMELINE} + - /** shows or hides the `Open Timeline` modal */ - private toggleShowModal = () => { - if (this.props.onToggle != null) { - this.props.onToggle(); - } - - this.setState(state => ({ - showModal: !state.showModal, - })); - }; + {showModal && ( + + + + + + )} + + )} + + ); +}); - private closeModalTimeline = () => { - this.toggleShowModal(); - }; -} +OpenTimelineModalButton.displayName = 'OpenTimelineModalButton'; diff --git a/x-pack/legacy/plugins/siem/public/components/page/add_to_kql/index.tsx b/x-pack/legacy/plugins/siem/public/components/page/add_to_kql/index.tsx index 693dcf7516bc4..5c0b449916a1f 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/add_to_kql/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/page/add_to_kql/index.tsx @@ -7,7 +7,6 @@ import { EuiIcon, EuiPanel, EuiToolTip } from '@elastic/eui'; import { isEmpty } from 'lodash/fp'; import React from 'react'; -import { pure } from 'recompose'; import styled from 'styled-components'; import { StaticIndexPattern } from 'ui/index_patterns'; @@ -25,15 +24,21 @@ interface Props { filterQueryDraft: KueryFilterQuery; } -class AddToKqlComponent extends React.PureComponent { - public render() { - const { children } = this.props; +const AddToKqlComponent = React.memo( + ({ children, expression, filterQueryDraft, applyFilterQueryFromKueryExpression }) => { + const addToKql = () => { + applyFilterQueryFromKueryExpression( + filterQueryDraft && !isEmpty(filterQueryDraft.expression) + ? `${filterQueryDraft.expression} and ${expression}` + : expression + ); + }; return ( - + } @@ -41,16 +46,9 @@ class AddToKqlComponent extends React.PureComponent { /> ); } +); - private addToKql = () => { - const { expression, filterQueryDraft, applyFilterQueryFromKueryExpression } = this.props; - applyFilterQueryFromKueryExpression( - filterQueryDraft && !isEmpty(filterQueryDraft.expression) - ? `${filterQueryDraft.expression} and ${expression}` - : expression - ); - }; -} +AddToKqlComponent.displayName = 'AddToKqlComponent'; const HoverActionsContainer = styled(EuiPanel)` align-items: center; @@ -75,7 +73,7 @@ interface AddToKqlProps { type: networkModel.NetworkType | hostsModel.HostsType; } -export const AddToKql = pure( +export const AddToKql = React.memo( ({ children, expression, type, componentFilterType, indexPattern }) => { switch (componentFilterType) { case 'hosts': diff --git a/x-pack/legacy/plugins/siem/public/components/page/hosts/hosts_table/index.tsx b/x-pack/legacy/plugins/siem/public/components/page/hosts/hosts_table/index.tsx index 65c8e9a654686..d4b3b5e875989 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/hosts/hosts_table/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/page/hosts/hosts_table/index.tsx @@ -4,8 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import memoizeOne from 'memoize-one'; -import React from 'react'; +import React, { useMemo } from 'react'; import { connect } from 'react-redux'; import { ActionCreator } from 'typescript-fsa'; import { StaticIndexPattern } from 'ui/index_patterns'; @@ -38,8 +37,8 @@ interface OwnProps { data: HostsEdges[]; fakeTotalCount: number; id: string; - isInspect: boolean; indexPattern: StaticIndexPattern; + isInspect: boolean; loading: boolean; loadPage: (newActivePage: number) => void; showMorePagesIndicator: boolean; @@ -49,15 +48,15 @@ interface OwnProps { interface HostsTableReduxProps { activePage: number; + direction: Direction; limit: number; sortField: HostsFields; - direction: Direction; } interface HostsTableDispatchProps { updateHostsSort: ActionCreator<{ - sort: HostsSortField; hostsType: hostsModel.HostsType; + sort: HostsSortField; }>; updateTableActivePage: ActionCreator<{ activePage: number; @@ -65,8 +64,8 @@ interface HostsTableDispatchProps { tableType: hostsModel.HostsTableType; }>; updateTableLimit: ActionCreator<{ - limit: number; hostsType: hostsModel.HostsType; + limit: number; tableType: hostsModel.HostsTableType; }>; } @@ -90,47 +89,58 @@ const rowItems: ItemsPerRow[] = [ numberOfRow: 10, }, ]; +const getSorting = ( + trigger: string, + sortField: HostsFields, + direction: Direction +): SortingBasicTable => ({ field: getNodeField(sortField), direction }); + +const HostsTableComponent = React.memo( + ({ + activePage, + data, + direction, + fakeTotalCount, + id, + indexPattern, + isInspect, + limit, + loading, + loadPage, + showMorePagesIndicator, + sortField, + totalCount, + type, + updateHostsSort, + updateTableActivePage, + updateTableLimit, + }) => { + const onChange = (criteria: Criteria) => { + if (criteria.sort != null) { + const sort: HostsSortField = { + field: getSortField(criteria.sort.field), + direction: criteria.sort.direction, + }; + if (sort.direction !== direction || sort.field !== sortField) { + updateHostsSort({ + sort, + hostsType: type, + }); + } + } + }; -class HostsTableComponent extends React.PureComponent { - private memoizedColumns: ( - type: hostsModel.HostsType, - indexPattern: StaticIndexPattern - ) => HostsTableColumns; - private memoizedSorting: ( - trigger: string, - sortField: HostsFields, - direction: Direction - ) => SortingBasicTable; - - constructor(props: HostsTableProps) { - super(props); - this.memoizedColumns = memoizeOne(this.getMemoizeHostsColumns); - this.memoizedSorting = memoizeOne(this.getSorting); - } + const hostsColumns = useMemo(() => getHostsColumns(type, indexPattern), [type, indexPattern]); - public render() { - const { - activePage, - data, - direction, - fakeTotalCount, - id, - isInspect, - indexPattern, - limit, - loading, - loadPage, - showMorePagesIndicator, - totalCount, + const sorting = useMemo(() => getSorting(`${sortField}-${direction}`, sortField, direction), [ sortField, - type, - updateTableActivePage, - updateTableLimit, - } = this.props; + direction, + ]); + return ( { limit={limit} loading={loading} loadPage={newActivePage => loadPage(newActivePage)} - onChange={this.onChange} + onChange={onChange} pageOfItems={data} showMorePagesIndicator={showMorePagesIndicator} - sorting={this.memoizedSorting(`${sortField}-${direction}`, sortField, direction)} + sorting={sorting} totalCount={fakeTotalCount} updateLimitPagination={newLimit => updateTableLimit({ @@ -163,33 +173,9 @@ class HostsTableComponent extends React.PureComponent { /> ); } +); - private getSorting = ( - trigger: string, - sortField: HostsFields, - direction: Direction - ): SortingBasicTable => ({ field: getNodeField(sortField), direction }); - - private getMemoizeHostsColumns = ( - type: hostsModel.HostsType, - indexPattern: StaticIndexPattern - ): HostsTableColumns => getHostsColumns(type, indexPattern); - - private onChange = (criteria: Criteria) => { - if (criteria.sort != null) { - const sort: HostsSortField = { - field: getSortField(criteria.sort.field), - direction: criteria.sort.direction, - }; - if (sort.direction !== this.props.direction || sort.field !== this.props.sortField) { - this.props.updateHostsSort({ - sort, - hostsType: this.props.type, - }); - } - } - }; -} +HostsTableComponent.displayName = 'HostsTableComponent'; const getSortField = (field: string): HostsFields => { switch (field) { diff --git a/x-pack/legacy/plugins/siem/public/components/page/network/domains_table/columns.tsx b/x-pack/legacy/plugins/siem/public/components/page/network/domains_table/columns.tsx index 24820b637d388..cf5da3fbebba6 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/network/domains_table/columns.tsx +++ b/x-pack/legacy/plugins/siem/public/components/page/network/domains_table/columns.tsx @@ -25,7 +25,7 @@ import { DragEffects, DraggableWrapper } from '../../../drag_and_drop/draggable_ import { escapeDataProviderId } from '../../../drag_and_drop/helpers'; import { defaultToEmptyTag, getEmptyTagValue } from '../../../empty_value'; import { PreferenceFormattedDate } from '../../../formatted_date'; -import { Columns } from '../../../load_more_table'; +import { Columns } from '../../../paginated_table'; import { LocalizedDateTooltip } from '../../../localized_date_tooltip'; import { IS_OPERATOR } from '../../../timeline/data_providers/data_provider'; import { PreferenceFormattedBytes } from '../../../formatted_bytes'; diff --git a/x-pack/legacy/plugins/siem/public/components/page/network/network_dns_table/columns.tsx b/x-pack/legacy/plugins/siem/public/components/page/network/network_dns_table/columns.tsx index 1fdea3f2b0332..353699c5158bc 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/network/network_dns_table/columns.tsx +++ b/x-pack/legacy/plugins/siem/public/components/page/network/network_dns_table/columns.tsx @@ -12,7 +12,7 @@ import { networkModel } from '../../../../store'; import { DragEffects, DraggableWrapper } from '../../../drag_and_drop/draggable_wrapper'; import { escapeDataProviderId } from '../../../drag_and_drop/helpers'; import { defaultToEmptyTag, getEmptyTagValue } from '../../../empty_value'; -import { Columns } from '../../../load_more_table'; +import { Columns } from '../../../paginated_table'; import { IS_OPERATOR } from '../../../timeline/data_providers/data_provider'; import { PreferenceFormattedBytes } from '../../../formatted_bytes'; import { Provider } from '../../../timeline/data_providers/provider'; diff --git a/x-pack/legacy/plugins/siem/public/components/page/network/network_top_n_flow_table/columns.tsx b/x-pack/legacy/plugins/siem/public/components/page/network/network_top_n_flow_table/columns.tsx index 38eda9810740c..97fa601a49af1 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/network/network_top_n_flow_table/columns.tsx +++ b/x-pack/legacy/plugins/siem/public/components/page/network/network_top_n_flow_table/columns.tsx @@ -21,7 +21,7 @@ import { DragEffects, DraggableWrapper } from '../../../drag_and_drop/draggable_ import { escapeDataProviderId } from '../../../drag_and_drop/helpers'; import { getEmptyTagValue } from '../../../empty_value'; import { IPDetailsLink } from '../../../links'; -import { Columns } from '../../../load_more_table'; +import { Columns } from '../../../paginated_table'; import { IS_OPERATOR } from '../../../timeline/data_providers/data_provider'; import { Provider } from '../../../timeline/data_providers/provider'; import * as i18n from './translations'; diff --git a/x-pack/legacy/plugins/siem/public/components/page/network/network_top_n_flow_table/index.tsx b/x-pack/legacy/plugins/siem/public/components/page/network/network_top_n_flow_table/index.tsx index 5abbdab9c980f..714d3f7a8131c 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/network/network_top_n_flow_table/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/page/network/network_top_n_flow_table/index.tsx @@ -79,26 +79,45 @@ const rowItems: ItemsPerRow[] = [ export const NetworkTopNFlowTableId = 'networkTopSourceFlow-top-talkers'; -class NetworkTopNFlowTableComponent extends React.PureComponent { - public render() { - const { - activePage, - data, - fakeTotalCount, - flowTargeted, - id, - indexPattern, - isInspect, - limit, - loading, - loadPage, - showMorePagesIndicator, - topNFlowSort, - totalCount, - type, - updateTopNFlowLimit, - updateTableActivePage, - } = this.props; +const NetworkTopNFlowTableComponent = React.memo( + ({ + activePage, + data, + fakeTotalCount, + flowTargeted, + id, + indexPattern, + isInspect, + limit, + loading, + loadPage, + showMorePagesIndicator, + topNFlowSort, + totalCount, + type, + updateTopNFlowLimit, + updateTopNFlowSort, + updateTableActivePage, + }) => { + const onChange = (criteria: Criteria, tableType: networkModel.TopNTableType) => { + if (criteria.sort != null) { + const splitField = criteria.sort.field.split('.'); + const field = last(splitField); + const newSortDirection = + field !== topNFlowSort.field ? Direction.desc : criteria.sort.direction; // sort by desc on init click + const newTopNFlowSort: NetworkTopNFlowSortField = { + field: field as NetworkTopNFlowFields, + direction: newSortDirection, + }; + if (!isEqual(newTopNFlowSort, topNFlowSort)) { + updateTopNFlowSort({ + topNFlowSort: newTopNFlowSort, + networkType: type, + tableType, + }); + } + } + }; let tableType: networkModel.TopNTableType; let headerTitle: string; @@ -136,7 +155,7 @@ class NetworkTopNFlowTableComponent extends React.PureComponent loadPage(newActivePage)} - onChange={criteria => this.onChange(criteria, tableType)} + onChange={criteria => onChange(criteria, tableType)} pageOfItems={data} showMorePagesIndicator={showMorePagesIndicator} sorting={{ field, direction: topNFlowSort.direction }} @@ -153,27 +172,9 @@ class NetworkTopNFlowTableComponent extends React.PureComponent ); } +); - private onChange = (criteria: Criteria, tableType: networkModel.TopNTableType) => { - if (criteria.sort != null) { - const splitField = criteria.sort.field.split('.'); - const field = last(splitField); - const newSortDirection = - field !== this.props.topNFlowSort.field ? Direction.desc : criteria.sort.direction; // sort by desc on init click - const newTopNFlowSort: NetworkTopNFlowSortField = { - field: field as NetworkTopNFlowFields, - direction: newSortDirection, - }; - if (!isEqual(newTopNFlowSort, this.props.topNFlowSort)) { - this.props.updateTopNFlowSort({ - topNFlowSort: newTopNFlowSort, - networkType: this.props.type, - tableType, - }); - } - } - }; -} +NetworkTopNFlowTableComponent.displayName = 'NetworkTopNFlowTableComponent'; const mapStateToProps = (state: State, ownProps: OwnProps) => networkSelectors.topNFlowSelector(ownProps.flowTargeted); diff --git a/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/columns.tsx b/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/columns.tsx index 7578c5decc851..aea8ee9e6b9e1 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/columns.tsx +++ b/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/columns.tsx @@ -7,7 +7,7 @@ import React from 'react'; import moment from 'moment'; import { TlsNode } from '../../../../graphql/types'; -import { Columns } from '../../../load_more_table'; +import { Columns } from '../../../paginated_table'; import { getRowItemDraggables, getRowItemDraggable } from '../../../tables/helpers'; import { LocalizedDateTooltip } from '../../../localized_date_tooltip'; diff --git a/x-pack/legacy/plugins/siem/public/components/page/network/users_table/columns.tsx b/x-pack/legacy/plugins/siem/public/components/page/network/users_table/columns.tsx index b17ec74fa0540..2c51fb8f94561 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/network/users_table/columns.tsx +++ b/x-pack/legacy/plugins/siem/public/components/page/network/users_table/columns.tsx @@ -6,7 +6,7 @@ import { FlowTarget, UsersItem } from '../../../../graphql/types'; import { defaultToEmptyTag } from '../../../empty_value'; -import { Columns } from '../../../load_more_table'; +import { Columns } from '../../../paginated_table'; import * as i18n from './translations'; import { getRowItemDraggables, getRowItemDraggable } from '../../../tables/helpers'; diff --git a/x-pack/legacy/plugins/siem/public/components/paginated_table/index.tsx b/x-pack/legacy/plugins/siem/public/components/paginated_table/index.tsx index b5678a36c1eed..257ee03c944bf 100644 --- a/x-pack/legacy/plugins/siem/public/components/paginated_table/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/paginated_table/index.tsx @@ -100,15 +100,17 @@ export interface BasicTableProps { updateActivePage: (activePage: number) => void; updateLimitPagination: (limit: number) => void; } +type Func = (arg: T) => string | number; -export interface Columns { +export interface Columns { + align?: string; field?: string; - name: string | React.ReactNode; + hideForMobile?: boolean; isMobileHeader?: boolean; - sortable?: boolean; + name: string | React.ReactNode; + render?: (item: T, node: U) => React.ReactNode; + sortable?: boolean | Func; truncateText?: boolean; - hideForMobile?: boolean; - render?: (item: T) => void; width?: string; } diff --git a/x-pack/legacy/plugins/siem/public/components/resize_handle/index.tsx b/x-pack/legacy/plugins/siem/public/components/resize_handle/index.tsx index 40df2c134047f..0a6203056fd20 100644 --- a/x-pack/legacy/plugins/siem/public/components/resize_handle/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/resize_handle/index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import * as React from 'react'; +import React, { useEffect, useRef } from 'react'; import { fromEvent, Observable, Subscription } from 'rxjs'; import { concatMap, takeUntil } from 'rxjs/operators'; import styled, { css } from 'styled-components'; @@ -41,10 +41,6 @@ interface Props extends ResizeHandleContainerProps { render: (isResizing: boolean) => React.ReactNode; } -interface State { - isResizing: boolean; -} - const ResizeHandleContainer = styled.div` ${({ bottom, height, left, positionAbsolute, right, theme, top }) => css` bottom: ${positionAbsolute && bottom}; @@ -67,88 +63,75 @@ export const removeGlobalResizeCursorStyleFromBody = () => { document.body.classList.remove(globalResizeCursorClassName); }; -export class Resizeable extends React.PureComponent { - private drag$: Observable | null; - private dragEventTargets: Array<{ htmlElement: HTMLElement; prevCursor: string }>; - private dragSubscription: Subscription | null; - private prevX: number = 0; - private ref: React.RefObject; - private upSubscription: Subscription | null; - - constructor(props: Props) { - super(props); - - // NOTE: the ref and observable below are NOT stored in component `State` - this.ref = React.createRef(); - this.drag$ = null; - this.dragSubscription = null; - this.upSubscription = null; - this.dragEventTargets = []; - - this.state = { - isResizing: false, +export const Resizeable = React.memo( + ({ bottom, handle, height, id, left, onResize, positionAbsolute, render, right, top }) => { + const drag$ = useRef | null>(null); + const dragEventTargets = useRef>([]); + const dragSubscription = useRef(null); + const prevX = useRef(0); + const ref = useRef>(React.createRef()); + const upSubscription = useRef(null); + const isResizingRef = useRef(false); + + const calculateDelta = (e: MouseEvent) => { + const deltaX = calculateDeltaX({ prevX: prevX.current, screenX: e.screenX }); + prevX.current = e.screenX; + return deltaX; }; - } - - public componentDidMount() { - const { id, onResize } = this.props; - - const move$ = fromEvent(document, 'mousemove'); - const down$ = fromEvent(this.ref.current!, 'mousedown'); - const up$ = fromEvent(document, 'mouseup'); - - this.drag$ = down$.pipe(concatMap(() => move$.pipe(takeUntil(up$)))); - this.dragSubscription = this.drag$.subscribe(event => { - // We do a feature detection of event.movementX here and if it is missing - // we calculate the delta manually. Browsers IE-11 and Safari will call calculateDelta - const delta = - event.movementX == null || isSafari ? this.calculateDelta(event) : event.movementX; - if (!this.state.isResizing) { - this.setState({ isResizing: true }); - } - onResize({ id, delta }); - if (event.target != null && event.target instanceof HTMLElement) { - const htmlElement: HTMLElement = event.target; - this.dragEventTargets = [ - ...this.dragEventTargets, - { htmlElement, prevCursor: htmlElement.style.cursor }, - ]; - htmlElement.style.cursor = resizeCursorStyle; - } - }); - - this.upSubscription = up$.subscribe(() => { - if (this.state.isResizing) { - this.dragEventTargets.reverse().forEach(eventTarget => { - eventTarget.htmlElement.style.cursor = eventTarget.prevCursor; + useEffect(() => { + const move$ = fromEvent(document, 'mousemove'); + const down$ = fromEvent(ref.current.current!, 'mousedown'); + const up$ = fromEvent(document, 'mouseup'); + + drag$.current = down$.pipe(concatMap(() => move$.pipe(takeUntil(up$)))); + dragSubscription.current = + drag$.current && + drag$.current.subscribe(event => { + // We do a feature detection of event.movementX here and if it is missing + // we calculate the delta manually. Browsers IE-11 and Safari will call calculateDelta + const delta = + event.movementX == null || isSafari ? calculateDelta(event) : event.movementX; + if (!isResizingRef.current) { + isResizingRef.current = true; + } + onResize({ id, delta }); + if (event.target != null && event.target instanceof HTMLElement) { + const htmlElement: HTMLElement = event.target; + dragEventTargets.current = [ + ...dragEventTargets.current, + { htmlElement, prevCursor: htmlElement.style.cursor }, + ]; + htmlElement.style.cursor = resizeCursorStyle; + } }); - this.dragEventTargets = []; - this.setState({ isResizing: false }); - } - }); - } - public componentWillUnmount() { - if (this.dragSubscription != null) { - this.dragSubscription.unsubscribe(); - } - - if (this.upSubscription != null) { - this.upSubscription.unsubscribe(); - } - } - - public render() { - const { bottom, handle, height, left, positionAbsolute, render, right, top } = this.props; + upSubscription.current = up$.subscribe(() => { + if (isResizingRef.current) { + dragEventTargets.current.reverse().forEach(eventTarget => { + eventTarget.htmlElement.style.cursor = eventTarget.prevCursor; + }); + dragEventTargets.current = []; + isResizingRef.current = false; + } + }); + return () => { + if (dragSubscription.current != null) { + dragSubscription.current.unsubscribe(); + } + if (upSubscription.current != null) { + upSubscription.current.unsubscribe(); + } + }; + }, []); return ( <> - {render(this.state.isResizing)} + {render(isResizingRef.current)} { ); } +); - private calculateDelta = (e: MouseEvent) => { - const deltaX = calculateDeltaX({ prevX: this.prevX, screenX: e.screenX }); - - this.prevX = e.screenX; - - return deltaX; - }; -} +Resizeable.displayName = 'Resizeable'; diff --git a/x-pack/legacy/plugins/siem/public/components/super_date_picker/index.tsx b/x-pack/legacy/plugins/siem/public/components/super_date_picker/index.tsx index 722cf9db731f7..fa695d76f9f3e 100644 --- a/x-pack/legacy/plugins/siem/public/components/super_date_picker/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/super_date_picker/index.tsx @@ -7,13 +7,13 @@ import dateMath from '@elastic/datemath'; import { EuiSuperDatePicker, - EuiSuperDatePickerProps, OnRefreshChangeProps, + EuiSuperDatePickerRecentRange, OnRefreshProps, OnTimeChangeProps, } from '@elastic/eui'; import { getOr, take } from 'lodash/fp'; -import React, { Component } from 'react'; +import React, { useState } from 'react'; import { connect } from 'react-redux'; import { Dispatch } from 'redux'; @@ -36,33 +36,17 @@ import { InputsRange, Policy } from '../../store/inputs/model'; const MAX_RECENTLY_USED_RANGES = 9; -type MyEuiSuperDatePickerProps = Pick< - EuiSuperDatePickerProps, - | 'end' - | 'isPaused' - | 'onTimeChange' - | 'onRefreshChange' - | 'onRefresh' - | 'recentlyUsedRanges' - | 'refreshInterval' - | 'showUpdateButton' - | 'start' -> & { - isLoading?: boolean; -}; -const MyEuiSuperDatePicker: React.SFC = EuiSuperDatePicker; - interface SuperDatePickerStateRedux { duration: number; - policy: Policy['kind']; - kind: string; - fromStr: string; - toStr: string; - start: number; end: number; + fromStr: string; isLoading: boolean; - queries: inputsModel.GlobalGraphqlQuery[]; + kind: string; kqlQuery: inputsModel.GlobalKqlQuery; + policy: Policy['kind']; + queries: inputsModel.GlobalGraphqlQuery[]; + start: number; + toStr: string; } interface UpdateReduxTime extends OnTimeChangeProps { @@ -85,145 +69,137 @@ type DispatchUpdateReduxTime = ({ }: UpdateReduxTime) => ReturnUpdateReduxTime; interface SuperDatePickerDispatchProps { + setDuration: ({ id, duration }: { id: InputsModelId; duration: number }) => void; startAutoReload: ({ id }: { id: InputsModelId }) => void; stopAutoReload: ({ id }: { id: InputsModelId }) => void; - setDuration: ({ id, duration }: { id: InputsModelId; duration: number }) => void; updateReduxTime: DispatchUpdateReduxTime; } interface OwnProps { - id: InputsModelId; disabled?: boolean; + id: InputsModelId; timelineId?: string; } -interface TimeArgs { - start: string; - end: string; -} - export type SuperDatePickerProps = OwnProps & SuperDatePickerDispatchProps & SuperDatePickerStateRedux; -export interface SuperDatePickerState { - isQuickSelection: boolean; - recentlyUsedRanges: TimeArgs[]; - showUpdateButton: boolean; -} +export const SuperDatePickerComponent = React.memo( + ({ + duration, + end, + fromStr, + id, + isLoading, + kind, + kqlQuery, + policy, + queries, + setDuration, + start, + startAutoReload, + stopAutoReload, + timelineId, + toStr, + updateReduxTime, + }) => { + const [isQuickSelection, setIsQuickSelection] = useState(true); + const [recentlyUsedRanges, setRecentlyUsedRanges] = useState( + [] + ); + const onRefresh = ({ start: newStart, end: newEnd }: OnRefreshProps): void => { + const { kqlHasBeenUpdated } = updateReduxTime({ + end: newEnd, + id, + isInvalid: false, + isQuickSelection, + kql: kqlQuery, + start: newStart, + timelineId, + }); + const currentStart = formatDate(newStart); + const currentEnd = isQuickSelection + ? formatDate(newEnd, { roundUp: true }) + : formatDate(newEnd); + if ( + !kqlHasBeenUpdated && + (!isQuickSelection || (start === currentStart && end === currentEnd)) + ) { + refetchQuery(queries); + } + }; + + const onRefreshChange = ({ isPaused, refreshInterval }: OnRefreshChangeProps): void => { + if (duration !== refreshInterval) { + setDuration({ id, duration: refreshInterval }); + } -export const SuperDatePickerComponent = class extends Component< - SuperDatePickerProps, - SuperDatePickerState -> { - constructor(props: SuperDatePickerProps) { - super(props); + if (isPaused && policy === 'interval') { + stopAutoReload({ id }); + } else if (!isPaused && policy === 'manual') { + startAutoReload({ id }); + } - this.state = { - isQuickSelection: true, - recentlyUsedRanges: [], - showUpdateButton: true, + if (!isPaused && (!isQuickSelection || (isQuickSelection && toStr !== 'now'))) { + refetchQuery(queries); + } + }; + + const refetchQuery = (newQueries: inputsModel.GlobalGraphqlQuery[]) => { + newQueries.forEach(q => q.refetch && (q.refetch as inputsModel.Refetch)()); }; - } - public render() { - const { duration, end, start, kind, fromStr, policy, toStr, isLoading } = this.props; + const onTimeChange = ({ + start: newStart, + end: newEnd, + isQuickSelection: newIsQuickSelection, + isInvalid, + }: OnTimeChangeProps) => { + if (!isInvalid) { + updateReduxTime({ + end: newEnd, + id, + isInvalid, + isQuickSelection: newIsQuickSelection, + kql: kqlQuery, + start: newStart, + timelineId, + }); + const newRecentlyUsedRanges = [ + { start: newStart, end: newEnd }, + ...take( + MAX_RECENTLY_USED_RANGES, + recentlyUsedRanges.filter( + recentlyUsedRange => + !(recentlyUsedRange.start === newStart && recentlyUsedRange.end === newEnd) + ) + ), + ]; + + setRecentlyUsedRanges(newRecentlyUsedRanges); + setIsQuickSelection(newIsQuickSelection); + } + }; const endDate = kind === 'relative' ? toStr : new Date(end).toISOString(); const startDate = kind === 'relative' ? fromStr : new Date(start).toISOString(); return ( - ); } - private onRefresh = ({ start, end }: OnRefreshProps): void => { - const { kqlHasBeenUpdated } = this.props.updateReduxTime({ - end, - id: this.props.id, - isInvalid: false, - isQuickSelection: this.state.isQuickSelection, - kql: this.props.kqlQuery, - start, - timelineId: this.props.timelineId, - }); - const currentStart = formatDate(start); - const currentEnd = this.state.isQuickSelection - ? formatDate(end, { roundUp: true }) - : formatDate(end); - if ( - !kqlHasBeenUpdated && - (!this.state.isQuickSelection || - (this.props.start === currentStart && this.props.end === currentEnd)) - ) { - this.refetchQuery(this.props.queries); - } - }; - - private onRefreshChange = ({ isPaused, refreshInterval }: OnRefreshChangeProps): void => { - const { id, duration, policy, stopAutoReload, startAutoReload } = this.props; - if (duration !== refreshInterval) { - this.props.setDuration({ id, duration: refreshInterval }); - } - - if (isPaused && policy === 'interval') { - stopAutoReload({ id }); - } else if (!isPaused && policy === 'manual') { - startAutoReload({ id }); - } - - if ( - !isPaused && - (!this.state.isQuickSelection || (this.state.isQuickSelection && this.props.toStr !== 'now')) - ) { - this.refetchQuery(this.props.queries); - } - }; - - private refetchQuery = (queries: inputsModel.GlobalGraphqlQuery[]) => { - queries.forEach(q => q.refetch && (q.refetch as inputsModel.Refetch)()); - }; - - private onTimeChange = ({ start, end, isQuickSelection, isInvalid }: OnTimeChangeProps) => { - if (!isInvalid) { - this.props.updateReduxTime({ - end, - id: this.props.id, - isInvalid, - isQuickSelection, - kql: this.props.kqlQuery, - start, - timelineId: this.props.timelineId, - }); - this.setState((prevState: SuperDatePickerState) => { - const recentlyUsedRanges = [ - { start, end }, - ...take( - MAX_RECENTLY_USED_RANGES, - prevState.recentlyUsedRanges.filter( - recentlyUsedRange => - !(recentlyUsedRange.start === start && recentlyUsedRange.end === end) - ) - ), - ]; - - return { - recentlyUsedRanges, - isQuickSelection, - }; - }); - } - }; -}; +); const formatDate = ( date: string, @@ -292,33 +268,35 @@ const dispatchUpdateReduxTime = (dispatch: Dispatch) => ({ }; export const makeMapStateToProps = () => { - const getPolicySelector = policySelector(); const getDurationSelector = durationSelector(); - const getKindSelector = kindSelector(); - const getStartSelector = startSelector(); const getEndSelector = endSelector(); const getFromStrSelector = fromStrSelector(); - const getToStrSelector = toStrSelector(); const getIsLoadingSelector = isLoadingSelector(); - const getQueriesSelector = queriesSelector(); + const getKindSelector = kindSelector(); const getKqlQuerySelector = kqlQuerySelector(); + const getPolicySelector = policySelector(); + const getQueriesSelector = queriesSelector(); + const getStartSelector = startSelector(); + const getToStrSelector = toStrSelector(); return (state: State, { id }: OwnProps) => { const inputsRange: InputsRange = getOr({}, `inputs.${id}`, state); return { - policy: getPolicySelector(inputsRange), duration: getDurationSelector(inputsRange), - kind: getKindSelector(inputsRange), - start: getStartSelector(inputsRange), end: getEndSelector(inputsRange), fromStr: getFromStrSelector(inputsRange), - toStr: getToStrSelector(inputsRange), isLoading: getIsLoadingSelector(inputsRange), - queries: getQueriesSelector(inputsRange), + kind: getKindSelector(inputsRange), kqlQuery: getKqlQuerySelector(inputsRange), + policy: getPolicySelector(inputsRange), + queries: getQueriesSelector(inputsRange), + start: getStartSelector(inputsRange), + toStr: getToStrSelector(inputsRange), }; }; }; +SuperDatePickerComponent.displayName = 'SuperDatePickerComponent'; + const mapDispatchToProps = (dispatch: Dispatch) => ({ startAutoReload: ({ id }: { id: InputsModelId }) => dispatch(inputsActions.startAutoReload({ id })), diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/header/index.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/header/index.tsx index 62f58e1b585d9..1e603b0c15779 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/header/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/header/index.tsx @@ -91,53 +91,56 @@ interface Props { } /** Renders a header */ -export class Header extends React.PureComponent { - public render() { - const { header } = this.props; +export const Header = React.memo( + ({ + header, + onColumnRemoved, + onColumnResized, + onColumnSorted, + onFilterChange = noop, + setIsResizing, + sort, + }) => { + const onClick = () => { + onColumnSorted!({ + columnId: header.id, + sortDirection: getNewSortDirectionOnClick({ + clickedHeader: header, + currentSort: sort, + }), + }); + }; + + const onResize: OnResize = ({ delta, id }) => { + onColumnResized({ columnId: id, delta }); + }; + + const renderActions = (isResizing: boolean) => { + setIsResizing(isResizing); + return ( + <> + + + + + + + ); + }; return ( } id={header.id} - onResize={this.onResize} + onResize={onResize} positionAbsolute - render={this.renderActions} + render={renderActions} right="-1px" top={0} /> ); } +); - private renderActions = (isResizing: boolean) => { - const { header, onColumnRemoved, onFilterChange = noop, setIsResizing, sort } = this.props; - - setIsResizing(isResizing); - - return ( - <> - - - - - - - ); - }; - - private onClick = () => { - const { header, onColumnSorted, sort } = this.props; - - onColumnSorted!({ - columnId: header.id, - sortDirection: getNewSortDirectionOnClick({ - clickedHeader: header, - currentSort: sort, - }), - }); - }; - - private onResize: OnResize = ({ delta, id }) => { - this.props.onColumnResized({ columnId: id, delta }); - }; -} +Header.displayName = 'Header'; diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/body/data_driven_columns/index.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/body/data_driven_columns/index.tsx index 44d0480bc5f28..2b2401519eb32 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/body/data_driven_columns/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/body/data_driven_columns/index.tsx @@ -22,10 +22,8 @@ interface Props { timelineId: string; } -export class DataDrivenColumns extends React.PureComponent { - public render() { - const { _id, columnHeaders, columnRenderers, data, timelineId } = this.props; - +export const DataDrivenColumns = React.memo( + ({ _id, columnHeaders, columnRenderers, data, timelineId }) => { // Passing the styles directly to the component because the width is // being calculated and is recommended by Styled Components for performance // https://github.com/styled-components/styled-components/issues/134#issuecomment-312415291 @@ -51,7 +49,9 @@ export class DataDrivenColumns extends React.PureComponent { ); } -} +); + +DataDrivenColumns.displayName = 'DataDrivenColumns'; const getMappedNonEcsValue = ({ data, diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/body/events/stateful_event.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/body/events/stateful_event.tsx index 1e3f7303c2e1d..766a75c05f17c 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/body/events/stateful_event.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/body/events/stateful_event.tsx @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import * as React from 'react'; -import VisibilitySensor from 'react-visibility-sensor'; +import React, { useEffect, useRef, useState } from 'react'; import uuid from 'uuid'; +import VisibilitySensor from 'react-visibility-sensor'; import { BrowserFields } from '../../../../containers/source'; import { TimelineDetailsComponentQuery } from '../../../../containers/timeline/details'; @@ -35,24 +35,18 @@ interface Props { columnRenderers: ColumnRenderer[]; event: TimelineItem; eventIdToNoteIds: Readonly>; - isEventViewer?: boolean; getNotesByIds: (noteIds: string[]) => Note[]; + isEventViewer?: boolean; + maxDelay?: number; onColumnResized: OnColumnResized; onPinEvent: OnPinEvent; - onUpdateColumns: OnUpdateColumns; onUnPinEvent: OnUnPinEvent; + onUpdateColumns: OnUpdateColumns; pinnedEventIds: Readonly>; rowRenderers: RowRenderer[]; timelineId: string; toggleColumn: (column: ColumnHeader) => void; updateNote: UpdateNote; - maxDelay?: number; -} - -interface State { - expanded: { [eventId: string]: boolean }; - showNotes: { [eventId: string]: boolean }; - initialRender: boolean; } export const getNewNoteId = (): string => uuid.v4(); @@ -105,69 +99,86 @@ const Attributes = React.memo(({ children }) => { ); }); -export class StatefulEvent extends React.Component { - private _isMounted: boolean = false; +export const StatefulEvent = React.memo( + ({ + actionsColumnWidth, + addNoteToEvent, + browserFields, + columnHeaders, + columnRenderers, + event, + eventIdToNoteIds, + getNotesByIds, + isEventViewer = false, + maxDelay = 0, + onColumnResized, + onPinEvent, + onUnPinEvent, + onUpdateColumns, + pinnedEventIds, + rowRenderers, + timelineId, + toggleColumn, + updateNote, + }) => { + const [expanded, setExpanded] = useState<{ [eventId: string]: boolean }>({}); + const [initialRender, setInitialRender] = useState(false); + const [showNotes, setShowNotes] = useState<{ [eventId: string]: boolean }>({}); - public readonly state: State = { - expanded: {}, - showNotes: {}, - initialRender: false, - }; + const divElement = useRef(null); - public divElement: HTMLDivElement | null = null; + const onToggleShowNotes = (eventId: string): (() => void) => () => { + setShowNotes({ ...showNotes, [eventId]: !showNotes[eventId] }); + }; - /** - * Incrementally loads the events when it mounts by trying to - * see if it resides within a window frame and if it is it will - * indicate to React that it should render its self by setting - * its initialRender to true. - */ - public componentDidMount() { - this._isMounted = true; + const onToggleExpanded = (eventId: string): (() => void) => () => { + setExpanded({ + ...expanded, + [eventId]: !expanded[eventId], + }); + }; - requestIdleCallbackViaScheduler( - () => { - if (!this.state.initialRender && this._isMounted) { - this.setState({ initialRender: true }); - } - }, - { timeout: this.props.maxDelay ? this.props.maxDelay : 0 } - ); - } + const associateNote = ( + eventId: string, + addNoteToEventChild: AddNoteToEvent, + onPinEventChild: OnPinEvent + ): ((noteId: string) => void) => (noteId: string) => { + addNoteToEventChild({ eventId, noteId }); + if (!eventIsPinned({ eventId, pinnedEventIds })) { + onPinEventChild(eventId); // pin the event, because it has notes + } + }; - componentWillUnmount() { - this._isMounted = false; - } + /** + * Incrementally loads the events when it mounts by trying to + * see if it resides within a window frame and if it is it will + * indicate to React that it should render its self by setting + * its initialRender to true. + */ - public render() { - const { - actionsColumnWidth, - addNoteToEvent, - browserFields, - columnHeaders, - columnRenderers, - event, - eventIdToNoteIds, - getNotesByIds, - isEventViewer = false, - onColumnResized, - onPinEvent, - onUpdateColumns, - onUnPinEvent, - pinnedEventIds, - rowRenderers, - timelineId, - toggleColumn, - updateNote, - } = this.props; + useEffect(() => { + let _isMounted = true; + + requestIdleCallbackViaScheduler( + () => { + if (!initialRender && _isMounted) { + setInitialRender(true); + } + }, + { timeout: maxDelay } + ); + return () => { + _isMounted = false; + }; + }, []); // Number of current columns plus one for actions. const columnCount = columnHeaders.length + 1; // If we are not ready to render yet, just return null - // see componentDidMount() for when it schedules the first + // see useEffect() for when it schedules the first // time this stateful component should be rendered. - if (!this.state.initialRender) { + if (!initialRender) { return ; } @@ -184,7 +195,7 @@ export class StatefulEvent extends React.Component { sourceId="default" indexName={event._index!} eventId={event._id} - executeQuery={!!this.state.expanded[event._id]} + executeQuery={!!expanded[event._id]} > {({ detailsData, loading }) => ( { data-test-subj="event" innerRef={c => { if (c != null) { - this.divElement = c; + divElement.current = c; } }} > @@ -201,26 +212,26 @@ export class StatefulEvent extends React.Component { data: event.ecs, children: ( ), @@ -231,9 +242,9 @@ export class StatefulEvent extends React.Component { { } else { // Height place holder for visibility detection as well as re-rendering sections. const height = - this.divElement != null ? this.divElement.clientHeight + 'px' : DEFAULT_ROW_HEIGHT; + divElement.current != null + ? `${divElement.current.clientHeight}px` + : DEFAULT_ROW_HEIGHT; // height is being inlined directly in here because of performance with StyledComponents // involving quick and constant changes to the DOM. @@ -257,33 +270,6 @@ export class StatefulEvent extends React.Component { ); } +); - private onToggleShowNotes = (eventId: string): (() => void) => () => { - this.setState(state => ({ - showNotes: { - ...state.showNotes, - [eventId]: !state.showNotes[eventId], - }, - })); - }; - - private onToggleExpanded = (eventId: string): (() => void) => () => { - this.setState(state => ({ - expanded: { - ...state.expanded, - [eventId]: !state.expanded[eventId], - }, - })); - }; - - private associateNote = ( - eventId: string, - addNoteToEvent: AddNoteToEvent, - onPinEvent: OnPinEvent - ): ((noteId: string) => void) => (noteId: string) => { - addNoteToEvent({ eventId, noteId }); - if (!eventIsPinned({ eventId, pinnedEventIds: this.props.pinnedEventIds })) { - onPinEvent(eventId); // pin the event, because it has notes - } - }; -} +StatefulEvent.displayName = 'StatefulEvent'; diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/body/stateful_body.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/body/stateful_body.tsx index 871a60d18404a..d93446b2af95b 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/body/stateful_body.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/body/stateful_body.tsx @@ -84,8 +84,10 @@ type StatefulBodyComponentProps = OwnProps & ReduxProps & DispatchProps; export const emptyColumnHeaders: ColumnHeader[] = []; -class StatefulBodyComponent extends React.Component { - public shouldComponentUpdate({ +const StatefulBodyComponent = React.memo( + ({ + addNoteToEvent, + applyDeltaToColumnWidth, browserFields, columnHeaders, data, @@ -93,45 +95,46 @@ class StatefulBodyComponent extends React.Component getNotesByIds, height, id, - isEventViewer, + isEventViewer = false, + pinEvent, pinnedEventIds, range, + removeColumn, sort, - }: StatefulBodyComponentProps) { - return ( - browserFields !== this.props.browserFields || - columnHeaders !== this.props.columnHeaders || - data !== this.props.data || - eventIdToNoteIds !== this.props.eventIdToNoteIds || - getNotesByIds !== this.props.getNotesByIds || - height !== this.props.height || - id !== this.props.id || - isEventViewer !== this.props.isEventViewer || - pinnedEventIds !== this.props.pinnedEventIds || - range !== this.props.range || - sort !== this.props.sort - ); - } + toggleColumn, + unPinEvent, + updateColumns, + updateNote, + updateSort, + }) => { + const onAddNoteToEvent: AddNoteToEvent = ({ + eventId, + noteId, + }: { + eventId: string; + noteId: string; + }) => addNoteToEvent!({ id, eventId, noteId }); + + const onColumnSorted: OnColumnSorted = sorted => { + updateSort!({ id, sort: sorted }); + }; - public render() { - const { - browserFields, - columnHeaders, - data, - eventIdToNoteIds, - getNotesByIds, - height, - id, - isEventViewer = false, - pinnedEventIds, - range, - sort, - toggleColumn, - } = this.props; + const onColumnRemoved: OnColumnRemoved = columnId => removeColumn!({ id, columnId }); + + const onColumnResized: OnColumnResized = ({ columnId, delta }) => + applyDeltaToColumnWidth!({ id, columnId, delta }); + + const onPinEvent: OnPinEvent = eventId => pinEvent!({ id, eventId }); + + const onUnPinEvent: OnUnPinEvent = eventId => unPinEvent!({ id, eventId }); + + const onUpdateNote: UpdateNote = (note: Note) => updateNote!({ note }); + + const onUpdateColumns: OnUpdateColumns = columns => updateColumns!({ id, columns }); return ( height={height} id={id} isEventViewer={isEventViewer} - onColumnResized={this.onColumnResized} - onColumnRemoved={this.onColumnRemoved} - onColumnSorted={this.onColumnSorted} + onColumnRemoved={onColumnRemoved} + onColumnResized={onColumnResized} + onColumnSorted={onColumnSorted} onFilterChange={noop} // TODO: this is the callback for column filters, which is out scope for this phase of delivery - onPinEvent={this.onPinEvent} - onUpdateColumns={this.onUpdateColumns} - onUnPinEvent={this.onUnPinEvent} + onPinEvent={onPinEvent} + onUnPinEvent={onUnPinEvent} + onUpdateColumns={onUpdateColumns} pinnedEventIds={pinnedEventIds} range={range!} rowRenderers={rowRenderers} sort={sort} toggleColumn={toggleColumn} - updateNote={this.onUpdateNote} + updateNote={onUpdateNote} /> ); + }, + (prevProps, nextProps) => { + return ( + prevProps.browserFields === nextProps.browserFields && + prevProps.columnHeaders === nextProps.columnHeaders && + prevProps.data === nextProps.data && + prevProps.eventIdToNoteIds === nextProps.eventIdToNoteIds && + prevProps.getNotesByIds === nextProps.getNotesByIds && + prevProps.height === nextProps.height && + prevProps.id === nextProps.id && + prevProps.isEventViewer === nextProps.isEventViewer && + prevProps.pinnedEventIds === nextProps.pinnedEventIds && + prevProps.range === nextProps.range && + prevProps.sort === nextProps.sort + ); } +); - private onAddNoteToEvent: AddNoteToEvent = ({ - eventId, - noteId, - }: { - eventId: string; - noteId: string; - }) => this.props.addNoteToEvent!({ id: this.props.id, eventId, noteId }); - - private onColumnSorted: OnColumnSorted = sorted => { - this.props.updateSort!({ id: this.props.id, sort: sorted }); - }; - - private onColumnRemoved: OnColumnRemoved = columnId => - this.props.removeColumn!({ id: this.props.id, columnId }); - - private onColumnResized: OnColumnResized = ({ columnId, delta }) => - this.props.applyDeltaToColumnWidth!({ id: this.props.id, columnId, delta }); - - private onPinEvent: OnPinEvent = eventId => this.props.pinEvent!({ id: this.props.id, eventId }); - - private onUnPinEvent: OnUnPinEvent = eventId => - this.props.unPinEvent!({ id: this.props.id, eventId }); - - private onUpdateNote: UpdateNote = (note: Note) => this.props.updateNote!({ note }); - - private onUpdateColumns: OnUpdateColumns = columns => - this.props.updateColumns!({ id: this.props.id, columns }); -} +StatefulBodyComponent.displayName = 'StatefulBodyComponent'; const makeMapStateToProps = () => { const memoizedColumnHeaders: ( @@ -201,9 +193,9 @@ const makeMapStateToProps = () => { return { columnHeaders: memoizedColumnHeaders(columns, browserFields), - id, eventIdToNoteIds, getNotesByIds: getNotesByIds(state), + id, pinnedEventIds, }; }; @@ -215,12 +207,12 @@ export const StatefulBody = connect( { addNoteToEvent: timelineActions.addNoteToEvent, applyDeltaToColumnWidth: timelineActions.applyDeltaToColumnWidth, - unPinEvent: timelineActions.unPinEvent, - updateColumns: timelineActions.updateColumns, - updateSort: timelineActions.updateSort, pinEvent: timelineActions.pinEvent, removeColumn: timelineActions.removeColumn, removeProvider: timelineActions.removeProvider, + unPinEvent: timelineActions.unPinEvent, + updateColumns: timelineActions.updateColumns, updateNote: appActions.updateNote, + updateSort: timelineActions.updateSort, } )(StatefulBodyComponent); diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/data_providers/provider_item_badge.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/data_providers/provider_item_badge.tsx index 29417bd0b578b..98cf0a78b1d1f 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/data_providers/provider_item_badge.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/data_providers/provider_item_badge.tsx @@ -5,7 +5,7 @@ */ import { noop } from 'lodash/fp'; -import React, { PureComponent } from 'react'; +import React, { useState } from 'react'; import { BrowserFields } from '../../../containers/source'; @@ -32,30 +32,42 @@ interface ProviderItemBadgeProps { val: string | number; } -interface OwnState { - isPopoverOpen: boolean; -} +export const ProviderItemBadge = React.memo( + ({ + andProviderId, + browserFields, + deleteProvider, + field, + kqlQuery, + isEnabled, + isExcluded, + onDataProviderEdited, + operator, + providerId, + timelineId, + toggleEnabledProvider, + toggleExcludedProvider, + val, + }) => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + function togglePopover() { + setIsPopoverOpen(!isPopoverOpen); + } -export class ProviderItemBadge extends PureComponent { - public readonly state = { - isPopoverOpen: false, - }; + function closePopover() { + setIsPopoverOpen(false); + } - public render() { - const { - andProviderId, - browserFields, - deleteProvider, - field, - kqlQuery, - isEnabled, - isExcluded, - onDataProviderEdited, - operator, - providerId, - timelineId, - val, - } = this.props; + function onToggleEnabledProvider() { + toggleEnabledProvider(); + closePopover(); + } + + function onToggleExcludedProvider() { + toggleExcludedProvider(); + closePopover(); + } return ( @@ -71,51 +83,31 @@ export class ProviderItemBadge extends PureComponent } - closePopover={this.closePopover} + closePopover={closePopover} deleteProvider={deleteProvider} field={field} kqlQuery={kqlQuery} isEnabled={isEnabled} isExcluded={isExcluded} isLoading={isLoading} - isOpen={this.state.isPopoverOpen} + isOpen={isPopoverOpen} onDataProviderEdited={onDataProviderEdited} operator={operator} providerId={providerId} timelineId={timelineId} - toggleEnabledProvider={this.toggleEnabledProvider} - toggleExcludedProvider={this.toggleExcludedProvider} + toggleEnabledProvider={onToggleEnabledProvider} + toggleExcludedProvider={onToggleExcludedProvider} value={val} /> )} ); } +); - private togglePopover = () => { - this.setState(prevState => ({ - isPopoverOpen: !prevState.isPopoverOpen, - })); - }; - - private closePopover = () => { - this.setState({ - isPopoverOpen: false, - }); - }; - - private toggleEnabledProvider = () => { - this.props.toggleEnabledProvider(); - this.closePopover(); - }; - - private toggleExcludedProvider = () => { - this.props.toggleExcludedProvider(); - this.closePopover(); - }; -} +ProviderItemBadge.displayName = 'ProviderItemBadge'; diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/footer/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/footer/index.test.tsx index 79f85103077b7..6e8a0e8cfb17f 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/footer/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/footer/index.test.tsx @@ -11,7 +11,7 @@ import * as React from 'react'; import { TestProviders } from '../../../mock/test_providers'; -import { Footer } from './index'; +import { Footer, PagingControl } from './index'; import { mockData } from './mock'; describe('Footer Timeline Component', () => { @@ -93,38 +93,36 @@ describe('Footer Timeline Component', () => { }); test('it renders the Loading... in the more load button when fetching new data', () => { - const wrapper = mount( - -
- + const wrapper = shallow( + ); - wrapper - .find(Footer) - .instance() - .setState({ paginationLoading: true }); - wrapper.update(); + + const loadButton = wrapper + .find('[data-test-subj="TimelineMoreButton"]') + .dive() + .text(); expect(wrapper.find('[data-test-subj="LoadingPanelTimeline"]').exists()).toBeFalsy(); - expect( - wrapper - .find('[data-test-subj="TimelineMoreButton"]') - .first() - .text() - ).toContain('Loading...'); + expect(loadButton).toContain('Loading...'); + }); + + test('it renders the Load More in the more load button when fetching new data', () => { + const wrapper = shallow( + + ); + + const loadButton = wrapper + .find('[data-test-subj="TimelineMoreButton"]') + .dive() + .text(); + expect(loadButton).toContain('Load More'); }); test('it does NOT render the loadMore button because there is nothing else to fetch', () => { diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/footer/index.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/footer/index.tsx index 104c8c9c03ddf..c1772d9e55577 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/footer/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/footer/index.tsx @@ -18,7 +18,7 @@ import { EuiToolTip, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import * as React from 'react'; +import React, { useEffect, useState } from 'react'; import { pure } from 'recompose'; import styled from 'styled-components'; @@ -73,27 +73,21 @@ ServerSideEventCount.displayName = 'ServerSideEventCount'; export const footerHeight = 40; // px interface FooterProps { - itemsCount: number; + compact: boolean; + getUpdatedAt: () => number; + hasNextPage: boolean; + height: number; isEventViewer?: boolean; isLive: boolean; isLoading: boolean; + itemsCount: number; itemsPerPage: number; itemsPerPageOptions: number[]; - hasNextPage: boolean; - height: number; nextCursor: string; onChangeItemsPerPage: OnChangeItemsPerPage; onLoadMore: OnLoadMore; serverSideEventCount: number; tieBreaker: string; - getUpdatedAt: () => number; - compact: boolean; -} - -interface FooterState { - isPopoverOpen: boolean; - paginationLoading: boolean; - updatedAt: number | null; } /** Displays the server-side count of events */ @@ -144,7 +138,7 @@ export const EventsCount = pure<{ EventsCount.displayName = 'EventsCount'; -export const PagingControl = pure<{ +export const PagingControl = React.memo<{ hasNextPage: boolean; isLoading: boolean; loadMore: () => void; @@ -166,81 +160,49 @@ export const PagingControl = pure<{ PagingControl.displayName = 'PagingControl'; /** Renders a loading indicator and paging controls */ -export class Footer extends React.Component { - public readonly state = { - isPopoverOpen: false, - paginationLoading: false, - updatedAt: null, - }; - - public shouldComponentUpdate( - { - compact, - hasNextPage, - height, - isEventViewer, - isLive, - isLoading, - itemsCount, - itemsPerPage, - itemsPerPageOptions, - serverSideEventCount, - }: FooterProps, - { isPopoverOpen, paginationLoading, updatedAt }: FooterState - ) { - return ( - compact !== this.props.compact || - hasNextPage !== this.props.hasNextPage || - height !== this.props.height || - isEventViewer !== this.props.isEventViewer || - isLive !== this.props.isLive || - isLoading !== this.props.isLoading || - isPopoverOpen !== this.state.isPopoverOpen || - itemsCount !== this.props.itemsCount || - itemsPerPage !== this.props.itemsPerPage || - itemsPerPageOptions !== this.props.itemsPerPageOptions || - paginationLoading !== this.state.paginationLoading || - serverSideEventCount !== this.props.serverSideEventCount || - updatedAt !== this.state.updatedAt - ); - } - - public componentDidUpdate(prevProps: FooterProps) { - const { paginationLoading, updatedAt } = this.state; - const { isLoading, getUpdatedAt } = this.props; - if (paginationLoading && prevProps.isLoading && !isLoading) { - this.setState(prevState => ({ - ...prevState, - paginationLoading: false, - updatedAt: getUpdatedAt(), - })); - } +export const Footer = React.memo( + ({ + compact, + getUpdatedAt, + hasNextPage, + height, + isEventViewer, + isLive, + isLoading, + itemsCount, + itemsPerPage, + itemsPerPageOptions, + nextCursor, + onChangeItemsPerPage, + onLoadMore, + serverSideEventCount, + tieBreaker, + }) => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const [paginationLoading, setPaginationLoading] = useState(false); + const [updatedAt, setUpdatedAt] = useState(null); + + const loadMore = () => { + setPaginationLoading(true); + onLoadMore(nextCursor, tieBreaker); + }; + + const onButtonClick = () => setIsPopoverOpen(!isPopoverOpen); + + const closePopover = () => setIsPopoverOpen(false); + + useEffect(() => { + if (paginationLoading && !isLoading) { + setPaginationLoading(false); + setUpdatedAt(getUpdatedAt()); + } - if (updatedAt === null || (prevProps.isLoading && !isLoading)) { - this.setState(prevState => ({ - ...prevState, - updatedAt: getUpdatedAt(), - })); - } - } + if (updatedAt === null || !isLoading) { + setUpdatedAt(getUpdatedAt()); + } + }, [isLoading]); - public render() { - const { - height, - isEventViewer, - isLive, - isLoading, - itemsCount, - itemsPerPage, - itemsPerPageOptions, - onChangeItemsPerPage, - serverSideEventCount, - hasNextPage, - getUpdatedAt, - compact, - } = this.props; - - if (isLoading && !this.state.paginationLoading) { + if (isLoading && !paginationLoading) { return ( { key={item} icon={itemsPerPage === item ? 'check' : 'empty'} onClick={() => { - this.closePopover(); + closePopover(); onChangeItemsPerPage(item); }} > {`${item} ${i18n.ROWS}`} )); + return ( <> { gutterSize="none" > @@ -327,44 +290,35 @@ export class Footer extends React.Component { data-test-subj="paging-control" hasNextPage={hasNextPage} isLoading={isLoading} - loadMore={this.loadMore} + loadMore={loadMore} /> )} - + ); + }, + (prevProps, nextProps) => { + return ( + prevProps.compact === nextProps.compact && + prevProps.hasNextPage === nextProps.hasNextPage && + prevProps.height === nextProps.height && + prevProps.isEventViewer === nextProps.isEventViewer && + prevProps.isLive === nextProps.isLive && + prevProps.isLoading === nextProps.isLoading && + prevProps.itemsCount === nextProps.itemsCount && + prevProps.itemsPerPage === nextProps.itemsPerPage && + prevProps.itemsPerPageOptions === nextProps.itemsPerPageOptions && + prevProps.serverSideEventCount === nextProps.serverSideEventCount + ); } +); - private loadMore = () => { - this.setState(prevState => ({ - ...prevState, - paginationLoading: true, - })); - this.props.onLoadMore(this.props.nextCursor, this.props.tieBreaker); - }; - - private onButtonClick = () => { - this.setState(prevState => ({ - ...prevState, - isPopoverOpen: !prevState.isPopoverOpen, - })); - }; - - private closePopover = () => { - this.setState(prevState => ({ - ...prevState, - isPopoverOpen: false, - })); - }; -} +Footer.displayName = 'Footer'; diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/footer/last_updated.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/footer/last_updated.tsx index 0953341fe8a90..a17e5a6da6331 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/footer/last_updated.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/footer/last_updated.tsx @@ -6,7 +6,7 @@ import { EuiIcon, EuiText, EuiToolTip } from '@elastic/eui'; import { FormattedRelative } from '@kbn/i18n/react'; -import * as React from 'react'; +import React, { useEffect, useState } from 'react'; import { pure } from 'recompose'; import * as i18n from './translations'; @@ -16,10 +16,6 @@ interface LastUpdatedAtProps { updatedAt: number; } -interface LastUpdatedAtState { - date: number; -} - export const Updated = pure<{ date: number; prefix: string; updatedAt: number }>( ({ date, prefix, updatedAt }) => ( <> @@ -37,46 +33,37 @@ export const Updated = pure<{ date: number; prefix: string; updatedAt: number }> Updated.displayName = 'Updated'; -export class LastUpdatedAt extends React.PureComponent { - public readonly state = { - date: Date.now(), - }; - private timerID?: NodeJS.Timeout; +const prefix = ` ${i18n.UPDATED} `; - public componentDidMount() { - this.timerID = setInterval(() => this.tick(), 10000); - } +export const LastUpdatedAt = React.memo(({ compact = false, updatedAt }) => { + const [date, setDate] = useState(Date.now()); - public componentWillUnmount() { - clearInterval(this.timerID!); + function tick() { + setDate(Date.now()); } - public tick() { - this.setState({ - date: Date.now(), - }); - } + useEffect(() => { + const timerID = setInterval(() => tick(), 10000); + return () => { + clearInterval(timerID); + }; + }, []); - public render() { - const { compact = false } = this.props; - const prefix = ` ${i18n.UPDATED} `; + return ( + + + + } + > + + + {!compact ? : null} + + + ); +}); - return ( - - - - } - > - - - {!compact ? ( - - ) : null} - - - ); - } -} +LastUpdatedAt.displayName = 'LastUpdatedAt'; diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/index.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/index.tsx index 153ca2abd24d1..ab92f22a4c89f 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/index.tsx @@ -5,7 +5,7 @@ */ import { isEqual } from 'lodash/fp'; -import * as React from 'react'; +import React, { useEffect } from 'react'; import { connect } from 'react-redux'; import { ActionCreator } from 'typescript-fsa'; @@ -131,70 +131,113 @@ interface DispatchProps { type Props = OwnProps & StateReduxProps & DispatchProps; -class StatefulTimelineComponent extends React.Component { - public shouldComponentUpdate = ({ - id, - flyoutHeaderHeight, - flyoutHeight, - activePage, +const StatefulTimelineComponent = React.memo( + ({ columns, + createTimeline, dataProviders, end, + flyoutHeaderHeight, + flyoutHeight, + id, isLive, itemsPerPage, itemsPerPageOptions, kqlMode, kqlQueryExpression, - pageCount, - sort, - start, + onDataProviderEdited, + removeColumn, + removeProvider, show, showCallOutUnauthorizedMsg, - }: Props) => - id !== this.props.id || - flyoutHeaderHeight !== this.props.flyoutHeaderHeight || - flyoutHeight !== this.props.flyoutHeight || - activePage !== this.props.activePage || - !isEqual(columns, this.props.columns) || - !isEqual(dataProviders, this.props.dataProviders) || - end !== this.props.end || - isLive !== this.props.isLive || - itemsPerPage !== this.props.itemsPerPage || - !isEqual(itemsPerPageOptions, this.props.itemsPerPageOptions) || - kqlMode !== this.props.kqlMode || - kqlQueryExpression !== this.props.kqlQueryExpression || - pageCount !== this.props.pageCount || - !isEqual(sort, this.props.sort) || - start !== this.props.start || - show !== this.props.show || - showCallOutUnauthorizedMsg !== this.props.showCallOutUnauthorizedMsg; + sort, + start, + updateDataProviderEnabled, + updateDataProviderExcluded, + updateDataProviderKqlQuery, + updateHighlightedDropAndProviderId, + updateItemsPerPage, + upsertColumn, + }) => { + const onDataProviderRemoved: OnDataProviderRemoved = ( + providerId: string, + andProviderId?: string + ) => removeProvider!({ id, providerId, andProviderId }); - public componentDidMount() { - const { createTimeline, id } = this.props; + const onToggleDataProviderEnabled: OnToggleDataProviderEnabled = ({ + providerId, + enabled, + andProviderId, + }) => + updateDataProviderEnabled!({ + id, + enabled, + providerId, + andProviderId, + }); - if (createTimeline != null) { - createTimeline({ id, columns: defaultHeaders, show: false }); - } - } + const onToggleDataProviderExcluded: OnToggleDataProviderExcluded = ({ + providerId, + excluded, + andProviderId, + }) => + updateDataProviderExcluded!({ + id, + excluded, + providerId, + andProviderId, + }); - public render() { - const { - columns, - dataProviders, - end, - flyoutHeight, - flyoutHeaderHeight, - id, - isLive, - itemsPerPage, - itemsPerPageOptions, - kqlMode, - kqlQueryExpression, - show, - showCallOutUnauthorizedMsg, - start, - sort, - } = this.props; + const onDataProviderEditedLocal: OnDataProviderEdited = ({ + andProviderId, + excluded, + field, + operator, + providerId, + value, + }) => + onDataProviderEdited!({ + andProviderId, + excluded, + field, + id, + operator, + providerId, + value, + }); + const onChangeDataProviderKqlQuery: OnChangeDataProviderKqlQuery = ({ providerId, kqlQuery }) => + updateDataProviderKqlQuery!({ id, kqlQuery, providerId }); + + const onChangeItemsPerPage: OnChangeItemsPerPage = itemsChangedPerPage => + updateItemsPerPage!({ id, itemsPerPage: itemsChangedPerPage }); + + const onChangeDroppableAndProvider: OnChangeDroppableAndProvider = providerId => + updateHighlightedDropAndProviderId!({ id, providerId }); + + const toggleColumn = (column: ColumnHeader) => { + const exists = columns.findIndex(c => c.id === column.id) !== -1; + + if (!exists && upsertColumn != null) { + upsertColumn({ + column, + id, + index: 1, + }); + } + + if (exists && removeColumn != null) { + removeColumn({ + columnId: column.id, + id, + }); + } + }; + + useEffect(() => { + if (createTimeline != null) { + createTimeline({ id, columns: defaultHeaders, show: false }); + } + }, []); return ( @@ -202,111 +245,58 @@ class StatefulTimelineComponent extends React.Component { )} ); + }, + (prevProps, nextProps) => { + return ( + prevProps.activePage === nextProps.activePage && + prevProps.end === nextProps.end && + prevProps.flyoutHeaderHeight === nextProps.flyoutHeaderHeight && + prevProps.flyoutHeight === nextProps.flyoutHeight && + prevProps.id === nextProps.id && + prevProps.isLive === nextProps.isLive && + prevProps.itemsPerPage === nextProps.itemsPerPage && + prevProps.kqlMode === nextProps.kqlMode && + prevProps.kqlQueryExpression === nextProps.kqlQueryExpression && + prevProps.pageCount === nextProps.pageCount && + prevProps.show === nextProps.show && + prevProps.showCallOutUnauthorizedMsg === nextProps.showCallOutUnauthorizedMsg && + prevProps.start === nextProps.start && + isEqual(prevProps.columns, nextProps.columns) && + isEqual(prevProps.dataProviders, nextProps.dataProviders) && + isEqual(prevProps.itemsPerPageOptions, nextProps.itemsPerPageOptions) && + isEqual(prevProps.sort, nextProps.sort) + ); } +); - private onDataProviderRemoved: OnDataProviderRemoved = ( - providerId: string, - andProviderId?: string - ) => this.props.removeProvider!({ id: this.props.id, providerId, andProviderId }); - - private onToggleDataProviderEnabled: OnToggleDataProviderEnabled = ({ - providerId, - enabled, - andProviderId, - }) => - this.props.updateDataProviderEnabled!({ - id: this.props.id, - enabled, - providerId, - andProviderId, - }); - - private onToggleDataProviderExcluded: OnToggleDataProviderExcluded = ({ - providerId, - excluded, - andProviderId, - }) => - this.props.updateDataProviderExcluded!({ - id: this.props.id, - excluded, - providerId, - andProviderId, - }); - - private onDataProviderEdited: OnDataProviderEdited = ({ - andProviderId, - excluded, - field, - operator, - providerId, - value, - }) => - this.props.onDataProviderEdited!({ - andProviderId, - excluded, - field, - id: this.props.id, - operator, - providerId, - value, - }); - - private onChangeDataProviderKqlQuery: OnChangeDataProviderKqlQuery = ({ providerId, kqlQuery }) => - this.props.updateDataProviderKqlQuery!({ id: this.props.id, kqlQuery, providerId }); - - private onChangeItemsPerPage: OnChangeItemsPerPage = itemsChangedPerPage => - this.props.updateItemsPerPage!({ id: this.props.id, itemsPerPage: itemsChangedPerPage }); - - private onChangeDroppableAndProvider: OnChangeDroppableAndProvider = providerId => - this.props.updateHighlightedDropAndProviderId!({ id: this.props.id, providerId }); - - private toggleColumn = (column: ColumnHeader) => { - const { columns, removeColumn, id, upsertColumn } = this.props; - const exists = columns.findIndex(c => c.id === column.id) !== -1; - - if (!exists && upsertColumn != null) { - upsertColumn({ - column, - id, - index: 1, - }); - } - - if (exists && removeColumn != null) { - removeColumn({ - columnId: column.id, - id, - }); - } - }; -} +StatefulTimelineComponent.displayName = 'StatefulTimelineComponent'; const makeMapStateToProps = () => { const getShowCallOutUnauthorizedMsg = timelineSelectors.getShowCallOutUnauthorizedMsg(); @@ -322,8 +312,8 @@ const makeMapStateToProps = () => { itemsPerPage, itemsPerPageOptions, kqlMode, - sort, show, + sort, } = timeline; const kqlQueryExpression = getKqlQueryTimeline(state, id); @@ -337,10 +327,10 @@ const makeMapStateToProps = () => { itemsPerPageOptions, kqlMode, kqlQueryExpression, + show, + showCallOutUnauthorizedMsg: getShowCallOutUnauthorizedMsg(state), sort, start: input.timerange.from, - showCallOutUnauthorizedMsg: getShowCallOutUnauthorizedMsg(state), - show, }; }; return mapStateToProps; @@ -352,6 +342,8 @@ export const StatefulTimeline = connect( addProvider: timelineActions.addProvider, createTimeline: timelineActions.createTimeline, onDataProviderEdited: timelineActions.dataProviderEdited, + removeColumn: timelineActions.removeColumn, + removeProvider: timelineActions.removeProvider, updateColumns: timelineActions.updateColumns, updateDataProviderEnabled: timelineActions.updateDataProviderEnabled, updateDataProviderExcluded: timelineActions.updateDataProviderExcluded, @@ -360,8 +352,6 @@ export const StatefulTimeline = connect( updateItemsPerPage: timelineActions.updateItemsPerPage, updateItemsPerPageOptions: timelineActions.updateItemsPerPageOptions, updateSort: timelineActions.updateSort, - removeProvider: timelineActions.removeProvider, - removeColumn: timelineActions.removeColumn, upsertColumn: timelineActions.upsertColumn, } )(StatefulTimelineComponent); diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/properties/index.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/properties/index.tsx index b5daad42f7c3a..b983963c34f55 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/properties/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/properties/index.tsx @@ -5,7 +5,7 @@ */ import { EuiAvatar, EuiFlexItem, EuiIcon } from '@elastic/eui'; -import * as React from 'react'; +import React, { useState } from 'react'; import styled, { injectGlobal } from 'styled-components'; import { Note } from '../../../lib/note'; @@ -62,28 +62,23 @@ HiddenFlexItem.displayName = 'HiddenFlexItem'; interface Props { associateNote: AssociateNote; createTimeline: CreateTimeline; + description: string; + getNotesByIds: (noteIds: string[]) => Note[]; isDataInTimeline: boolean; isDatepickerLocked: boolean; isFavorite: boolean; - title: string; - description: string; - getNotesByIds: (noteIds: string[]) => Note[]; noteIds: string[]; timelineId: string; + title: string; toggleLock: ToggleLock; updateDescription: UpdateDescription; updateIsFavorite: UpdateIsFavorite; - updateTitle: UpdateTitle; updateNote: UpdateNote; + updateTitle: UpdateTitle; usersViewing: string[]; width: number; } -interface State { - showActions: boolean; - showNotes: boolean; -} - const rightGutter = 60; // px export const datePickerThreshold = 600; export const showNotesThreshold = 810; @@ -96,51 +91,40 @@ const noteWidth = 130; const settingsWidth = 50; /** Displays the properties of a timeline, i.e. name, description, notes, etc */ -export class Properties extends React.PureComponent { - constructor(props: Props) { - super(props); +export const Properties = React.memo( + ({ + associateNote, + createTimeline, + description, + getNotesByIds, + isDataInTimeline, + isDatepickerLocked, + isFavorite, + noteIds, + timelineId, + title, + toggleLock, + updateDescription, + updateIsFavorite, + updateNote, + updateTitle, + usersViewing, + width, + }) => { + const [showActions, setShowActions] = useState(false); + const [showNotes, setShowNotes] = useState(false); + + const onButtonClick = () => { + setShowActions(!showActions); + }; - this.state = { - showActions: false, - showNotes: false, + const onToggleShowNotes = () => { + setShowNotes(!showNotes); }; - } - public onButtonClick = () => { - this.setState(prevState => ({ - showActions: !prevState.showActions, - })); - }; - - public onToggleShowNotes = () => { - this.setState(state => ({ showNotes: !state.showNotes })); - }; - - public onClosePopover = () => { - this.setState({ - showActions: false, - }); - }; - - public render() { - const { - associateNote, - createTimeline, - description, - getNotesByIds, - isFavorite, - isDataInTimeline, - isDatepickerLocked, - title, - noteIds, - timelineId, - updateDescription, - updateIsFavorite, - updateTitle, - updateNote, - usersViewing, - width, - } = this.props; + const onClosePopover = () => { + setShowActions(false); + }; const datePickerWidth = width - @@ -157,52 +141,52 @@ export class Properties extends React.PureComponent { return ( datePickerThreshold ? datePickerThreshold : datePickerWidth + } + description={description} + getNotesByIds={getNotesByIds} + isDatepickerLocked={isDatepickerLocked} isFavorite={isFavorite} - timelineId={timelineId} - updateIsFavorite={updateIsFavorite} + noteIds={noteIds} + onToggleShowNotes={onToggleShowNotes} showDescription={width >= showDescriptionThreshold} - description={description} + showNotes={showNotes} + showNotesFromWidth={width >= showNotesThreshold} + timelineId={timelineId} title={title} - updateTitle={updateTitle} + toggleLock={() => { + toggleLock({ linkToId: 'timeline' }); + }} updateDescription={updateDescription} - showNotes={this.state.showNotes} - showNotesFromWidth={width >= showNotesThreshold} - associateNote={associateNote} - getNotesByIds={getNotesByIds} - noteIds={noteIds} - onToggleShowNotes={this.onToggleShowNotes} + updateIsFavorite={updateIsFavorite} updateNote={updateNote} - isDatepickerLocked={isDatepickerLocked} - toggleLock={this.toggleLock} - datePickerWidth={ - datePickerWidth > datePickerThreshold ? datePickerThreshold : datePickerWidth - } + updateTitle={updateTitle} /> 0} - usersViewing={usersViewing} - description={description} + timelineId={timelineId} updateDescription={updateDescription} - associateNote={associateNote} - getNotesByIds={getNotesByIds} - noteIds={noteIds} - onToggleShowNotes={this.onToggleShowNotes} updateNote={updateNote} + usersViewing={usersViewing} /> ); } +); - private toggleLock = () => { - this.props.toggleLock({ linkToId: 'timeline' }); - }; -} +Properties.displayName = 'Properties'; diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/search_or_filter/index.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/search_or_filter/index.tsx index 9213ff79ccc50..91113a545821d 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/search_or_filter/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/search_or_filter/index.tsx @@ -45,19 +45,17 @@ interface DispatchProps { type Props = OwnProps & StateReduxProps & DispatchProps; -class StatefulSearchOrFilterComponent extends React.PureComponent { - public render() { - const { - applyKqlFilterQuery, - indexPattern, - filterQueryDraft, - isFilterQueryDraftValid, - kqlMode, - timelineId, - setKqlFilterQueryDraft, - updateKqlMode, - } = this.props; - +const StatefulSearchOrFilterComponent = React.memo( + ({ + applyKqlFilterQuery, + filterQueryDraft, + indexPattern, + isFilterQueryDraftValid, + kqlMode, + setKqlFilterQueryDraft, + timelineId, + updateKqlMode, + }) => { const applyFilterQueryFromKueryExpression = (expression: string) => applyKqlFilterQuery({ id: timelineId, @@ -86,13 +84,14 @@ class StatefulSearchOrFilterComponent extends React.PureComponent { indexPattern={indexPattern} isFilterQueryDraftValid={isFilterQueryDraftValid} kqlMode={kqlMode!} + setKqlFilterQueryDraft={setFilterQueryDraftFromKueryExpression!} timelineId={timelineId} updateKqlMode={updateKqlMode!} - setKqlFilterQueryDraft={setFilterQueryDraftFromKueryExpression!} /> ); } -} +); +StatefulSearchOrFilterComponent.displayName = 'StatefulSearchOrFilterComponent'; const makeMapStateToProps = () => { const getTimeline = timelineSelectors.getTimelineByIdSelector(); @@ -101,9 +100,9 @@ const makeMapStateToProps = () => { const mapStateToProps = (state: State, { timelineId }: OwnProps) => { const timeline: TimelineModel | {} = getTimeline(state, timelineId); return { - kqlMode: getOr('filter', 'kqlMode', timeline), filterQueryDraft: getKqlFilterQueryDraft(state, timelineId), isFilterQueryDraftValid: isFilterQueryDraftValid(state, timelineId), + kqlMode: getOr('filter', 'kqlMode', timeline), }; }; return mapStateToProps; diff --git a/x-pack/legacy/plugins/siem/public/components/url_state/use_url_state.tsx b/x-pack/legacy/plugins/siem/public/components/url_state/use_url_state.tsx index 78c3850057fc1..26826ace6fcfd 100644 --- a/x-pack/legacy/plugins/siem/public/components/url_state/use_url_state.tsx +++ b/x-pack/legacy/plugins/siem/public/components/url_state/use_url_state.tsx @@ -33,7 +33,7 @@ import { } from './types'; function usePrevious(value: PreviousLocationUrlState) { - const ref = useRef(value); + const ref = useRef(value); useEffect(() => { ref.current = value; }); diff --git a/x-pack/legacy/plugins/siem/public/components/with_hover_actions/index.tsx b/x-pack/legacy/plugins/siem/public/components/with_hover_actions/index.tsx index 972ace6870d14..2569e8d303b69 100644 --- a/x-pack/legacy/plugins/siem/public/components/with_hover_actions/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/with_hover_actions/index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import * as React from 'react'; +import React, { useState } from 'react'; import { pure } from 'recompose'; import styled from 'styled-components'; @@ -28,10 +28,6 @@ interface Props { render: (showHoverContent: boolean) => JSX.Element; } -interface State { - showHoverContent: boolean; -} - const HoverActionsPanelContainer = styled.div` color: ${props => props.theme.eui.textColors.default} height: 100%; @@ -67,31 +63,25 @@ WithHoverActionsContainer.displayName = 'WithHoverActionsContainer'; * component also passes `showHoverContent` as a render prop, which * provides a signal to the content that the user is in a hover state. */ -export class WithHoverActions extends React.PureComponent { - constructor(props: Props) { - super(props); - - this.state = { showHoverContent: false }; - } - - public render() { - const { alwaysShow = false, hoverContent, render } = this.props; +export const WithHoverActions = React.memo( + ({ alwaysShow = false, hoverContent, render }) => { + const [showHoverContent, setShowHoverContent] = useState(false); + function onMouseEnter() { + setShowHoverContent(true); + } + function onMouseLeave() { + setShowHoverContent(false); + } return ( - - <>{render(this.state.showHoverContent)} - + + <>{render(showHoverContent)} + {hoverContent != null ? hoverContent : <>} ); } +); - private onMouseEnter = () => { - this.setState({ showHoverContent: true }); - }; - - private onMouseLeave = () => { - this.setState({ showHoverContent: false }); - }; -} +WithHoverActions.displayName = 'WithHoverActions'; diff --git a/x-pack/legacy/plugins/siem/public/containers/hosts/filter.tsx b/x-pack/legacy/plugins/siem/public/containers/hosts/filter.tsx index 5a5d25e699528..4bacb2f87c458 100644 --- a/x-pack/legacy/plugins/siem/public/containers/hosts/filter.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/hosts/filter.tsx @@ -4,9 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { isEqual } from 'lodash/fp'; +import React, { useEffect } from 'react'; import memoizeOne from 'memoize-one'; -import React from 'react'; import { connect } from 'react-redux'; import { ActionCreator } from 'typescript-fsa'; import { StaticIndexPattern } from 'ui/index_patterns'; @@ -61,80 +60,72 @@ interface HostsFilterDispatchProps { export type HostsFilterProps = OwnProps & HostsFilterReduxProps & HostsFilterDispatchProps; -class HostsFilterComponent extends React.PureComponent { - private memoizedApplyFilterQueryFromKueryExpression: (expression: string) => void; - private memoizedSetFilterQueryDraftFromKueryExpression: (expression: string) => void; - - constructor(props: HostsFilterProps) { - super(props); - this.memoizedApplyFilterQueryFromKueryExpression = memoizeOne( - this.applyFilterQueryFromKueryExpression - ); - this.memoizedSetFilterQueryDraftFromKueryExpression = memoizeOne( - this.setFilterQueryDraftFromKueryExpression - ); - } +const HostsFilterComponent = React.memo( + ({ + applyHostsFilterQuery, + children, + hostsFilterQueryDraft, + indexPattern, + isHostFilterQueryDraftValid, + kueryFilterQuery, + setHostsFilterQueryDraft, + setQuery, + type, + }) => { + const applyFilterQueryFromKueryExpression = (expression: string) => + applyHostsFilterQuery({ + filterQuery: { + kuery: { + kind: 'kuery', + expression, + }, + serializedQuery: convertKueryToElasticSearchQuery(expression, indexPattern), + }, + hostsType: type, + }); - public componentDidUpdate(prevProps: HostsFilterProps) { - const { indexPattern, hostsFilterQueryDraft, kueryFilterQuery, setQuery, type } = this.props; - if ( - setQuery && - (!isEqual(prevProps.hostsFilterQueryDraft, hostsFilterQueryDraft) || - !isEqual(prevProps.kueryFilterQuery, kueryFilterQuery) || - prevProps.type !== type) - ) { - setQuery({ - id: 'kql', - inspect: null, - loading: false, - refetch: useUpdateKql({ - indexPattern, - kueryFilterQuery, - kueryFilterQueryDraft: hostsFilterQueryDraft, - storeType: 'hostsType', - type, - }), + const setFilterQueryDraftFromKueryExpression = (expression: string) => + setHostsFilterQueryDraft({ + filterQueryDraft: { + kind: 'kuery', + expression, + }, + hostsType: type, }); - } - } - public render() { - const { children, hostsFilterQueryDraft, isHostFilterQueryDraftValid } = this.props; + const memoizedApplyFilter = memoizeOne(applyFilterQueryFromKueryExpression); + const memoizedSetFilter = memoizeOne(setFilterQueryDraftFromKueryExpression); + useEffect(() => { + if (setQuery) { + setQuery({ + id: 'kql', + inspect: null, + loading: false, + refetch: useUpdateKql({ + indexPattern, + kueryFilterQuery, + kueryFilterQueryDraft: hostsFilterQueryDraft, + storeType: 'hostsType', + type, + }), + }); + } + }, [hostsFilterQueryDraft, kueryFilterQuery, type]); return ( <> {children({ - applyFilterQueryFromKueryExpression: this.memoizedApplyFilterQueryFromKueryExpression, + applyFilterQueryFromKueryExpression: memoizedApplyFilter, filterQueryDraft: hostsFilterQueryDraft, isFilterQueryDraftValid: isHostFilterQueryDraftValid, - setFilterQueryDraftFromKueryExpression: this - .memoizedSetFilterQueryDraftFromKueryExpression, + setFilterQueryDraftFromKueryExpression: memoizedSetFilter, })} ); } +); - private applyFilterQueryFromKueryExpression = (expression: string) => - this.props.applyHostsFilterQuery({ - filterQuery: { - kuery: { - kind: 'kuery', - expression, - }, - serializedQuery: convertKueryToElasticSearchQuery(expression, this.props.indexPattern), - }, - hostsType: this.props.type, - }); - - private setFilterQueryDraftFromKueryExpression = (expression: string) => - this.props.setHostsFilterQueryDraft({ - filterQueryDraft: { - kind: 'kuery', - expression, - }, - hostsType: this.props.type, - }); -} +HostsFilterComponent.displayName = 'HostsFilterComponent'; const makeMapStateToProps = () => { const getHostsFilterQueryDraft = hostsSelectors.hostsFilterQueryDraft(); diff --git a/x-pack/legacy/plugins/siem/public/containers/hosts/first_last_seen/index.ts b/x-pack/legacy/plugins/siem/public/containers/hosts/first_last_seen/index.ts index 7d0451adcd18f..042de56fbd99d 100644 --- a/x-pack/legacy/plugins/siem/public/containers/hosts/first_last_seen/index.ts +++ b/x-pack/legacy/plugins/siem/public/containers/hosts/first_last_seen/index.ts @@ -36,9 +36,9 @@ export function useFirstLastSeenHostQuery( apolloClient: ApolloClient ) { const [loading, updateLoading] = useState(false); - const [firstSeen, updateFirstSeen] = useState(null); - const [lastSeen, updateLastSeen] = useState(null); - const [errorMessage, updateErrorMessage] = useState(null); + const [firstSeen, updateFirstSeen] = useState(null); + const [lastSeen, updateLastSeen] = useState(null); + const [errorMessage, updateErrorMessage] = useState(null); async function fetchFirstLastSeenHost(signal: AbortSignal) { updateLoading(true); diff --git a/x-pack/legacy/plugins/siem/public/containers/kuery_autocompletion/index.tsx b/x-pack/legacy/plugins/siem/public/containers/kuery_autocompletion/index.tsx index 9235580563a53..9cf7331441da5 100644 --- a/x-pack/legacy/plugins/siem/public/containers/kuery_autocompletion/index.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/kuery_autocompletion/index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { useState } from 'react'; import { npStart } from 'ui/new_platform'; import { StaticIndexPattern } from 'ui/index_patterns'; import { AutocompleteSuggestion } from '../../../../../../../src/plugins/data/public'; @@ -26,78 +26,65 @@ interface KueryAutocompletionCurrentRequest { cursorPosition: number; } -interface KueryAutocompletionLifecycleState { - // lacking cancellation support in the autocompletion api, - // this is used to keep older, slower requests from clobbering newer ones - currentRequest: KueryAutocompletionCurrentRequest | null; - suggestions: AutocompleteSuggestion[]; -} - const getAutocompleteProvider = (language: string) => npStart.plugins.data.autocomplete.getProvider(language); -export class KueryAutocompletion extends React.PureComponent< - KueryAutocompletionLifecycleProps, - KueryAutocompletionLifecycleState -> { - public readonly state: KueryAutocompletionLifecycleState = { - currentRequest: null, - suggestions: [], - }; - - public render() { - const { currentRequest, suggestions } = this.state; - return this.props.children({ - isLoadingSuggestions: currentRequest !== null, - loadSuggestions: this.loadSuggestions, - suggestions, - }); - } +export const KueryAutocompletion = React.memo( + ({ children, indexPattern }) => { + const [currentRequest, setCurrentRequest] = useState( + null + ); - private loadSuggestions = async ( - expression: string, - cursorPosition: number, - maxSuggestions?: number - ) => { - const { indexPattern } = this.props; - const autocompletionProvider = getAutocompleteProvider('kuery'); - const config = { - get: () => true, - }; - if (!autocompletionProvider) { - return; - } + const [suggestions, setSuggestions] = useState([]); - const getSuggestions = autocompletionProvider({ - config, - indexPatterns: [indexPattern], - boolFilter: [], - }); + const loadSuggestions = async ( + expression: string, + cursorPosition: number, + maxSuggestions?: number + ) => { + const autocompletionProvider = getAutocompleteProvider('kuery'); + const config = { + get: () => true, + }; + if (!autocompletionProvider) { + return; + } - this.setState({ - currentRequest: { + const getSuggestions = autocompletionProvider({ + config, + indexPatterns: [indexPattern], + boolFilter: [], + }); + const futureRequest = { expression, cursorPosition, - }, - suggestions: [], - }); + }; + setCurrentRequest({ + expression, + cursorPosition, + }); + setSuggestions([]); + const newSuggestions = await getSuggestions({ + query: expression, + selectionStart: cursorPosition, + selectionEnd: cursorPosition, + }); + if ( + futureRequest && + futureRequest.expression !== (currentRequest && currentRequest.expression) && + futureRequest.cursorPosition !== (currentRequest && currentRequest.cursorPosition) + ) { + setCurrentRequest(null); + setSuggestions(maxSuggestions ? newSuggestions.slice(0, maxSuggestions) : newSuggestions); + } + }; - const suggestions = await getSuggestions({ - query: expression, - selectionStart: cursorPosition, - selectionEnd: cursorPosition, + return children({ + isLoadingSuggestions: currentRequest !== null, + loadSuggestions, + suggestions, }); + } +); - this.setState(state => - state.currentRequest && - state.currentRequest.expression !== expression && - state.currentRequest.cursorPosition !== cursorPosition - ? state // ignore this result, since a newer request is in flight - : { - ...state, - currentRequest: null, - suggestions: maxSuggestions ? suggestions.slice(0, maxSuggestions) : suggestions, - } - ); - }; -} +KueryAutocompletion.displayName = 'KueryAutocompletion'; diff --git a/x-pack/legacy/plugins/siem/public/containers/network/filter.tsx b/x-pack/legacy/plugins/siem/public/containers/network/filter.tsx index cb2fb12a0ca52..6ae2f3ef777e8 100644 --- a/x-pack/legacy/plugins/siem/public/containers/network/filter.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/network/filter.tsx @@ -4,13 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { isEqual } from 'lodash/fp'; -import React from 'react'; +import React, { useEffect } from 'react'; +import memoizeOne from 'memoize-one'; import { connect } from 'react-redux'; import { ActionCreator } from 'typescript-fsa'; import { StaticIndexPattern } from 'ui/index_patterns'; -import memoizeOne from 'memoize-one'; import { convertKueryToElasticSearchQuery } from '../../lib/keury'; import { KueryFilterQuery, @@ -33,13 +32,13 @@ export interface NetworkFilterArgs { interface OwnProps { children: (args: NetworkFilterArgs) => React.ReactNode; indexPattern: StaticIndexPattern; - type: networkModel.NetworkType; setQuery?: (params: { id: string; inspect: null; loading: boolean; refetch: inputsModel.Refetch | inputsModel.RefetchKql; }) => void; + type: networkModel.NetworkType; } interface NetworkFilterReduxProps { @@ -61,80 +60,71 @@ interface NetworkFilterDispatchProps { export type NetworkFilterProps = OwnProps & NetworkFilterReduxProps & NetworkFilterDispatchProps; -class NetworkFilterComponent extends React.PureComponent { - private memoizedApplyFilterQueryFromKueryExpression: (expression: string) => void; - private memoizedSetFilterQueryDraftFromKueryExpression: (expression: string) => void; - - constructor(props: NetworkFilterProps) { - super(props); - this.memoizedApplyFilterQueryFromKueryExpression = memoizeOne( - this.applyFilterQueryFromKueryExpression - ); - this.memoizedSetFilterQueryDraftFromKueryExpression = memoizeOne( - this.setFilterQueryDraftFromKueryExpression - ); - } - - public componentDidUpdate(prevProps: NetworkFilterProps) { - const { indexPattern, networkFilterQueryDraft, kueryFilterQuery, setQuery, type } = this.props; - - if ( - setQuery && - (!isEqual(prevProps.networkFilterQueryDraft, networkFilterQueryDraft) || - !isEqual(prevProps.kueryFilterQuery, kueryFilterQuery) || - prevProps.type !== type) - ) { - setQuery({ - id: 'kql', - inspect: null, - loading: false, - refetch: useUpdateKql({ - indexPattern, - kueryFilterQuery, - kueryFilterQueryDraft: networkFilterQueryDraft, - storeType: 'networkType', - type, - }), +const NetworkFilterComponent = React.memo( + ({ + applyNetworkFilterQuery, + children, + indexPattern, + isNetworkFilterQueryDraftValid, + kueryFilterQuery, + networkFilterQueryDraft, + setNetworkFilterQueryDraft, + setQuery, + type, + }) => { + const applyFilterQueryFromKueryExpression = (expression: string) => + applyNetworkFilterQuery({ + filterQuery: { + kuery: { + kind: 'kuery', + expression, + }, + serializedQuery: convertKueryToElasticSearchQuery(expression, indexPattern), + }, + networkType: type, }); - } - } - - public render() { - const { children, networkFilterQueryDraft, isNetworkFilterQueryDraftValid } = this.props; + const setFilterQueryDraftFromKueryExpression = (expression: string) => + setNetworkFilterQueryDraft({ + filterQueryDraft: { + kind: 'kuery', + expression, + }, + networkType: type, + }); + const memoizedApplyFilter = memoizeOne(applyFilterQueryFromKueryExpression); + const memoizedSetFilter = memoizeOne(setFilterQueryDraftFromKueryExpression); + + useEffect(() => { + if (setQuery) { + setQuery({ + id: 'kql', + inspect: null, + loading: false, + refetch: useUpdateKql({ + indexPattern, + kueryFilterQuery, + kueryFilterQueryDraft: networkFilterQueryDraft, + storeType: 'networkType', + type, + }), + }); + } + }, [networkFilterQueryDraft, kueryFilterQuery, type]); return ( <> {children({ - applyFilterQueryFromKueryExpression: this.memoizedApplyFilterQueryFromKueryExpression, + applyFilterQueryFromKueryExpression: memoizedApplyFilter, filterQueryDraft: networkFilterQueryDraft, isFilterQueryDraftValid: isNetworkFilterQueryDraftValid, - setFilterQueryDraftFromKueryExpression: this - .memoizedSetFilterQueryDraftFromKueryExpression, + setFilterQueryDraftFromKueryExpression: memoizedSetFilter, })} ); } - private applyFilterQueryFromKueryExpression = (expression: string) => - this.props.applyNetworkFilterQuery({ - filterQuery: { - kuery: { - kind: 'kuery', - expression, - }, - serializedQuery: convertKueryToElasticSearchQuery(expression, this.props.indexPattern), - }, - networkType: this.props.type, - }); +); - private setFilterQueryDraftFromKueryExpression = (expression: string) => - this.props.setNetworkFilterQueryDraft({ - filterQueryDraft: { - kind: 'kuery', - expression, - }, - networkType: this.props.type, - }); -} +NetworkFilterComponent.displayName = 'NetworkFilterComponent'; const makeMapStateToProps = () => { const getNetworkFilterQueryDraft = networkSelectors.networkFilterQueryDraft(); @@ -142,9 +132,9 @@ const makeMapStateToProps = () => { const getNetworkKueryFilterQuery = networkSelectors.networkFilterQueryAsKuery(); const mapStateToProps = (state: State, { type }: OwnProps) => { return { - networkFilterQueryDraft: getNetworkFilterQueryDraft(state, type), isNetworkFilterQueryDraftValid: getIsNetworkFilterQueryDraftValid(state, type), kueryFilterQuery: getNetworkKueryFilterQuery(state, type), + networkFilterQueryDraft: getNetworkFilterQueryDraft(state, type), }; }; return mapStateToProps; diff --git a/x-pack/legacy/plugins/siem/public/containers/source/index.tsx b/x-pack/legacy/plugins/siem/public/containers/source/index.tsx index 18b2641a16008..ead483baab43e 100644 --- a/x-pack/legacy/plugins/siem/public/containers/source/index.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/source/index.tsx @@ -8,10 +8,10 @@ import { isUndefined } from 'lodash'; import { get, keyBy, pick, set } from 'lodash/fp'; import { Query } from 'react-apollo'; import React from 'react'; +import memoizeOne from 'memoize-one'; import { StaticIndexPattern } from 'ui/index_patterns'; import chrome from 'ui/chrome'; -import memoizeOne from 'memoize-one'; import { DEFAULT_INDEX_KEY } from '../../../common/constants'; import { IndexField, SourceQuery } from '../../graphql/types'; @@ -57,47 +57,8 @@ interface WithSourceProps { sourceId: string; } -export class WithSource extends React.PureComponent { - private memoizedIndexFields: (title: string, fields: IndexField[]) => StaticIndexPattern; - private memoizedBrowserFields: (fields: IndexField[]) => BrowserFields; - - constructor(props: WithSourceProps) { - super(props); - this.memoizedIndexFields = memoizeOne(this.getIndexFields); - this.memoizedBrowserFields = memoizeOne(this.getBrowserFields); - } - - public render() { - const { children, sourceId } = this.props; - - return ( - - query={sourceQuery} - fetchPolicy="cache-first" - notifyOnNetworkStatusChange - variables={{ - sourceId, - defaultIndex: chrome.getUiSettingsClient().get(DEFAULT_INDEX_KEY), - }} - > - {({ data }) => { - return children({ - indicesExist: get('source.status.indicesExist', data), - browserFields: this.memoizedBrowserFields(get('source.status.indexFields', data)), - indexPattern: this.memoizedIndexFields( - chrome - .getUiSettingsClient() - .get(DEFAULT_INDEX_KEY) - .join(), - get('source.status.indexFields', data) - ), - }); - }} - - ); - } - - private getIndexFields = (title: string, fields: IndexField[]): StaticIndexPattern => +export const WithSource = React.memo(({ children, sourceId }) => { + const getIndexFields = (title: string, fields: IndexField[]): StaticIndexPattern => fields && fields.length > 0 ? { fields: fields.map(field => pick(['name', 'searchable', 'type', 'aggregatable'], field)), @@ -105,7 +66,7 @@ export class WithSource extends React.PureComponent { } : { fields: [], title }; - private getBrowserFields = (fields: IndexField[]): BrowserFields => + const getBrowserFields = (fields: IndexField[]): BrowserFields => fields && fields.length > 0 ? fields.reduce( (accumulator: BrowserFields, field: IndexField) => @@ -113,7 +74,39 @@ export class WithSource extends React.PureComponent { {} ) : {}; -} + const getBrowserFieldsMemo: (fields: IndexField[]) => BrowserFields = memoizeOne( + getBrowserFields + ); + const getIndexFieldsMemo: ( + title: string, + fields: IndexField[] + ) => StaticIndexPattern = memoizeOne(getIndexFields); + return ( + + query={sourceQuery} + fetchPolicy="cache-first" + notifyOnNetworkStatusChange + variables={{ + sourceId, + defaultIndex: chrome.getUiSettingsClient().get(DEFAULT_INDEX_KEY), + }} + > + {({ data }) => + children({ + indicesExist: get('source.status.indicesExist', data), + browserFields: getBrowserFieldsMemo(get('source.status.indexFields', data)), + indexPattern: getIndexFieldsMemo( + chrome + .getUiSettingsClient() + .get(DEFAULT_INDEX_KEY) + .join(), + get('source.status.indexFields', data) + ), + }) + } + + ); +}); export const indicesExistOrDataTemporarilyUnavailable = (indicesExist: boolean | undefined) => indicesExist || isUndefined(indicesExist); diff --git a/x-pack/legacy/plugins/siem/public/containers/timeline/all/index.tsx b/x-pack/legacy/plugins/siem/public/containers/timeline/all/index.tsx index 88bc333b66e97..c3bff998fdefd 100644 --- a/x-pack/legacy/plugins/siem/public/containers/timeline/all/index.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/timeline/all/index.tsx @@ -6,9 +6,10 @@ import { getOr } from 'lodash/fp'; import React from 'react'; +import memoizeOne from 'memoize-one'; + import { Query } from 'react-apollo'; -import memoizeOne from 'memoize-one'; import { OpenTimelineResult } from '../../../components/open_timeline/types'; import { GetAllTimeline, @@ -35,19 +36,43 @@ interface OwnProps extends AllTimelinesVariables { children?: (args: AllTimelinesArgs) => React.ReactNode; } -export class AllTimelinesQuery extends React.PureComponent { - private memoizedAllTimeline: ( - variables: string, - timelines: TimelineResult[] - ) => OpenTimelineResult[]; +const getAllTimeline = (variables: string, timelines: TimelineResult[]): OpenTimelineResult[] => + timelines.map(timeline => ({ + created: timeline.created, + description: timeline.description, + eventIdToNoteIds: + timeline.eventIdToNoteIds != null + ? timeline.eventIdToNoteIds.reduce((acc, note) => { + if (note.eventId != null) { + const notes = getOr([], note.eventId, acc); + return { ...acc, [note.eventId]: [...notes, note.noteId] }; + } + return acc; + }, {}) + : null, + favorite: timeline.favorite, + noteIds: timeline.noteIds, + notes: + timeline.notes != null + ? timeline.notes.map(note => ({ ...note, savedObjectId: note.noteId })) + : null, + pinnedEventIds: + timeline.pinnedEventIds != null + ? timeline.pinnedEventIds.reduce( + (acc, pinnedEventId) => ({ ...acc, [pinnedEventId]: true }), + {} + ) + : null, + savedObjectId: timeline.savedObjectId, + title: timeline.title, + updated: timeline.updated, + updatedBy: timeline.updatedBy, + })); - constructor(props: OwnProps) { - super(props); - this.memoizedAllTimeline = memoizeOne(this.getAllTimeline); - } +export const AllTimelinesQuery = React.memo( + ({ children, onlyUserFavorite, pageInfo, search, sort }) => { + const memoizedAllTimeline = memoizeOne(getAllTimeline); - public render() { - const { children, onlyUserFavorite, pageInfo, search, sort } = this.props; const variables: GetAllTimeline.Variables = { onlyUserFavorite, pageInfo, @@ -65,7 +90,7 @@ export class AllTimelinesQuery extends React.PureComponent { return children!({ loading, totalCount: getOr(0, 'getAllTimeline.totalCount', data), - timelines: this.memoizedAllTimeline( + timelines: memoizedAllTimeline( JSON.stringify(variables), getOr([], 'getAllTimeline.timeline', data) ), @@ -74,41 +99,4 @@ export class AllTimelinesQuery extends React.PureComponent { ); } - - private getAllTimeline = ( - variables: string, - timelines: TimelineResult[] - ): OpenTimelineResult[] => { - return timelines.map(timeline => ({ - created: timeline.created, - description: timeline.description, - eventIdToNoteIds: - timeline.eventIdToNoteIds != null - ? timeline.eventIdToNoteIds.reduce((acc, note) => { - if (note.eventId != null) { - const notes = getOr([], note.eventId, acc); - return { ...acc, [note.eventId]: [...notes, note.noteId] }; - } - return acc; - }, {}) - : null, - favorite: timeline.favorite, - noteIds: timeline.noteIds, - notes: - timeline.notes != null - ? timeline.notes.map(note => ({ ...note, savedObjectId: note.noteId })) - : null, - pinnedEventIds: - timeline.pinnedEventIds != null - ? timeline.pinnedEventIds.reduce( - (acc, pinnedEventId) => ({ ...acc, [pinnedEventId]: true }), - {} - ) - : null, - savedObjectId: timeline.savedObjectId, - title: timeline.title, - updated: timeline.updated, - updatedBy: timeline.updatedBy, - })); - }; -} +); diff --git a/x-pack/legacy/plugins/siem/public/containers/timeline/details/index.tsx b/x-pack/legacy/plugins/siem/public/containers/timeline/details/index.tsx index 9a038a61c9f88..54dd44063f5da 100644 --- a/x-pack/legacy/plugins/siem/public/containers/timeline/details/index.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/timeline/details/index.tsx @@ -21,30 +21,24 @@ export interface EventsArgs { } export interface TimelineDetailsProps { - children?: (args: EventsArgs) => React.ReactNode; + children?: (args: EventsArgs) => React.ReactElement; indexName: string; eventId: string; executeQuery: boolean; sourceId: string; } -export class TimelineDetailsComponentQuery extends React.PureComponent { - private memoizedDetailsEvents: (variables: string, detail: DetailItem[]) => DetailItem[]; +export const TimelineDetailsComponentQuery = React.memo( + ({ children, indexName, eventId, executeQuery, sourceId }) => { + const getDetailsEvent = (variables: string, detail: DetailItem[]): DetailItem[] => detail; + const getDetailsEventMemo = memoizeOne(getDetailsEvent); - constructor(props: TimelineDetailsProps) { - super(props); - this.memoizedDetailsEvents = memoizeOne(this.getDetailsEvent); - } - - public render() { - const { children, indexName, eventId, executeQuery, sourceId } = this.props; const variables: GetTimelineDetailsQuery.Variables = { sourceId, indexName, eventId, defaultIndex: chrome.getUiSettingsClient().get(DEFAULT_INDEX_KEY), }; - return executeQuery ? ( query={timelineDetailsQuery} @@ -55,7 +49,7 @@ export class TimelineDetailsComponentQuery extends React.PureComponent { return children!({ loading, - detailsData: this.memoizedDetailsEvents( + detailsData: getDetailsEventMemo( JSON.stringify(variables), getOr([], 'source.TimelineDetails.data', data) ), @@ -66,6 +60,4 @@ export class TimelineDetailsComponentQuery extends React.PureComponent detail; -} +); diff --git a/x-pack/legacy/plugins/siem/public/pages/timelines/timelines_page.tsx b/x-pack/legacy/plugins/siem/public/pages/timelines/timelines_page.tsx index adc5471cc37a7..90eae605de4b7 100644 --- a/x-pack/legacy/plugins/siem/public/pages/timelines/timelines_page.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/timelines/timelines_page.tsx @@ -28,22 +28,18 @@ type OwnProps = TimelinesProps; export const DEFAULT_SEARCH_RESULTS_PER_PAGE = 10; -export class TimelinesPage extends React.PureComponent { - public render() { - return ( - <> - - - - - - - - ); - } -} +export const TimelinesPage = React.memo(({ apolloClient }) => ( + <> + + + + + + + +)); diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index adc64130aab42..d4fb208bc858a 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -10259,10 +10259,6 @@ "xpack.siem.kpiNetwork.uniquePrivateIps.sourceChartLabel": "Src.", "xpack.siem.kpiNetwork.uniquePrivateIps.sourceUnitLabel": "ソース", "xpack.siem.kpiNetwork.uniquePrivateIps.title": "固有のプライベート IP", - "xpack.siem.loadMoreTable.loadingButtonLabel": "読み込み中…", - "xpack.siem.loadMoreTable.loadMoreButtonLabel": "さらに読み込む", - "xpack.siem.loadMoreTable.rowsButtonLabel": "ページごとの行数", - "xpack.siem.loadMoreTable.showingSubtitle": "表示中", "xpack.siem.ml.score.anomalousEntityTitle": "異常エンティティ", "xpack.siem.ml.table.timestampTitle": "タイムスタンプ", "xpack.siem.modalAllErrors.close.button": "閉じる", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 31616c9e6db7c..a002fdaa10257 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -10261,10 +10261,6 @@ "xpack.siem.kpiNetwork.uniquePrivateIps.sourceChartLabel": "源", "xpack.siem.kpiNetwork.uniquePrivateIps.sourceUnitLabel": "源", "xpack.siem.kpiNetwork.uniquePrivateIps.title": "唯一专用 IP", - "xpack.siem.loadMoreTable.loadingButtonLabel": "正在加载……", - "xpack.siem.loadMoreTable.loadMoreButtonLabel": "加载更多", - "xpack.siem.loadMoreTable.rowsButtonLabel": "每页行数", - "xpack.siem.loadMoreTable.showingSubtitle": "显示", "xpack.siem.ml.score.anomalousEntityTitle": "异常实体", "xpack.siem.ml.table.timestampTitle": "时间戳", "xpack.siem.modalAllErrors.close.button": "关闭", From e81494fb5de95b96e988eb97b5d001213326d90d Mon Sep 17 00:00:00 2001 From: Alison Goryachev Date: Wed, 2 Oct 2019 14:05:59 -0400 Subject: [PATCH 04/19] SR: SLM retention UI (#45193) --- .../client_integration/helpers/constant.ts | 7 +- .../helpers/http_requests.ts | 34 ++ .../client_integration/helpers/index.ts | 4 + .../helpers/policy_add.helpers.ts | 25 + .../helpers/policy_edit.helpers.ts | 26 + .../helpers/policy_form.helpers.ts | 56 +++ .../client_integration/helpers/providers.tsx | 10 +- .../helpers/setup_environment.ts | 9 +- .../__jest__/client_integration/home.test.ts | 70 ++- .../client_integration/policy_add.test.ts | 215 +++++++++ .../client_integration/policy_edit.test.ts | 136 ++++++ .../snapshot_restore/common/constants.ts | 7 + .../snapshot_restore/common/lib/index.ts | 2 + .../common/lib/policy_serialization.test.ts | 57 ++- .../common/lib/policy_serialization.ts | 40 +- .../common/lib/snapshot_serialization.ts | 76 ++- .../common/lib/time_serialization.test.ts | 29 ++ .../common/lib/time_serialization.ts | 33 ++ .../snapshot_restore/common/types/policy.ts | 22 +- .../snapshot_restore/common/types/snapshot.ts | 13 + .../public/app/components/index.ts | 4 + .../app/components/policy_form/navigation.tsx | 15 +- .../components/policy_form/policy_form.tsx | 19 +- .../app/components/policy_form/steps/index.ts | 1 + .../policy_form/steps/step_logistics.tsx | 2 +- .../policy_form/steps/step_retention.tsx | 238 +++++++++ .../policy_form/steps/step_review.tsx | 110 +++-- .../policy_form/steps/step_settings.tsx | 8 +- .../update_retention_modal_provider.tsx | 297 ++++++++++++ .../public/app/constants/index.ts | 4 + .../policy_details/tabs/tab_summary.tsx | 454 ++++++++++++------ .../sections/home/policy_list/policy_list.tsx | 23 +- .../policy_retention_schedule/index.ts | 7 + .../policy_retention_schedule.tsx | 175 +++++++ .../policy_list/policy_table/policy_table.tsx | 18 + .../snapshot_details/tabs/tab_failures.tsx | 4 +- .../app/sections/policy_add/policy_add.tsx | 11 +- .../app/sections/policy_edit/policy_edit.tsx | 39 +- .../documentation/documentation_links.ts | 22 +- .../app/services/http/policy_requests.ts | 24 +- .../public/app/services/text/text.ts | 27 +- .../services/validation/validate_policy.ts | 27 +- .../plugins/snapshot_restore/public/plugin.ts | 6 +- .../plugins/snapshot_restore/public/shim.ts | 2 - .../server/routes/api/policy.test.ts | 39 +- .../server/routes/api/policy.ts | 47 +- .../server/routes/api/register_routes.ts | 2 +- .../snapshot_restore/test/fixtures/index.ts | 1 + .../snapshot_restore/test/fixtures/policy.ts | 48 ++ .../translations/translations/ja-JP.json | 7 - .../translations/translations/zh-CN.json | 7 - x-pack/test_utils/testbed/types.ts | 2 +- 52 files changed, 2270 insertions(+), 291 deletions(-) create mode 100644 x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/policy_add.helpers.ts create mode 100644 x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/policy_edit.helpers.ts create mode 100644 x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/policy_form.helpers.ts create mode 100644 x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/policy_add.test.ts create mode 100644 x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/policy_edit.test.ts create mode 100644 x-pack/legacy/plugins/snapshot_restore/common/lib/time_serialization.test.ts create mode 100644 x-pack/legacy/plugins/snapshot_restore/common/lib/time_serialization.ts create mode 100644 x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/steps/step_retention.tsx create mode 100644 x-pack/legacy/plugins/snapshot_restore/public/app/components/update_retention_modal_provider.tsx create mode 100644 x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_retention_schedule/index.ts create mode 100644 x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_retention_schedule/policy_retention_schedule.tsx create mode 100644 x-pack/legacy/plugins/snapshot_restore/test/fixtures/policy.ts diff --git a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/constant.ts b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/constant.ts index 372b5bc6f6529..1fb6aa8686b7e 100644 --- a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/constant.ts +++ b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/constant.ts @@ -4,7 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { getRepository } from '../../../test/fixtures'; +import { getRepository, getPolicy } from '../../../test/fixtures'; + export const REPOSITORY_NAME = 'my-test-repository'; export const REPOSITORY_EDIT = getRepository({ name: REPOSITORY_NAME }); + +export const POLICY_NAME = 'my-test-policy'; + +export const POLICY_EDIT = getPolicy({ name: POLICY_NAME }); diff --git a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/http_requests.ts b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/http_requests.ts index dd9d51a9990cc..d9f2c1b510a14 100644 --- a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/http_requests.ts +++ b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/http_requests.ts @@ -72,6 +72,37 @@ const registerHttpRequestMockHelpers = (server: SinonFakeServer) => { ); }; + const setLoadIndicesResponse = (response: HttpResponse = {}) => { + const defaultResponse = { indices: [] }; + + server.respondWith( + 'GET', + `${API_BASE_PATH}policies/indices`, + response + ? mockResponse(defaultResponse, response) + : [200, { 'Content-Type': 'application/json' }, ''] + ); + }; + + const setAddPolicyResponse = (response?: HttpResponse, error?: any) => { + const status = error ? error.status || 400 : 200; + const body = error ? JSON.stringify(error.body) : JSON.stringify(response); + + server.respondWith('PUT', `${API_BASE_PATH}policies`, [ + status, + { 'Content-Type': 'application/json' }, + body, + ]); + }; + + const setGetPolicyResponse = (response?: HttpResponse) => { + server.respondWith('GET', `${API_BASE_PATH}policy/:name`, [ + 200, + { 'Content-Type': 'application/json' }, + JSON.stringify(response), + ]); + }; + return { setLoadRepositoriesResponse, setLoadRepositoryTypesResponse, @@ -79,6 +110,9 @@ const registerHttpRequestMockHelpers = (server: SinonFakeServer) => { setSaveRepositoryResponse, setLoadSnapshotsResponse, setGetSnapshotResponse, + setLoadIndicesResponse, + setAddPolicyResponse, + setGetPolicyResponse, }; }; diff --git a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/index.ts b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/index.ts index d8bb3d4c25e10..e6fea41d86928 100644 --- a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/index.ts +++ b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/index.ts @@ -7,6 +7,8 @@ import { setup as homeSetup } from './home.helpers'; import { setup as repositoryAddSetup } from './repository_add.helpers'; import { setup as repositoryEditSetup } from './repository_edit.helpers'; +import { setup as policyAddSetup } from './policy_add.helpers'; +import { setup as policyEditSetup } from './policy_edit.helpers'; export { nextTick, getRandomString, findTestSubject, TestBed } from '../../../../../../test_utils'; @@ -16,4 +18,6 @@ export const pageHelpers = { home: { setup: homeSetup }, repositoryAdd: { setup: repositoryAddSetup }, repositoryEdit: { setup: repositoryEditSetup }, + policyAdd: { setup: policyAddSetup }, + policyEdit: { setup: policyEditSetup }, }; diff --git a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/policy_add.helpers.ts b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/policy_add.helpers.ts new file mode 100644 index 0000000000000..ff59bd83dc1e8 --- /dev/null +++ b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/policy_add.helpers.ts @@ -0,0 +1,25 @@ +/* + * 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 { registerTestBed, TestBedConfig } from '../../../../../../test_utils'; +import { PolicyAdd } from '../../../public/app/sections/policy_add'; +import { WithProviders } from './providers'; +import { formSetup, PolicyFormTestSubjects } from './policy_form.helpers'; + +const testBedConfig: TestBedConfig = { + memoryRouter: { + initialEntries: ['/add_policy'], + componentRoutePath: '/add_policy', + }, + doMountAsync: true, +}; + +const initTestBed = registerTestBed( + WithProviders(PolicyAdd), + testBedConfig +); + +export const setup = formSetup.bind(null, initTestBed); diff --git a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/policy_edit.helpers.ts b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/policy_edit.helpers.ts new file mode 100644 index 0000000000000..b2c0e4242a3fd --- /dev/null +++ b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/policy_edit.helpers.ts @@ -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 { registerTestBed, TestBedConfig } from '../../../../../../test_utils'; +import { PolicyEdit } from '../../../public/app/sections/policy_edit'; +import { WithProviders } from './providers'; +import { POLICY_NAME } from './constant'; +import { formSetup, PolicyFormTestSubjects } from './policy_form.helpers'; + +const testBedConfig: TestBedConfig = { + memoryRouter: { + initialEntries: [`/edit_policy/${POLICY_NAME}`], + componentRoutePath: '/edit_policy/:name', + }, + doMountAsync: true, +}; + +const initTestBed = registerTestBed( + WithProviders(PolicyEdit), + testBedConfig +); + +export const setup = formSetup.bind(null, initTestBed); diff --git a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/policy_form.helpers.ts b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/policy_form.helpers.ts new file mode 100644 index 0000000000000..302af7a1ec7f0 --- /dev/null +++ b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/policy_form.helpers.ts @@ -0,0 +1,56 @@ +/* + * 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 { TestBed, SetupFunc } from '../../../../../../test_utils'; + +export interface PolicyFormTestBed extends TestBed { + actions: { + clickNextButton: () => void; + clickSubmitButton: () => void; + }; +} + +export const formSetup = async ( + initTestBed: SetupFunc +): Promise => { + const testBed = await initTestBed(); + + // User actions + const clickNextButton = () => { + testBed.find('nextButton').simulate('click'); + }; + + const clickSubmitButton = () => { + testBed.find('submitButton').simulate('click'); + }; + + return { + ...testBed, + actions: { + clickNextButton, + clickSubmitButton, + }, + }; +}; + +export type PolicyFormTestSubjects = + | 'advancedCronInput' + | 'allIndicesToggle' + | 'backButton' + | 'deselectIndicesLink' + | 'expireAfterValueInput' + | 'expireAfterUnitSelect' + | 'ignoreUnavailableIndicesToggle' + | 'nameInput' + | 'maxCountInput' + | 'minCountInput' + | 'nextButton' + | 'pageTitle' + | 'savePolicyApiError' + | 'selectIndicesLink' + | 'showAdvancedCronLink' + | 'snapshotNameInput' + | 'submitButton'; diff --git a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/providers.tsx b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/providers.tsx index 5257c030518ba..187d2da0d7a3d 100644 --- a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/providers.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/providers.tsx @@ -10,7 +10,15 @@ import { setAppDependencies } from '../../../public/app/index'; const { core, plugins } = createShim(); const appDependencies = { - core, + core: { + ...core, + chrome: { + ...core.chrome, + // mock getInjected() to return true + // this is used so the policy tab renders (slmUiEnabled config) + getInjected: () => true, + }, + }, plugins, }; diff --git a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/setup_environment.ts b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/setup_environment.ts index dcfcdb1031dd5..e914f06d8e16f 100644 --- a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/setup_environment.ts +++ b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/setup_environment.ts @@ -9,11 +9,15 @@ import axiosXhrAdapter from 'axios/lib/adapters/xhr'; import { i18n } from '@kbn/i18n'; +import { docTitle } from 'ui/doc_title/doc_title'; import { httpService } from '../../../public/app/services/http'; -import { breadcrumbService } from '../../../public/app/services/navigation'; +import { breadcrumbService, docTitleService } from '../../../public/app/services/navigation'; import { textService } from '../../../public/app/services/text'; import { chrome } from '../../../public/test/mocks'; import { init as initHttpRequests } from './http_requests'; +import { uiMetricService } from '../../../public/app/services/ui_metric'; +import { documentationLinksService } from '../../../public/app/services/documentation'; +import { createUiStatsReporter } from '../../../../../../../src/legacy/core_plugins/ui_metric/public'; export const setupEnvironment = () => { httpService.init(axios.create({ adapter: axiosXhrAdapter }), { @@ -21,6 +25,9 @@ export const setupEnvironment = () => { }); breadcrumbService.init(chrome, {}); textService.init(i18n); + uiMetricService.init(createUiStatsReporter); + documentationLinksService.init('', ''); + docTitleService.init(docTitle.change); const { server, httpRequestsMockHelpers } = initHttpRequests(); diff --git a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/home.test.ts b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/home.test.ts index 1cbafab69da7c..7f4860e74bafe 100644 --- a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/home.test.ts +++ b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/home.test.ts @@ -17,6 +17,7 @@ import { } from './helpers'; import { HomeTestBed } from './helpers/home.helpers'; import { REPOSITORY_NAME } from './helpers/constant'; +import moment from 'moment-timezone'; const { setup } = pageHelpers.home; @@ -51,7 +52,7 @@ describe.skip('', () => { test('should set the correct app title', () => { const { exists, find } = testBed; expect(exists('appTitle')).toBe(true); - expect(find('appTitle').text()).toEqual('Snapshot Repositories'); + expect(find('appTitle').text()).toEqual('Snapshot and Restore'); }); test('should display a loading while fetching the repositories', () => { @@ -63,7 +64,7 @@ describe.skip('', () => { test('should have a link to the documentation', () => { const { exists, find } = testBed; expect(exists('documentationLink')).toBe(true); - expect(find('documentationLink').text()).toBe('Snapshot docs'); + expect(find('documentationLink').text()).toBe('Snapshot and Restore docs'); }); describe('tabs', () => { @@ -77,14 +78,19 @@ describe.skip('', () => { }); }); - test('should have 2 tabs', () => { + test('should have 4 tabs', () => { const { find } = testBed; - expect(find('tab').length).toBe(2); - expect(find('tab').map(t => t.text())).toEqual(['Snapshots', 'Repositories']); + expect(find('tab').length).toBe(4); + expect(find('tab').map(t => t.text())).toEqual([ + 'Snapshots', + 'Repositories', + 'Policies', + 'Restore Status', + ]); }); - test('should navigate to snapshot list tab', () => { + test('should navigate to snapshot list tab', async () => { const { exists, actions } = testBed; expect(exists('repositoryList')).toBe(true); @@ -92,6 +98,12 @@ describe.skip('', () => { actions.selectTab('snapshots'); + // @ts-ignore (remove when react 16.9.0 is released) + await act(async () => { + await nextTick(); + testBed.component.update(); + }); + expect(exists('repositoryList')).toBe(false); expect(exists('snapshotList')).toBe(true); }); @@ -264,6 +276,11 @@ describe.skip('', () => { expect(exists('repositoryDetail')).toBe(false); await actions.clickRepositoryAt(0); + // @ts-ignore (remove when react 16.9.0 is released) + await act(async () => { + await nextTick(); + testBed.component.update(); + }); expect(exists('repositoryDetail')).toBe(true); }); @@ -454,14 +471,19 @@ describe.skip('', () => { const { tableCellsValues } = table.getMetaData('snapshotTable'); tableCellsValues.forEach((row, i) => { const snapshot = snapshots[i]; + const startTime = moment(new Date(snapshot.startTimeInMillis)); + const timezone = moment.tz.guess(); + expect(row).toEqual([ + '', // Checkbox snapshot.snapshot, // Snapshot REPOSITORY_NAME, // Repository - 'foo', // TODO: fix this with FormattedDateTime value - `${Math.ceil(snapshot.durationInMillis / 1000).toString()}s`, // Duration snapshot.indices.length.toString(), // Indices snapshot.shards.total.toString(), // Shards snapshot.shards.failed.toString(), // Failed shards + startTime.tz(timezone).format('MMMM D, YYYY h:mm A z'), // Start time + `${Math.ceil(snapshot.durationInMillis / 1000).toString()}s`, // Duration + '', ]); }); }); @@ -590,22 +612,38 @@ describe.skip('', () => { describe('summary tab', () => { test('should set the correct summary values', () => { + const { + version, + versionId, + uuid, + indices, + endTimeInMillis, + startTimeInMillis, + } = snapshot1; + const { find } = testBed; + const startTime = moment(new Date(startTimeInMillis)); + const endTime = moment(new Date(endTimeInMillis)); + const timezone = moment.tz.guess(); expect(find('snapshotDetail.version.value').text()).toBe( - `${snapshot1.version} / ${snapshot1.versionId}` + `${version} / ${versionId}` ); - expect(find('snapshotDetail.uuid.value').text()).toBe(snapshot1.uuid); + expect(find('snapshotDetail.uuid.value').text()).toBe(uuid); expect(find('snapshotDetail.state.value').text()).toBe('Snapshot complete'); expect(find('snapshotDetail.includeGlobalState.value').text()).toBe('Yes'); expect(find('snapshotDetail.indices.title').text()).toBe( - `Indices (${snapshot1.indices.length})` + `Indices (${indices.length})` + ); + expect(find('snapshotDetail.indices.value').text()).toContain( + indices.splice(0, 10).join('') + ); + expect(find('snapshotDetail.startTime.value').text()).toBe( + startTime.tz(timezone).format('MMMM D, YYYY h:mm A z') ); - expect(find('snapshotDetail.indices.value').text()).toBe( - snapshot1.indices.join('') + expect(find('snapshotDetail.endTime.value').text()).toBe( + endTime.tz(timezone).format('MMMM D, YYYY h:mm A z') ); - expect(find('snapshotDetail.startTime.value').text()).toBe('foo'); // TODO: fix this with FormattedDateTime value - expect(find('snapshotDetail.endTime.value').text()).toBe('foo'); // TODO: fix this with FormattedDateTime value }); test('should indicate the different snapshot states', async () => { @@ -647,7 +685,7 @@ describe.skip('', () => { [SNAPSHOT_STATE.INCOMPATIBLE]: 'Incompatible version ', }; - // Call sequencially each state and verify that the message is ok + // Call sequentially each state and verify that the message is ok return Object.entries(mapStateToMessage).reduce((promise, [state, message]) => { return promise.then(async () => expectMessageForSnapshotState(state, message)); }, Promise.resolve()); diff --git a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/policy_add.test.ts b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/policy_add.test.ts new file mode 100644 index 0000000000000..19feb85e4f04e --- /dev/null +++ b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/policy_add.test.ts @@ -0,0 +1,215 @@ +/* + * 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 { act } from 'react-dom/test-utils'; + +import * as fixtures from '../../test/fixtures'; + +import { setupEnvironment, pageHelpers, nextTick, getRandomString } from './helpers'; +import { PolicyFormTestBed } from './helpers/policy_form.helpers'; +import { DEFAULT_POLICY_SCHEDULE } from '../../public/app/constants'; + +const { setup } = pageHelpers.policyAdd; + +jest.mock('ui/i18n', () => { + const I18nContext = ({ children }: any) => children; + return { I18nContext }; +}); + +const POLICY_NAME = 'my_policy'; +const SNAPSHOT_NAME = 'my_snapshot'; +const MIN_COUNT = '5'; +const MAX_COUNT = '10'; +const EXPIRE_AFTER_VALUE = '30'; +const repository = fixtures.getRepository({ name: `a${getRandomString()}`, type: 'fs' }); + +// We need to skip the tests until react 16.9.0 is released +// which supports asynchronous code inside act() +describe.skip('', () => { + let testBed: PolicyFormTestBed; + + const { server, httpRequestsMockHelpers } = setupEnvironment(); + + afterAll(() => { + server.restore(); + }); + + describe('on component mount', () => { + beforeEach(async () => { + httpRequestsMockHelpers.setLoadRepositoriesResponse({ repositories: [repository] }); + httpRequestsMockHelpers.setLoadIndicesResponse({ indices: ['my_index'] }); + + testBed = await setup(); + await nextTick(); + testBed.component.update(); + }); + + test('should set the correct page title', () => { + const { exists, find } = testBed; + expect(exists('pageTitle')).toBe(true); + expect(find('pageTitle').text()).toEqual('Create policy'); + }); + + test('should not let the user go to the next step if required fields are missing', () => { + const { find } = testBed; + + expect(find('nextButton').props().disabled).toBe(true); + }); + + describe('form validation', () => { + describe('logistics (step 1)', () => { + test('should require a policy name', async () => { + const { form, find } = testBed; + + form.setInputValue('nameInput', ''); + find('nameInput').simulate('blur'); + + expect(form.getErrorsMessages()).toEqual(['Policy name is required.']); + }); + + test('should require a snapshot name', () => { + const { form, find } = testBed; + + form.setInputValue('snapshotNameInput', ''); + find('snapshotNameInput').simulate('blur'); + + expect(form.getErrorsMessages()).toEqual(['Snapshot name is required.']); + }); + + it('should require a schedule', () => { + const { form, find } = testBed; + + find('showAdvancedCronLink').simulate('click'); + form.setInputValue('advancedCronInput', ''); + find('advancedCronInput').simulate('blur'); + + expect(form.getErrorsMessages()).toEqual(['Schedule is required.']); + }); + }); + + describe('snapshot settings (step 2)', () => { + beforeEach(() => { + const { form, actions } = testBed; + // Complete step 1 + form.setInputValue('nameInput', POLICY_NAME); + form.setInputValue('snapshotNameInput', SNAPSHOT_NAME); + actions.clickNextButton(); + }); + + test('should require at least one index', async () => { + const { find, form, component } = testBed; + + // @ts-ignore (remove when react 16.9.0 is released) + await act(async () => { + // Toggle "All indices" switch + form.toggleEuiSwitch('allIndicesToggle', false); + await nextTick(); + component.update(); + }); + + // Deselect all indices from list + find('deselectIndicesLink').simulate('click'); + + expect(form.getErrorsMessages()).toEqual(['You must select at least one index.']); + }); + }); + + describe('retention (step 3)', () => { + beforeEach(() => { + const { form, actions } = testBed; + // Complete step 1 + form.setInputValue('nameInput', POLICY_NAME); + form.setInputValue('snapshotNameInput', SNAPSHOT_NAME); + actions.clickNextButton(); + + // Complete step 2 + actions.clickNextButton(); + }); + + test('should not allow the minimum count be greater than the maximum count', () => { + const { find, form } = testBed; + + form.setInputValue('minCountInput', MAX_COUNT + 1); + find('minCountInput').simulate('blur'); + + form.setInputValue('maxCountInput', MAX_COUNT); + find('maxCountInput').simulate('blur'); + + expect(form.getErrorsMessages()).toEqual(['Min count cannot be greater than max count.']); + }); + }); + }); + + describe('form payload & api errors', () => { + beforeEach(async () => { + const { actions, form } = testBed; + + // Complete step 1 + form.setInputValue('nameInput', POLICY_NAME); + form.setInputValue('snapshotNameInput', SNAPSHOT_NAME); + actions.clickNextButton(); + + // Complete step 2 + actions.clickNextButton(); + + // Complete step 3 + form.setInputValue('expireAfterValueInput', EXPIRE_AFTER_VALUE); + form.setInputValue('minCountInput', MIN_COUNT); + form.setInputValue('maxCountInput', MAX_COUNT); + actions.clickNextButton(); + }); + + it('should send the correct payload', async () => { + const { actions } = testBed; + + // @ts-ignore (remove when react 16.9.0 is released) + await act(async () => { + actions.clickSubmitButton(); + await nextTick(); + }); + + const latestRequest = server.requests[server.requests.length - 1]; + + const expected = { + config: {}, + name: POLICY_NAME, + repository: repository.name, + retention: { + expireAfterUnit: 'd', // default + expireAfterValue: Number(EXPIRE_AFTER_VALUE), + maxCount: Number(MAX_COUNT), + minCount: Number(MIN_COUNT), + }, + schedule: DEFAULT_POLICY_SCHEDULE, + snapshotName: SNAPSHOT_NAME, + }; + + expect(JSON.parse(latestRequest.requestBody)).toEqual(expected); + }); + + it('should surface the API errors from the put HTTP request', async () => { + const { component, actions, find, exists } = testBed; + + const error = { + status: 409, + error: 'Conflict', + message: `There is already a policy with name '${POLICY_NAME}'`, + }; + + httpRequestsMockHelpers.setAddPolicyResponse(undefined, { body: error }); + + // @ts-ignore (remove when react 16.9.0 is released) + await act(async () => { + actions.clickSubmitButton(); + await nextTick(); + component.update(); + }); + + expect(exists('savePolicyApiError')).toBe(true); + expect(find('savePolicyApiError').text()).toContain(error.message); + }); + }); + }); +}); diff --git a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/policy_edit.test.ts b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/policy_edit.test.ts new file mode 100644 index 0000000000000..efcb338e6d268 --- /dev/null +++ b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/policy_edit.test.ts @@ -0,0 +1,136 @@ +/* + * 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 { act } from 'react-dom/test-utils'; + +import { setupEnvironment, pageHelpers, nextTick } from './helpers'; +import { PolicyForm } from '../../public/app/components/policy_form'; +import { PolicyFormTestBed } from './helpers/policy_form.helpers'; +import { POLICY_EDIT } from './helpers/constant'; + +const { setup } = pageHelpers.policyEdit; +const { setup: setupPolicyAdd } = pageHelpers.policyAdd; + +const EXPIRE_AFTER_VALUE = '5'; +const EXPIRE_AFTER_UNIT = 'm'; + +jest.mock('ui/i18n', () => { + const I18nContext = ({ children }: any) => children; + return { I18nContext }; +}); + +// We need to skip the tests until react 16.9.0 is released +// which supports asynchronous code inside act() +describe.skip('', () => { + let testBed: PolicyFormTestBed; + let testBedPolicyAdd: PolicyFormTestBed; + const { server, httpRequestsMockHelpers } = setupEnvironment(); + + afterAll(() => { + server.restore(); + }); + + describe('on component mount', () => { + beforeEach(async () => { + httpRequestsMockHelpers.setGetPolicyResponse({ policy: POLICY_EDIT }); + httpRequestsMockHelpers.setLoadIndicesResponse({ indices: ['my_index'] }); + httpRequestsMockHelpers.setLoadRepositoriesResponse({ + repositories: [{ name: POLICY_EDIT.repository }], + }); + + testBed = await setup(); + + // @ts-ignore (remove when react 16.9.0 is released) + await act(async () => { + await nextTick(); + testBed.component.update(); + }); + }); + + test('should set the correct page title', () => { + const { exists, find } = testBed; + expect(exists('pageTitle')).toBe(true); + expect(find('pageTitle').text()).toEqual('Edit policy'); + }); + + /** + * As the "edit" policy component uses the same form underneath that + * the "create" policy, we won't test it again but simply make sure that + * the same form component is indeed shared between the 2 app sections. + */ + test('should use the same Form component as the "" section', async () => { + testBedPolicyAdd = await setupPolicyAdd(); + + // @ts-ignore (remove when react 16.9.0 is released) + await act(async () => { + await nextTick(); + testBedPolicyAdd.component.update(); + }); + + const formEdit = testBed.component.find(PolicyForm); + const formAdd = testBedPolicyAdd.component.find(PolicyForm); + + expect(formEdit.length).toBe(1); + expect(formAdd.length).toBe(1); + }); + + test('should disable the policy name field', () => { + const { find } = testBed; + + const nameInput = find('nameInput'); + expect(nameInput.props().disabled).toEqual(true); + }); + + describe('form payload', () => { + beforeEach(async () => { + const { form, actions } = testBed; + + const { snapshotName } = POLICY_EDIT; + + // Complete step 1 + form.setInputValue('snapshotNameInput', `${snapshotName}-edited`); + actions.clickNextButton(); + + // Complete step 2 + // console.log(testBed.component.debug()); + form.toggleEuiSwitch('ignoreUnavailableIndicesToggle'); + actions.clickNextButton(); + + // Complete step 3 + form.setInputValue('expireAfterValueInput', EXPIRE_AFTER_VALUE); + form.setInputValue('expireAfterUnitSelect', EXPIRE_AFTER_UNIT); + actions.clickNextButton(); + }); + + it('should send the correct payload with changed values', async () => { + const { actions } = testBed; + + // @ts-ignore (remove when react 16.9.0 is released) + await act(async () => { + actions.clickSubmitButton(); + await nextTick(); + }); + + const latestRequest = server.requests[server.requests.length - 1]; + + const expected = { + ...POLICY_EDIT, + ...{ + config: { + ignoreUnavailable: true, + }, + retention: { + expireAfterValue: Number(EXPIRE_AFTER_VALUE), + expireAfterUnit: EXPIRE_AFTER_UNIT, + }, + snapshotName: `${POLICY_EDIT.snapshotName}-edited`, + }, + }; + expect(JSON.parse(latestRequest.requestBody)).toEqual(expected); + }); + }); + }); +}); diff --git a/x-pack/legacy/plugins/snapshot_restore/common/constants.ts b/x-pack/legacy/plugins/snapshot_restore/common/constants.ts index a881bf3081c5e..f04a5d6dc6e75 100644 --- a/x-pack/legacy/plugins/snapshot_restore/common/constants.ts +++ b/x-pack/legacy/plugins/snapshot_restore/common/constants.ts @@ -54,3 +54,10 @@ export const APP_REQUIRED_CLUSTER_PRIVILEGES = [ ]; export const APP_RESTORE_INDEX_PRIVILEGES = ['monitor']; export const APP_SLM_CLUSTER_PRIVILEGES = ['manage_slm']; + +export const TIME_UNITS: { [key: string]: 'd' | 'h' | 'm' | 's' } = { + DAY: 'd', + HOUR: 'h', + MINUTE: 'm', + SECOND: 's', +}; diff --git a/x-pack/legacy/plugins/snapshot_restore/common/lib/index.ts b/x-pack/legacy/plugins/snapshot_restore/common/lib/index.ts index bede2689bb855..579dae0265939 100644 --- a/x-pack/legacy/plugins/snapshot_restore/common/lib/index.ts +++ b/x-pack/legacy/plugins/snapshot_restore/common/lib/index.ts @@ -12,5 +12,7 @@ export { deserializeSnapshotDetails, deserializeSnapshotConfig, serializeSnapshotConfig, + deserializeSnapshotRetention, + serializeSnapshotRetention, } from './snapshot_serialization'; export { deserializePolicy, serializePolicy } from './policy_serialization'; diff --git a/x-pack/legacy/plugins/snapshot_restore/common/lib/policy_serialization.test.ts b/x-pack/legacy/plugins/snapshot_restore/common/lib/policy_serialization.test.ts index 86adde4db7f99..9ce9367bc0e0e 100644 --- a/x-pack/legacy/plugins/snapshot_restore/common/lib/policy_serialization.test.ts +++ b/x-pack/legacy/plugins/snapshot_restore/common/lib/policy_serialization.test.ts @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { deserializePolicy } from './policy_serialization'; +import { deserializePolicy, serializePolicy } from './policy_serialization'; describe('repository_serialization', () => { describe('deserializePolicy()', () => { @@ -25,6 +25,11 @@ describe('repository_serialization', () => { foo: 'bar', }, }, + retention: { + expire_after: '14d', + max_count: 30, + min_count: 4, + }, }, next_execution: '2019-07-11T01:30:00.000Z', next_execution_millis: 1562722200000, @@ -45,6 +50,12 @@ describe('repository_serialization', () => { foo: 'bar', }, }, + retention: { + expireAfterValue: 14, + expireAfterUnit: 'd', + maxCount: 30, + minCount: 4, + }, nextExecution: '2019-07-11T01:30:00.000Z', nextExecutionMillis: 1562722200000, }); @@ -112,4 +123,48 @@ describe('repository_serialization', () => { }); }); }); + + describe('serializePolicy()', () => { + it('should serialize a slm policy', () => { + expect( + serializePolicy({ + name: 'my-snapshot-policy', + snapshotName: 'my-backups-snapshots', + schedule: '0 30 1 * * ?', + repository: 'my-backups', + config: { + indices: ['kibana-*'], + includeGlobalState: false, + ignoreUnavailable: false, + metadata: { + foo: 'bar', + }, + }, + retention: { + expireAfterValue: 14, + expireAfterUnit: 'd', + maxCount: 30, + minCount: 4, + }, + }) + ).toEqual({ + name: 'my-backups-snapshots', + schedule: '0 30 1 * * ?', + repository: 'my-backups', + config: { + indices: ['kibana-*'], + include_global_state: false, + ignore_unavailable: false, + metadata: { + foo: 'bar', + }, + }, + retention: { + expire_after: '14d', + max_count: 30, + min_count: 4, + }, + }); + }); + }); }); diff --git a/x-pack/legacy/plugins/snapshot_restore/common/lib/policy_serialization.ts b/x-pack/legacy/plugins/snapshot_restore/common/lib/policy_serialization.ts index dc52765670540..4652ac4bc5cc4 100644 --- a/x-pack/legacy/plugins/snapshot_restore/common/lib/policy_serialization.ts +++ b/x-pack/legacy/plugins/snapshot_restore/common/lib/policy_serialization.ts @@ -4,19 +4,25 @@ * you may not use this file except in compliance with the Elastic License. */ import { SlmPolicy, SlmPolicyEs, SlmPolicyPayload } from '../types'; -import { deserializeSnapshotConfig, serializeSnapshotConfig } from './'; +import { + deserializeSnapshotConfig, + serializeSnapshotConfig, + deserializeSnapshotRetention, + serializeSnapshotRetention, +} from './'; export const deserializePolicy = (name: string, esPolicy: SlmPolicyEs): SlmPolicy => { const { version, modified_date: modifiedDate, modified_date_millis: modifiedDateMillis, - policy: { name: snapshotName, schedule, repository, config }, + policy: { name: snapshotName, schedule, repository, config, retention }, next_execution: nextExecution, next_execution_millis: nextExecutionMillis, last_failure: lastFailure, last_success: lastSuccess, in_progress: inProgress, + stats, } = esPolicy; const policy: SlmPolicy = { @@ -35,6 +41,10 @@ export const deserializePolicy = (name: string, esPolicy: SlmPolicyEs): SlmPolic policy.config = deserializeSnapshotConfig(config); } + if (retention) { + policy.retention = deserializeSnapshotRetention(retention); + } + if (lastFailure) { const { snapshot_name: failureSnapshotName, @@ -82,11 +92,27 @@ export const deserializePolicy = (name: string, esPolicy: SlmPolicyEs): SlmPolic }; } + if (stats) { + const { + snapshots_taken: snapshotsTaken, + snapshots_failed: snapshotsFailed, + snapshots_deleted: snapshotsDeleted, + snapshot_deletion_failures: snapshotDeletionFailures, + } = stats; + + policy.stats = { + snapshotsTaken, + snapshotsFailed, + snapshotsDeleted, + snapshotDeletionFailures, + }; + } + return policy; }; export const serializePolicy = (policy: SlmPolicyPayload): SlmPolicyEs['policy'] => { - const { snapshotName: name, schedule, repository, config } = policy; + const { snapshotName: name, schedule, repository, config, retention } = policy; const policyEs: SlmPolicyEs['policy'] = { name, schedule, @@ -97,5 +123,13 @@ export const serializePolicy = (policy: SlmPolicyPayload): SlmPolicyEs['policy'] policyEs.config = serializeSnapshotConfig(config); } + if (retention) { + const serializedRetention = serializeSnapshotRetention(retention); + + if (serializedRetention) { + policyEs.retention = serializeSnapshotRetention(retention); + } + } + return policyEs; }; diff --git a/x-pack/legacy/plugins/snapshot_restore/common/lib/snapshot_serialization.ts b/x-pack/legacy/plugins/snapshot_restore/common/lib/snapshot_serialization.ts index b1f6d2005a2e3..50fdef4175787 100644 --- a/x-pack/legacy/plugins/snapshot_restore/common/lib/snapshot_serialization.ts +++ b/x-pack/legacy/plugins/snapshot_restore/common/lib/snapshot_serialization.ts @@ -6,7 +6,16 @@ import { sortBy } from 'lodash'; -import { SnapshotDetails, SnapshotDetailsEs, SnapshotConfig, SnapshotConfigEs } from '../types'; +import { + SnapshotDetails, + SnapshotDetailsEs, + SnapshotConfig, + SnapshotConfigEs, + SnapshotRetention, + SnapshotRetentionEs, +} from '../types'; + +import { deserializeTime, serializeTime } from './time_serialization'; export function deserializeSnapshotDetails( repository: string, @@ -128,3 +137,68 @@ export function serializeSnapshotConfig(snapshotConfig: SnapshotConfig): Snapsho return config; }, {}); } + +export function deserializeSnapshotRetention( + snapshotRetentionEs: SnapshotRetentionEs +): SnapshotRetention { + const { + expire_after: expireAfter, + max_count: maxCount, + min_count: minCount, + } = snapshotRetentionEs; + + let expireAfterValue; + let expireAfterUnit; + + if (expireAfter) { + const { timeValue, timeUnit } = deserializeTime(expireAfter); + + if (timeValue && timeUnit) { + expireAfterValue = timeValue; + expireAfterUnit = timeUnit; + } + } + + const snapshotRetention: SnapshotRetention = { + expireAfterValue, + expireAfterUnit, + maxCount, + minCount, + }; + + return Object.entries(snapshotRetention).reduce((retention: any, [key, value]) => { + if (value !== undefined) { + retention[key] = value; + } + return retention; + }, {}); +} + +export function serializeSnapshotRetention( + snapshotRetention: SnapshotRetention +): SnapshotRetentionEs | undefined { + const { expireAfterValue, expireAfterUnit, minCount, maxCount } = snapshotRetention; + + const snapshotRetentionEs: SnapshotRetentionEs = { + expire_after: + expireAfterValue && expireAfterUnit + ? serializeTime(expireAfterValue, expireAfterUnit) + : undefined, + min_count: !minCount ? undefined : minCount, + max_count: !maxCount ? undefined : maxCount, + }; + + const flattenedSnapshotRetentionEs = Object.entries(snapshotRetentionEs).reduce( + (retention: any, [key, value]) => { + if (value !== undefined) { + retention[key] = value; + } + return retention; + }, + {} + ); + + return Object.entries(flattenedSnapshotRetentionEs).length + ? flattenedSnapshotRetentionEs + : undefined; +} diff --git a/x-pack/legacy/plugins/snapshot_restore/common/lib/time_serialization.test.ts b/x-pack/legacy/plugins/snapshot_restore/common/lib/time_serialization.test.ts new file mode 100644 index 0000000000000..f661c0c585852 --- /dev/null +++ b/x-pack/legacy/plugins/snapshot_restore/common/lib/time_serialization.test.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { deserializeTime, serializeTime } from './time_serialization'; +import { TIME_UNITS } from '../constants'; + +describe('time_serialization', () => { + describe('deserializeTime()', () => { + it('should deserialize valid ES time', () => { + Object.values(TIME_UNITS).forEach(unit => { + expect(deserializeTime(`15${unit}`)).toEqual({ + timeValue: 15, + timeUnit: unit, + }); + }); + }); + it('should return an empty object if time unit is invalid', () => { + expect(deserializeTime('15foobar')).toEqual({}); + expect(deserializeTime('15minutes')).toEqual({}); + }); + }); + describe('serializeTime()', () => { + it('should serialize ES time', () => { + expect(serializeTime(15, 'd')).toEqual('15d'); + }); + }); +}); diff --git a/x-pack/legacy/plugins/snapshot_restore/common/lib/time_serialization.ts b/x-pack/legacy/plugins/snapshot_restore/common/lib/time_serialization.ts new file mode 100644 index 0000000000000..5f65ec861e81b --- /dev/null +++ b/x-pack/legacy/plugins/snapshot_restore/common/lib/time_serialization.ts @@ -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 { TIME_UNITS } from '../constants'; + +export const deserializeTime = (time: string) => { + const timeUnits = Object.values(TIME_UNITS); + + const timeUnit = timeUnits.find(unit => { + const unitIndex = time.indexOf(unit); + return unitIndex !== -1 && unitIndex === time.length - 1; + }); + + if (timeUnit) { + const timeValue = Number(time.replace(timeUnit, '')); + + if (!isNaN(timeValue)) { + return { + timeValue, + timeUnit, + }; + } + } + + return {}; +}; + +export const serializeTime = (timeValue: number, timeUnit: string) => { + return `${timeValue}${timeUnit}`; // e.g., '15d' +}; diff --git a/x-pack/legacy/plugins/snapshot_restore/common/types/policy.ts b/x-pack/legacy/plugins/snapshot_restore/common/types/policy.ts index 888cad13d213b..ed67b1eb77063 100644 --- a/x-pack/legacy/plugins/snapshot_restore/common/types/policy.ts +++ b/x-pack/legacy/plugins/snapshot_restore/common/types/policy.ts @@ -4,14 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SnapshotConfig, SnapshotConfigEs } from './snapshot'; - +import { + SnapshotConfig, + SnapshotConfigEs, + SnapshotRetention, + SnapshotRetentionEs, +} from './snapshot'; export interface SlmPolicyPayload { name: string; snapshotName: string; schedule: string; repository: string; config?: SnapshotConfig; + retention?: SnapshotRetention; } export interface SlmPolicy extends SlmPolicyPayload { @@ -34,6 +39,12 @@ export interface SlmPolicy extends SlmPolicyPayload { inProgress?: { snapshotName: string; }; + stats?: { + snapshotsTaken: number; + snapshotsFailed: number; + snapshotsDeleted: number; + snapshotDeletionFailures: number; + }; } export interface SlmPolicyEs { @@ -45,6 +56,7 @@ export interface SlmPolicyEs { schedule: string; repository: string; config?: SnapshotConfigEs; + retention?: SnapshotRetentionEs; }; next_execution: string; next_execution_millis: number; @@ -66,4 +78,10 @@ export interface SlmPolicyEs { start_time: string; start_time_millis: number; }; + stats?: { + snapshots_taken: number; + snapshots_failed: number; + snapshots_deleted: number; + snapshot_deletion_failures: number; + }; } diff --git a/x-pack/legacy/plugins/snapshot_restore/common/types/snapshot.ts b/x-pack/legacy/plugins/snapshot_restore/common/types/snapshot.ts index dd561bd50d352..46713c937fd3f 100644 --- a/x-pack/legacy/plugins/snapshot_restore/common/types/snapshot.ts +++ b/x-pack/legacy/plugins/snapshot_restore/common/types/snapshot.ts @@ -79,3 +79,16 @@ interface SnapshotDetailsShardsStatusEs { failed: number; successful: number; } + +export interface SnapshotRetention { + expireAfterValue?: number | ''; + expireAfterUnit?: string; + maxCount?: number | ''; + minCount?: number | ''; +} + +export interface SnapshotRetentionEs { + expire_after?: string; + max_count?: number; + min_count?: number; +} diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/components/index.ts b/x-pack/legacy/plugins/snapshot_restore/public/app/components/index.ts index 85d543642a92b..e94aa9287e0dd 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/components/index.ts +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/components/index.ts @@ -16,4 +16,8 @@ export { SnapshotDeleteProvider } from './snapshot_delete_provider'; export { RestoreSnapshotForm } from './restore_snapshot_form'; export { PolicyExecuteProvider } from './policy_execute_provider'; export { PolicyDeleteProvider } from './policy_delete_provider'; +export { + UpdateRetentionModalProvider, + UpdateRetentionSetting, +} from './update_retention_modal_provider'; export { PolicyForm } from './policy_form'; diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/navigation.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/navigation.tsx index ba9877a9e9f41..6bb376b9298ed 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/navigation.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/navigation.tsx @@ -41,14 +41,23 @@ export const PolicyNavigation: React.FunctionComponent = ({ onClick: () => updateCurrentStep(2), }, { - title: i18n.translate('xpack.snapshotRestore.policyForm.navigation.stepReviewName', { - defaultMessage: 'Review', + title: i18n.translate('xpack.snapshotRestore.policyForm.navigation.stepRetentionName', { + defaultMessage: 'Snapshot retention', }), - isComplete: maxCompletedStep >= 2, + isComplete: maxCompletedStep >= 3, isSelected: currentStep === 3, disabled: maxCompletedStep < 2, onClick: () => updateCurrentStep(3), }, + { + title: i18n.translate('xpack.snapshotRestore.policyForm.navigation.stepReviewName', { + defaultMessage: 'Review', + }), + isComplete: maxCompletedStep >= 3, + isSelected: currentStep === 4, + disabled: maxCompletedStep < 3, + onClick: () => updateCurrentStep(4), + }, ]; return ; diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/policy_form.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/policy_form.tsx index 6c631ab8e6c69..7e55cee63a0ac 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/policy_form.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/policy_form.tsx @@ -13,9 +13,15 @@ import { EuiSpacer, } from '@elastic/eui'; import { SlmPolicyPayload } from '../../../../common/types'; +import { TIME_UNITS } from '../../../../common/constants'; import { PolicyValidation, validatePolicy } from '../../services/validation'; import { useAppDependencies } from '../../index'; -import { PolicyStepLogistics, PolicyStepSettings, PolicyStepReview } from './steps'; +import { + PolicyStepLogistics, + PolicyStepSettings, + PolicyStepRetention, + PolicyStepReview, +} from './steps'; import { PolicyNavigation } from './navigation'; interface Props { @@ -53,7 +59,8 @@ export const PolicyForm: React.FunctionComponent = ({ const stepMap: { [key: number]: any } = { 1: PolicyStepLogistics, 2: PolicyStepSettings, - 3: PolicyStepReview, + 3: PolicyStepRetention, + 4: PolicyStepReview, }; const CurrentStepForm = stepMap[currentStep]; @@ -63,6 +70,11 @@ export const PolicyForm: React.FunctionComponent = ({ config: { ...(originalPolicy.config || {}), }, + retention: { + ...(originalPolicy.retention || { + expireAfterUnit: TIME_UNITS.DAY, + }), + }, }); // Policy validation state @@ -161,7 +173,9 @@ export const PolicyForm: React.FunctionComponent = ({ fill iconType="arrowRight" onClick={() => onNext()} + iconSide="right" disabled={!validation.isValid} + data-test-subj="nextButton" > = ({ iconType="check" onClick={() => savePolicy()} isLoading={isSaving} + data-test-subj="submitButton" > {isSaving ? ( = ({ }} onBlur={() => setTouched({ ...touched, schedule: true })} placeholder={DEFAULT_POLICY_SCHEDULE} - data-test-subj="snapshotNameInput" + data-test-subj="advancedCronInput" /> diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/steps/step_retention.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/steps/step_retention.tsx new file mode 100644 index 0000000000000..b32d579650134 --- /dev/null +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/steps/step_retention.tsx @@ -0,0 +1,238 @@ +/* + * 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 React, { Fragment, useState } from 'react'; + +import { + EuiDescribedFormGroup, + EuiTitle, + EuiFormRow, + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, + EuiFieldNumber, + EuiSelect, +} from '@elastic/eui'; + +import { SlmPolicyPayload } from '../../../../../common/types'; +import { TIME_UNITS } from '../../../../../common/constants'; +import { documentationLinksService } from '../../../services/documentation'; +import { useAppDependencies } from '../../../index'; +import { StepProps } from './'; +import { textService } from '../../../services/text'; + +const getExpirationTimeOptions = (unitSize = '0') => + Object.entries(TIME_UNITS).map(([_key, value]) => ({ + text: textService.getTimeUnitLabel(value, unitSize), + value, + })); + +export const PolicyStepRetention: React.FunctionComponent = ({ + policy, + updatePolicy, + errors, +}) => { + const { + core: { i18n }, + } = useAppDependencies(); + const { FormattedMessage } = i18n; + + const { retention = {} } = policy; + + const updatePolicyRetention = (updatedFields: Partial): void => { + const newRetention = { ...retention, ...updatedFields }; + updatePolicy({ + retention: newRetention, + }); + }; + + // State for touched inputs + const [touched, setTouched] = useState({ + expireAfterValue: false, + minCount: false, + maxCount: false, + }); + + const renderExpireAfterField = () => ( + +

+ +

+ + } + description={ + + } + idAria="expirationDescription" + fullWidth + > + + } + describedByIds={['expirationDescription']} + isInvalid={touched.expireAfterValue && Boolean(errors.expireAfterValue)} + error={errors.expireAfter} + fullWidth + > + + + setTouched({ ...touched, expireAfterValue: true })} + onChange={e => { + const value = e.target.value; + updatePolicyRetention({ + expireAfterValue: value !== '' ? Number(value) : value, + }); + }} + data-test-subj="expireAfterValueInput" + /> + + + { + updatePolicyRetention({ + expireAfterUnit: e.target.value, + }); + }} + data-test-subj="expireAfterUnitSelect" + /> + + + +
+ ); + + const renderCountFields = () => ( + +

+ +

+ + } + description={ + + } + idAria="countDescription" + fullWidth + > + + + + } + describedByIds={['countDescription']} + isInvalid={touched.minCount && Boolean(errors.minCount)} + error={errors.minCount} + fullWidth + > + setTouched({ ...touched, minCount: true })} + onChange={e => { + const value = e.target.value; + updatePolicyRetention({ + minCount: value !== '' ? Number(value) : value, + }); + }} + data-test-subj="minCountInput" + /> + + + + + } + describedByIds={['countDescription']} + error={errors.maxCount} + fullWidth + > + setTouched({ ...touched, maxCount: true })} + onChange={e => { + const value = e.target.value; + updatePolicyRetention({ + maxCount: value !== '' ? Number(value) : value, + }); + }} + data-test-subj="maxCountInput" + /> + + + +
+ ); + + return ( + + {/* Step title and doc link */} + + + +

+ +

+
+
+ + + + + + +
+ + {renderExpireAfterField()} + {renderCountFields()} +
+ ); +}; diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/steps/step_review.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/steps/step_review.tsx index 2599aa4b19bb1..b2f9a4231e853 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/steps/step_review.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/steps/step_review.tsx @@ -13,11 +13,11 @@ import { EuiDescriptionListDescription, EuiSpacer, EuiTabbedContent, + EuiText, EuiTitle, EuiLink, EuiIcon, EuiToolTip, - EuiText, } from '@elastic/eui'; import { serializePolicy } from '../../../../../common/lib'; import { useAppDependencies } from '../../../index'; @@ -31,7 +31,7 @@ export const PolicyStepReview: React.FunctionComponent = ({ core: { i18n }, } = useAppDependencies(); const { FormattedMessage } = i18n; - const { name, snapshotName, schedule, repository, config } = policy; + const { name, snapshotName, schedule, repository, config, retention } = policy; const { indices, includeGlobalState, ignoreUnavailable, partial } = config || { indices: undefined, includeGlobalState: undefined, @@ -48,8 +48,27 @@ export const PolicyStepReview: React.FunctionComponent = ({ const hiddenIndicesCount = displayIndices && displayIndices.length > 10 ? displayIndices.length - 10 : 0; + const serializedPolicy = serializePolicy(policy); + const { retention: serializedRetention } = serializedPolicy; + + const EditStepTooltip = ({ step }: { step: number }) => ( + + } + > + updateCurrentStep(step)}> + + + + ); + const renderSummaryTab = () => ( + {/* Logistics summary */}

@@ -57,18 +76,7 @@ export const PolicyStepReview: React.FunctionComponent = ({ id="xpack.snapshotRestore.policyForm.stepReview.summaryTab.sectionLogisticsTitle" defaultMessage="Logistics" />{' '} - - } - > - updateCurrentStep(1)}> - - - +

@@ -125,24 +133,15 @@ export const PolicyStepReview: React.FunctionComponent = ({ + + {/* Snapshot settings summary */}

{' '} - - } - > - updateCurrentStep(2)}> - - - +

@@ -279,12 +278,69 @@ export const PolicyStepReview: React.FunctionComponent = ({ + + {/* Retention summary */} + {serializedRetention ? ( + + + +

+ {' '} + +

+
+ + + + {retention!.expireAfterValue && ( + + + + + + {retention!.expireAfterValue} + {retention!.expireAfterUnit} + + + )} + {retention!.minCount && ( + + + + + {retention!.minCount} + + )} + {retention!.maxCount && ( + + + + + {retention!.maxCount} + + )} + +
+ ) : null}
); const renderRequestTab = () => { const endpoint = `PUT _slm/policy/${name}`; - const json = JSON.stringify(serializePolicy(policy), null, 2); + const json = JSON.stringify(serializedPolicy, null, 2); + return ( diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/steps/step_settings.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/steps/step_settings.tsx index 642440a8c5e91..6f1b2ed2cef4d 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/steps/step_settings.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/steps/step_settings.tsx @@ -109,6 +109,7 @@ export const PolicyStepSettings: React.FunctionComponent = ({ /> } checked={isAllIndices} + data-test-subj="allIndicesToggle" onChange={e => { const isChecked = e.target.checked; setIsAllIndices(isChecked); @@ -162,6 +163,7 @@ export const PolicyStepSettings: React.FunctionComponent = ({ { setSelectIndicesMode('list'); updatePolicyConfig({ indices: indicesSelection }); @@ -186,6 +188,7 @@ export const PolicyStepSettings: React.FunctionComponent = ({ selectOrDeselectAllLink: config.indices && config.indices.length > 0 ? ( { // TODO: Change this to setIndicesOptions() when https://github.com/elastic/eui/issues/2071 is fixed indicesOptions.forEach((option: Option) => { @@ -313,6 +316,7 @@ export const PolicyStepSettings: React.FunctionComponent = ({ fullWidth > = ({ > } @@ -397,6 +402,7 @@ export const PolicyStepSettings: React.FunctionComponent = ({ fullWidth > React.ReactElement; +} + +export type UpdateRetentionSetting = ( + retentionSchedule?: string, + onSuccess?: OnSuccessCallback +) => void; + +type OnSuccessCallback = () => void; + +export const UpdateRetentionModalProvider: React.FunctionComponent = ({ children }) => { + const { + core: { + i18n, + notification: { toastNotifications }, + }, + } = useAppDependencies(); + const { FormattedMessage } = i18n; + + const [retentionSchedule, setRetentionSchedule] = useState(DEFAULT_RETENTION_SCHEDULE); + const [isModalOpen, setIsModalOpen] = useState(false); + const [isEditing, setIsEditing] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + const [isAdvancedCronVisible, setIsAdvancedCronVisible] = useState(false); + + const onSuccessCallback = useRef(null); + + const [simpleCron, setSimpleCron] = useState<{ + expression: string; + frequency: string; + }>({ + expression: DEFAULT_RETENTION_SCHEDULE, + frequency: DEFAULT_RETENTION_FREQUENCY, + }); + + const [fieldToPreferredValueMap, setFieldToPreferredValueMap] = useState({}); + + const [isInvalid, setIsInvalid] = useState(false); + + const updateRetentionPrompt: UpdateRetentionSetting = ( + originalRetentionSchedule, + onSuccess = () => undefined + ) => { + setIsModalOpen(true); + + setIsAdvancedCronVisible( + Boolean(originalRetentionSchedule && originalRetentionSchedule !== DEFAULT_RETENTION_SCHEDULE) + ); + + if (originalRetentionSchedule) { + setIsEditing(true); + setRetentionSchedule(originalRetentionSchedule); + } + + onSuccessCallback.current = onSuccess; + }; + + const closeModal = () => { + setIsModalOpen(false); + }; + + const updateRetentionSetting = async () => { + if (!retentionSchedule) { + setIsInvalid(true); + return; + } + + setIsSaving(true); + setSaveError(null); + + const { error } = await updateRetentionSchedule(retentionSchedule); + + setIsSaving(false); + + if (error) { + setSaveError(error); + } else { + closeModal(); + + toastNotifications.addSuccess( + i18n.translate( + 'xpack.snapshotRestore.policyForm.stepRetention.policyUpdateRetentionSuccessMessage', + { + defaultMessage: 'Updated retention schedule', + } + ) + ); + + if (onSuccessCallback.current) { + onSuccessCallback.current(); + } + } + }; + + const renderModal = () => { + if (!isModalOpen) { + return null; + } + + return ( + + + + + {isEditing ? ( + + ) : ( + + )} + + + + + {saveError && ( + + + } + color="danger" + iconType="alert" + > + {saveError.data && saveError.data.message ? ( +

{saveError.data.message}

+ ) : null} +
+ +
+ )} + {isAdvancedCronVisible ? ( + + + } + isInvalid={isInvalid} + error={i18n.translate( + 'xpack.snapshotRestore.policyForm.stepRetention.policyUpdateRetentionScheduleFieldErrorMessage', + { + defaultMessage: 'Retention schedule is required.', + } + )} + helpText={ + + +
+ ), + }} + /> + } + fullWidth + > + setRetentionSchedule(e.target.value)} + /> + + + + + + { + setIsAdvancedCronVisible(false); + setRetentionSchedule(simpleCron.expression); + }} + data-test-subj="showBasicCronLink" + > + + + +
+ ) : ( + + { + setSimpleCron({ + expression, + frequency, + }); + setFieldToPreferredValueMap(newFieldToPreferredValueMap); + setRetentionSchedule(expression); + }} + /> + + + + + { + setIsAdvancedCronVisible(true); + }} + data-test-subj="showAdvancedCronLink" + > + + + + + )} + + + + + + + + + + + + + + ); + }; + + return ( + + {children(updateRetentionPrompt)} + {renderModal()} + + ); +}; diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/constants/index.ts b/x-pack/legacy/plugins/snapshot_restore/public/app/constants/index.ts index d95c243aeed62..56da4d8a50972 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/constants/index.ts +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/constants/index.ts @@ -91,6 +91,9 @@ export const REMOVE_INDEX_SETTINGS_SUGGESTIONS: string[] = INDEX_SETTING_SUGGEST export const DEFAULT_POLICY_SCHEDULE = '0 30 1 * * ?'; export const DEFAULT_POLICY_FREQUENCY = DAY; +export const DEFAULT_RETENTION_SCHEDULE = '0 30 1 * * ?'; +export const DEFAULT_RETENTION_FREQUENCY = DAY; + // UI Metric constants export const UIM_APP_NAME = 'snapshot_restore'; export const UIM_REPOSITORY_LIST_LOAD = 'repository_list_load'; @@ -119,3 +122,4 @@ export const UIM_POLICY_DELETE = 'policy_delete'; export const UIM_POLICY_DELETE_MANY = 'policy_delete_many'; export const UIM_POLICY_CREATE = 'policy_create'; export const UIM_POLICY_UPDATE = 'policy_update'; +export const UIM_POLICY_RETENTION_SETTINGS_UPDATE = 'policy_retention_settings_update'; diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_details/tabs/tab_summary.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_details/tabs/tab_summary.tsx index ea29d6492cb4b..68dc9fb164c70 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_details/tabs/tab_summary.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_details/tabs/tab_summary.tsx @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, Fragment } from 'react'; import { EuiFlexGroup, EuiFlexItem, @@ -14,6 +14,10 @@ import { EuiDescriptionListDescription, EuiIcon, EuiText, + EuiPanel, + EuiStat, + EuiSpacer, + EuiHorizontalRule, } from '@elastic/eui'; import { SlmPolicy } from '../../../../../../../common/types'; @@ -40,6 +44,8 @@ export const TabSummary: React.FunctionComponent = ({ policy }) => { schedule, nextExecutionMillis, config, + stats, + retention, } = policy; const { includeGlobalState, ignoreUnavailable, indices, partial } = config || { includeGlobalState: undefined, @@ -123,176 +129,306 @@ export const TabSummary: React.FunctionComponent = ({ policy }) => { }, []); return ( - - - - - - - - - {version} - - - - - - - - - - - - - - - - - - - - - - {snapshotName} - - - - - - - - - - {repository} - - - - - - - - - - - - {schedule} - - - - - - - - - - - - - - - - - - - - - - {isShowingFullIndicesList ? fullIndicesList : shortIndicesList} - - - - - - - - - - {ignoreUnavailable ? ( + + {/** Stats panel */} + {stats && ( + + + + + + + + + + + + + + + + + + + + + )} + + {/** General description list */} + +

+ +

+
+ + + + + + - ) : ( + + + + {version} + + + + + - )} -
-
-
- - - - - - - - - {partial ? ( + + + + + + + + + + + - ) : ( + + + + {snapshotName} + + + + + - )} - - - - - - - - - - {includeGlobalState === false ? ( + + + + {repository} + + + + + + + + + + + + {schedule} + + + + + + + + + + + + + + + + + - ) : ( + + + + {isShowingFullIndicesList ? fullIndicesList : shortIndicesList} + + + + + + + + + + {ignoreUnavailable ? ( + + ) : ( + + )} + + + + + + + + + + + {partial ? ( + + ) : ( + + )} + + + + + + + + + + {includeGlobalState === false ? ( + + ) : ( + + )} + + + +
+ + {retention && ( + + + + + {/** Retention description list */} + +

+ +

+
+ + + + {retention.expireAfterValue && ( + + + + + + {retention.expireAfterValue} + {retention.expireAfterUnit} + + + )} + {retention.minCount && ( + + + + + {retention.minCount} + + )} + {retention.maxCount && ( + + + + + {retention.maxCount} + )} - - - - + +
+ )} + ); }; diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_list.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_list.tsx index 270eb8d43aa99..a1688b8e35486 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_list.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_list.tsx @@ -13,13 +13,14 @@ import { APP_SLM_CLUSTER_PRIVILEGES } from '../../../../../common/constants'; import { SectionError, SectionLoading, Error } from '../../../components'; import { BASE_PATH, UIM_POLICY_LIST_LOAD } from '../../../constants'; import { useAppDependencies } from '../../../index'; -import { useLoadPolicies } from '../../../services/http'; +import { useLoadPolicies, useLoadRetentionSettings } from '../../../services/http'; import { uiMetricService } from '../../../services/ui_metric'; import { linkToAddPolicy, linkToPolicy } from '../../../services/navigation'; import { WithPrivileges, NotAuthorizedSection } from '../../../lib/authorization'; import { PolicyDetails } from './policy_details'; import { PolicyTable } from './policy_table'; +import { PolicyRetentionSchedule } from './policy_retention_schedule'; interface MatchParams { policyName?: SlmPolicy['name']; @@ -46,6 +47,14 @@ export const PolicyList: React.FunctionComponent { return linkToPolicy(newPolicyName); }; @@ -137,6 +146,8 @@ export const PolicyList: React.FunctionComponent policy.schedule); const hasDuplicateSchedules = policySchedules.length > new Set(policySchedules).size; + const hasRetention = Boolean(policies.find((policy: SlmPolicy) => policy.retention)); + content = ( {hasDuplicateSchedules ? ( @@ -159,6 +170,16 @@ export const PolicyList: React.FunctionComponent ) : null} + + {hasRetention ? ( + + ) : null} + void; + isLoading: boolean; + error: any; +} + +export const PolicyRetentionSchedule: React.FunctionComponent = ({ + retentionSettings, + onRetentionScheduleUpdated, + isLoading, + error, +}) => { + const { + core: { i18n }, + } = useAppDependencies(); + + const { FormattedMessage } = i18n; + + if (isLoading) { + return ( + + + + + + + ); + } + + if (error) { + return ( + + + } + color="danger" + iconType="alert" + > + {error.data && error.data.message ?

{error.data.message}

: null} + + + +
+ +
+ ); + } + + if (retentionSettings && retentionSettings.retentionSchedule) { + const { retentionSchedule } = retentionSettings; + + return ( + + + + + +

+ {retentionSchedule} }} + /> +

+
+
+ + + {(updateRetentionPrompt: UpdateRetentionSetting) => { + return ( + + } + > + + updateRetentionPrompt(retentionSchedule, onRetentionScheduleUpdated) + } + aria-label={i18n.translate( + 'xpack.snapshotRestore.policyRetentionSchedulePanel.retentionScheduleEditLinkAriaLabel', + { + defaultMessage: 'Edit retention schedule', + } + )} + /> + + ); + }} + + +
+
+ +
+ ); + } else { + return ( + + + } + color="warning" + iconType="alert" + > +

+ +

+ + {(updateRetentionPrompt: UpdateRetentionSetting) => { + return ( + updateRetentionPrompt(undefined, onRetentionScheduleUpdated)} + > + + + ); + }} + +
+ +
+ ); + } +}; diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_table/policy_table.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_table/policy_table.tsx index 19239a282eb29..ee906eb9f8747 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_table/policy_table.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/policy_list/policy_table/policy_table.tsx @@ -147,6 +147,24 @@ export const PolicyTable: React.FunctionComponent = ({ truncateText: true, sortable: true, }, + { + field: 'retention', + name: i18n.translate('xpack.snapshotRestore.policyList.table.retentionColumnTitle', { + defaultMessage: 'Retention', + }), + render: (retention: SlmPolicy['retention']) => + retention ? ( + + ) : null, + }, { field: 'nextExecutionMillis', name: i18n.translate('xpack.snapshotRestore.policyList.table.nextExecutionColumnTitle', { diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/snapshot_list/snapshot_details/tabs/tab_failures.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/snapshot_list/snapshot_details/tabs/tab_failures.tsx index 1af3cfb4d133e..ea2b8b9904d8f 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/snapshot_list/snapshot_details/tabs/tab_failures.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/snapshot_list/snapshot_details/tabs/tab_failures.tsx @@ -70,8 +70,8 @@ export const TabFailures: React.SFC = ({ indexFailures, snapshotState })

- - {status}: {reason} + + {`${status}: ${reason}`} {failuresCount < failures.length - 1 ? : undefined} diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/policy_add/policy_add.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/policy_add/policy_add.tsx index 30a4c337fb9ba..191d31cfba629 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/policy_add/policy_add.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/policy_add/policy_add.tsx @@ -8,12 +8,13 @@ import { RouteComponentProps } from 'react-router-dom'; import { EuiPageBody, EuiPageContent, EuiSpacer, EuiTitle } from '@elastic/eui'; import { SlmPolicyPayload } from '../../../../common/types'; +import { TIME_UNITS } from '../../../../common/constants'; import { PolicyForm, SectionError, SectionLoading, Error } from '../../components'; import { useAppDependencies } from '../../index'; import { BASE_PATH, DEFAULT_POLICY_SCHEDULE } from '../../constants'; import { breadcrumbService, docTitleService } from '../../services/navigation'; -import { addPolicy, useLoadIndicies } from '../../services/http'; +import { addPolicy, useLoadIndices } from '../../services/http'; export const PolicyAdd: React.FunctionComponent = ({ history, @@ -33,7 +34,7 @@ export const PolicyAdd: React.FunctionComponent = ({ data: { indices } = { indices: [], }, - } = useLoadIndicies(); + } = useLoadIndices(); // Set breadcrumb and page title useEffect(() => { @@ -64,6 +65,12 @@ export const PolicyAdd: React.FunctionComponent = ({ schedule: DEFAULT_POLICY_SCHEDULE, repository: '', config: {}, + retention: { + expireAfterValue: '', + expireAfterUnit: TIME_UNITS.DAY, + maxCount: '', + minCount: '', + }, }; const renderSaveError = () => { diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/policy_edit/policy_edit.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/policy_edit/policy_edit.tsx index 8ecee219b66b9..0bfb84cef93b4 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/policy_edit/policy_edit.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/policy_edit/policy_edit.tsx @@ -3,17 +3,18 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import React, { useEffect, useState, Fragment } from 'react'; +import React, { useEffect, useState } from 'react'; import { RouteComponentProps } from 'react-router-dom'; import { EuiPageBody, EuiPageContent, EuiSpacer, EuiTitle } from '@elastic/eui'; import { SlmPolicyPayload } from '../../../../common/types'; +import { TIME_UNITS } from '../../../../common/constants'; import { SectionError, SectionLoading, PolicyForm, Error } from '../../components'; import { BASE_PATH } from '../../constants'; import { useAppDependencies } from '../../index'; import { breadcrumbService, docTitleService } from '../../services/navigation'; -import { editPolicy, useLoadPolicy, useLoadIndicies } from '../../services/http'; +import { editPolicy, useLoadPolicy, useLoadIndices } from '../../services/http'; interface MatchParams { name: string; @@ -44,6 +45,12 @@ export const PolicyEdit: React.FunctionComponent - - + ); }; @@ -195,7 +200,7 @@ export const PolicyEdit: React.FunctionComponent -

+

{ }); }; -export const useLoadIndicies = () => { +export const useLoadIndices = () => { return useRequest({ path: httpService.addBasePath(`${API_BASE_PATH}policies/indices`), method: 'get', @@ -86,3 +87,24 @@ export const editPolicy = async (editedPolicy: SlmPolicyPayload) => { trackUiMetric(UIM_POLICY_UPDATE); return result; }; + +export const useLoadRetentionSettings = () => { + return useRequest({ + path: httpService.addBasePath(`${API_BASE_PATH}policies/retention_settings`), + method: 'get', + }); +}; + +export const updateRetentionSchedule = (retentionSchedule: string) => { + const result = sendRequest({ + path: httpService.addBasePath(`${API_BASE_PATH}policies/retention_settings`), + method: 'put', + body: { + retentionSchedule, + }, + }); + + const { trackUiMetric } = uiMetricService; + trackUiMetric(UIM_POLICY_RETENTION_SETTINGS_UPDATE); + return result; +}; diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/services/text/text.ts b/x-pack/legacy/plugins/snapshot_restore/public/app/services/text/text.ts index ec92250373a05..e3b5b0115d687 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/services/text/text.ts +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/services/text/text.ts @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { REPOSITORY_TYPES } from '../../../../common/constants'; +import { REPOSITORY_TYPES, TIME_UNITS } from '../../../../common/constants'; class TextService { public breadcrumbs: { [key: string]: string } = {}; @@ -112,6 +112,31 @@ class TextService { }, }); } + + public getTimeUnitLabel(timeUnit: 'd' | 'h' | 'm' | 's', timeValue: string) { + switch (timeUnit) { + case TIME_UNITS.SECOND: + return this.i18n.translate('xpack.snapshotRestore.policyForm.timeUnits.secondLabel', { + defaultMessage: '{timeValue, plural, one {second} other {seconds}}', + values: { timeValue }, + }); + case TIME_UNITS.MINUTE: + return this.i18n.translate('xpack.snapshotRestore.policyForm.timeUnits.minuteLabel', { + defaultMessage: '{timeValue, plural, one {minute} other {minutes}}', + values: { timeValue }, + }); + case TIME_UNITS.HOUR: + return this.i18n.translate('xpack.snapshotRestore.policyForm.timeUnits.hourLabel', { + defaultMessage: '{timeValue, plural, one {hour} other {hours}}', + values: { timeValue }, + }); + case TIME_UNITS.DAY: + return this.i18n.translate('xpack.snapshotRestore.policyForm.timeUnits.dayLabel', { + defaultMessage: '{timeValue, plural, one {day} other {days}}', + values: { timeValue }, + }); + } + } } export const textService = new TextService(); diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/services/validation/validate_policy.ts b/x-pack/legacy/plugins/snapshot_restore/public/app/services/validation/validate_policy.ts index 53c62da97bdac..8a60740b1610c 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/services/validation/validate_policy.ts +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/services/validation/validate_policy.ts @@ -18,7 +18,7 @@ const isStringEmpty = (str: string | null): boolean => { export const validatePolicy = (policy: SlmPolicyPayload): PolicyValidation => { const i18n = textService.i18n; - const { name, snapshotName, schedule, repository, config } = policy; + const { name, snapshotName, schedule, repository, config, retention } = policy; const validation: PolicyValidation = { isValid: true, @@ -28,12 +28,13 @@ export const validatePolicy = (policy: SlmPolicyPayload): PolicyValidation => { schedule: [], repository: [], indices: [], + minCount: [], }, }; if (isStringEmpty(name)) { validation.errors.name.push( - i18n.translate('xpack.snapshotRestore.policyValidation.nameRequiredError', { + i18n.translate('xpack.snapshotRestore.policyValidation.nameRequiredErroMessage', { defaultMessage: 'Policy name is required.', }) ); @@ -41,7 +42,7 @@ export const validatePolicy = (policy: SlmPolicyPayload): PolicyValidation => { if (isStringEmpty(snapshotName)) { validation.errors.snapshotName.push( - i18n.translate('xpack.snapshotRestore.policyValidation.snapshotNameRequiredError', { + i18n.translate('xpack.snapshotRestore.policyValidation.snapshotNameRequiredErrorMessage', { defaultMessage: 'Snapshot name is required.', }) ); @@ -49,7 +50,7 @@ export const validatePolicy = (policy: SlmPolicyPayload): PolicyValidation => { if (isStringEmpty(schedule)) { validation.errors.schedule.push( - i18n.translate('xpack.snapshotRestore.policyValidation.scheduleRequiredError', { + i18n.translate('xpack.snapshotRestore.policyValidation.scheduleRequiredErrorMessage', { defaultMessage: 'Schedule is required.', }) ); @@ -57,7 +58,7 @@ export const validatePolicy = (policy: SlmPolicyPayload): PolicyValidation => { if (isStringEmpty(repository)) { validation.errors.repository.push( - i18n.translate('xpack.snapshotRestore.policyValidation.repositoryRequiredError', { + i18n.translate('xpack.snapshotRestore.policyValidation.repositoryRequiredErrorMessage', { defaultMessage: 'Repository is required.', }) ); @@ -65,7 +66,7 @@ export const validatePolicy = (policy: SlmPolicyPayload): PolicyValidation => { if (config && typeof config.indices === 'string' && config.indices.trim().length === 0) { validation.errors.indices.push( - i18n.translate('xpack.snapshotRestore.policyValidation.indexPatternRequiredError', { + i18n.translate('xpack.snapshotRestore.policyValidation.indexPatternRequiredErrorMessage', { defaultMessage: 'At least one index pattern is required.', }) ); @@ -73,12 +74,24 @@ export const validatePolicy = (policy: SlmPolicyPayload): PolicyValidation => { if (config && Array.isArray(config.indices) && config.indices.length === 0) { validation.errors.indices.push( - i18n.translate('xpack.snapshotRestore.policyValidation.indicesRequiredError', { + i18n.translate('xpack.snapshotRestore.policyValidation.indicesRequiredErrorMessage', { defaultMessage: 'You must select at least one index.', }) ); } + if ( + retention && + retention.minCount && + retention.maxCount && + retention.minCount > retention.maxCount + ) { + validation.errors.minCount.push( + i18n.translate('xpack.snapshotRestore.policyValidation.invalidMinCountErrorMessage', { + defaultMessage: 'Min count cannot be greater than max count.', + }) + ); + } // Remove fields with no errors validation.errors = Object.entries(validation.errors) .filter(([key, value]) => value.length > 0) diff --git a/x-pack/legacy/plugins/snapshot_restore/public/plugin.ts b/x-pack/legacy/plugins/snapshot_restore/public/plugin.ts index 10c7a86d640e6..77db8dd993c2e 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/plugin.ts +++ b/x-pack/legacy/plugins/snapshot_restore/public/plugin.ts @@ -39,11 +39,7 @@ export class Plugin { textService.init(i18n); breadcrumbService.init(chrome, management.constants.BREADCRUMB); uiMetricService.init(uiMetric.createUiStatsReporter); - documentationLinksService.init( - documentation.esDocBasePath, - documentation.esPluginDocBasePath, - documentation.esStackOverviewDocBasePath - ); + documentationLinksService.init(documentation.esDocBasePath, documentation.esPluginDocBasePath); docTitleService.init(docTitle.change); const unmountReactApp = (): void => { diff --git a/x-pack/legacy/plugins/snapshot_restore/public/shim.ts b/x-pack/legacy/plugins/snapshot_restore/public/shim.ts index 02574890afffd..595edbfd1cea4 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/shim.ts +++ b/x-pack/legacy/plugins/snapshot_restore/public/shim.ts @@ -53,7 +53,6 @@ export interface Core extends AppCore { documentation: { esDocBasePath: string; esPluginDocBasePath: string; - esStackOverviewDocBasePath: string; }; docTitle: { change: typeof docTitle.change; @@ -113,7 +112,6 @@ export function createShim(): { core: Core; plugins: Plugins } { documentation: { esDocBasePath: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}/`, esPluginDocBasePath: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/plugins/${DOC_LINK_VERSION}/`, - esStackOverviewDocBasePath: `${ELASTIC_WEBSITE_URL}guide/en/elastic-stack-overview/${DOC_LINK_VERSION}/`, }, docTitle: { change: docTitle.change, diff --git a/x-pack/legacy/plugins/snapshot_restore/server/routes/api/policy.test.ts b/x-pack/legacy/plugins/snapshot_restore/server/routes/api/policy.test.ts index 52e6449559bcc..c0016a4f643cd 100644 --- a/x-pack/legacy/plugins/snapshot_restore/server/routes/api/policy.test.ts +++ b/x-pack/legacy/plugins/snapshot_restore/server/routes/api/policy.test.ts @@ -12,9 +12,10 @@ import { createHandler, updateHandler, getIndicesHandler, + updateRetentionSettingsHandler, } from './policy'; -describe('[Snapshot and Restore API Routes] Restore', () => { +describe('[Snapshot and Restore API Routes] Policy', () => { const mockRequest = {} as Request; const mockResponseToolkit = {} as ResponseToolkit; const mockEsPolicy = { @@ -25,6 +26,11 @@ describe('[Snapshot and Restore API Routes] Restore', () => { schedule: '0 30 1 * * ?', repository: 'my-backups', config: {}, + retention: { + expire_after: '15d', + min_count: 5, + max_count: 10, + }, }, next_execution_millis: 1562722200000, }; @@ -35,6 +41,12 @@ describe('[Snapshot and Restore API Routes] Restore', () => { schedule: '0 30 1 * * ?', repository: 'my-backups', config: {}, + retention: { + expireAfterValue: 15, + expireAfterUnit: 'd', + minCount: 5, + maxCount: 10, + }, nextExecutionMillis: 1562722200000, }; @@ -323,4 +335,29 @@ describe('[Snapshot and Restore API Routes] Restore', () => { ).rejects.toThrow(); }); }); + + describe('updateRetentionSettingsHandler()', () => { + const retentionSettings = { + retentionSchedule: '0 30 1 * * ?', + }; + const mockCreateRequest = ({ + payload: retentionSettings, + } as unknown) as Request; + + it('should return successful ES response', async () => { + const mockEsResponse = { acknowledged: true }; + const callWithRequest = jest.fn().mockReturnValueOnce(mockEsResponse); + const expectedResponse = { ...mockEsResponse }; + await expect( + updateRetentionSettingsHandler(mockCreateRequest, callWithRequest, mockResponseToolkit) + ).resolves.toEqual(expectedResponse); + }); + + it('should throw if ES error', async () => { + const callWithRequest = jest.fn().mockRejectedValueOnce(new Error()); + await expect( + updateRetentionSettingsHandler(mockCreateRequest, callWithRequest, mockResponseToolkit) + ).rejects.toThrow(); + }); + }); }); diff --git a/x-pack/legacy/plugins/snapshot_restore/server/routes/api/policy.ts b/x-pack/legacy/plugins/snapshot_restore/server/routes/api/policy.ts index ed16a44bccdc6..ef9e48190a5b7 100644 --- a/x-pack/legacy/plugins/snapshot_restore/server/routes/api/policy.ts +++ b/x-pack/legacy/plugins/snapshot_restore/server/routes/api/policy.ts @@ -10,8 +10,12 @@ import { } from '../../../../../server/lib/create_router/error_wrappers'; import { SlmPolicyEs, SlmPolicy, SlmPolicyPayload } from '../../../common/types'; import { deserializePolicy, serializePolicy } from '../../../common/lib'; +import { Plugins } from '../../../shim'; -export function registerPolicyRoutes(router: Router) { +let callWithInternalUser: any; + +export function registerPolicyRoutes(router: Router, plugins: Plugins) { + callWithInternalUser = plugins.elasticsearch.getCluster('data').callWithInternalUser; router.get('policies', getAllHandler); router.get('policy/{name}', getOneHandler); router.post('policy/{name}/run', executeHandler); @@ -19,10 +23,12 @@ export function registerPolicyRoutes(router: Router) { router.put('policies', createHandler); router.put('policies/{name}', updateHandler); router.get('policies/indices', getIndicesHandler); + router.get('policies/retention_settings', getRetentionSettingsHandler); + router.put('policies/retention_settings', updateRetentionSettingsHandler); } export const getAllHandler: RouterRouteHandler = async ( - req, + _req, callWithRequest ): Promise<{ policies: SlmPolicy[]; @@ -144,7 +150,7 @@ export const updateHandler: RouterRouteHandler = async (req, callWithRequest) => }; export const getIndicesHandler: RouterRouteHandler = async ( - req, + _req, callWithRequest ): Promise<{ indices: string[]; @@ -161,3 +167,38 @@ export const getIndicesHandler: RouterRouteHandler = async ( indices: indices.map(({ index }) => index).sort(), }; }; + +export const getRetentionSettingsHandler: RouterRouteHandler = async (): Promise< + | { + [key: string]: string; + } + | undefined +> => { + const { persistent, transient, defaults } = await callWithInternalUser('cluster.getSettings', { + filterPath: '**.slm.retention*', + includeDefaults: true, + }); + const { slm: retentionSettings = undefined } = { + ...defaults, + ...persistent, + ...transient, + }; + + const { retention_schedule: retentionSchedule } = retentionSettings; + + return { retentionSchedule }; +}; + +export const updateRetentionSettingsHandler: RouterRouteHandler = async (req, callWithRequest) => { + const { retentionSchedule } = req.payload as { retentionSchedule: string }; + + return await callWithRequest('cluster.putSettings', { + body: { + persistent: { + slm: { + retention_schedule: retentionSchedule, + }, + }, + }, + }); +}; diff --git a/x-pack/legacy/plugins/snapshot_restore/server/routes/api/register_routes.ts b/x-pack/legacy/plugins/snapshot_restore/server/routes/api/register_routes.ts index 5a76f1c268138..11a6cad86640e 100644 --- a/x-pack/legacy/plugins/snapshot_restore/server/routes/api/register_routes.ts +++ b/x-pack/legacy/plugins/snapshot_restore/server/routes/api/register_routes.ts @@ -20,6 +20,6 @@ export const registerRoutes = (router: Router, plugins: Plugins): void => { registerRestoreRoutes(router); if (isSlmEnabled) { - registerPolicyRoutes(router); + registerPolicyRoutes(router, plugins); } }; diff --git a/x-pack/legacy/plugins/snapshot_restore/test/fixtures/index.ts b/x-pack/legacy/plugins/snapshot_restore/test/fixtures/index.ts index 1e744a96d81cf..f3f2f0faa744d 100644 --- a/x-pack/legacy/plugins/snapshot_restore/test/fixtures/index.ts +++ b/x-pack/legacy/plugins/snapshot_restore/test/fixtures/index.ts @@ -6,3 +6,4 @@ export * from './repository'; export * from './snapshot'; +export * from './policy'; diff --git a/x-pack/legacy/plugins/snapshot_restore/test/fixtures/policy.ts b/x-pack/legacy/plugins/snapshot_restore/test/fixtures/policy.ts new file mode 100644 index 0000000000000..3dc5f78c42457 --- /dev/null +++ b/x-pack/legacy/plugins/snapshot_restore/test/fixtures/policy.ts @@ -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 { getRandomString, getRandomNumber } from '../../../../../test_utils'; +import { SlmPolicy } from '../../common/types'; +import { DEFAULT_POLICY_SCHEDULE } from '../../public/app/constants'; + +const dateNow = new Date(); +const randomModifiedDateMillis = new Date().setDate(dateNow.getDate() - 1); +const randomExecutionDateMillis = new Date().setDate(dateNow.getDate() + 1); + +const DEFAULT_STATS: SlmPolicy['stats'] = { + snapshotsTaken: 0, + snapshotsFailed: 0, + snapshotsDeleted: 0, + snapshotDeletionFailures: 0, +}; + +export const getPolicy = ({ + name = `policy-${getRandomString()}`, + config = {}, + modifiedDate = new Date(randomModifiedDateMillis).toString(), + modifiedDateMillis = randomModifiedDateMillis, + nextExecution = new Date(randomExecutionDateMillis).toString(), + nextExecutionMillis = randomExecutionDateMillis, + repository = `repo-${getRandomString()}`, + retention = {}, + schedule = DEFAULT_POLICY_SCHEDULE, + snapshotName = `snapshot-${getRandomString()}`, + stats = DEFAULT_STATS, + version = getRandomNumber(), +}: Partial = {}): SlmPolicy => ({ + name, + config, + modifiedDate, + modifiedDateMillis, + nextExecution, + nextExecutionMillis, + repository, + retention, + schedule, + snapshotName, + stats, + version, +}); diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index d4fb208bc858a..97b509f0e3ffe 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -10796,7 +10796,6 @@ "xpack.snapshotRestore.policyForm.stepSettings.indicesToggleListLink": "インデックスを選択", "xpack.snapshotRestore.policyForm.stepSettings.partialDescription": "利用不可能なプライマリシャードのインデックスのスナップショットを許可します。これが設定されていない場合、スナップショット全体がエラーになります。", "xpack.snapshotRestore.policyForm.stepSettings.partialDescriptionTitle": "部分インデックスを許可", - "xpack.snapshotRestore.policyForm.stepSettings.partialLabel": "部分インデックスを許可", "xpack.snapshotRestore.policyForm.stepSettings.policyIncludeGlobalStateLabel": "グローバルステータスを含める", "xpack.snapshotRestore.policyForm.stepSettings.selectAllIndicesLink": "すべて選択", "xpack.snapshotRestore.policyForm.stepSettings.selectIndicesHelpText": "{count} 件の{count, plural, one {インデックス} other {インデックス}}がバックアップされます。{selectOrDeselectAllLink}", @@ -10828,12 +10827,6 @@ "xpack.snapshotRestore.policyList.table.snapshotNameColumnTitle": "スナップショット名", "xpack.snapshotRestore.policyScheduleWarningDescription": "一度に 1 つのスナップショットしか撮影できません。スナップショットのエラーを避けるために、ポリシーを編集または削除してください。", "xpack.snapshotRestore.policyScheduleWarningTitle": "2 つ以上のポリシーに同じスケジュールが設定されています", - "xpack.snapshotRestore.policyValidation.indexPatternRequiredError": "インデックスパターンが最低 1 つ必要です。", - "xpack.snapshotRestore.policyValidation.indicesRequiredError": "1 つ以上のインデックスを選択する必要があります。", - "xpack.snapshotRestore.policyValidation.nameRequiredError": "ポリシー名が必要です。", - "xpack.snapshotRestore.policyValidation.repositoryRequiredError": "レポジトリが必要です。", - "xpack.snapshotRestore.policyValidation.scheduleRequiredError": "スケジュールが必要です。", - "xpack.snapshotRestore.policyValidation.snapshotNameRequiredError": "スナップショット名が必要です。", "xpack.snapshotRestore.repositories.breadcrumbTitle": "レポジトリ", "xpack.snapshotRestore.repositoryList.table.typeFilterLabel": "タイプ", "xpack.snapshotRestore.restoreForm.stepLogistics.indicesPatternLabel": "インデックスパターン", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index a002fdaa10257..aaced82cb5b14 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -10798,7 +10798,6 @@ "xpack.snapshotRestore.policyForm.stepSettings.indicesToggleListLink": "选择索引", "xpack.snapshotRestore.policyForm.stepSettings.partialDescription": "允许具有不可用主分片的索引的快照。否则,整个快照将失败。", "xpack.snapshotRestore.policyForm.stepSettings.partialDescriptionTitle": "允许部分索引", - "xpack.snapshotRestore.policyForm.stepSettings.partialLabel": "允许部分索引", "xpack.snapshotRestore.policyForm.stepSettings.policyIncludeGlobalStateLabel": "包括全局状态", "xpack.snapshotRestore.policyForm.stepSettings.selectAllIndicesLink": "全选", "xpack.snapshotRestore.policyForm.stepSettings.selectIndicesHelpText": "将备份 {count} 个 {count, plural, one {索引} other {索引}}。{selectOrDeselectAllLink}", @@ -10830,12 +10829,6 @@ "xpack.snapshotRestore.policyList.table.snapshotNameColumnTitle": "快照名称", "xpack.snapshotRestore.policyScheduleWarningDescription": "一次仅可以拍取一个快照。要避免快照失败,请编辑或删除策略。", "xpack.snapshotRestore.policyScheduleWarningTitle": "两个或更多策略有相同的计划", - "xpack.snapshotRestore.policyValidation.indexPatternRequiredError": "至少需要一个索引模式。", - "xpack.snapshotRestore.policyValidation.indicesRequiredError": "必须至少选择一个索引。", - "xpack.snapshotRestore.policyValidation.nameRequiredError": "策略名称必填。", - "xpack.snapshotRestore.policyValidation.repositoryRequiredError": "存储库必填。", - "xpack.snapshotRestore.policyValidation.scheduleRequiredError": "计划必填。", - "xpack.snapshotRestore.policyValidation.snapshotNameRequiredError": "快照名称必填。", "xpack.snapshotRestore.repositories.breadcrumbTitle": "存储库", "xpack.snapshotRestore.repositoryList.table.typeFilterLabel": "类型", "xpack.snapshotRestore.restoreForm.stepLogistics.indicesPatternLabel": "索引模式", diff --git a/x-pack/test_utils/testbed/types.ts b/x-pack/test_utils/testbed/types.ts index 31140c21cb530..b9ced88f3774c 100644 --- a/x-pack/test_utils/testbed/types.ts +++ b/x-pack/test_utils/testbed/types.ts @@ -85,7 +85,7 @@ export interface TestBed { * * @param switchTestSubject The test subject of the EuiSwitch (can be a nested path. e.g. "myForm.mySwitch"). */ - toggleEuiSwitch: (switchTestSubject: T) => void; + toggleEuiSwitch: (switchTestSubject: T, isChecked?: boolean) => void; /** * The EUI ComboBox is a special input as it needs the ENTER key to be pressed * in order to register the value set. This helpers automatically does that. From 96e40d6edec484ea321192b294bd459210d0d729 Mon Sep 17 00:00:00 2001 From: Chris Roberson Date: Wed, 2 Oct 2019 15:09:25 -0400 Subject: [PATCH 05/19] [Monitoring] Use server side pagination for Logstash Pipelines page (#46587) * Basic version working for cluster pipelines * More support * Refactoring * Fixes * Fix sorting issues * Reduce the number of buckets too * Fix tests * This is actually not helping - it seems that the filter in the query doesn't work as expected - maybe related to the fact that are using nested fields * Add more data for metric.debug * Support sorting on throughput and node count * Fix broken test * Use getMetrics and support with numOfBuckets parameter * Fix test for realz * Fix logstash management pages by introducing a new api to just retrieve ids * We need this to go back to 1000 but it doesn't affect the number of created buckets * Fix issue with pagination data when filtering * Fix sorting by id not working * Make this a little more sturdy --- .../services/monitoring/monitoring_service.js | 4 +- .../pipeline_listing/pipeline_listing.js | 18 +- .../public/components/table/eui_table_ssp.js | 79 ++++++++ .../public/components/table/index.js | 1 + .../monitoring/public/views/alerts/index.js | 1 + .../public/views/base_controller.js | 11 +- .../public/views/base_eui_table_controller.js | 75 +++++++- .../views/logstash/node/pipelines/index.js | 19 +- .../public/views/logstash/pipelines/index.js | 18 +- .../lib/cluster/get_clusters_from_request.js | 2 +- .../server/lib/details/get_metrics.js | 11 +- .../server/lib/details/get_series.js | 59 ++++-- .../lib/logstash/__tests__/get_pipelines.js | 6 +- .../lib/logstash/get_paginated_pipelines.js | 69 +++++++ .../server/lib/logstash/get_pipeline_ids.js | 78 ++++++++ .../server/lib/logstash/get_pipelines.js | 37 ++-- .../server/lib/logstash/sort_pipelines.js | 14 ++ .../__snapshots__/metrics.test.js.snap | 172 +----------------- .../server/lib/metrics/logstash/classes.js | 58 +++--- .../server/lib/metrics/logstash/metrics.js | 2 +- .../server/lib/pagination/filter.js | 23 +++ .../server/lib/pagination/paginate.js | 10 + .../server/routes/api/v1/logstash/index.js | 1 + .../pipelines/cluster_pipeline_ids.js | 49 +++++ .../logstash/pipelines/cluster_pipelines.js | 42 ++++- .../v1/logstash/pipelines/node_pipelines.js | 43 ++++- .../monitoring/server/routes/api/v1/ui.js | 3 +- .../apps/monitoring/logstash/pipelines.js | 11 +- .../page_objects/monitoring_page.js | 3 +- .../services/monitoring/logstash_pipelines.js | 7 + 30 files changed, 653 insertions(+), 273 deletions(-) create mode 100644 x-pack/legacy/plugins/monitoring/public/components/table/eui_table_ssp.js create mode 100644 x-pack/legacy/plugins/monitoring/server/lib/logstash/get_paginated_pipelines.js create mode 100644 x-pack/legacy/plugins/monitoring/server/lib/logstash/get_pipeline_ids.js create mode 100644 x-pack/legacy/plugins/monitoring/server/lib/logstash/sort_pipelines.js create mode 100644 x-pack/legacy/plugins/monitoring/server/lib/pagination/filter.js create mode 100644 x-pack/legacy/plugins/monitoring/server/lib/pagination/paginate.js create mode 100644 x-pack/legacy/plugins/monitoring/server/routes/api/v1/logstash/pipelines/cluster_pipeline_ids.js diff --git a/x-pack/legacy/plugins/logstash/public/services/monitoring/monitoring_service.js b/x-pack/legacy/plugins/logstash/public/services/monitoring/monitoring_service.js index a160408bcaa02..6a948ad2e39ed 100755 --- a/x-pack/legacy/plugins/logstash/public/services/monitoring/monitoring_service.js +++ b/x-pack/legacy/plugins/logstash/public/services/monitoring/monitoring_service.js @@ -32,7 +32,7 @@ export class MonitoringService { return this.clusterService.loadCluster() .then(cluster => { - const url = `${this.basePath}/v1/clusters/${cluster.uuid}/logstash/pipelines`; + const url = `${this.basePath}/v1/clusters/${cluster.uuid}/logstash/pipeline_ids`; const now = moment.utc(); const body = { timeRange: { @@ -42,7 +42,7 @@ export class MonitoringService { }; return this.$http.post(url, body); }) - .then(response => response.data.pipelines.map(pipeline => PipelineListItem.fromUpstreamMonitoringJSON(pipeline))) + .then(response => response.data.map(pipeline => PipelineListItem.fromUpstreamMonitoringJSON(pipeline))) .catch(() => []); } } diff --git a/x-pack/legacy/plugins/monitoring/public/components/logstash/pipeline_listing/pipeline_listing.js b/x-pack/legacy/plugins/monitoring/public/components/logstash/pipeline_listing/pipeline_listing.js index 9dc336f24a40b..ef306a9a2f06c 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/logstash/pipeline_listing/pipeline_listing.js +++ b/x-pack/legacy/plugins/monitoring/public/components/logstash/pipeline_listing/pipeline_listing.js @@ -11,7 +11,7 @@ import { EuiPage, EuiLink, EuiPageBody, EuiPageContent, EuiPanel, EuiSpacer, Eui import { formatMetric } from '../../../lib/format_number'; import { ClusterStatus } from '../cluster_status'; import { Sparkline } from 'plugins/monitoring/components/sparkline'; -import { EuiMonitoringTable } from '../../table'; +import { EuiMonitoringSSPTable } from '../../table'; import { i18n } from '@kbn/i18n'; export class PipelineListing extends Component { @@ -137,6 +137,7 @@ export class PipelineListing extends Component { sorting, pagination, onTableChange, + fetchMoreData, upgradeMessage, className } = this.props; @@ -151,31 +152,22 @@ export class PipelineListing extends Component { - diff --git a/x-pack/legacy/plugins/monitoring/public/components/table/eui_table_ssp.js b/x-pack/legacy/plugins/monitoring/public/components/table/eui_table_ssp.js new file mode 100644 index 0000000000000..868464e8703be --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/public/components/table/eui_table_ssp.js @@ -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 React from 'react'; +import { + EuiBasicTable, + EuiSpacer, + EuiSearchBar +} from '@elastic/eui'; + +export function EuiMonitoringSSPTable({ + rows: items, + search = {}, + pagination, + columns: _columns, + onTableChange, + fetchMoreData, + ...props +}) { + const [isLoading, setIsLoading] = React.useState(false); + const [queryText, setQueryText] = React.useState(''); + const [page, setPage] = React.useState({ + index: pagination.pageIndex, + size: pagination.pageSize + }); + const [sort, setSort] = React.useState(props.sorting); + + if (search.box && !search.box['data-test-subj']) { + search.box['data-test-subj'] = 'monitoringTableToolBar'; + } + + const columns = _columns.map(column => { + if (!column['data-test-subj']) { + column['data-test-subj'] = 'monitoringTableHasData'; + } + + if (!('sortable' in column)) { + column.sortable = true; + } + + return column; + }); + + const onChange = async ({ page, sort }) => { + setPage(page); + setSort({ sort }); + setIsLoading(true); + await fetchMoreData({ page, sort: { sort }, queryText }); + setIsLoading(false); + onTableChange({ page, sort }); + }; + + const onQueryChange = async ({ queryText }) => { + const newPage = { ...page, index: 0 }; + setPage(newPage); + setQueryText(queryText); + setIsLoading(true); + await fetchMoreData({ page: newPage, sort, queryText }); + setIsLoading(false); + }; + + return ( +
+ + + +
+ ); +} diff --git a/x-pack/legacy/plugins/monitoring/public/components/table/index.js b/x-pack/legacy/plugins/monitoring/public/components/table/index.js index d807352ff14c8..66bdb46904dba 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/table/index.js +++ b/x-pack/legacy/plugins/monitoring/public/components/table/index.js @@ -5,4 +5,5 @@ */ export { EuiMonitoringTable } from './eui_table'; +export { EuiMonitoringSSPTable } from './eui_table_ssp'; export { tableStorageGetter, tableStorageSetter, euiTableStorageGetter, euiTableStorageSetter } from './storage'; diff --git a/x-pack/legacy/plugins/monitoring/public/views/alerts/index.js b/x-pack/legacy/plugins/monitoring/public/views/alerts/index.js index 5f278d661db1d..8ffb30cee9623 100644 --- a/x-pack/legacy/plugins/monitoring/public/views/alerts/index.js +++ b/x-pack/legacy/plugins/monitoring/public/views/alerts/index.js @@ -66,6 +66,7 @@ uiRoutes.when('/alerts', { getPageData, $scope, $injector, + storageKey: 'alertsTable', reactNodeId: 'monitoringAlertsApp' }); diff --git a/x-pack/legacy/plugins/monitoring/public/views/base_controller.js b/x-pack/legacy/plugins/monitoring/public/views/base_controller.js index 15abe5ed3e949..600e229b031bf 100644 --- a/x-pack/legacy/plugins/monitoring/public/views/base_controller.js +++ b/x-pack/legacy/plugins/monitoring/public/views/base_controller.js @@ -70,7 +70,8 @@ export class MonitoringViewBaseController { reactNodeId = null, // WIP: https://github.com/elastic/x-pack-kibana/issues/5198 $scope, $injector, - options = {} + options = {}, + fetchDataImmediately = true }) { const titleService = $injector.get('title'); const $executor = $injector.get('$executor'); @@ -119,7 +120,7 @@ export class MonitoringViewBaseController { this.updateDataPromise = null; } const _api = apiUrlFn ? apiUrlFn() : api; - const promises = [_getPageData($injector, _api)]; + const promises = [_getPageData($injector, _api, this.getPaginationRouteOptions())]; const setupMode = getSetupModeState(); if (setupMode.enabled) { promises.push(updateSetupModeData()); @@ -132,7 +133,7 @@ export class MonitoringViewBaseController { }); }); }; - this.updateData(); + fetchDataImmediately && this.updateData(); $executor.register({ execute: () => this.updateData() @@ -175,4 +176,8 @@ export class MonitoringViewBaseController { render(component, document.getElementById(this.reactNodeId)); } } + + getPaginationRouteOptions() { + return {}; + } } diff --git a/x-pack/legacy/plugins/monitoring/public/views/base_eui_table_controller.js b/x-pack/legacy/plugins/monitoring/public/views/base_eui_table_controller.js index dfc548aeb97f2..fb712fa3e7c6c 100644 --- a/x-pack/legacy/plugins/monitoring/public/views/base_eui_table_controller.js +++ b/x-pack/legacy/plugins/monitoring/public/views/base_eui_table_controller.js @@ -8,6 +8,8 @@ import { MonitoringViewBaseController } from './'; import { euiTableStorageGetter, euiTableStorageSetter } from 'plugins/monitoring/components/table'; import { EUI_SORT_ASCENDING } from '../../common/constants'; +const PAGE_SIZE_OPTIONS = [5, 10, 20, 50]; + /** * Class to manage common instantiation behaviors in a view controller * And add persistent state to a table: @@ -42,17 +44,22 @@ export class MonitoringViewBaseEuiTableController extends MonitoringViewBaseCont const setLocalStorageData = euiTableStorageSetter(storageKey); const { page, sort } = getLocalStorageData(storage); - this.pagination = page || { + this.pagination = { + pageSize: 20, initialPageSize: 20, - pageSizeOptions: [5, 10, 20, 50] + pageIndex: 0, + initialPageIndex: 0, + pageSizeOptions: PAGE_SIZE_OPTIONS }; - this.sorting = sort || { - sort: { - field: 'name', - direction: EUI_SORT_ASCENDING + if (page) { + if (!PAGE_SIZE_OPTIONS.includes(page.size)) { + page.size = 20; } - }; + this.setPagination(page); + } + + this.setSorting(sort); this.onTableChange = ({ page, sort }) => { setLocalStorageData(storage, { @@ -62,5 +69,59 @@ export class MonitoringViewBaseEuiTableController extends MonitoringViewBaseCont } }); }; + + this.updateData(); + } + + setPagination(page) { + this.pagination = { + pageSize: page.size, + pageIndex: page.index, + pageSizeOptions: PAGE_SIZE_OPTIONS + }; + } + + setSorting(sort) { + this.sorting = sort || { sort: {} }; + + if (!this.sorting.sort.field) { + this.sorting.sort.field = 'name'; + } + if (!this.sorting.sort.direction) { + this.sorting.sort.direction = EUI_SORT_ASCENDING; + } + } + + setQueryText(queryText) { + this.queryText = queryText; + } + + getPaginationRouteOptions() { + if (!this.pagination || !this.sorting) { + return {}; + } + + return { + pagination: { + size: this.pagination.pageSize, + index: this.pagination.pageIndex + }, + ...this.sorting, + queryText: this.queryText, + }; + } + + getPaginationTableProps(pagination) { + return { + sorting: this.sorting, + pagination: pagination, + onTableChange: this.onTableChange, + fetchMoreData: async ({ page, sort, queryText }) => { + this.setPagination(page); + this.setSorting(sort); + this.setQueryText(queryText); + this.updateData(); + } + }; } } diff --git a/x-pack/legacy/plugins/monitoring/public/views/logstash/node/pipelines/index.js b/x-pack/legacy/plugins/monitoring/public/views/logstash/node/pipelines/index.js index 80fcde7642758..b8bdccab34977 100644 --- a/x-pack/legacy/plugins/monitoring/public/views/logstash/node/pipelines/index.js +++ b/x-pack/legacy/plugins/monitoring/public/views/logstash/node/pipelines/index.js @@ -24,7 +24,7 @@ import { PipelineListing } from '../../../../components/logstash/pipeline_listin import { DetailStatus } from '../../../../components/logstash/detail_status'; import { CODE_PATH_LOGSTASH } from '../../../../../common/constants'; -const getPageData = ($injector) => { +const getPageData = ($injector, _api = undefined, routeOptions = {}) => { const $route = $injector.get('$route'); const $http = $injector.get('$http'); const globalState = $injector.get('globalState'); @@ -39,7 +39,8 @@ const getPageData = ($injector) => { timeRange: { min: timeBounds.min.toISOString(), max: timeBounds.max.toISOString() - } + }, + ...routeOptions }) .then(response => response.data) .catch((err) => { @@ -70,7 +71,6 @@ uiRoutes const routeInit = Private(routeInitProvider); return routeInit({ codePaths: [CODE_PATH_LOGSTASH] }); }, - pageData: getPageData }, controller: class extends MonitoringViewBaseEuiTableController { constructor($injector, $scope) { @@ -82,9 +82,11 @@ uiRoutes getPageData, reactNodeId: 'monitoringLogstashNodePipelinesApp', $scope, - $injector + $injector, + fetchDataImmediately: false // We want to apply pagination before sending the first request }); + $scope.$watch(() => this.data, data => { if (!data || !data.nodeSummary) { return; @@ -97,6 +99,11 @@ uiRoutes } })); + const pagination = { + ...this.pagination, + totalItemCount: data.totalPipelineCount + }; + this.renderReact( { +const getPageData = ($injector, _api = undefined, routeOptions = {}) => { const $http = $injector.get('$http'); const globalState = $injector.get('globalState'); const Private = $injector.get('Private'); @@ -37,7 +37,8 @@ const getPageData = ($injector) => { timeRange: { min: timeBounds.min.toISOString(), max: timeBounds.max.toISOString() - } + }, + ...routeOptions }) .then(response => response.data) .catch((err) => { @@ -64,7 +65,6 @@ uiRoutes const routeInit = Private(routeInitProvider); return routeInit({ codePaths: [CODE_PATH_LOGSTASH] }); }, - pageData: getPageData }, controller: class LogstashPipelinesList extends MonitoringViewBaseEuiTableController { constructor($injector, $scope) { @@ -74,7 +74,8 @@ uiRoutes getPageData, reactNodeId: 'monitoringLogstashPipelinesApp', $scope, - $injector + $injector, + fetchDataImmediately: false // We want to apply pagination before sending the first request }); const $route = $injector.get('$route'); @@ -93,6 +94,11 @@ uiRoutes ? makeUpgradeMessage(pageData.clusterStatus.versions, i18n) : null; + const pagination = { + ...this.pagination, + totalItemCount: pageData.totalPipelineCount + }; + super.renderReact( this.onBrush({ xaxis })} stats={pageData.clusterStatus} data={pageData.pipelines} - sorting={this.sorting} - pagination={this.pagination} - onTableChange={this.onTableChange} + {...this.getPaginationTableProps(pagination)} upgradeMessage={upgradeMessage} dateFormat={config.get('dateFormat')} angular={{ diff --git a/x-pack/legacy/plugins/monitoring/server/lib/cluster/get_clusters_from_request.js b/x-pack/legacy/plugins/monitoring/server/lib/cluster/get_clusters_from_request.js index 9b6a2380bf4ec..9fc6866f0f579 100644 --- a/x-pack/legacy/plugins/monitoring/server/lib/cluster/get_clusters_from_request.js +++ b/x-pack/legacy/plugins/monitoring/server/lib/cluster/get_clusters_from_request.js @@ -150,7 +150,7 @@ export async function getClustersFromRequest(req, indexPatterns, { clusterUuid, : []; const clusterPipelineNodesCount = isInCodePath(codePaths, [CODE_PATH_LOGSTASH]) - ? await getPipelines(req, lsIndexPattern, ['logstash_cluster_pipeline_nodes_count']) + ? await getPipelines(req, lsIndexPattern, null, ['logstash_cluster_pipeline_nodes_count']) : []; // add the logstash data to each cluster diff --git a/x-pack/legacy/plugins/monitoring/server/lib/details/get_metrics.js b/x-pack/legacy/plugins/monitoring/server/lib/details/get_metrics.js index c5d2ee2032b01..e11de68b55c1f 100644 --- a/x-pack/legacy/plugins/monitoring/server/lib/details/get_metrics.js +++ b/x-pack/legacy/plugins/monitoring/server/lib/details/get_metrics.js @@ -12,18 +12,23 @@ import { getSeries } from './get_series'; import { calculateTimeseriesInterval } from '../calculate_timeseries_interval'; import { getTimezone } from '../get_timezone'; -export async function getMetrics(req, indexPattern, metricSet = [], filters = []) { +export async function getMetrics(req, indexPattern, metricSet = [], filters = [], metricOptions = {}, numOfBuckets = 0) { checkParam(indexPattern, 'indexPattern in details/getMetrics'); checkParam(metricSet, 'metricSet in details/getMetrics'); const config = req.server.config(); // TODO: Pass in req parameters as explicit function parameters - const min = moment.utc(req.payload.timeRange.min).valueOf(); + let min = moment.utc(req.payload.timeRange.min).valueOf(); const max = moment.utc(req.payload.timeRange.max).valueOf(); const minIntervalSeconds = config.get('xpack.monitoring.min_interval_seconds'); const bucketSize = calculateTimeseriesInterval(min, max, minIntervalSeconds); const timezone = await getTimezone(req); + // If specified, adjust the time period to ensure we only return this many buckets + if (numOfBuckets > 0) { + min = max - (numOfBuckets * bucketSize * 1000); + } + return Promise.map(metricSet, metric => { // metric names match the literal metric name, but they can be supplied in groups or individually let metricNames; @@ -35,7 +40,7 @@ export async function getMetrics(req, indexPattern, metricSet = [], filters = [] } return Promise.map(metricNames, metricName => { - return getSeries(req, indexPattern, metricName, filters, { min, max, bucketSize, timezone }); + return getSeries(req, indexPattern, metricName, metricOptions, filters, { min, max, bucketSize, timezone }); }); }) .then(rows => { diff --git a/x-pack/legacy/plugins/monitoring/server/lib/details/get_series.js b/x-pack/legacy/plugins/monitoring/server/lib/details/get_series.js index e66878f522ecb..059b02980a427 100644 --- a/x-pack/legacy/plugins/monitoring/server/lib/details/get_series.js +++ b/x-pack/legacy/plugins/monitoring/server/lib/details/get_series.js @@ -71,18 +71,27 @@ function createMetricAggs(metric) { return metric.aggs; } -function fetchSeries(req, indexPattern, metric, min, max, bucketSize, filters) { +function fetchSeries(req, indexPattern, metric, metricOptions, min, max, bucketSize, filters) { // if we're using a derivative metric, offset the min (also @see comment on offsetMinForDerivativeMetric function) const adjustedMin = metric.derivative ? offsetMinForDerivativeMetric(min, bucketSize) : min; - const dateHistogramSubAggs = metric.dateHistogramSubAggs || { - metric: { - [metric.metricAgg]: { - field: metric.field - } - }, - ...createMetricAggs(metric) - }; + let dateHistogramSubAggs = null; + if (metric.getDateHistogramSubAggs) { + dateHistogramSubAggs = metric.getDateHistogramSubAggs(metricOptions); + } + else if (metric.dateHistogramSubAggs) { + dateHistogramSubAggs = metric.dateHistogramSubAggs; + } + else { + dateHistogramSubAggs = { + metric: { + [metric.metricAgg]: { + field: metric.field + } + }, + ...createMetricAggs(metric) + }; + } const params = { index: indexPattern, @@ -178,6 +187,30 @@ const formatBucketSize = bucketSizeInSeconds => { return formatTimestampToDuration(timestamp, CALCULATE_DURATION_UNTIL, now); }; +function isObject(value) { + return typeof value === 'object' && !!value && !Array.isArray(value); +} + +function countBuckets(data, count = 0) { + if (data.buckets) { + count += data.buckets.length; + for (const bucket of data.buckets) { + for (const key of Object.keys(bucket)) { + if (isObject(bucket[key])) { + count = countBuckets(bucket[key], count); + } + } + } + } else { + for (const key of Object.keys(data)) { + if (isObject(data[key])) { + count = countBuckets(data[key], count); + } + } + } + return count; +} + function handleSeries(metric, min, max, bucketSizeInSeconds, timezone, response) { const { derivative, calculation: customCalculation } = metric; const buckets = get(response, 'aggregations.check.buckets', []); @@ -185,6 +218,10 @@ function handleSeries(metric, min, max, bucketSizeInSeconds, timezone, response) const lastUsableBucketIndex = findLastUsableBucketIndex(buckets, max, firstUsableBucketIndex, bucketSizeInSeconds * 1000); let data = []; + if (metric.debug) { + console.log(`metric.debug field=${metric.field} bucketsCreated: ${countBuckets(get(response, 'aggregations.check'))}`); + console.log(`metric.debug`, { bucketsLength: buckets.length, firstUsableBucketIndex, lastUsableBucketIndex }); + } if (firstUsableBucketIndex <= lastUsableBucketIndex) { // map buckets to values for charts @@ -221,14 +258,14 @@ function handleSeries(metric, min, max, bucketSizeInSeconds, timezone, response) * @param {Array} filters Any filters that should be applied to the query. * @return {Promise} The object response containing the {@code timeRange}, {@code metric}, and {@code data}. */ -export async function getSeries(req, indexPattern, metricName, filters, { min, max, bucketSize, timezone }) { +export async function getSeries(req, indexPattern, metricName, metricOptions, filters, { min, max, bucketSize, timezone }) { checkParam(indexPattern, 'indexPattern in details/getSeries'); const metric = metrics[metricName]; if (!metric) { throw new Error(`Not a valid metric: ${metricName}`); } - const response = await fetchSeries(req, indexPattern, metric, min, max, bucketSize, filters); + const response = await fetchSeries(req, indexPattern, metric, metricOptions, min, max, bucketSize, filters); return handleSeries(metric, min, max, bucketSize, timezone, response); } diff --git a/x-pack/legacy/plugins/monitoring/server/lib/logstash/__tests__/get_pipelines.js b/x-pack/legacy/plugins/monitoring/server/lib/logstash/__tests__/get_pipelines.js index 47735e08c470a..d4327049b2b41 100644 --- a/x-pack/legacy/plugins/monitoring/server/lib/logstash/__tests__/get_pipelines.js +++ b/x-pack/legacy/plugins/monitoring/server/lib/logstash/__tests__/get_pipelines.js @@ -6,7 +6,7 @@ import expect from '@kbn/expect'; import { - _handleResponse, + handleGetPipelinesResponse, processPipelinesAPIResponse } from '../get_pipelines'; describe('processPipelinesAPIResponse', () => { @@ -71,7 +71,7 @@ describe('get_pipelines', () => { }); it ('returns an empty array', () => { - const result = _handleResponse(fetchPipelinesWithMetricsResult); + const result = handleGetPipelinesResponse(fetchPipelinesWithMetricsResult); expect(result).to.eql([]); }); }); @@ -97,7 +97,7 @@ describe('get_pipelines', () => { }); it ('returns the correct structure for a non-empty response', () => { - const result = _handleResponse(fetchPipelinesWithMetricsResult); + const result = handleGetPipelinesResponse(fetchPipelinesWithMetricsResult); expect(result).to.eql([ { id: 'apache_logs', diff --git a/x-pack/legacy/plugins/monitoring/server/lib/logstash/get_paginated_pipelines.js b/x-pack/legacy/plugins/monitoring/server/lib/logstash/get_paginated_pipelines.js new file mode 100644 index 0000000000000..9d988c2da2224 --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/server/lib/logstash/get_paginated_pipelines.js @@ -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 { get } from 'lodash'; +import { filter } from '../pagination/filter'; +import { getLogstashPipelineIds } from './get_pipeline_ids'; +import { handleGetPipelinesResponse } from './get_pipelines'; +import { sortPipelines } from './sort_pipelines'; +import { paginate } from '../pagination/paginate'; +import { getMetrics } from '../details/get_metrics'; + +/** + * This function performs an optimization around the pipeline listing tables in the UI. To avoid + * query performances in Elasticsearch (mainly thinking of `search.max_buckets` overflows), we do + * not want to fetch all time-series data for all pipelines. Instead, we only want to fetch the + * time-series data for the pipelines visible in the listing table. This function accepts + * pagination/sorting/filtering data to determine which pipelines will be visible in the table + * and returns that so the caller can perform their normal call to get the time-series data. + * + * @param {*} req - Server request object + * @param {*} lsIndexPattern - The index pattern to search against (`.monitoring-logstash-*`) + * @param {*} uuids - The optional `clusterUuid` and `logstashUuid` to filter the results from + * @param {*} metricSet - The array of metrics that are sortable in the UI + * @param {*} pagination - ({ index, size }) + * @param {*} sort - ({ field, direction }) + * @param {*} queryText - Text that will be used to filter out pipelines + */ +export async function getPaginatedPipelines(req, lsIndexPattern, { clusterUuid, logstashUuid }, metricSet, pagination, sort, queryText) { + const config = req.server.config(); + const size = config.get('xpack.monitoring.max_bucket_size'); + const pipelines = await getLogstashPipelineIds(req, lsIndexPattern, { clusterUuid, logstashUuid }, size); + + // `metricSet` defines a list of metrics that are sortable in the UI + // but we don't need to fetch all the data for these metrics to perform + // the necessary sort - we only need the last bucket of data so we + // fetch the last two buckets of data (to ensure we have a single full bucekt), + // then return the value from that last bucket + const metricSeriesData = await getMetrics(req, lsIndexPattern, metricSet, [], { pageOfPipelines: pipelines }, 2); + const pipelineAggregationsData = handleGetPipelinesResponse(metricSeriesData, pipelines.map(p => p.id)); + for (const pipelineAggregationData of pipelineAggregationsData) { + for (const pipeline of pipelines) { + if (pipelineAggregationData.id === pipeline.id) { + for (const metric of metricSet) { + const dataSeries = get(pipelineAggregationData, `metrics.${metric}.data`, [[]]); + pipeline[metric] = dataSeries[dataSeries.length - 1][1]; + } + } + } + } + + // Manually apply pagination/sorting/filtering concerns + + // Filtering + const filteredPipelines = filter(pipelines, queryText, ['id']); // We only support filtering by id right now + + // Sorting + const sortedPipelines = sortPipelines(filteredPipelines, sort); + + // Pagination + const pageOfPipelines = paginate(pagination, sortedPipelines); + + return { + pageOfPipelines, + totalPipelineCount: filteredPipelines.length + }; +} diff --git a/x-pack/legacy/plugins/monitoring/server/lib/logstash/get_pipeline_ids.js b/x-pack/legacy/plugins/monitoring/server/lib/logstash/get_pipeline_ids.js new file mode 100644 index 0000000000000..f99925425abd3 --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/server/lib/logstash/get_pipeline_ids.js @@ -0,0 +1,78 @@ +/* + * 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 moment from 'moment'; +import { get } from 'lodash'; +import { createQuery } from '../create_query'; +import { LogstashMetric } from '../metrics'; + +export async function getLogstashPipelineIds(req, logstashIndexPattern, { clusterUuid, logstashUuid }, size) { + const start = moment.utc(req.payload.timeRange.min).valueOf(); + const end = moment.utc(req.payload.timeRange.max).valueOf(); + + const filters = []; + if (logstashUuid) { + filters.push({ term: { 'logstash_stats.logstash.uuid': logstashUuid } }); + } + + const params = { + index: logstashIndexPattern, + size: 0, + ignoreUnavailable: true, + filterPath: [ + 'aggregations.nested_context.composite_data.buckets' + ], + body: { + query: createQuery({ + start, + end, + metric: LogstashMetric.getMetricFields(), + clusterUuid, + filters, + }), + aggs: { + nested_context: { + nested: { + path: 'logstash_stats.pipelines' + }, + aggs: { + composite_data: { + composite: { + size, + sources: [ + { + id: { + terms: { + field: 'logstash_stats.pipelines.id', + } + } + }, + { + hash: { + terms: { + field: 'logstash_stats.pipelines.hash', + } + } + }, + { + ephemeral_id: { + terms: { + field: 'logstash_stats.pipelines.ephemeral_id', + } + } + } + ] + } + } + } + } + } + } + }; + + const { callWithRequest } = req.server.plugins.elasticsearch.getCluster('monitoring'); + const response = await callWithRequest(req, 'search', params); + return get(response, 'aggregations.nested_context.composite_data.buckets', []).map(bucket => bucket.key); +} diff --git a/x-pack/legacy/plugins/monitoring/server/lib/logstash/get_pipelines.js b/x-pack/legacy/plugins/monitoring/server/lib/logstash/get_pipelines.js index 8cec101477ecf..c059e62815917 100644 --- a/x-pack/legacy/plugins/monitoring/server/lib/logstash/get_pipelines.js +++ b/x-pack/legacy/plugins/monitoring/server/lib/logstash/get_pipelines.js @@ -3,12 +3,11 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - import { cloneDeep, last, omit } from 'lodash'; import { checkParam } from '../error_missing_required'; import { getMetrics } from '../details/get_metrics'; -export function _handleResponse(response) { +export function handleGetPipelinesResponse(response, exclusivePipelineIds) { const pipelinesById = {}; const metrics = Object.keys(response); @@ -16,6 +15,9 @@ export function _handleResponse(response) { response[metric][0].data.forEach(([x, y]) => { const pipelineIds = Object.keys(y); pipelineIds.forEach(pipelineId => { + if (exclusivePipelineIds && !exclusivePipelineIds.includes(pipelineId)) { + return; + } // Create new pipeline object if necessary if (!pipelinesById.hasOwnProperty(pipelineId)) { pipelinesById[pipelineId] = { @@ -40,14 +42,24 @@ export function _handleResponse(response) { }); }); - // Convert pipelinesById map to array + // Convert pipelinesById map to array and preserve sorting const pipelines = []; - Object.keys(pipelinesById).forEach(pipelineId => { - pipelines.push({ - id: pipelineId, - ...pipelinesById[pipelineId] + if (exclusivePipelineIds) { + for (const exclusivePipelineId of exclusivePipelineIds) { + pipelines.push({ + id: exclusivePipelineId, + ...pipelinesById[exclusivePipelineId] + }); + } + } + else { + Object.keys(pipelinesById).forEach(pipelineId => { + pipelines.push({ + id: pipelineId, + ...pipelinesById[pipelineId] + }); }); - }); + } return pipelines; } @@ -71,10 +83,13 @@ export async function processPipelinesAPIResponse(response, throughputMetricKey, return processedResponse; } -export async function getPipelines(req, logstashIndexPattern, metricSet) { + +export async function getPipelines(req, logstashIndexPattern, pipelineIds, metricSet, metricOptions = {}) { checkParam(logstashIndexPattern, 'logstashIndexPattern in logstash/getPipelines'); checkParam(metricSet, 'metricSet in logstash/getPipelines'); - const metricsResponse = await getMetrics(req, logstashIndexPattern, metricSet); - return _handleResponse(metricsResponse); + const filters = []; + + const metricsResponse = await getMetrics(req, logstashIndexPattern, metricSet, filters, metricOptions); + return handleGetPipelinesResponse(metricsResponse, pipelineIds); } diff --git a/x-pack/legacy/plugins/monitoring/server/lib/logstash/sort_pipelines.js b/x-pack/legacy/plugins/monitoring/server/lib/logstash/sort_pipelines.js new file mode 100644 index 0000000000000..994e910c8ec4b --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/server/lib/logstash/sort_pipelines.js @@ -0,0 +1,14 @@ +/* + * 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 { sortByOrder } from 'lodash'; + +export function sortPipelines(pipelines, sort) { + if (!sort) { + return pipelines; + } + + return sortByOrder(pipelines, pipeline => pipeline[sort.field], sort.direction); +} diff --git a/x-pack/legacy/plugins/monitoring/server/lib/metrics/__test__/__snapshots__/metrics.test.js.snap b/x-pack/legacy/plugins/monitoring/server/lib/metrics/__test__/__snapshots__/metrics.test.js.snap index a899dc9dfc748..21fce7e64376c 100644 --- a/x-pack/legacy/plugins/monitoring/server/lib/metrics/__test__/__snapshots__/metrics.test.js.snap +++ b/x-pack/legacy/plugins/monitoring/server/lib/metrics/__test__/__snapshots__/metrics.test.js.snap @@ -2996,37 +2996,11 @@ Object { "logstash_cluster_pipeline_nodes_count": LogstashPipelineNodeCountMetric { "app": "logstash", "calculation": [Function], - "dateHistogramSubAggs": Object { - "pipelines_nested": Object { - "aggs": Object { - "by_pipeline_id": Object { - "aggs": Object { - "to_root": Object { - "aggs": Object { - "node_count": Object { - "cardinality": Object { - "field": "logstash_stats.logstash.uuid", - }, - }, - }, - "reverse_nested": Object {}, - }, - }, - "terms": Object { - "field": "logstash_stats.pipelines.id", - "size": 1000, - }, - }, - }, - "nested": Object { - "path": "logstash_stats.pipelines", - }, - }, - }, "derivative": false, "description": "Number of nodes on which the Logstash pipeline is running.", "field": "logstash_stats.logstash.uuid", "format": "0,0.[00]", + "getDateHistogramSubAggs": [Function], "label": "Pipeline Node Count", "timestampField": "logstash_stats.timestamp", "units": "", @@ -3035,67 +3009,11 @@ Object { "logstash_cluster_pipeline_throughput": LogstashPipelineThroughputMetric { "app": "logstash", "calculation": [Function], - "dateHistogramSubAggs": Object { - "pipelines_nested": Object { - "aggs": Object { - "by_pipeline_id": Object { - "aggs": Object { - "by_pipeline_hash": Object { - "aggs": Object { - "by_ephemeral_id": Object { - "aggs": Object { - "events_stats": Object { - "stats": Object { - "field": "logstash_stats.pipelines.events.out", - }, - }, - "throughput": Object { - "bucket_script": Object { - "buckets_path": Object { - "max": "events_stats.max", - "min": "events_stats.min", - }, - "script": "params.max - params.min", - }, - }, - }, - "terms": Object { - "field": "logstash_stats.pipelines.ephemeral_id", - "size": 1000, - }, - }, - "throughput": Object { - "sum_bucket": Object { - "buckets_path": "by_ephemeral_id>throughput", - }, - }, - }, - "terms": Object { - "field": "logstash_stats.pipelines.hash", - "size": 1000, - }, - }, - "throughput": Object { - "sum_bucket": Object { - "buckets_path": "by_pipeline_hash>throughput", - }, - }, - }, - "terms": Object { - "field": "logstash_stats.pipelines.id", - "size": 1000, - }, - }, - }, - "nested": Object { - "path": "logstash_stats.pipelines", - }, - }, - }, "derivative": false, "description": "Number of events emitted per second by the Logstash pipeline at the outputs stage.", "field": "logstash_stats.pipelines.events.out", "format": "0,0.[00]", + "getDateHistogramSubAggs": [Function], "label": "Pipeline Throughput", "timestampField": "logstash_stats.timestamp", "units": "e/s", @@ -3354,37 +3272,11 @@ Object { "logstash_node_pipeline_nodes_count": LogstashPipelineNodeCountMetric { "app": "logstash", "calculation": [Function], - "dateHistogramSubAggs": Object { - "pipelines_nested": Object { - "aggs": Object { - "by_pipeline_id": Object { - "aggs": Object { - "to_root": Object { - "aggs": Object { - "node_count": Object { - "cardinality": Object { - "field": "logstash_stats.logstash.uuid", - }, - }, - }, - "reverse_nested": Object {}, - }, - }, - "terms": Object { - "field": "logstash_stats.pipelines.id", - "size": 1000, - }, - }, - }, - "nested": Object { - "path": "logstash_stats.pipelines", - }, - }, - }, "derivative": false, "description": "Number of nodes on which the Logstash pipeline is running.", "field": "logstash_stats.logstash.uuid", "format": "0,0.[00]", + "getDateHistogramSubAggs": [Function], "label": "Pipeline Node Count", "timestampField": "logstash_stats.timestamp", "units": "", @@ -3393,67 +3285,11 @@ Object { "logstash_node_pipeline_throughput": LogstashPipelineThroughputMetric { "app": "logstash", "calculation": [Function], - "dateHistogramSubAggs": Object { - "pipelines_nested": Object { - "aggs": Object { - "by_pipeline_id": Object { - "aggs": Object { - "by_pipeline_hash": Object { - "aggs": Object { - "by_ephemeral_id": Object { - "aggs": Object { - "events_stats": Object { - "stats": Object { - "field": "logstash_stats.pipelines.events.out", - }, - }, - "throughput": Object { - "bucket_script": Object { - "buckets_path": Object { - "max": "events_stats.max", - "min": "events_stats.min", - }, - "script": "params.max - params.min", - }, - }, - }, - "terms": Object { - "field": "logstash_stats.pipelines.ephemeral_id", - "size": 1000, - }, - }, - "throughput": Object { - "sum_bucket": Object { - "buckets_path": "by_ephemeral_id>throughput", - }, - }, - }, - "terms": Object { - "field": "logstash_stats.pipelines.hash", - "size": 1000, - }, - }, - "throughput": Object { - "sum_bucket": Object { - "buckets_path": "by_pipeline_hash>throughput", - }, - }, - }, - "terms": Object { - "field": "logstash_stats.pipelines.id", - "size": 1000, - }, - }, - }, - "nested": Object { - "path": "logstash_stats.pipelines", - }, - }, - }, "derivative": false, "description": "Number of events emitted per second by the Logstash pipeline at the outputs stage.", "field": "logstash_stats.pipelines.events.out", "format": "0,0.[00]", + "getDateHistogramSubAggs": [Function], "label": "Pipeline Throughput", "timestampField": "logstash_stats.timestamp", "units": "e/s", diff --git a/x-pack/legacy/plugins/monitoring/server/lib/metrics/logstash/classes.js b/x-pack/legacy/plugins/monitoring/server/lib/metrics/logstash/classes.js index dade736cd53f8..3af726328aca8 100644 --- a/x-pack/legacy/plugins/monitoring/server/lib/metrics/logstash/classes.js +++ b/x-pack/legacy/plugins/monitoring/server/lib/metrics/logstash/classes.js @@ -270,7 +270,7 @@ export class LogstashPipelineThroughputMetric extends LogstashMetric { derivative: false }); - this.dateHistogramSubAggs = { + this.getDateHistogramSubAggs = ({ pageOfPipelines }) => ({ pipelines_nested: { nested: { path: 'logstash_stats.pipelines' @@ -279,7 +279,8 @@ export class LogstashPipelineThroughputMetric extends LogstashMetric { by_pipeline_id: { terms: { field: 'logstash_stats.pipelines.id', - size: 1000 + size: 1000, + include: pageOfPipelines.map(pipeline => pipeline.id), }, aggs: { throughput: { @@ -290,7 +291,8 @@ export class LogstashPipelineThroughputMetric extends LogstashMetric { by_pipeline_hash: { terms: { field: 'logstash_stats.pipelines.hash', - size: 1000 + size: 1000, + include: pageOfPipelines.map(pipeline => pipeline.hash), }, aggs: { throughput: { @@ -301,7 +303,8 @@ export class LogstashPipelineThroughputMetric extends LogstashMetric { by_ephemeral_id: { terms: { field: 'logstash_stats.pipelines.ephemeral_id', - size: 1000 + size: 1000, + include: pageOfPipelines.map(pipeline => pipeline.ephemeral_id), }, aggs: { events_stats: { @@ -326,7 +329,7 @@ export class LogstashPipelineThroughputMetric extends LogstashMetric { } } } - }; + }); this.calculation = (bucket, _key, _metric, bucketSizeInSeconds) => { const pipelineThroughputs = {}; @@ -353,24 +356,31 @@ export class LogstashPipelineNodeCountMetric extends LogstashMetric { derivative: false }); - this.dateHistogramSubAggs = { - pipelines_nested: { - nested: { - path: 'logstash_stats.pipelines' - }, - aggs: { - by_pipeline_id: { - terms: { - field: 'logstash_stats.pipelines.id', - size: 1000 - }, - aggs: { - to_root: { - reverse_nested: {}, - aggs: { - node_count: { - cardinality: { - field: this.field + this.getDateHistogramSubAggs = ({ pageOfPipelines }) => { + const termAggExtras = {}; + if (pageOfPipelines) { + termAggExtras.include = pageOfPipelines.map(pipeline => pipeline.id); + } + return { + pipelines_nested: { + nested: { + path: 'logstash_stats.pipelines' + }, + aggs: { + by_pipeline_id: { + terms: { + field: 'logstash_stats.pipelines.id', + size: 1000, + ...termAggExtras + }, + aggs: { + to_root: { + reverse_nested: {}, + aggs: { + node_count: { + cardinality: { + field: this.field + } } } } @@ -378,7 +388,7 @@ export class LogstashPipelineNodeCountMetric extends LogstashMetric { } } } - } + }; }; this.calculation = bucket => { diff --git a/x-pack/legacy/plugins/monitoring/server/lib/metrics/logstash/metrics.js b/x-pack/legacy/plugins/monitoring/server/lib/metrics/logstash/metrics.js index a87f79533da3b..6c9bc31585806 100644 --- a/x-pack/legacy/plugins/monitoring/server/lib/metrics/logstash/metrics.js +++ b/x-pack/legacy/plugins/monitoring/server/lib/metrics/logstash/metrics.js @@ -324,7 +324,7 @@ export const metrics = { label: pipelineThroughputLabel, description: pipelineThroughputDescription, format: LARGE_FLOAT, - units: eventsPerSecondUnitLabel + units: eventsPerSecondUnitLabel, }), logstash_node_pipeline_throughput: new LogstashPipelineThroughputMetric({ uuidField: 'logstash_stats.logstash.uuid', // TODO: add comment explaining why diff --git a/x-pack/legacy/plugins/monitoring/server/lib/pagination/filter.js b/x-pack/legacy/plugins/monitoring/server/lib/pagination/filter.js new file mode 100644 index 0000000000000..7cc91d8deeb32 --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/server/lib/pagination/filter.js @@ -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 { get } from 'lodash'; + +function defaultFilterFn(value, query) { + if (value.toLowerCase().includes(query.toLowerCase())) { + return true; + } + return false; +} + +export function filter(data, queryText, fields, filterFn = defaultFilterFn) { + return data.filter(item => { + for (const field of fields) { + if (filterFn(get(item, field), queryText)) { + return true; + } + } + }); +} diff --git a/x-pack/legacy/plugins/monitoring/server/lib/pagination/paginate.js b/x-pack/legacy/plugins/monitoring/server/lib/pagination/paginate.js new file mode 100644 index 0000000000000..b5e63fb862fe7 --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/server/lib/pagination/paginate.js @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export function paginate({ size, index }, data) { + const start = index * size; + return data.slice(start, Math.min(data.length, start + size)); +} diff --git a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/logstash/index.js b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/logstash/index.js index 7f9e6d71621b9..796b5f29cef6c 100644 --- a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/logstash/index.js +++ b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/logstash/index.js @@ -10,3 +10,4 @@ export { logstashOverviewRoute } from './overview'; export { logstashPipelineRoute } from './pipeline'; export { logstashNodePipelinesRoute } from './pipelines/node_pipelines'; export { logstashClusterPipelinesRoute } from './pipelines/cluster_pipelines'; +export { logstashClusterPipelineIdsRoute } from './pipelines/cluster_pipeline_ids'; diff --git a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/logstash/pipelines/cluster_pipeline_ids.js b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/logstash/pipelines/cluster_pipeline_ids.js new file mode 100644 index 0000000000000..066b89fc325bb --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/logstash/pipelines/cluster_pipeline_ids.js @@ -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 Joi from 'joi'; +import { handleError } from '../../../../../lib/errors'; +import { prefixIndexPattern } from '../../../../../lib/ccs_utils'; +import { INDEX_PATTERN_LOGSTASH } from '../../../../../../common/constants'; +import { getLogstashPipelineIds } from '../../../../../lib/logstash/get_pipeline_ids'; + +/** + * Retrieve pipelines for a cluster + */ +export function logstashClusterPipelineIdsRoute(server) { + server.route({ + method: 'POST', + path: '/api/monitoring/v1/clusters/{clusterUuid}/logstash/pipeline_ids', + config: { + validate: { + params: Joi.object({ + clusterUuid: Joi.string().required() + }), + payload: Joi.object({ + ccs: Joi.string().optional(), + timeRange: Joi.object({ + min: Joi.date().required(), + max: Joi.date().required() + }).required() + }) + } + }, + handler: async (req) => { + const config = server.config(); + const { ccs } = req.payload; + const clusterUuid = req.params.clusterUuid; + const lsIndexPattern = prefixIndexPattern(config, INDEX_PATTERN_LOGSTASH, ccs); + const size = config.get('xpack.monitoring.max_bucket_size'); + + try { + const pipelines = await getLogstashPipelineIds(req, lsIndexPattern, { clusterUuid }, size); + return pipelines; + } catch (err) { + throw handleError(err, req); + } + } + }); +} diff --git a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/logstash/pipelines/cluster_pipelines.js b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/logstash/pipelines/cluster_pipelines.js index 247e734360dc9..c55f8c19037d5 100644 --- a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/logstash/pipelines/cluster_pipelines.js +++ b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/logstash/pipelines/cluster_pipelines.js @@ -10,6 +10,7 @@ import { getPipelines, processPipelinesAPIResponse } from '../../../../../lib/lo import { handleError } from '../../../../../lib/errors'; import { prefixIndexPattern } from '../../../../../lib/ccs_utils'; import { INDEX_PATTERN_LOGSTASH } from '../../../../../../common/constants'; +import { getPaginatedPipelines } from '../../../../../lib/logstash/get_paginated_pipelines'; /** * Retrieve pipelines for a cluster @@ -28,13 +29,22 @@ export function logstashClusterPipelinesRoute(server) { timeRange: Joi.object({ min: Joi.date().required(), max: Joi.date().required() - }).required() + }).required(), + pagination: Joi.object({ + index: Joi.number().required(), + size: Joi.number().required() + }).required(), + sort: Joi.object({ + field: Joi.string().required(), + direction: Joi.string().required() + }).optional(), + queryText: Joi.string().default('').allow('').optional(), }) } }, handler: async (req) => { const config = server.config(); - const { ccs } = req.payload; + const { ccs, pagination, sort, queryText } = req.payload; const clusterUuid = req.params.clusterUuid; const lsIndexPattern = prefixIndexPattern(config, INDEX_PATTERN_LOGSTASH, ccs); @@ -46,16 +56,40 @@ export function logstashClusterPipelinesRoute(server) { nodesCountMetric ]; + // The client side fields do not match the server side metric names + // so adjust that here. See processPipelinesAPIResponse + const sortMetricSetMap = { + latestThroughput: throughputMetric, + latestNodesCount: nodesCountMetric + }; + if (sort) { + sort.field = sortMetricSetMap[sort.field] || sort.field; + } + + const { pageOfPipelines, totalPipelineCount } = + await getPaginatedPipelines(req, lsIndexPattern, {}, metricSet, pagination, sort, queryText); + + // Just the IDs for the rest + const pipelineIds = pageOfPipelines.map(pipeline => pipeline.id); + + const metricOptions = { + pageOfPipelines, + }; + try { + const pipelineData = await getPipelines(req, lsIndexPattern, pipelineIds, metricSet, metricOptions); const response = await processPipelinesAPIResponse( { - pipelines: await getPipelines(req, lsIndexPattern, metricSet), + pipelines: pipelineData, clusterStatus: await getClusterStatus(req, lsIndexPattern, { clusterUuid }) }, throughputMetric, nodesCountMetric ); - return response; + return { + ...response, + totalPipelineCount + }; } catch (err) { throw handleError(err, req); } diff --git a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/logstash/pipelines/node_pipelines.js b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/logstash/pipelines/node_pipelines.js index faec0791d7c32..84f1626770127 100644 --- a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/logstash/pipelines/node_pipelines.js +++ b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/logstash/pipelines/node_pipelines.js @@ -10,6 +10,7 @@ import { getPipelines, processPipelinesAPIResponse } from '../../../../../lib/lo import { handleError } from '../../../../../lib/errors'; import { prefixIndexPattern } from '../../../../../lib/ccs_utils'; import { INDEX_PATTERN_LOGSTASH } from '../../../../../../common/constants'; +import { getPaginatedPipelines } from '../../../../../lib/logstash/get_paginated_pipelines'; /** * Retrieve pipelines for a node @@ -29,15 +30,25 @@ export function logstashNodePipelinesRoute(server) { timeRange: Joi.object({ min: Joi.date().required(), max: Joi.date().required() - }).required() + }).required(), + pagination: Joi.object({ + index: Joi.number().required(), + size: Joi.number().required() + }).required(), + sort: Joi.object({ + field: Joi.string().required(), + direction: Joi.string().required() + }).optional(), + queryText: Joi.string().default('').allow('').optional(), }) } }, handler: async (req) => { const config = server.config(); - const { ccs } = req.payload; + const { ccs, pagination, sort, queryText } = req.payload; const { clusterUuid, logstashUuid } = req.params; const lsIndexPattern = prefixIndexPattern(config, INDEX_PATTERN_LOGSTASH, ccs); + const throughputMetric = 'logstash_node_pipeline_throughput'; const nodesCountMetric = 'logstash_node_pipeline_nodes_count'; const metricSet = [ @@ -45,16 +56,40 @@ export function logstashNodePipelinesRoute(server) { nodesCountMetric ]; + // The client side fields do not match the server side metric names + // so adjust that here. See processPipelinesAPIResponse + const sortMetricSetMap = { + latestThroughput: throughputMetric, + latestNodesCount: nodesCountMetric + }; + if (sort) { + sort.field = sortMetricSetMap[sort.field] || sort.field; + } + + const { pageOfPipelines, totalPipelineCount } + = await getPaginatedPipelines(req, lsIndexPattern, { clusterUuid, logstashUuid }, metricSet, pagination, sort, queryText); + + // Just the IDs for the rest + const pipelineIds = pageOfPipelines.map(pipeline => pipeline.id); + + const metricOptions = { + pageOfPipelines, + }; + try { + const pipelineData = await getPipelines(req, lsIndexPattern, pipelineIds, metricSet, metricOptions); const response = await processPipelinesAPIResponse( { - pipelines: await getPipelines(req, lsIndexPattern, metricSet), + pipelines: pipelineData, nodeSummary: await getNodeInfo(req, lsIndexPattern, { clusterUuid, logstashUuid }) }, throughputMetric, nodesCountMetric ); - return response; + return { + ...response, + totalPipelineCount + }; } catch (err) { throw handleError(err, req); } diff --git a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/ui.js b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/ui.js index 302cdafc72561..dc0549a283972 100644 --- a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/ui.js +++ b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/ui.js @@ -52,6 +52,7 @@ export { logstashNodeRoute, logstashNodesRoute, logstashOverviewRoute, - logstashPipelineRoute + logstashPipelineRoute, + logstashClusterPipelineIdsRoute } from './logstash'; export * from './setup'; diff --git a/x-pack/test/functional/apps/monitoring/logstash/pipelines.js b/x-pack/test/functional/apps/monitoring/logstash/pipelines.js index 4d2a7dbc68887..fb9fcf8bab8bb 100644 --- a/x-pack/test/functional/apps/monitoring/logstash/pipelines.js +++ b/x-pack/test/functional/apps/monitoring/logstash/pipelines.js @@ -8,6 +8,8 @@ import expect from '@kbn/expect'; import { getLifecycleMethods } from '../_get_lifecycle_methods'; export default function ({ getService, getPageObjects }) { + const PageObjects = getPageObjects(['common']); + const retry = getService('retry'); const overview = getService('monitoringClusterOverview'); const pipelinesList = getService('monitoringLogstashPipelines'); const lsClusterSummaryStatus = getService('monitoringLogstashSummaryStatus'); @@ -43,6 +45,8 @@ export default function ({ getService, getPageObjects }) { const rows = await pipelinesList.getRows(); expect(rows.length).to.be(4); + await pipelinesList.clickIdCol(); + const pipelinesAll = await pipelinesList.getPipelinesAll(); const tableData = [ @@ -85,8 +89,11 @@ export default function ({ getService, getPageObjects }) { it('should filter for specific pipelines', async () => { await pipelinesList.setFilter('la'); - const rows = await pipelinesList.getRows(); - expect(rows.length).to.be(2); + await PageObjects.common.pressEnterKey(); + await retry.try(async () => { + const rows = await pipelinesList.getRows(); + expect(rows.length).to.be(2); + }); await pipelinesList.clearFilter(); }); diff --git a/x-pack/test/functional/page_objects/monitoring_page.js b/x-pack/test/functional/page_objects/monitoring_page.js index 9eef3dff01bba..84c9981a1bcc9 100644 --- a/x-pack/test/functional/page_objects/monitoring_page.js +++ b/x-pack/test/functional/page_objects/monitoring_page.js @@ -50,7 +50,8 @@ export function MonitoringPageProvider({ getPageObjects, getService }) { } async tableSetFilter(subj, text) { - return await testSubjects.setValue(subj, text); + await testSubjects.setValue(subj, text); + await PageObjects.common.pressEnterKey(); } async tableClearFilter(subj) { diff --git a/x-pack/test/functional/services/monitoring/logstash_pipelines.js b/x-pack/test/functional/services/monitoring/logstash_pipelines.js index f2c3fb839f7af..d4d367665ef5b 100644 --- a/x-pack/test/functional/services/monitoring/logstash_pipelines.js +++ b/x-pack/test/functional/services/monitoring/logstash_pipelines.js @@ -18,6 +18,7 @@ export function MonitoringLogstashPipelinesProvider({ getService, getPageObjects const SUBJ_SEARCH_BAR = `${SUBJ_TABLE_CONTAINER} > monitoringTableToolBar`; const SUBJ_TABLE_SORT_EVENTS_EMITTED_RATE_COL = `${SUBJ_TABLE_CONTAINER} > tableHeaderCell_latestThroughput_1`; + const SUBJ_TABLE_SORT_ID_COL = `${SUBJ_TABLE_CONTAINER} > tableHeaderCell_id_0`; const SUBJ_PIPELINES_IDS = `${SUBJ_TABLE_CONTAINER} > id`; const SUBJ_PIPELINES_EVENTS_EMITTED_RATES = `${SUBJ_TABLE_CONTAINER} > eventsEmittedRate`; @@ -53,6 +54,12 @@ export function MonitoringLogstashPipelinesProvider({ getService, getPageObjects }, []); } + async clickIdCol() { + const headerCell = await testSubjects.find(SUBJ_TABLE_SORT_ID_COL); + const button = await headerCell.findByTagName('button'); + return button.click(); + } + async clickEventsEmittedRateCol() { const headerCell = await testSubjects.find(SUBJ_TABLE_SORT_EVENTS_EMITTED_RATE_COL); const button = await headerCell.findByTagName('button'); From bc840e678933a0e95ee69862f719f3975b63d7f7 Mon Sep 17 00:00:00 2001 From: Aaron Caldwell Date: Wed, 2 Oct 2019 13:34:20 -0600 Subject: [PATCH 06/19] [Maps] Add 'InjectedData' class and revise so File Upload Features are assigned to new InjectedData instances (#46381) * Add 'PushedData' class and revise so data is assigned to new instances * Update 'pushedData' refs to 'injectedData' * Set default visibility to true on injected data * Review feedback * Update tests to account for injectedData * Review feedback --- .../public/angular/get_initial_layers.test.js | 2 + .../plugins/maps/public/layers/layer.js | 16 +++++- .../client_file_source/geojson_file_source.js | 41 +++++---------- .../maps/public/layers/sources/source.js | 4 ++ .../maps/public/layers/util/injected_data.js | 21 ++++++++ .../maps/public/layers/vector_layer.js | 52 ++++++++++++++----- 6 files changed, 94 insertions(+), 42 deletions(-) create mode 100644 x-pack/legacy/plugins/maps/public/layers/util/injected_data.js diff --git a/x-pack/legacy/plugins/maps/public/angular/get_initial_layers.test.js b/x-pack/legacy/plugins/maps/public/angular/get_initial_layers.test.js index 5dc08751347e4..b15b94a49cebc 100644 --- a/x-pack/legacy/plugins/maps/public/angular/get_initial_layers.test.js +++ b/x-pack/legacy/plugins/maps/public/angular/get_initial_layers.test.js @@ -44,6 +44,7 @@ describe('kibana.yml configured with map.tilemap.url', () => { expect(layers).toEqual([{ alpha: 1, __dataRequests: [], + __injectedData: null, id: layers[0].id, applyGlobalQuery: true, label: null, @@ -86,6 +87,7 @@ describe('EMS is enabled', () => { expect(layers).toEqual([{ alpha: 1, __dataRequests: [], + __injectedData: null, id: layers[0].id, applyGlobalQuery: true, label: null, diff --git a/x-pack/legacy/plugins/maps/public/layers/layer.js b/x-pack/legacy/plugins/maps/public/layers/layer.js index ab1af73634c60..c1e45d9b7c7a8 100644 --- a/x-pack/legacy/plugins/maps/public/layers/layer.js +++ b/x-pack/legacy/plugins/maps/public/layers/layer.js @@ -9,7 +9,11 @@ import { EuiIcon, EuiLoadingSpinner } from '@elastic/eui'; import turf from 'turf'; import turfBooleanContains from '@turf/boolean-contains'; import { DataRequest } from './util/data_request'; -import { MB_SOURCE_ID_LAYER_ID_PREFIX_DELIMITER, SOURCE_DATA_ID_ORIGIN } from '../../common/constants'; +import { InjectedData } from './util/injected_data'; +import { + MB_SOURCE_ID_LAYER_ID_PREFIX_DELIMITER, + SOURCE_DATA_ID_ORIGIN +} from '../../common/constants'; import uuid from 'uuid/v4'; import { copyPersistentState } from '../reducers/util'; import { i18n } from '@kbn/i18n'; @@ -28,6 +32,11 @@ export class AbstractLayer { } else { this._dataRequests = []; } + if (this._descriptor.__injectedData) { + this._injectedData = new InjectedData(this._descriptor.__injectedData); + } else { + this._injectedData = null; + } } static getBoundDataForSource(mbMap, sourceId) { @@ -39,6 +48,7 @@ export class AbstractLayer { const layerDescriptor = { ...options }; layerDescriptor.__dataRequests = _.get(options, '__dataRequests', []); + layerDescriptor.__injectedData = _.get(options, '__injectedData', null); layerDescriptor.id = _.get(options, 'id', uuid()); layerDescriptor.label = options.label && options.label.length > 0 ? options.label : null; layerDescriptor.minZoom = _.get(options, 'minZoom', 0); @@ -277,6 +287,10 @@ export class AbstractLayer { return this._dataRequests.find(dataRequest => dataRequest.getDataId() === id); } + getInjectedData() { + return this._injectedData ? this._injectedData.getData() : null; + } + isLayerLoading() { return this._dataRequests.some(dataRequest => dataRequest.isLoading()); } diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/client_file_source/geojson_file_source.js b/x-pack/legacy/plugins/maps/public/layers/sources/client_file_source/geojson_file_source.js index db255074fa261..ba5993f28b360 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/client_file_source/geojson_file_source.js +++ b/x-pack/legacy/plugins/maps/public/layers/sources/client_file_source/geojson_file_source.js @@ -36,18 +36,9 @@ export class GeojsonFileSource extends AbstractVectorSource { applyGlobalQuery: DEFAULT_APPLY_GLOBAL_QUERY } - static createDescriptor(geoJson, name) { - // Wrap feature as feature collection if needed - const featureCollection = (geoJson.type === 'Feature') - ? { - type: 'FeatureCollection', - features: [{ ...geoJson }] - } - : geoJson; - + static createDescriptor(name) { return { type: GeojsonFileSource.type, - featureCollection, name }; } @@ -95,9 +86,16 @@ export class GeojsonFileSource extends AbstractVectorSource { onPreviewSource(null); return; } - const sourceDescriptor = GeojsonFileSource.createDescriptor(geojsonFile, name); + const sourceDescriptor = GeojsonFileSource.createDescriptor(name); const source = new GeojsonFileSource(sourceDescriptor, inspectorAdapters); - onPreviewSource(source); + const featureCollection = (geojsonFile.type === 'Feature') + ? { + type: 'FeatureCollection', + features: [{ ...geojsonFile }] + } + : geojsonFile; + + onPreviewSource(source, { __injectedData: featureCollection }); }; }; @@ -128,22 +126,6 @@ export class GeojsonFileSource extends AbstractVectorSource { ); } - async getGeoJsonWithMeta() { - const copiedPropsFeatures = this._descriptor.featureCollection.features - .map(feature => ({ - type: 'Feature', - geometry: feature.geometry, - properties: feature.properties ? { ...feature.properties } : {} - })); - return { - data: { - type: 'FeatureCollection', - features: copiedPropsFeatures - }, - meta: {} - }; - } - async getDisplayName() { return this._descriptor.name; } @@ -156,4 +138,7 @@ export class GeojsonFileSource extends AbstractVectorSource { return GeojsonFileSource.isIndexingSource; } + isInjectedData() { + return true; + } } diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/source.js b/x-pack/legacy/plugins/maps/public/layers/sources/source.js index a029ffe2d2ab5..01479c4319d94 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/source.js +++ b/x-pack/legacy/plugins/maps/public/layers/sources/source.js @@ -115,6 +115,10 @@ export class AbstractSource { return AbstractSource.isIndexingSource; } + isInjectedData() { + return false; + } + supportsElasticsearchFilters() { return false; } diff --git a/x-pack/legacy/plugins/maps/public/layers/util/injected_data.js b/x-pack/legacy/plugins/maps/public/layers/util/injected_data.js new file mode 100644 index 0000000000000..8c18819e9f8b5 --- /dev/null +++ b/x-pack/legacy/plugins/maps/public/layers/util/injected_data.js @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +export class InjectedData { + + constructor(data) { + this._descriptor = { data }; + } + + getData() { + return this._descriptor.data; + } + + hasData() { + return !!this._descriptor.data; + } + +} + diff --git a/x-pack/legacy/plugins/maps/public/layers/vector_layer.js b/x-pack/legacy/plugins/maps/public/layers/vector_layer.js index 7f7875d7fb995..829078d98996e 100644 --- a/x-pack/legacy/plugins/maps/public/layers/vector_layer.js +++ b/x-pack/legacy/plugins/maps/public/layers/vector_layer.js @@ -131,9 +131,20 @@ export class VectorLayer extends AbstractLayer { return true; } + getInjectedData() { + const featureCollection = super.getInjectedData(); + if (!featureCollection) { + return null; + } + // Set default visible property on data + featureCollection.features.forEach( + feature => _.set(feature, `properties.${FEATURE_VISIBLE_PROPERTY_NAME}`, true) + ); + return featureCollection; + } + getCustomIconAndTooltipContent() { - const sourceDataRequest = this.getSourceDataRequest(); - const featureCollection = sourceDataRequest ? sourceDataRequest.getData() : null; + const featureCollection = this._getSourceFeatureCollection(); const noResultsIcon = ( Date: Wed, 2 Oct 2019 18:23:44 -0400 Subject: [PATCH 07/19] Add KQL functionality in the find function of the saved objects (#41136) * Add KQL functionality in the find function of the saved objects wip rename variable from KQL to filter, fix unit test + add new ones miss security pluggins review I fix api changes refactor after reviewing with Rudolf fix type review III review IV for security put back allowed logic back to return empty results remove StaticIndexPattern review V fix core_api_changes fix type * validate filter to match requirement type.attributes.key or type.savedObjectKey * Fix types * fix a bug + add more api integration test * fix types in test until we create package @kbn/types * fix type issue * fix api integration test * export nodeTypes from packages @kbn/es-query instead of the function buildNodeKuery * throw 400- bad request when validation error in find * fix type issue * accept api change * renove _ to represent private * fix unit test + add doc * add comment to explain why we removed the private --- docs/api/saved-objects/find.asciidoc | 5 + ...a-plugin-public.savedobjectsclient.find.md | 2 +- ...kibana-plugin-public.savedobjectsclient.md | 2 +- ...n-public.savedobjectsfindoptions.filter.md | 11 + ...a-plugin-public.savedobjectsfindoptions.md | 1 + ...n-server.savedobjectsfindoptions.filter.md | 11 + ...a-plugin-server.savedobjectsfindoptions.md | 1 + packages/kbn-es-query/src/kuery/ast/ast.d.ts | 11 +- .../kbn-es-query/src/kuery/functions/is.js | 3 +- packages/kbn-es-query/src/kuery/index.d.ts | 10 + packages/kbn-es-query/src/kuery/index.js | 2 +- .../src/kuery/node_types/index.d.ts | 76 ++ .../notifications/notifications_service.ts | 2 +- src/core/public/public.api.md | 4 +- .../saved_objects/saved_objects_client.ts | 1 + .../server/saved_objects/service/index.ts | 1 + .../service/lib/cache_index_patterns.test.ts | 108 +++ .../service/lib/cache_index_patterns.ts | 82 ++ .../service/lib/filter_utils.test.ts | 457 ++++++++++++ .../saved_objects/service/lib/filter_utils.ts | 190 +++++ .../server/saved_objects/service/lib/index.ts | 2 + .../service/lib/repository.test.js | 18 +- .../saved_objects/service/lib/repository.ts | 40 +- .../lib/search_dsl/query_params.test.ts | 698 +++++++++++++++--- .../service/lib/search_dsl/query_params.ts | 46 +- .../service/lib/search_dsl/search_dsl.test.ts | 20 +- .../service/lib/search_dsl/search_dsl.ts | 14 +- src/core/server/saved_objects/types.ts | 1 + src/core/server/server.api.md | 3 + .../core_plugins/elasticsearch/index.d.ts | 2 +- ...create_saved_objects_stream_from_ndjson.ts | 2 +- .../server/saved_objects/routes/find.ts | 5 + .../saved_objects/saved_objects_mixin.js | 9 + .../saved_objects/saved_objects_mixin.test.js | 5 + .../data/common/field_formats/field_format.ts | 16 +- .../apis/saved_objects/find.js | 87 +++ test/tsconfig.json | 4 +- test/typings/index.d.ts | 6 + .../common/suites/find.ts | 75 ++ .../security_and_spaces/apis/find.ts | 251 +++++++ .../security_only/apis/find.ts | 276 +++++++ .../spaces_only/apis/find.ts | 51 ++ 42 files changed, 2459 insertions(+), 152 deletions(-) create mode 100644 docs/development/core/public/kibana-plugin-public.savedobjectsfindoptions.filter.md create mode 100644 docs/development/core/server/kibana-plugin-server.savedobjectsfindoptions.filter.md create mode 100644 packages/kbn-es-query/src/kuery/node_types/index.d.ts create mode 100644 src/core/server/saved_objects/service/lib/cache_index_patterns.test.ts create mode 100644 src/core/server/saved_objects/service/lib/cache_index_patterns.ts create mode 100644 src/core/server/saved_objects/service/lib/filter_utils.test.ts create mode 100644 src/core/server/saved_objects/service/lib/filter_utils.ts diff --git a/docs/api/saved-objects/find.asciidoc b/docs/api/saved-objects/find.asciidoc index fd80951b1c9f2..f20ded78e0743 100644 --- a/docs/api/saved-objects/find.asciidoc +++ b/docs/api/saved-objects/find.asciidoc @@ -41,6 +41,11 @@ experimental[] Retrieve a paginated set of {kib} saved objects by various condit `has_reference`:: (Optional, object) Filters to objects that have a relationship with the type and ID combination. +`filter`:: + (Optional, string) The filter is a KQL string with the caveat that if you filter with an attribute from your type saved object. + It should look like that savedObjectType.attributes.title: "myTitle". However, If you used a direct attribute of a saved object like `updatedAt`, + you will have to define your filter like that savedObjectType.updatedAt > 2018-12-22. + NOTE: As objects change in {kib}, the results on each page of the response also change. Use the find API for traditional paginated results, but avoid using it to export large amounts of data. diff --git a/docs/development/core/public/kibana-plugin-public.savedobjectsclient.find.md b/docs/development/core/public/kibana-plugin-public.savedobjectsclient.find.md index 80ddb1aea18d1..a4fa3f17d0d94 100644 --- a/docs/development/core/public/kibana-plugin-public.savedobjectsclient.find.md +++ b/docs/development/core/public/kibana-plugin-public.savedobjectsclient.find.md @@ -9,5 +9,5 @@ Search for objects Signature: ```typescript -find: (options: Pick) => Promise>; +find: (options: Pick) => Promise>; ``` diff --git a/docs/development/core/public/kibana-plugin-public.savedobjectsclient.md b/docs/development/core/public/kibana-plugin-public.savedobjectsclient.md index 2ad9591426ab2..00a71d25cea38 100644 --- a/docs/development/core/public/kibana-plugin-public.savedobjectsclient.md +++ b/docs/development/core/public/kibana-plugin-public.savedobjectsclient.md @@ -20,7 +20,7 @@ export declare class SavedObjectsClient | [bulkGet](./kibana-plugin-public.savedobjectsclient.bulkget.md) | | (objects?: {
id: string;
type: string;
}[]) => Promise<SavedObjectsBatchResponse<SavedObjectAttributes>> | Returns an array of objects by id | | [create](./kibana-plugin-public.savedobjectsclient.create.md) | | <T extends SavedObjectAttributes>(type: string, attributes: T, options?: SavedObjectsCreateOptions) => Promise<SimpleSavedObject<T>> | Persists an object | | [delete](./kibana-plugin-public.savedobjectsclient.delete.md) | | (type: string, id: string) => Promise<{}> | Deletes an object | -| [find](./kibana-plugin-public.savedobjectsclient.find.md) | | <T extends SavedObjectAttributes>(options: Pick<SavedObjectFindOptionsServer, "search" | "type" | "defaultSearchOperator" | "searchFields" | "sortField" | "hasReference" | "page" | "perPage" | "fields">) => Promise<SavedObjectsFindResponsePublic<T>> | Search for objects | +| [find](./kibana-plugin-public.savedobjectsclient.find.md) | | <T extends SavedObjectAttributes>(options: Pick<SavedObjectFindOptionsServer, "search" | "filter" | "type" | "page" | "perPage" | "sortField" | "fields" | "searchFields" | "hasReference" | "defaultSearchOperator">) => Promise<SavedObjectsFindResponsePublic<T>> | Search for objects | | [get](./kibana-plugin-public.savedobjectsclient.get.md) | | <T extends SavedObjectAttributes>(type: string, id: string) => Promise<SimpleSavedObject<T>> | Fetches a single object | ## Methods diff --git a/docs/development/core/public/kibana-plugin-public.savedobjectsfindoptions.filter.md b/docs/development/core/public/kibana-plugin-public.savedobjectsfindoptions.filter.md new file mode 100644 index 0000000000000..82237134e0b22 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.savedobjectsfindoptions.filter.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [SavedObjectsFindOptions](./kibana-plugin-public.savedobjectsfindoptions.md) > [filter](./kibana-plugin-public.savedobjectsfindoptions.filter.md) + +## SavedObjectsFindOptions.filter property + +Signature: + +```typescript +filter?: string; +``` diff --git a/docs/development/core/public/kibana-plugin-public.savedobjectsfindoptions.md b/docs/development/core/public/kibana-plugin-public.savedobjectsfindoptions.md index f90c60ebdd0dc..4c916431d4333 100644 --- a/docs/development/core/public/kibana-plugin-public.savedobjectsfindoptions.md +++ b/docs/development/core/public/kibana-plugin-public.savedobjectsfindoptions.md @@ -17,6 +17,7 @@ export interface SavedObjectsFindOptions extends SavedObjectsBaseOptions | --- | --- | --- | | [defaultSearchOperator](./kibana-plugin-public.savedobjectsfindoptions.defaultsearchoperator.md) | 'AND' | 'OR' | | | [fields](./kibana-plugin-public.savedobjectsfindoptions.fields.md) | string[] | An array of fields to include in the results | +| [filter](./kibana-plugin-public.savedobjectsfindoptions.filter.md) | string | | | [hasReference](./kibana-plugin-public.savedobjectsfindoptions.hasreference.md) | {
type: string;
id: string;
} | | | [page](./kibana-plugin-public.savedobjectsfindoptions.page.md) | number | | | [perPage](./kibana-plugin-public.savedobjectsfindoptions.perpage.md) | number | | diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsfindoptions.filter.md b/docs/development/core/server/kibana-plugin-server.savedobjectsfindoptions.filter.md new file mode 100644 index 0000000000000..308bebbeaf60b --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsfindoptions.filter.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsFindOptions](./kibana-plugin-server.savedobjectsfindoptions.md) > [filter](./kibana-plugin-server.savedobjectsfindoptions.filter.md) + +## SavedObjectsFindOptions.filter property + +Signature: + +```typescript +filter?: string; +``` diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsfindoptions.md b/docs/development/core/server/kibana-plugin-server.savedobjectsfindoptions.md index ad81c439d902c..dfd51d480db92 100644 --- a/docs/development/core/server/kibana-plugin-server.savedobjectsfindoptions.md +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsfindoptions.md @@ -17,6 +17,7 @@ export interface SavedObjectsFindOptions extends SavedObjectsBaseOptions | --- | --- | --- | | [defaultSearchOperator](./kibana-plugin-server.savedobjectsfindoptions.defaultsearchoperator.md) | 'AND' | 'OR' | | | [fields](./kibana-plugin-server.savedobjectsfindoptions.fields.md) | string[] | An array of fields to include in the results | +| [filter](./kibana-plugin-server.savedobjectsfindoptions.filter.md) | string | | | [hasReference](./kibana-plugin-server.savedobjectsfindoptions.hasreference.md) | {
type: string;
id: string;
} | | | [page](./kibana-plugin-server.savedobjectsfindoptions.page.md) | number | | | [perPage](./kibana-plugin-server.savedobjectsfindoptions.perpage.md) | number | | diff --git a/packages/kbn-es-query/src/kuery/ast/ast.d.ts b/packages/kbn-es-query/src/kuery/ast/ast.d.ts index 915c024f2ab48..448ef0e9cca75 100644 --- a/packages/kbn-es-query/src/kuery/ast/ast.d.ts +++ b/packages/kbn-es-query/src/kuery/ast/ast.d.ts @@ -17,6 +17,8 @@ * under the License. */ +import { JsonObject } from '..'; + /** * WARNING: these typings are incomplete */ @@ -30,15 +32,6 @@ export interface KueryParseOptions { startRule: string; } -type JsonValue = null | boolean | number | string | JsonObject | JsonArray; - -interface JsonObject { - [key: string]: JsonValue; -} - -// eslint-disable-next-line @typescript-eslint/no-empty-interface -interface JsonArray extends Array {} - export function fromKueryExpression( expression: string, parseOptions?: KueryParseOptions diff --git a/packages/kbn-es-query/src/kuery/functions/is.js b/packages/kbn-es-query/src/kuery/functions/is.js index 0338671e9b3fe..690f98b08ba82 100644 --- a/packages/kbn-es-query/src/kuery/functions/is.js +++ b/packages/kbn-es-query/src/kuery/functions/is.js @@ -32,7 +32,6 @@ export function buildNodeParams(fieldName, value, isPhrase = false) { if (_.isUndefined(value)) { throw new Error('value is a required argument'); } - const fieldNode = typeof fieldName === 'string' ? ast.fromLiteralExpression(fieldName) : literal.buildNode(fieldName); const valueNode = typeof value === 'string' ? ast.fromLiteralExpression(value) : literal.buildNode(value); const isPhraseNode = literal.buildNode(isPhrase); @@ -42,7 +41,7 @@ export function buildNodeParams(fieldName, value, isPhrase = false) { } export function toElasticsearchQuery(node, indexPattern = null, config = {}) { - const { arguments: [ fieldNameArg, valueArg, isPhraseArg ] } = node; + const { arguments: [fieldNameArg, valueArg, isPhraseArg] } = node; const fieldName = ast.toElasticsearchQuery(fieldNameArg); const value = !_.isUndefined(valueArg) ? ast.toElasticsearchQuery(valueArg) : valueArg; const type = isPhraseArg.value ? 'phrase' : 'best_fields'; diff --git a/packages/kbn-es-query/src/kuery/index.d.ts b/packages/kbn-es-query/src/kuery/index.d.ts index 9d797406420d4..b01a8914f68ef 100644 --- a/packages/kbn-es-query/src/kuery/index.d.ts +++ b/packages/kbn-es-query/src/kuery/index.d.ts @@ -18,3 +18,13 @@ */ export * from './ast'; +export { nodeTypes } from './node_types'; + +export type JsonValue = null | boolean | number | string | JsonObject | JsonArray; + +export interface JsonObject { + [key: string]: JsonValue; +} + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface JsonArray extends Array {} diff --git a/packages/kbn-es-query/src/kuery/index.js b/packages/kbn-es-query/src/kuery/index.js index 84c6a205b42ce..08fa9829d4a56 100644 --- a/packages/kbn-es-query/src/kuery/index.js +++ b/packages/kbn-es-query/src/kuery/index.js @@ -19,5 +19,5 @@ export * from './ast'; export * from './filter_migration'; -export * from './node_types'; +export { nodeTypes } from './node_types'; export * from './errors'; diff --git a/packages/kbn-es-query/src/kuery/node_types/index.d.ts b/packages/kbn-es-query/src/kuery/node_types/index.d.ts new file mode 100644 index 0000000000000..0d1f2c28e39f0 --- /dev/null +++ b/packages/kbn-es-query/src/kuery/node_types/index.d.ts @@ -0,0 +1,76 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * WARNING: these typings are incomplete + */ + +import { JsonObject, JsonValue } from '..'; + +type FunctionName = + | 'is' + | 'and' + | 'or' + | 'not' + | 'range' + | 'exists' + | 'geoBoundingBox' + | 'geoPolygon'; + +interface FunctionTypeBuildNode { + type: 'function'; + function: FunctionName; + // TODO -> Need to define a better type for DSL query + arguments: any[]; +} + +interface FunctionType { + buildNode: (functionName: FunctionName, ...args: any[]) => FunctionTypeBuildNode; + buildNodeWithArgumentNodes: (functionName: FunctionName, ...args: any[]) => FunctionTypeBuildNode; + toElasticsearchQuery: (node: any, indexPattern: any, config: JsonObject) => JsonValue; +} + +interface LiteralType { + buildNode: ( + value: null | boolean | number | string + ) => { type: 'literal'; value: null | boolean | number | string }; + toElasticsearchQuery: (node: any) => null | boolean | number | string; +} + +interface NamedArgType { + buildNode: (name: string, value: any) => { type: 'namedArg'; name: string; value: any }; + toElasticsearchQuery: (node: any) => string; +} + +interface WildcardType { + buildNode: (value: string) => { type: 'wildcard'; value: string }; + test: (node: any, string: string) => boolean; + toElasticsearchQuery: (node: any) => string; + toQueryStringQuery: (node: any) => string; + hasLeadingWildcard: (node: any) => boolean; +} + +interface NodeTypes { + function: FunctionType; + literal: LiteralType; + namedArg: NamedArgType; + wildcard: WildcardType; +} + +export const nodeTypes: NodeTypes; diff --git a/src/core/public/notifications/notifications_service.ts b/src/core/public/notifications/notifications_service.ts index 2dc2b2ef06094..33221522fa83c 100644 --- a/src/core/public/notifications/notifications_service.ts +++ b/src/core/public/notifications/notifications_service.ts @@ -48,7 +48,7 @@ export class NotificationsService { public setup({ uiSettings }: SetupDeps): NotificationsSetup { const notificationSetup = { toasts: this.toasts.setup({ uiSettings }) }; - this.uiSettingsErrorSubscription = uiSettings.getUpdateErrors$().subscribe(error => { + this.uiSettingsErrorSubscription = uiSettings.getUpdateErrors$().subscribe((error: Error) => { notificationSetup.toasts.addDanger({ title: i18n.translate('core.notifications.unableUpdateUISettingNotificationMessageTitle', { defaultMessage: 'Unable to update UI setting', diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index b2d730d7fa467..102e77b564a6d 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -752,7 +752,7 @@ export class SavedObjectsClient { }[]) => Promise>; create: (type: string, attributes: T, options?: SavedObjectsCreateOptions) => Promise>; delete: (type: string, id: string) => Promise<{}>; - find: (options: Pick) => Promise>; + find: (options: Pick) => Promise>; get: (type: string, id: string) => Promise>; update(type: string, id: string, attributes: T, { version, migrationVersion, references }?: SavedObjectsUpdateOptions): Promise>; } @@ -775,6 +775,8 @@ export interface SavedObjectsFindOptions extends SavedObjectsBaseOptions { defaultSearchOperator?: 'AND' | 'OR'; fields?: string[]; // (undocumented) + filter?: string; + // (undocumented) hasReference?: { type: string; id: string; diff --git a/src/core/public/saved_objects/saved_objects_client.ts b/src/core/public/saved_objects/saved_objects_client.ts index dc13d001643a3..cf0300157aece 100644 --- a/src/core/public/saved_objects/saved_objects_client.ts +++ b/src/core/public/saved_objects/saved_objects_client.ts @@ -297,6 +297,7 @@ export class SavedObjectsClient { searchFields: 'search_fields', sortField: 'sort_field', type: 'type', + filter: 'filter', }; const renamedQuery = renameKeys(renameMap, options); diff --git a/src/core/server/saved_objects/service/index.ts b/src/core/server/saved_objects/service/index.ts index 685ce51bc7d29..dbf35ff4e134d 100644 --- a/src/core/server/saved_objects/service/index.ts +++ b/src/core/server/saved_objects/service/index.ts @@ -56,6 +56,7 @@ export { SavedObjectsClientWrapperFactory, SavedObjectsClientWrapperOptions, SavedObjectsErrorHelpers, + SavedObjectsCacheIndexPatterns, } from './lib'; export * from './saved_objects_client'; diff --git a/src/core/server/saved_objects/service/lib/cache_index_patterns.test.ts b/src/core/server/saved_objects/service/lib/cache_index_patterns.test.ts new file mode 100644 index 0000000000000..e3aeca42d1cf0 --- /dev/null +++ b/src/core/server/saved_objects/service/lib/cache_index_patterns.test.ts @@ -0,0 +1,108 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { SavedObjectsCacheIndexPatterns } from './cache_index_patterns'; + +const mockGetFieldsForWildcard = jest.fn(); +const mockIndexPatternsService: jest.Mock = jest.fn().mockImplementation(() => ({ + getFieldsForWildcard: mockGetFieldsForWildcard, + getFieldsForTimePattern: jest.fn(), +})); + +describe('SavedObjectsRepository', () => { + let cacheIndexPatterns: SavedObjectsCacheIndexPatterns; + + const fields = [ + { + aggregatable: true, + name: 'config.type', + searchable: true, + type: 'string', + }, + { + aggregatable: true, + name: 'foo.type', + searchable: true, + type: 'string', + }, + { + aggregatable: true, + name: 'bar.type', + searchable: true, + type: 'string', + }, + { + aggregatable: true, + name: 'baz.type', + searchable: true, + type: 'string', + }, + { + aggregatable: true, + name: 'dashboard.otherField', + searchable: true, + type: 'string', + }, + { + aggregatable: true, + name: 'hiddenType.someField', + searchable: true, + type: 'string', + }, + ]; + + beforeEach(() => { + cacheIndexPatterns = new SavedObjectsCacheIndexPatterns(); + jest.clearAllMocks(); + }); + + it('setIndexPatterns should return an error object when indexPatternsService is undefined', async () => { + try { + await cacheIndexPatterns.setIndexPatterns('test-index'); + } catch (error) { + expect(error.message).toMatch('indexPatternsService is not defined'); + } + }); + + it('setIndexPatterns should return an error object if getFieldsForWildcard is not defined', async () => { + mockGetFieldsForWildcard.mockImplementation(() => { + throw new Error('something happen'); + }); + try { + cacheIndexPatterns.setIndexPatternsService(new mockIndexPatternsService()); + await cacheIndexPatterns.setIndexPatterns('test-index'); + } catch (error) { + expect(error.message).toMatch('Index Pattern Error - something happen'); + } + }); + + it('setIndexPatterns should return empty array when getFieldsForWildcard is returning null or undefined', async () => { + mockGetFieldsForWildcard.mockImplementation(() => null); + cacheIndexPatterns.setIndexPatternsService(new mockIndexPatternsService()); + await cacheIndexPatterns.setIndexPatterns('test-index'); + expect(cacheIndexPatterns.getIndexPatterns()).toEqual(undefined); + }); + + it('setIndexPatterns should return index pattern when getFieldsForWildcard is returning fields', async () => { + mockGetFieldsForWildcard.mockImplementation(() => fields); + cacheIndexPatterns.setIndexPatternsService(new mockIndexPatternsService()); + await cacheIndexPatterns.setIndexPatterns('test-index'); + expect(cacheIndexPatterns.getIndexPatterns()).toEqual({ fields, title: 'test-index' }); + }); +}); diff --git a/src/core/server/saved_objects/service/lib/cache_index_patterns.ts b/src/core/server/saved_objects/service/lib/cache_index_patterns.ts new file mode 100644 index 0000000000000..e96cf996f504c --- /dev/null +++ b/src/core/server/saved_objects/service/lib/cache_index_patterns.ts @@ -0,0 +1,82 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { FieldDescriptor } from 'src/legacy/server/index_patterns/service/index_patterns_service'; +import { IndexPatternsService } from 'src/legacy/server/index_patterns'; + +export interface SavedObjectsIndexPatternField { + name: string; + type: string; + aggregatable: boolean; + searchable: boolean; +} + +export interface SavedObjectsIndexPattern { + fields: SavedObjectsIndexPatternField[]; + title: string; +} + +export class SavedObjectsCacheIndexPatterns { + private _indexPatterns: SavedObjectsIndexPattern | undefined = undefined; + private _indexPatternsService: IndexPatternsService | undefined = undefined; + + public setIndexPatternsService(indexPatternsService: IndexPatternsService) { + this._indexPatternsService = indexPatternsService; + } + + public getIndexPatternsService() { + return this._indexPatternsService; + } + + public getIndexPatterns(): SavedObjectsIndexPattern | undefined { + return this._indexPatterns; + } + + public async setIndexPatterns(index: string) { + await this._getIndexPattern(index); + } + + private async _getIndexPattern(index: string) { + try { + if (this._indexPatternsService == null) { + throw new TypeError('indexPatternsService is not defined'); + } + const fieldsDescriptor: FieldDescriptor[] = await this._indexPatternsService.getFieldsForWildcard( + { + pattern: index, + } + ); + + this._indexPatterns = + fieldsDescriptor && Array.isArray(fieldsDescriptor) && fieldsDescriptor.length > 0 + ? { + fields: fieldsDescriptor.map(field => ({ + aggregatable: field.aggregatable, + name: field.name, + searchable: field.searchable, + type: field.type, + })), + title: index, + } + : undefined; + } catch (err) { + throw new Error(`Index Pattern Error - ${err.message}`); + } + } +} diff --git a/src/core/server/saved_objects/service/lib/filter_utils.test.ts b/src/core/server/saved_objects/service/lib/filter_utils.test.ts new file mode 100644 index 0000000000000..73a0804512ed1 --- /dev/null +++ b/src/core/server/saved_objects/service/lib/filter_utils.test.ts @@ -0,0 +1,457 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { fromKueryExpression } from '@kbn/es-query'; + +import { + validateFilterKueryNode, + getSavedObjectTypeIndexPatterns, + validateConvertFilterToKueryNode, +} from './filter_utils'; +import { SavedObjectsIndexPattern } from './cache_index_patterns'; + +const mockIndexPatterns: SavedObjectsIndexPattern = { + fields: [ + { + name: 'updatedAt', + type: 'date', + aggregatable: true, + searchable: true, + }, + { + name: 'foo.title', + type: 'text', + aggregatable: true, + searchable: true, + }, + { + name: 'foo.description', + type: 'text', + aggregatable: true, + searchable: true, + }, + { + name: 'foo.bytes', + type: 'number', + aggregatable: true, + searchable: true, + }, + { + name: 'bar.foo', + type: 'text', + aggregatable: true, + searchable: true, + }, + { + name: 'bar.description', + type: 'text', + aggregatable: true, + searchable: true, + }, + { + name: 'hiddentype.description', + type: 'text', + aggregatable: true, + searchable: true, + }, + ], + title: 'mock', +}; + +describe('Filter Utils', () => { + describe('#validateConvertFilterToKueryNode', () => { + test('Validate a simple filter', () => { + expect( + validateConvertFilterToKueryNode(['foo'], 'foo.attributes.title: "best"', mockIndexPatterns) + ).toEqual(fromKueryExpression('foo.title: "best"')); + }); + test('Assemble filter kuery node saved object attributes with one saved object type', () => { + expect( + validateConvertFilterToKueryNode( + ['foo'], + 'foo.updatedAt: 5678654567 and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or foo.attributes.description :*)', + mockIndexPatterns + ) + ).toEqual( + fromKueryExpression( + '(type: foo and updatedAt: 5678654567) and foo.bytes > 1000 and foo.bytes < 8000 and foo.title: "best" and (foo.description: t* or foo.description :*)' + ) + ); + }); + + test('Assemble filter with one type kuery node saved object attributes with multiple saved object type', () => { + expect( + validateConvertFilterToKueryNode( + ['foo', 'bar'], + 'foo.updatedAt: 5678654567 and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or foo.attributes.description :*)', + mockIndexPatterns + ) + ).toEqual( + fromKueryExpression( + '(type: foo and updatedAt: 5678654567) and foo.bytes > 1000 and foo.bytes < 8000 and foo.title: "best" and (foo.description: t* or foo.description :*)' + ) + ); + }); + + test('Assemble filter with two types kuery node saved object attributes with multiple saved object type', () => { + expect( + validateConvertFilterToKueryNode( + ['foo', 'bar'], + '(bar.updatedAt: 5678654567 OR foo.updatedAt: 5678654567) and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or bar.attributes.description :*)', + mockIndexPatterns + ) + ).toEqual( + fromKueryExpression( + '((type: bar and updatedAt: 5678654567) or (type: foo and updatedAt: 5678654567)) and foo.bytes > 1000 and foo.bytes < 8000 and foo.title: "best" and (foo.description: t* or bar.description :*)' + ) + ); + }); + + test('Lets make sure that we are throwing an exception if we get an error', () => { + expect(() => { + validateConvertFilterToKueryNode( + ['foo', 'bar'], + 'updatedAt: 5678654567 and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or foo.attributes.description :*)', + mockIndexPatterns + ); + }).toThrowErrorMatchingInlineSnapshot( + `"This key 'updatedAt' need to be wrapped by a saved object type like foo,bar: Bad Request"` + ); + }); + + test('Lets make sure that we are throwing an exception if we are using hiddentype with types', () => { + expect(() => { + validateConvertFilterToKueryNode([], 'hiddentype.title: "title"', mockIndexPatterns); + }).toThrowErrorMatchingInlineSnapshot(`"This type hiddentype is not allowed: Bad Request"`); + }); + }); + + describe('#validateFilterKueryNode', () => { + test('Validate filter query through KueryNode - happy path', () => { + const validationObject = validateFilterKueryNode( + fromKueryExpression( + 'foo.updatedAt: 5678654567 and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or foo.attributes.description :*)' + ), + ['foo'], + getSavedObjectTypeIndexPatterns(['foo'], mockIndexPatterns) + ); + + expect(validationObject).toEqual([ + { + astPath: 'arguments.0', + error: null, + isSavedObjectAttr: true, + key: 'foo.updatedAt', + type: 'foo', + }, + { + astPath: 'arguments.1.arguments.0', + error: null, + isSavedObjectAttr: false, + key: 'foo.attributes.bytes', + type: 'foo', + }, + { + astPath: 'arguments.1.arguments.1.arguments.0', + error: null, + isSavedObjectAttr: false, + key: 'foo.attributes.bytes', + type: 'foo', + }, + { + astPath: 'arguments.1.arguments.1.arguments.1.arguments.0', + error: null, + isSavedObjectAttr: false, + key: 'foo.attributes.title', + type: 'foo', + }, + { + astPath: 'arguments.1.arguments.1.arguments.1.arguments.1.arguments.0', + error: null, + isSavedObjectAttr: false, + key: 'foo.attributes.description', + type: 'foo', + }, + { + astPath: 'arguments.1.arguments.1.arguments.1.arguments.1.arguments.1', + error: null, + isSavedObjectAttr: false, + key: 'foo.attributes.description', + type: 'foo', + }, + ]); + }); + + test('Return Error if key is not wrapper by a saved object type', () => { + const validationObject = validateFilterKueryNode( + fromKueryExpression( + 'updatedAt: 5678654567 and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or foo.attributes.description :*)' + ), + ['foo'], + getSavedObjectTypeIndexPatterns(['foo'], mockIndexPatterns) + ); + + expect(validationObject).toEqual([ + { + astPath: 'arguments.0', + error: "This key 'updatedAt' need to be wrapped by a saved object type like foo", + isSavedObjectAttr: true, + key: 'updatedAt', + type: null, + }, + { + astPath: 'arguments.1.arguments.0', + error: null, + isSavedObjectAttr: false, + key: 'foo.attributes.bytes', + type: 'foo', + }, + { + astPath: 'arguments.1.arguments.1.arguments.0', + error: null, + isSavedObjectAttr: false, + key: 'foo.attributes.bytes', + type: 'foo', + }, + { + astPath: 'arguments.1.arguments.1.arguments.1.arguments.0', + error: null, + isSavedObjectAttr: false, + key: 'foo.attributes.title', + type: 'foo', + }, + { + astPath: 'arguments.1.arguments.1.arguments.1.arguments.1.arguments.0', + error: null, + isSavedObjectAttr: false, + key: 'foo.attributes.description', + type: 'foo', + }, + { + astPath: 'arguments.1.arguments.1.arguments.1.arguments.1.arguments.1', + error: null, + isSavedObjectAttr: false, + key: 'foo.attributes.description', + type: 'foo', + }, + ]); + }); + + test('Return Error if key of a saved object type is not wrapped with attributes', () => { + const validationObject = validateFilterKueryNode( + fromKueryExpression( + 'foo.updatedAt: 5678654567 and foo.attributes.bytes > 1000 and foo.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or foo.description :*)' + ), + ['foo'], + getSavedObjectTypeIndexPatterns(['foo'], mockIndexPatterns) + ); + + expect(validationObject).toEqual([ + { + astPath: 'arguments.0', + error: null, + isSavedObjectAttr: true, + key: 'foo.updatedAt', + type: 'foo', + }, + { + astPath: 'arguments.1.arguments.0', + error: null, + isSavedObjectAttr: false, + key: 'foo.attributes.bytes', + type: 'foo', + }, + { + astPath: 'arguments.1.arguments.1.arguments.0', + error: + "This key 'foo.bytes' does NOT match the filter proposition SavedObjectType.attributes.key", + isSavedObjectAttr: false, + key: 'foo.bytes', + type: 'foo', + }, + { + astPath: 'arguments.1.arguments.1.arguments.1.arguments.0', + error: null, + isSavedObjectAttr: false, + key: 'foo.attributes.title', + type: 'foo', + }, + { + astPath: 'arguments.1.arguments.1.arguments.1.arguments.1.arguments.0', + error: null, + isSavedObjectAttr: false, + key: 'foo.attributes.description', + type: 'foo', + }, + { + astPath: 'arguments.1.arguments.1.arguments.1.arguments.1.arguments.1', + error: + "This key 'foo.description' does NOT match the filter proposition SavedObjectType.attributes.key", + isSavedObjectAttr: false, + key: 'foo.description', + type: 'foo', + }, + ]); + }); + + test('Return Error if filter is not using an allowed type', () => { + const validationObject = validateFilterKueryNode( + fromKueryExpression( + 'bar.updatedAt: 5678654567 and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.title: "best" and (foo.attributes.description: t* or foo.attributes.description :*)' + ), + ['foo'], + getSavedObjectTypeIndexPatterns(['foo'], mockIndexPatterns) + ); + + expect(validationObject).toEqual([ + { + astPath: 'arguments.0', + error: 'This type bar is not allowed', + isSavedObjectAttr: true, + key: 'bar.updatedAt', + type: 'bar', + }, + { + astPath: 'arguments.1.arguments.0', + error: null, + isSavedObjectAttr: false, + key: 'foo.attributes.bytes', + type: 'foo', + }, + { + astPath: 'arguments.1.arguments.1.arguments.0', + error: null, + isSavedObjectAttr: false, + key: 'foo.attributes.bytes', + type: 'foo', + }, + { + astPath: 'arguments.1.arguments.1.arguments.1.arguments.0', + error: null, + isSavedObjectAttr: false, + key: 'foo.attributes.title', + type: 'foo', + }, + { + astPath: 'arguments.1.arguments.1.arguments.1.arguments.1.arguments.0', + error: null, + isSavedObjectAttr: false, + key: 'foo.attributes.description', + type: 'foo', + }, + { + astPath: 'arguments.1.arguments.1.arguments.1.arguments.1.arguments.1', + error: null, + isSavedObjectAttr: false, + key: 'foo.attributes.description', + type: 'foo', + }, + ]); + }); + + test('Return Error if filter is using an non-existing key in the index patterns of the saved object type', () => { + const validationObject = validateFilterKueryNode( + fromKueryExpression( + 'foo.updatedAt33: 5678654567 and foo.attributes.bytes > 1000 and foo.attributes.bytes < 8000 and foo.attributes.header: "best" and (foo.attributes.description: t* or foo.attributes.description :*)' + ), + ['foo'], + getSavedObjectTypeIndexPatterns(['foo'], mockIndexPatterns) + ); + + expect(validationObject).toEqual([ + { + astPath: 'arguments.0', + error: "This key 'foo.updatedAt33' does NOT exist in foo saved object index patterns", + isSavedObjectAttr: false, + key: 'foo.updatedAt33', + type: 'foo', + }, + { + astPath: 'arguments.1.arguments.0', + error: null, + isSavedObjectAttr: false, + key: 'foo.attributes.bytes', + type: 'foo', + }, + { + astPath: 'arguments.1.arguments.1.arguments.0', + error: null, + isSavedObjectAttr: false, + key: 'foo.attributes.bytes', + type: 'foo', + }, + { + astPath: 'arguments.1.arguments.1.arguments.1.arguments.0', + error: + "This key 'foo.attributes.header' does NOT exist in foo saved object index patterns", + isSavedObjectAttr: false, + key: 'foo.attributes.header', + type: 'foo', + }, + { + astPath: 'arguments.1.arguments.1.arguments.1.arguments.1.arguments.0', + error: null, + isSavedObjectAttr: false, + key: 'foo.attributes.description', + type: 'foo', + }, + { + astPath: 'arguments.1.arguments.1.arguments.1.arguments.1.arguments.1', + error: null, + isSavedObjectAttr: false, + key: 'foo.attributes.description', + type: 'foo', + }, + ]); + }); + }); + + describe('#getSavedObjectTypeIndexPatterns', () => { + test('Get index patterns related to your type', () => { + const indexPatternsFilterByType = getSavedObjectTypeIndexPatterns(['foo'], mockIndexPatterns); + + expect(indexPatternsFilterByType).toEqual([ + { + name: 'updatedAt', + type: 'date', + aggregatable: true, + searchable: true, + }, + { + name: 'foo.title', + type: 'text', + aggregatable: true, + searchable: true, + }, + { + name: 'foo.description', + type: 'text', + aggregatable: true, + searchable: true, + }, + { + name: 'foo.bytes', + type: 'number', + aggregatable: true, + searchable: true, + }, + ]); + }); + }); +}); diff --git a/src/core/server/saved_objects/service/lib/filter_utils.ts b/src/core/server/saved_objects/service/lib/filter_utils.ts new file mode 100644 index 0000000000000..2397971e66966 --- /dev/null +++ b/src/core/server/saved_objects/service/lib/filter_utils.ts @@ -0,0 +1,190 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { fromKueryExpression, KueryNode, nodeTypes } from '@kbn/es-query'; +import { get, set } from 'lodash'; + +import { SavedObjectsIndexPattern, SavedObjectsIndexPatternField } from './cache_index_patterns'; +import { SavedObjectsErrorHelpers } from './errors'; + +export const validateConvertFilterToKueryNode = ( + types: string[], + filter: string, + indexPattern: SavedObjectsIndexPattern | undefined +): KueryNode => { + if (filter && filter.length > 0 && indexPattern) { + const filterKueryNode = fromKueryExpression(filter); + + const typeIndexPatterns = getSavedObjectTypeIndexPatterns(types, indexPattern); + const validationFilterKuery = validateFilterKueryNode( + filterKueryNode, + types, + typeIndexPatterns, + filterKueryNode.type === 'function' && ['is', 'range'].includes(filterKueryNode.function) + ); + + if (validationFilterKuery.length === 0) { + throw SavedObjectsErrorHelpers.createBadRequestError( + 'If we have a filter options defined, we should always have validationFilterKuery defined too' + ); + } + + if (validationFilterKuery.some(obj => obj.error != null)) { + throw SavedObjectsErrorHelpers.createBadRequestError( + validationFilterKuery + .filter(obj => obj.error != null) + .map(obj => obj.error) + .join('\n') + ); + } + + validationFilterKuery.forEach(item => { + const path: string[] = item.astPath.length === 0 ? [] : item.astPath.split('.'); + const existingKueryNode: KueryNode = + path.length === 0 ? filterKueryNode : get(filterKueryNode, path); + if (item.isSavedObjectAttr) { + existingKueryNode.arguments[0].value = existingKueryNode.arguments[0].value.split('.')[1]; + const itemType = types.filter(t => t === item.type); + if (itemType.length === 1) { + set( + filterKueryNode, + path, + nodeTypes.function.buildNode('and', [ + nodeTypes.function.buildNode('is', 'type', itemType[0]), + existingKueryNode, + ]) + ); + } + } else { + existingKueryNode.arguments[0].value = existingKueryNode.arguments[0].value.replace( + '.attributes', + '' + ); + set(filterKueryNode, path, existingKueryNode); + } + }); + return filterKueryNode; + } + return null; +}; + +export const getSavedObjectTypeIndexPatterns = ( + types: string[], + indexPattern: SavedObjectsIndexPattern | undefined +): SavedObjectsIndexPatternField[] => { + return indexPattern != null + ? indexPattern.fields.filter( + ip => + !ip.name.includes('.') || (ip.name.includes('.') && types.includes(ip.name.split('.')[0])) + ) + : []; +}; + +interface ValidateFilterKueryNode { + astPath: string; + error: string; + isSavedObjectAttr: boolean; + key: string; + type: string | null; +} + +export const validateFilterKueryNode = ( + astFilter: KueryNode, + types: string[], + typeIndexPatterns: SavedObjectsIndexPatternField[], + storeValue: boolean = false, + path: string = 'arguments' +): ValidateFilterKueryNode[] => { + return astFilter.arguments.reduce((kueryNode: string[], ast: KueryNode, index: number) => { + if (ast.arguments) { + const myPath = `${path}.${index}`; + return [ + ...kueryNode, + ...validateFilterKueryNode( + ast, + types, + typeIndexPatterns, + ast.type === 'function' && ['is', 'range'].includes(ast.function), + `${myPath}.arguments` + ), + ]; + } + if (storeValue && index === 0) { + const splitPath = path.split('.'); + return [ + ...kueryNode, + { + astPath: splitPath.slice(0, splitPath.length - 1).join('.'), + error: hasFilterKeyError(ast.value, types, typeIndexPatterns), + isSavedObjectAttr: isSavedObjectAttr(ast.value, typeIndexPatterns), + key: ast.value, + type: getType(ast.value), + }, + ]; + } + return kueryNode; + }, []); +}; + +const getType = (key: string) => (key.includes('.') ? key.split('.')[0] : null); + +export const isSavedObjectAttr = ( + key: string, + typeIndexPatterns: SavedObjectsIndexPatternField[] +) => { + const splitKey = key.split('.'); + if (splitKey.length === 1 && typeIndexPatterns.some(tip => tip.name === splitKey[0])) { + return true; + } else if (splitKey.length > 1 && typeIndexPatterns.some(tip => tip.name === splitKey[1])) { + return true; + } + return false; +}; + +export const hasFilterKeyError = ( + key: string, + types: string[], + typeIndexPatterns: SavedObjectsIndexPatternField[] +): string | null => { + if (!key.includes('.')) { + return `This key '${key}' need to be wrapped by a saved object type like ${types.join()}`; + } else if (key.includes('.')) { + const keySplit = key.split('.'); + if (keySplit.length <= 1 || !types.includes(keySplit[0])) { + return `This type ${keySplit[0]} is not allowed`; + } + if ( + (keySplit.length === 2 && typeIndexPatterns.some(tip => tip.name === key)) || + (keySplit.length > 2 && types.includes(keySplit[0]) && keySplit[1] !== 'attributes') + ) { + return `This key '${key}' does NOT match the filter proposition SavedObjectType.attributes.key`; + } + if ( + (keySplit.length === 2 && !typeIndexPatterns.some(tip => tip.name === keySplit[1])) || + (keySplit.length > 2 && + !typeIndexPatterns.some( + tip => + tip.name === [...keySplit.slice(0, 1), ...keySplit.slice(2, keySplit.length)].join('.') + )) + ) { + return `This key '${key}' does NOT exist in ${types.join()} saved object index patterns`; + } + } + return null; +}; diff --git a/src/core/server/saved_objects/service/lib/index.ts b/src/core/server/saved_objects/service/lib/index.ts index d987737c2ffa0..be78fdde76210 100644 --- a/src/core/server/saved_objects/service/lib/index.ts +++ b/src/core/server/saved_objects/service/lib/index.ts @@ -26,3 +26,5 @@ export { } from './scoped_client_provider'; export { SavedObjectsErrorHelpers } from './errors'; + +export { SavedObjectsCacheIndexPatterns } from './cache_index_patterns'; diff --git a/src/core/server/saved_objects/service/lib/repository.test.js b/src/core/server/saved_objects/service/lib/repository.test.js index c35502b719d58..bc646c8c1d2e1 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -18,6 +18,7 @@ */ import { delay } from 'bluebird'; + import { SavedObjectsRepository } from './repository'; import * as getSearchDslNS from './search_dsl/search_dsl'; import { SavedObjectsErrorHelpers } from './errors'; @@ -272,6 +273,10 @@ describe('SavedObjectsRepository', () => { savedObjectsRepository = new SavedObjectsRepository({ index: '.kibana-test', + cacheIndexPatterns: { + setIndexPatterns: jest.fn(), + getIndexPatterns: () => undefined, + }, mappings, callCluster: callAdminCluster, migrator, @@ -285,7 +290,7 @@ describe('SavedObjectsRepository', () => { getSearchDslNS.getSearchDsl.mockReset(); }); - afterEach(() => {}); + afterEach(() => { }); describe('#create', () => { beforeEach(() => { @@ -993,7 +998,7 @@ describe('SavedObjectsRepository', () => { expect(onBeforeWrite).toHaveBeenCalledTimes(1); }); - it('should return objects in the same order regardless of type', () => {}); + it('should return objects in the same order regardless of type', () => { }); }); describe('#delete', () => { @@ -1154,6 +1159,13 @@ describe('SavedObjectsRepository', () => { } }); + it('requires index pattern to be defined if filter is defined', async () => { + callAdminCluster.mockReturnValue(noNamespaceSearchResults); + expect(savedObjectsRepository.find({ type: 'foo', filter: 'foo.type: hello' })) + .rejects + .toThrowErrorMatchingInlineSnapshot('"options.filter is missing index pattern to work correctly: Bad Request"'); + }); + it('passes mappings, schema, search, defaultSearchOperator, searchFields, type, sortField, sortOrder and hasReference to getSearchDsl', async () => { callAdminCluster.mockReturnValue(namespacedSearchResults); @@ -1169,6 +1181,8 @@ describe('SavedObjectsRepository', () => { type: 'foo', id: '1', }, + indexPattern: undefined, + kueryNode: null, }; await savedObjectsRepository.find(relevantOpts); diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index 3c2a644f003bd..aadb82486ccce 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -19,11 +19,13 @@ import { omit } from 'lodash'; import { CallCluster } from 'src/legacy/core_plugins/elasticsearch'; + import { getRootPropertiesObjects, IndexMapping } from '../../mappings'; import { getSearchDsl } from './search_dsl'; import { includedFields } from './included_fields'; import { decorateEsError } from './decorate_es_error'; import { SavedObjectsErrorHelpers } from './errors'; +import { SavedObjectsCacheIndexPatterns } from './cache_index_patterns'; import { decodeRequestVersion, encodeVersion, encodeHitVersion } from '../../version'; import { SavedObjectsSchema } from '../../schema'; import { KibanaMigrator } from '../../migrations'; @@ -45,6 +47,7 @@ import { SavedObjectsFindOptions, SavedObjectsMigrationVersion, } from '../../types'; +import { validateConvertFilterToKueryNode } from './filter_utils'; // BEWARE: The SavedObjectClient depends on the implementation details of the SavedObjectsRepository // so any breaking changes to this repository are considered breaking changes to the SavedObjectsClient. @@ -74,6 +77,7 @@ export interface SavedObjectsRepositoryOptions { serializer: SavedObjectsSerializer; migrator: KibanaMigrator; allowedTypes: string[]; + cacheIndexPatterns: SavedObjectsCacheIndexPatterns; onBeforeWrite?: (...args: Parameters) => Promise; } @@ -91,11 +95,13 @@ export class SavedObjectsRepository { private _onBeforeWrite: (...args: Parameters) => Promise; private _unwrappedCallCluster: CallCluster; private _serializer: SavedObjectsSerializer; + private _cacheIndexPatterns: SavedObjectsCacheIndexPatterns; constructor(options: SavedObjectsRepositoryOptions) { const { index, config, + cacheIndexPatterns, mappings, callCluster, schema, @@ -106,7 +112,7 @@ export class SavedObjectsRepository { } = options; // It's important that we migrate documents / mark them as up-to-date - // prior to writing them to the index. Otherwise, we'll cause unecessary + // prior to writing them to the index. Otherwise, we'll cause unnecessary // index migrations to run at Kibana startup, and those will probably fail // due to invalidly versioned documents in the index. // @@ -117,6 +123,7 @@ export class SavedObjectsRepository { this._config = config; this._mappings = mappings; this._schema = schema; + this._cacheIndexPatterns = cacheIndexPatterns; if (allowedTypes.length === 0) { throw new Error('Empty or missing types for saved object repository!'); } @@ -126,6 +133,9 @@ export class SavedObjectsRepository { this._unwrappedCallCluster = async (...args: Parameters) => { await migrator.runMigrations(); + if (this._cacheIndexPatterns.getIndexPatterns() == null) { + await this._cacheIndexPatterns.setIndexPatterns(index); + } return callCluster(...args); }; this._schema = schema; @@ -404,9 +414,12 @@ export class SavedObjectsRepository { fields, namespace, type, + filter, }: SavedObjectsFindOptions): Promise> { if (!type) { - throw new TypeError(`options.type must be a string or an array of strings`); + throw SavedObjectsErrorHelpers.createBadRequestError( + 'options.type must be a string or an array of strings' + ); } const types = Array.isArray(type) ? type : [type]; @@ -421,13 +434,28 @@ export class SavedObjectsRepository { } if (searchFields && !Array.isArray(searchFields)) { - throw new TypeError('options.searchFields must be an array'); + throw SavedObjectsErrorHelpers.createBadRequestError('options.searchFields must be an array'); } if (fields && !Array.isArray(fields)) { - throw new TypeError('options.fields must be an array'); + throw SavedObjectsErrorHelpers.createBadRequestError('options.fields must be an array'); } + if (filter && filter !== '' && this._cacheIndexPatterns.getIndexPatterns() == null) { + throw SavedObjectsErrorHelpers.createBadRequestError( + 'options.filter is missing index pattern to work correctly' + ); + } + + const kueryNode = + filter && filter !== '' + ? validateConvertFilterToKueryNode( + allowedTypes, + filter, + this._cacheIndexPatterns.getIndexPatterns() + ) + : null; + const esOptions = { index: this.getIndicesForTypes(allowedTypes), size: perPage, @@ -446,6 +474,8 @@ export class SavedObjectsRepository { sortOrder, namespace, hasReference, + indexPattern: kueryNode != null ? this._cacheIndexPatterns.getIndexPatterns() : undefined, + kueryNode, }), }, }; @@ -769,7 +799,7 @@ export class SavedObjectsRepository { // The internal representation of the saved object that the serializer returns // includes the namespace, and we use this for migrating documents. However, we don't - // want the namespcae to be returned from the repository, as the repository scopes each + // want the namespace to be returned from the repository, as the repository scopes each // method transparently to the specified namespace. private _rawToSavedObject(raw: RawDoc): SavedObject { const savedObject = this._serializer.rawToSavedObject(raw); diff --git a/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts b/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts index b13d86819716b..75b3058029227 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts @@ -18,6 +18,7 @@ */ import { schemaMock } from '../../../schema/schema.mock'; +import { SavedObjectsIndexPattern } from '../cache_index_patterns'; import { getQueryParams } from './query_params'; const SCHEMA = schemaMock.create(); @@ -61,6 +62,41 @@ const MAPPINGS = { }, }, }; +const INDEX_PATTERN: SavedObjectsIndexPattern = { + fields: [ + { + aggregatable: true, + name: 'type', + searchable: true, + type: 'string', + }, + { + aggregatable: true, + name: 'pending.title', + searchable: true, + type: 'string', + }, + { + aggregatable: true, + name: 'saved.title', + searchable: true, + type: 'string', + }, + { + aggregatable: true, + name: 'saved.obj.key1', + searchable: true, + type: 'string', + }, + { + aggregatable: true, + name: 'global.name', + searchable: true, + type: 'string', + }, + ], + title: 'test', +}; // create a type clause to be used within the "should", if a namespace is specified // the clause will ensure the namespace matches; otherwise, the clause will ensure @@ -85,7 +121,7 @@ const createTypeClause = (type: string, namespace?: string) => { describe('searchDsl/queryParams', () => { describe('no parameters', () => { it('searches for all known types without a namespace specified', () => { - expect(getQueryParams(MAPPINGS, SCHEMA)).toEqual({ + expect(getQueryParams({ mappings: MAPPINGS, schema: SCHEMA })).toEqual({ query: { bool: { filter: [ @@ -108,7 +144,9 @@ describe('searchDsl/queryParams', () => { describe('namespace', () => { it('filters namespaced types for namespace, and ensures namespace agnostic types have no namespace', () => { - expect(getQueryParams(MAPPINGS, SCHEMA, 'foo-namespace')).toEqual({ + expect( + getQueryParams({ mappings: MAPPINGS, schema: SCHEMA, namespace: 'foo-namespace' }) + ).toEqual({ query: { bool: { filter: [ @@ -131,7 +169,9 @@ describe('searchDsl/queryParams', () => { describe('type (singular, namespaced)', () => { it('includes a terms filter for type and namespace not being specified', () => { - expect(getQueryParams(MAPPINGS, SCHEMA, undefined, 'saved')).toEqual({ + expect( + getQueryParams({ mappings: MAPPINGS, schema: SCHEMA, namespace: undefined, type: 'saved' }) + ).toEqual({ query: { bool: { filter: [ @@ -150,7 +190,9 @@ describe('searchDsl/queryParams', () => { describe('type (singular, global)', () => { it('includes a terms filter for type and namespace not being specified', () => { - expect(getQueryParams(MAPPINGS, SCHEMA, undefined, 'global')).toEqual({ + expect( + getQueryParams({ mappings: MAPPINGS, schema: SCHEMA, namespace: undefined, type: 'global' }) + ).toEqual({ query: { bool: { filter: [ @@ -169,7 +211,14 @@ describe('searchDsl/queryParams', () => { describe('type (plural, namespaced and global)', () => { it('includes term filters for types and namespace not being specified', () => { - expect(getQueryParams(MAPPINGS, SCHEMA, undefined, ['saved', 'global'])).toEqual({ + expect( + getQueryParams({ + mappings: MAPPINGS, + schema: SCHEMA, + namespace: undefined, + type: ['saved', 'global'], + }) + ).toEqual({ query: { bool: { filter: [ @@ -188,7 +237,14 @@ describe('searchDsl/queryParams', () => { describe('namespace, type (plural, namespaced and global)', () => { it('includes a terms filter for type and namespace not being specified', () => { - expect(getQueryParams(MAPPINGS, SCHEMA, 'foo-namespace', ['saved', 'global'])).toEqual({ + expect( + getQueryParams({ + mappings: MAPPINGS, + schema: SCHEMA, + namespace: 'foo-namespace', + type: ['saved', 'global'], + }) + ).toEqual({ query: { bool: { filter: [ @@ -207,7 +263,15 @@ describe('searchDsl/queryParams', () => { describe('search', () => { it('includes a sqs query and all known types without a namespace specified', () => { - expect(getQueryParams(MAPPINGS, SCHEMA, undefined, undefined, 'us*')).toEqual({ + expect( + getQueryParams({ + mappings: MAPPINGS, + schema: SCHEMA, + namespace: undefined, + type: undefined, + search: 'us*', + }) + ).toEqual({ query: { bool: { filter: [ @@ -239,7 +303,15 @@ describe('searchDsl/queryParams', () => { describe('namespace, search', () => { it('includes a sqs query and namespaced types with the namespace and global types without a namespace', () => { - expect(getQueryParams(MAPPINGS, SCHEMA, 'foo-namespace', undefined, 'us*')).toEqual({ + expect( + getQueryParams({ + mappings: MAPPINGS, + schema: SCHEMA, + namespace: 'foo-namespace', + type: undefined, + search: 'us*', + }) + ).toEqual({ query: { bool: { filter: [ @@ -271,7 +343,15 @@ describe('searchDsl/queryParams', () => { describe('type (plural, namespaced and global), search', () => { it('includes a sqs query and types without a namespace', () => { - expect(getQueryParams(MAPPINGS, SCHEMA, undefined, ['saved', 'global'], 'us*')).toEqual({ + expect( + getQueryParams({ + mappings: MAPPINGS, + schema: SCHEMA, + namespace: undefined, + type: ['saved', 'global'], + search: 'us*', + }) + ).toEqual({ query: { bool: { filter: [ @@ -299,40 +379,52 @@ describe('searchDsl/queryParams', () => { describe('namespace, type (plural, namespaced and global), search', () => { it('includes a sqs query and namespace type with a namespace and global type without a namespace', () => { - expect(getQueryParams(MAPPINGS, SCHEMA, 'foo-namespace', ['saved', 'global'], 'us*')).toEqual( - { - query: { - bool: { - filter: [ - { - bool: { - should: [ - createTypeClause('saved', 'foo-namespace'), - createTypeClause('global'), - ], - minimum_should_match: 1, - }, + expect( + getQueryParams({ + mappings: MAPPINGS, + schema: SCHEMA, + namespace: 'foo-namespace', + type: ['saved', 'global'], + search: 'us*', + }) + ).toEqual({ + query: { + bool: { + filter: [ + { + bool: { + should: [createTypeClause('saved', 'foo-namespace'), createTypeClause('global')], + minimum_should_match: 1, }, - ], - must: [ - { - simple_query_string: { - query: 'us*', - lenient: true, - fields: ['*'], - }, + }, + ], + must: [ + { + simple_query_string: { + query: 'us*', + lenient: true, + fields: ['*'], }, - ], - }, + }, + ], }, - } - ); + }, + }); }); }); describe('search, searchFields', () => { it('includes all types for field', () => { - expect(getQueryParams(MAPPINGS, SCHEMA, undefined, undefined, 'y*', ['title'])).toEqual({ + expect( + getQueryParams({ + mappings: MAPPINGS, + schema: SCHEMA, + namespace: undefined, + type: undefined, + search: 'y*', + searchFields: ['title'], + }) + ).toEqual({ query: { bool: { filter: [ @@ -360,7 +452,16 @@ describe('searchDsl/queryParams', () => { }); }); it('supports field boosting', () => { - expect(getQueryParams(MAPPINGS, SCHEMA, undefined, undefined, 'y*', ['title^3'])).toEqual({ + expect( + getQueryParams({ + mappings: MAPPINGS, + schema: SCHEMA, + namespace: undefined, + type: undefined, + search: 'y*', + searchFields: ['title^3'], + }) + ).toEqual({ query: { bool: { filter: [ @@ -389,7 +490,14 @@ describe('searchDsl/queryParams', () => { }); it('supports field and multi-field', () => { expect( - getQueryParams(MAPPINGS, SCHEMA, undefined, undefined, 'y*', ['title', 'title.raw']) + getQueryParams({ + mappings: MAPPINGS, + schema: SCHEMA, + namespace: undefined, + type: undefined, + search: 'y*', + searchFields: ['title', 'title.raw'], + }) ).toEqual({ query: { bool: { @@ -428,38 +536,52 @@ describe('searchDsl/queryParams', () => { describe('namespace, search, searchFields', () => { it('includes all types for field', () => { - expect(getQueryParams(MAPPINGS, SCHEMA, 'foo-namespace', undefined, 'y*', ['title'])).toEqual( - { - query: { - bool: { - filter: [ - { - bool: { - should: [ - createTypeClause('pending', 'foo-namespace'), - createTypeClause('saved', 'foo-namespace'), - createTypeClause('global'), - ], - minimum_should_match: 1, - }, + expect( + getQueryParams({ + mappings: MAPPINGS, + schema: SCHEMA, + namespace: 'foo-namespace', + type: undefined, + search: 'y*', + searchFields: ['title'], + }) + ).toEqual({ + query: { + bool: { + filter: [ + { + bool: { + should: [ + createTypeClause('pending', 'foo-namespace'), + createTypeClause('saved', 'foo-namespace'), + createTypeClause('global'), + ], + minimum_should_match: 1, }, - ], - must: [ - { - simple_query_string: { - query: 'y*', - fields: ['pending.title', 'saved.title', 'global.title'], - }, + }, + ], + must: [ + { + simple_query_string: { + query: 'y*', + fields: ['pending.title', 'saved.title', 'global.title'], }, - ], - }, + }, + ], }, - } - ); + }, + }); }); it('supports field boosting', () => { expect( - getQueryParams(MAPPINGS, SCHEMA, 'foo-namespace', undefined, 'y*', ['title^3']) + getQueryParams({ + mappings: MAPPINGS, + schema: SCHEMA, + namespace: 'foo-namespace', + type: undefined, + search: 'y*', + searchFields: ['title^3'], + }) ).toEqual({ query: { bool: { @@ -489,7 +611,14 @@ describe('searchDsl/queryParams', () => { }); it('supports field and multi-field', () => { expect( - getQueryParams(MAPPINGS, SCHEMA, 'foo-namespace', undefined, 'y*', ['title', 'title.raw']) + getQueryParams({ + mappings: MAPPINGS, + schema: SCHEMA, + namespace: 'foo-namespace', + type: undefined, + search: 'y*', + searchFields: ['title', 'title.raw'], + }) ).toEqual({ query: { bool: { @@ -529,7 +658,14 @@ describe('searchDsl/queryParams', () => { describe('type (plural, namespaced and global), search, searchFields', () => { it('includes all types for field', () => { expect( - getQueryParams(MAPPINGS, SCHEMA, undefined, ['saved', 'global'], 'y*', ['title']) + getQueryParams({ + mappings: MAPPINGS, + schema: SCHEMA, + namespace: undefined, + type: ['saved', 'global'], + search: 'y*', + searchFields: ['title'], + }) ).toEqual({ query: { bool: { @@ -555,7 +691,14 @@ describe('searchDsl/queryParams', () => { }); it('supports field boosting', () => { expect( - getQueryParams(MAPPINGS, SCHEMA, undefined, ['saved', 'global'], 'y*', ['title^3']) + getQueryParams({ + mappings: MAPPINGS, + schema: SCHEMA, + namespace: undefined, + type: ['saved', 'global'], + search: 'y*', + searchFields: ['title^3'], + }) ).toEqual({ query: { bool: { @@ -581,10 +724,14 @@ describe('searchDsl/queryParams', () => { }); it('supports field and multi-field', () => { expect( - getQueryParams(MAPPINGS, SCHEMA, undefined, ['saved', 'global'], 'y*', [ - 'title', - 'title.raw', - ]) + getQueryParams({ + mappings: MAPPINGS, + schema: SCHEMA, + namespace: undefined, + type: ['saved', 'global'], + search: 'y*', + searchFields: ['title', 'title.raw'], + }) ).toEqual({ query: { bool: { @@ -613,7 +760,14 @@ describe('searchDsl/queryParams', () => { describe('namespace, type (plural, namespaced and global), search, searchFields', () => { it('includes all types for field', () => { expect( - getQueryParams(MAPPINGS, SCHEMA, 'foo-namespace', ['saved', 'global'], 'y*', ['title']) + getQueryParams({ + mappings: MAPPINGS, + schema: SCHEMA, + namespace: 'foo-namespace', + type: ['saved', 'global'], + search: 'y*', + searchFields: ['title'], + }) ).toEqual({ query: { bool: { @@ -639,7 +793,14 @@ describe('searchDsl/queryParams', () => { }); it('supports field boosting', () => { expect( - getQueryParams(MAPPINGS, SCHEMA, 'foo-namespace', ['saved', 'global'], 'y*', ['title^3']) + getQueryParams({ + mappings: MAPPINGS, + schema: SCHEMA, + namespace: 'foo-namespace', + type: ['saved', 'global'], + search: 'y*', + searchFields: ['title^3'], + }) ).toEqual({ query: { bool: { @@ -665,10 +826,14 @@ describe('searchDsl/queryParams', () => { }); it('supports field and multi-field', () => { expect( - getQueryParams(MAPPINGS, SCHEMA, 'foo-namespace', ['saved', 'global'], 'y*', [ - 'title', - 'title.raw', - ]) + getQueryParams({ + mappings: MAPPINGS, + schema: SCHEMA, + namespace: 'foo-namespace', + type: ['saved', 'global'], + search: 'y*', + searchFields: ['title', 'title.raw'], + }) ).toEqual({ query: { bool: { @@ -697,15 +862,15 @@ describe('searchDsl/queryParams', () => { describe('type (plural, namespaced and global), search, defaultSearchOperator', () => { it('supports defaultSearchOperator', () => { expect( - getQueryParams( - MAPPINGS, - SCHEMA, - 'foo-namespace', - ['saved', 'global'], - 'foo', - undefined, - 'AND' - ) + getQueryParams({ + mappings: MAPPINGS, + schema: SCHEMA, + namespace: 'foo-namespace', + type: ['saved', 'global'], + search: 'foo', + searchFields: undefined, + defaultSearchOperator: 'AND', + }) ).toEqual({ query: { bool: { @@ -771,19 +936,19 @@ describe('searchDsl/queryParams', () => { describe('type (plural, namespaced and global), hasReference', () => { it('supports hasReference', () => { expect( - getQueryParams( - MAPPINGS, - SCHEMA, - 'foo-namespace', - ['saved', 'global'], - undefined, - undefined, - 'OR', - { + getQueryParams({ + mappings: MAPPINGS, + schema: SCHEMA, + namespace: 'foo-namespace', + type: ['saved', 'global'], + search: undefined, + searchFields: undefined, + defaultSearchOperator: 'OR', + hasReference: { type: 'bar', id: '1', - } - ) + }, + }) ).toEqual({ query: { bool: { @@ -823,4 +988,345 @@ describe('searchDsl/queryParams', () => { }); }); }); + + describe('type filter', () => { + it(' with namespace', () => { + expect( + getQueryParams({ + mappings: MAPPINGS, + schema: SCHEMA, + namespace: 'foo-namespace', + kueryNode: { + type: 'function', + function: 'is', + arguments: [ + { type: 'literal', value: 'global.name' }, + { type: 'literal', value: 'GLOBAL' }, + { type: 'literal', value: false }, + ], + }, + indexPattern: INDEX_PATTERN, + }) + ).toEqual({ + query: { + bool: { + filter: [ + { + bool: { + should: [ + { + match: { + 'global.name': 'GLOBAL', + }, + }, + ], + minimum_should_match: 1, + }, + }, + { + bool: { + should: [ + { + bool: { + must: [ + { + term: { + type: 'pending', + }, + }, + { + term: { + namespace: 'foo-namespace', + }, + }, + ], + }, + }, + { + bool: { + must: [ + { + term: { + type: 'saved', + }, + }, + { + term: { + namespace: 'foo-namespace', + }, + }, + ], + }, + }, + { + bool: { + must: [ + { + term: { + type: 'global', + }, + }, + ], + must_not: [ + { + exists: { + field: 'namespace', + }, + }, + ], + }, + }, + ], + minimum_should_match: 1, + }, + }, + ], + }, + }, + }); + }); + it(' with namespace and more complex filter', () => { + expect( + getQueryParams({ + mappings: MAPPINGS, + schema: SCHEMA, + namespace: 'foo-namespace', + kueryNode: { + type: 'function', + function: 'and', + arguments: [ + { + type: 'function', + function: 'is', + arguments: [ + { type: 'literal', value: 'global.name' }, + { type: 'literal', value: 'GLOBAL' }, + { type: 'literal', value: false }, + ], + }, + { + type: 'function', + function: 'not', + arguments: [ + { + type: 'function', + function: 'is', + arguments: [ + { type: 'literal', value: 'saved.obj.key1' }, + { type: 'literal', value: 'key' }, + { type: 'literal', value: true }, + ], + }, + ], + }, + ], + }, + indexPattern: INDEX_PATTERN, + }) + ).toEqual({ + query: { + bool: { + filter: [ + { + bool: { + filter: [ + { + bool: { + should: [ + { + match: { + 'global.name': 'GLOBAL', + }, + }, + ], + minimum_should_match: 1, + }, + }, + { + bool: { + must_not: { + bool: { + should: [ + { + match_phrase: { + 'saved.obj.key1': 'key', + }, + }, + ], + minimum_should_match: 1, + }, + }, + }, + }, + ], + }, + }, + { + bool: { + should: [ + { + bool: { + must: [ + { + term: { + type: 'pending', + }, + }, + { + term: { + namespace: 'foo-namespace', + }, + }, + ], + }, + }, + { + bool: { + must: [ + { + term: { + type: 'saved', + }, + }, + { + term: { + namespace: 'foo-namespace', + }, + }, + ], + }, + }, + { + bool: { + must: [ + { + term: { + type: 'global', + }, + }, + ], + must_not: [ + { + exists: { + field: 'namespace', + }, + }, + ], + }, + }, + ], + minimum_should_match: 1, + }, + }, + ], + }, + }, + }); + }); + it(' with search and searchFields', () => { + expect( + getQueryParams({ + mappings: MAPPINGS, + schema: SCHEMA, + namespace: 'foo-namespace', + search: 'y*', + searchFields: ['title'], + kueryNode: { + type: 'function', + function: 'is', + arguments: [ + { type: 'literal', value: 'global.name' }, + { type: 'literal', value: 'GLOBAL' }, + { type: 'literal', value: false }, + ], + }, + indexPattern: INDEX_PATTERN, + }) + ).toEqual({ + query: { + bool: { + filter: [ + { + bool: { + should: [ + { + match: { + 'global.name': 'GLOBAL', + }, + }, + ], + minimum_should_match: 1, + }, + }, + { + bool: { + should: [ + { + bool: { + must: [ + { + term: { + type: 'pending', + }, + }, + { + term: { + namespace: 'foo-namespace', + }, + }, + ], + }, + }, + { + bool: { + must: [ + { + term: { + type: 'saved', + }, + }, + { + term: { + namespace: 'foo-namespace', + }, + }, + ], + }, + }, + { + bool: { + must: [ + { + term: { + type: 'global', + }, + }, + ], + must_not: [ + { + exists: { + field: 'namespace', + }, + }, + ], + }, + }, + ], + minimum_should_match: 1, + }, + }, + ], + must: [ + { + simple_query_string: { + query: 'y*', + fields: ['pending.title', 'saved.title', 'global.title'], + }, + }, + ], + }, + }, + }); + }); + }); }); diff --git a/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts b/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts index 9c145258a755f..125b0c40af9e4 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts @@ -16,9 +16,11 @@ * specific language governing permissions and limitations * under the License. */ +import { toElasticsearchQuery, KueryNode } from '@kbn/es-query'; import { getRootPropertiesObjects, IndexMapping } from '../../../mappings'; import { SavedObjectsSchema } from '../../../schema'; +import { SavedObjectsIndexPattern } from '../cache_index_patterns'; /** * Gets the types based on the type. Uses mappings to support @@ -76,25 +78,43 @@ function getClauseForType(schema: SavedObjectsSchema, namespace: string | undefi }; } +interface HasReferenceQueryParams { + type: string; + id: string; +} + +interface QueryParams { + mappings: IndexMapping; + schema: SavedObjectsSchema; + namespace?: string; + type?: string | string[]; + search?: string; + searchFields?: string[]; + defaultSearchOperator?: string; + hasReference?: HasReferenceQueryParams; + kueryNode?: KueryNode; + indexPattern?: SavedObjectsIndexPattern; +} + /** * Get the "query" related keys for the search body */ -export function getQueryParams( - mappings: IndexMapping, - schema: SavedObjectsSchema, - namespace?: string, - type?: string | string[], - search?: string, - searchFields?: string[], - defaultSearchOperator?: string, - hasReference?: { - type: string; - id: string; - } -) { +export function getQueryParams({ + mappings, + schema, + namespace, + type, + search, + searchFields, + defaultSearchOperator, + hasReference, + kueryNode, + indexPattern, +}: QueryParams) { const types = getTypes(mappings, type); const bool: any = { filter: [ + ...(kueryNode != null ? [toElasticsearchQuery(kueryNode, indexPattern)] : []), { bool: { must: hasReference diff --git a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts index 7bd04ca8f3494..97cab3e566d5e 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts @@ -72,16 +72,16 @@ describe('getSearchDsl', () => { getSearchDsl(MAPPINGS, SCHEMA, opts); expect(getQueryParams).toHaveBeenCalledTimes(1); - expect(getQueryParams).toHaveBeenCalledWith( - MAPPINGS, - SCHEMA, - opts.namespace, - opts.type, - opts.search, - opts.searchFields, - opts.defaultSearchOperator, - opts.hasReference - ); + expect(getQueryParams).toHaveBeenCalledWith({ + mappings: MAPPINGS, + schema: SCHEMA, + namespace: opts.namespace, + type: opts.type, + search: opts.search, + searchFields: opts.searchFields, + defaultSearchOperator: opts.defaultSearchOperator, + hasReference: opts.hasReference, + }); }); it('passes (mappings, type, sortField, sortOrder) to getSortingParams', () => { diff --git a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts index 1c2c87bca6ea7..68f6060702505 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts @@ -17,12 +17,14 @@ * under the License. */ +import { KueryNode } from '@kbn/es-query'; import Boom from 'boom'; import { IndexMapping } from '../../../mappings'; import { SavedObjectsSchema } from '../../../schema'; import { getQueryParams } from './query_params'; import { getSortingParams } from './sorting_params'; +import { SavedObjectsIndexPattern } from '../cache_index_patterns'; interface GetSearchDslOptions { type: string | string[]; @@ -36,6 +38,8 @@ interface GetSearchDslOptions { type: string; id: string; }; + kueryNode?: KueryNode; + indexPattern?: SavedObjectsIndexPattern; } export function getSearchDsl( @@ -52,6 +56,8 @@ export function getSearchDsl( sortOrder, namespace, hasReference, + kueryNode, + indexPattern, } = options; if (!type) { @@ -63,7 +69,7 @@ export function getSearchDsl( } return { - ...getQueryParams( + ...getQueryParams({ mappings, schema, namespace, @@ -71,8 +77,10 @@ export function getSearchDsl( search, searchFields, defaultSearchOperator, - hasReference - ), + hasReference, + kueryNode, + indexPattern, + }), ...getSortingParams(mappings, type, sortField, sortOrder), }; } diff --git a/src/core/server/saved_objects/types.ts b/src/core/server/saved_objects/types.ts index 1cc424199b887..e7e7a4c64392a 100644 --- a/src/core/server/saved_objects/types.ts +++ b/src/core/server/saved_objects/types.ts @@ -123,6 +123,7 @@ export interface SavedObjectsFindOptions extends SavedObjectsBaseOptions { searchFields?: string[]; hasReference?: { type: string; id: string }; defaultSearchOperator?: 'AND' | 'OR'; + filter?: string; } /** diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 4ae1c0c267ea9..ae839644fc2e2 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -10,6 +10,7 @@ import { ConfigOptions } from 'elasticsearch'; import { DetailedPeerCertificate } from 'tls'; import { Duration } from 'moment'; import { IncomingHttpHeaders } from 'http'; +import { IndexPatternsService } from 'src/legacy/server/index_patterns'; import { KibanaConfigType } from 'src/core/server/kibana_config'; import { Logger as Logger_2 } from 'src/core/server/logging'; import { ObjectType } from '@kbn/config-schema'; @@ -841,6 +842,8 @@ export interface SavedObjectsFindOptions extends SavedObjectsBaseOptions { defaultSearchOperator?: 'AND' | 'OR'; fields?: string[]; // (undocumented) + filter?: string; + // (undocumented) hasReference?: { type: string; id: string; diff --git a/src/legacy/core_plugins/elasticsearch/index.d.ts b/src/legacy/core_plugins/elasticsearch/index.d.ts index eeee5f3f4c6c7..4cbb1c82cc1e4 100644 --- a/src/legacy/core_plugins/elasticsearch/index.d.ts +++ b/src/legacy/core_plugins/elasticsearch/index.d.ts @@ -482,7 +482,7 @@ export interface CallCluster { (endpoint: 'indices.upgrade', params: IndicesUpgradeParams, options?: CallClusterOptions): ReturnType; (endpoint: 'indices.validateQuery', params: IndicesValidateQueryParams, options?: CallClusterOptions): ReturnType; - // ingest namepsace + // ingest namespace (endpoint: 'ingest.deletePipeline', params: IngestDeletePipelineParams, options?: CallClusterOptions): ReturnType; (endpoint: 'ingest.getPipeline', params: IngestGetPipelineParams, options?: CallClusterOptions): ReturnType; (endpoint: 'ingest.putPipeline', params: IngestPutPipelineParams, options?: CallClusterOptions): ReturnType; diff --git a/src/legacy/server/saved_objects/lib/create_saved_objects_stream_from_ndjson.ts b/src/legacy/server/saved_objects/lib/create_saved_objects_stream_from_ndjson.ts index fa82e54e9fb0a..10047284f5c96 100644 --- a/src/legacy/server/saved_objects/lib/create_saved_objects_stream_from_ndjson.ts +++ b/src/legacy/server/saved_objects/lib/create_saved_objects_stream_from_ndjson.ts @@ -17,7 +17,7 @@ * under the License. */ import { Readable } from 'stream'; -import { SavedObject } from 'kibana/server'; +import { SavedObject } from 'src/core/server'; import { createSplitStream, createMapStream, createFilterStream } from '../../../utils/streams'; export function createSavedObjectsStreamFromNdJson(ndJsonStream: Readable) { diff --git a/src/legacy/server/saved_objects/routes/find.ts b/src/legacy/server/saved_objects/routes/find.ts index bb8fb21aea29c..f8cb8c50d9684 100644 --- a/src/legacy/server/saved_objects/routes/find.ts +++ b/src/legacy/server/saved_objects/routes/find.ts @@ -39,6 +39,7 @@ interface FindRequest extends WithoutQueryAndParams { id: string; }; fields?: string[]; + filter?: string; }; } @@ -79,6 +80,9 @@ export const createFindRoute = (prereqs: Prerequisites) => ({ fields: Joi.array() .items(Joi.string()) .single(), + filter: Joi.string() + .allow('') + .optional(), }) .default(), }, @@ -94,6 +98,7 @@ export const createFindRoute = (prereqs: Prerequisites) => ({ sortField: query.sort_field, hasReference: query.has_reference, fields: query.fields, + filter: query.filter, }); }, }, diff --git a/src/legacy/server/saved_objects/saved_objects_mixin.js b/src/legacy/server/saved_objects/saved_objects_mixin.js index edaa285006422..156c92ef6bdc0 100644 --- a/src/legacy/server/saved_objects/saved_objects_mixin.js +++ b/src/legacy/server/saved_objects/saved_objects_mixin.js @@ -26,6 +26,7 @@ import { SavedObjectsClient, SavedObjectsRepository, ScopedSavedObjectsClientProvider, + SavedObjectsCacheIndexPatterns, getSortedObjectsForExport, importSavedObjects, resolveImportErrors, @@ -63,6 +64,7 @@ export function savedObjectsMixin(kbnServer, server) { const schema = new SavedObjectsSchema(kbnServer.uiExports.savedObjectSchemas); const visibleTypes = allTypes.filter(type => !schema.isHiddenType(type)); const importableAndExportableTypes = getImportableAndExportableTypes({ kbnServer, visibleTypes }); + const cacheIndexPatterns = new SavedObjectsCacheIndexPatterns(); server.decorate('server', 'kibanaMigrator', migrator); server.decorate( @@ -113,11 +115,18 @@ export function savedObjectsMixin(kbnServer, server) { }); const combinedTypes = visibleTypes.concat(extraTypes); const allowedTypes = [...new Set(combinedTypes)]; + + if (cacheIndexPatterns.getIndexPatternsService() == null) { + cacheIndexPatterns.setIndexPatternsService( + server.indexPatternsServiceFactory({ callCluster }) + ); + } const config = server.config(); return new SavedObjectsRepository({ index: config.get('kibana.index'), config, + cacheIndexPatterns, migrator, mappings, schema, diff --git a/src/legacy/server/saved_objects/saved_objects_mixin.test.js b/src/legacy/server/saved_objects/saved_objects_mixin.test.js index cdbc642485706..d3a40583dfe23 100644 --- a/src/legacy/server/saved_objects/saved_objects_mixin.test.js +++ b/src/legacy/server/saved_objects/saved_objects_mixin.test.js @@ -84,6 +84,11 @@ describe('Saved Objects Mixin', () => { get: stubConfig, }; }, + indexPatternsServiceFactory: () => { + return { + getFieldsForWildcard: jest.fn(), + }; + }, plugins: { elasticsearch: { getCluster: () => { diff --git a/src/plugins/data/common/field_formats/field_format.ts b/src/plugins/data/common/field_formats/field_format.ts index cdf82cd9eb9d1..962dc6b23d098 100644 --- a/src/plugins/data/common/field_formats/field_format.ts +++ b/src/plugins/data/common/field_formats/field_format.ts @@ -56,8 +56,10 @@ export abstract class FieldFormat { /** * @property {FieldFormatConvert} * @private + * have to remove the private because of + * https://github.com/Microsoft/TypeScript/issues/17293 */ - private convertObject: FieldFormatConvert | undefined; + convertObject: FieldFormatConvert | undefined; /** * @property {Function} - ref to child class @@ -171,7 +173,11 @@ export abstract class FieldFormat { return createCustomFieldFormat(convertFn); } - private static setupContentType( + /* + * have to remove the private because of + * https://github.com/Microsoft/TypeScript/issues/17293 + */ + static setupContentType( fieldFormat: IFieldFormat, convert: Partial | FieldFormatConvertFunction = {} ): FieldFormatConvert { @@ -185,7 +191,11 @@ export abstract class FieldFormat { }; } - private static toConvertObject(convert: FieldFormatConvertFunction): Partial { + /* + * have to remove the private because of + * https://github.com/Microsoft/TypeScript/issues/17293 + */ + static toConvertObject(convert: FieldFormatConvertFunction): Partial { if (isFieldFormatConvertFn(convert)) { return { [TEXT_CONTEXT_TYPE]: convert, diff --git a/test/api_integration/apis/saved_objects/find.js b/test/api_integration/apis/saved_objects/find.js index fa03d46765e92..a41df24ea7a41 100644 --- a/test/api_integration/apis/saved_objects/find.js +++ b/test/api_integration/apis/saved_objects/find.js @@ -109,6 +109,63 @@ export default function ({ getService }) { }) )); }); + + describe('with a filter', () => { + it('should return 200 with a valid response', async () => ( + await supertest + .get('/api/saved_objects/_find?type=visualization&filter=visualization.attributes.title:"Count of requests"') + .expect(200) + .then(resp => { + expect(resp.body).to.eql({ + page: 1, + per_page: 20, + total: 1, + saved_objects: [ + { + type: 'visualization', + id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', + attributes: { + title: 'Count of requests', + visState: resp.body.saved_objects[0].attributes.visState, + uiStateJSON: '{"spy":{"mode":{"name":null,"fill":false}}}', + description: '', + version: 1, + kibanaSavedObjectMeta: { + searchSourceJSON: resp.body.saved_objects[0].attributes.kibanaSavedObjectMeta.searchSourceJSON, + }, + }, + references: [ + { + name: 'kibanaSavedObjectMeta.searchSourceJSON.index', + type: 'index-pattern', + id: '91200a00-9efd-11e7-acb3-3dab96693fab', + } + ], + migrationVersion: { + visualization: '7.3.1', + }, + updated_at: '2017-09-21T18:51:23.794Z', + version: 'WzIsMV0=', + }, + ], + }); + }) + )); + + it('wrong type should return 400 with Bad Request', async () => ( + await supertest + .get('/api/saved_objects/_find?type=visualization&filter=dashboard.attributes.title:foo') + .expect(400) + .then(resp => { + console.log('body', JSON.stringify(resp.body)); + expect(resp.body).to.eql({ + error: 'Bad Request', + message: 'This type dashboard is not allowed: Bad Request', + statusCode: 400, + }); + }) + )); + }); }); describe('without kibana index', () => { @@ -200,6 +257,36 @@ export default function ({ getService }) { }) )); }); + + describe('with a filter', () => { + it('should return 200 with an empty response', async () => ( + await supertest + .get('/api/saved_objects/_find?type=visualization&filter=visualization.attributes.title:"Count of requests"') + .expect(200) + .then(resp => { + expect(resp.body).to.eql({ + page: 1, + per_page: 20, + total: 0, + saved_objects: [] + }); + }) + )); + + it('wrong type should return 400 with Bad Request', async () => ( + await supertest + .get('/api/saved_objects/_find?type=visualization&filter=dashboard.attributes.title:foo') + .expect(400) + .then(resp => { + console.log('body', JSON.stringify(resp.body)); + expect(resp.body).to.eql({ + error: 'Bad Request', + message: 'This type dashboard is not allowed: Bad Request', + statusCode: 400, + }); + }) + )); + }); }); }); } diff --git a/test/tsconfig.json b/test/tsconfig.json index 276238adf5901..71c9e375a4124 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -14,9 +14,9 @@ "**/*.ts", "**/*.tsx", "../typings/lodash.topath/*.ts", - "typings/**/*", + "typings/**/*" ], "exclude": [ "plugin_functional/plugins/**/*" ] -} +} \ No newline at end of file diff --git a/test/typings/index.d.ts b/test/typings/index.d.ts index ba43e7e7184e5..fd2500257b315 100644 --- a/test/typings/index.d.ts +++ b/test/typings/index.d.ts @@ -17,6 +17,12 @@ * under the License. */ +declare module '*.html' { + const template: string; + // eslint-disable-next-line import/no-default-export + export default template; +} + type MethodKeysOf = { [K in keyof T]: T[K] extends (...args: any[]) => any ? K : never; }[keyof T]; diff --git a/x-pack/test/saved_object_api_integration/common/suites/find.ts b/x-pack/test/saved_object_api_integration/common/suites/find.ts index 45e4c1ed2aa4e..6799f0ec63846 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/find.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/find.ts @@ -23,6 +23,11 @@ interface FindTests { unknownSearchField: FindTest; hiddenType: FindTest; noType: FindTest; + filterWithNotSpaceAwareType: FindTest; + filterWithHiddenType: FindTest; + filterWithUnknownType: FindTest; + filterWithNoType: FindTest; + filterWithUnAllowedType: FindTest; } interface FindTestDefinition { @@ -73,6 +78,14 @@ export function findTestSuiteFactory(esArchiver: any, supertest: SuperTest) }); }; + const expectFilterWrongTypeError = (resp: { [key: string]: any }) => { + expect(resp.body).to.eql({ + error: 'Bad Request', + message: 'This type dashboard is not allowed: Bad Request', + statusCode: 400, + }); + }; + const expectTypeRequired = (resp: { [key: string]: any }) => { expect(resp.body).to.eql({ error: 'Bad Request', @@ -184,6 +197,67 @@ export function findTestSuiteFactory(esArchiver: any, supertest: SuperTest) .expect(tests.noType.statusCode) .then(tests.noType.response)); }); + + describe('filter', () => { + it(`by wrong type should return ${tests.filterWithUnAllowedType.statusCode} with ${tests.filterWithUnAllowedType.description}`, async () => + await supertest + .get( + `${getUrlPrefix( + spaceId + )}/api/saved_objects/_find?type=globaltype&filter=dashboard.title:'Requests'` + ) + .auth(user.username, user.password) + .expect(tests.filterWithUnAllowedType.statusCode) + .then(tests.filterWithUnAllowedType.response)); + + it(`not space aware type should return ${tests.filterWithNotSpaceAwareType.statusCode} with ${tests.filterWithNotSpaceAwareType.description}`, async () => + await supertest + .get( + `${getUrlPrefix( + spaceId + )}/api/saved_objects/_find?type=globaltype&filter=globaltype.attributes.name:*global*` + ) + .auth(user.username, user.password) + .expect(tests.filterWithNotSpaceAwareType.statusCode) + .then(tests.filterWithNotSpaceAwareType.response)); + + it(`finding a hiddentype should return ${tests.filterWithHiddenType.statusCode} with ${tests.filterWithHiddenType.description}`, async () => + await supertest + .get( + `${getUrlPrefix( + spaceId + )}/api/saved_objects/_find?type=hiddentype&fields=name&filter=hiddentype.attributes.name:'hello'` + ) + .auth(user.username, user.password) + .expect(tests.filterWithHiddenType.statusCode) + .then(tests.filterWithHiddenType.response)); + + describe('unknown type', () => { + it(`should return ${tests.filterWithUnknownType.statusCode} with ${tests.filterWithUnknownType.description}`, async () => + await supertest + .get( + `${getUrlPrefix( + spaceId + )}/api/saved_objects/_find?type=wigwags&filter=wigwags.attributes.title:'unknown'` + ) + .auth(user.username, user.password) + .expect(tests.filterWithUnknownType.statusCode) + .then(tests.filterWithUnknownType.response)); + }); + + describe('no type', () => { + it(`should return ${tests.filterWithNoType.statusCode} with ${tests.filterWithNoType.description}`, async () => + await supertest + .get( + `${getUrlPrefix( + spaceId + )}/api/saved_objects/_find?filter=global.attributes.name:*global*` + ) + .auth(user.username, user.password) + .expect(tests.filterWithNoType.statusCode) + .then(tests.filterWithNoType.response)); + }); + }); }); }; @@ -195,6 +269,7 @@ export function findTestSuiteFactory(esArchiver: any, supertest: SuperTest) createExpectEmpty, createExpectRbacForbidden, createExpectVisualizationResults, + expectFilterWrongTypeError, expectNotSpaceAwareResults, expectTypeRequired, findTest, diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/find.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/find.ts index 92e6ec850dc0e..366b8b44585cd 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/find.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/find.ts @@ -18,6 +18,7 @@ export default function({ getService }: FtrProviderContext) { createExpectEmpty, createExpectRbacForbidden, createExpectVisualizationResults, + expectFilterWrongTypeError, expectNotSpaceAwareResults, expectTypeRequired, findTest, @@ -94,6 +95,31 @@ export default function({ getService }: FtrProviderContext) { statusCode: 400, response: expectTypeRequired, }, + filterWithNotSpaceAwareType: { + description: 'forbidden login and find globaltype message', + statusCode: 403, + response: createExpectRbacForbidden('globaltype'), + }, + filterWithHiddenType: { + description: 'forbidden find hiddentype message', + statusCode: 403, + response: createExpectRbacForbidden('hiddentype'), + }, + filterWithUnknownType: { + description: 'forbidden find wigwags message', + statusCode: 403, + response: createExpectRbacForbidden('wigwags'), + }, + filterWithNoType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + filterWithUnAllowedType: { + description: 'forbidden', + statusCode: 403, + response: createExpectRbacForbidden('globaltype'), + }, }, }); @@ -136,6 +162,31 @@ export default function({ getService }: FtrProviderContext) { statusCode: 400, response: expectTypeRequired, }, + filterWithNotSpaceAwareType: { + description: 'only the globaltype', + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + filterWithHiddenType: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(1, 20, 0), + }, + filterWithUnknownType: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(1, 20, 0), + }, + filterWithNoType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + filterWithUnAllowedType: { + description: 'Bad Request', + statusCode: 400, + response: expectFilterWrongTypeError, + }, }, }); @@ -178,6 +229,31 @@ export default function({ getService }: FtrProviderContext) { statusCode: 400, response: expectTypeRequired, }, + filterWithNotSpaceAwareType: { + description: 'forbidden login and find globaltype message', + statusCode: 403, + response: createExpectRbacForbidden('globaltype'), + }, + filterWithHiddenType: { + description: 'forbidden find hiddentype message', + statusCode: 403, + response: createExpectRbacForbidden('hiddentype'), + }, + filterWithUnknownType: { + description: 'forbidden find wigwags message', + statusCode: 403, + response: createExpectRbacForbidden('wigwags'), + }, + filterWithNoType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + filterWithUnAllowedType: { + description: 'forbidden', + statusCode: 403, + response: createExpectRbacForbidden('globaltype'), + }, }, }); @@ -220,6 +296,31 @@ export default function({ getService }: FtrProviderContext) { statusCode: 400, response: expectTypeRequired, }, + filterWithNotSpaceAwareType: { + description: 'only the globaltype', + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + filterWithHiddenType: { + description: 'forbidden find hiddentype message', + statusCode: 403, + response: createExpectRbacForbidden('hiddentype'), + }, + filterWithUnknownType: { + description: 'forbidden find wigwags message', + statusCode: 403, + response: createExpectRbacForbidden('wigwags'), + }, + filterWithNoType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + filterWithUnAllowedType: { + description: 'Bad Request', + statusCode: 400, + response: expectFilterWrongTypeError, + }, }, }); @@ -262,6 +363,31 @@ export default function({ getService }: FtrProviderContext) { statusCode: 400, response: expectTypeRequired, }, + filterWithNotSpaceAwareType: { + description: 'only the globaltype', + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + filterWithHiddenType: { + description: 'forbidden find hiddentype message', + statusCode: 403, + response: createExpectRbacForbidden('hiddentype'), + }, + filterWithUnknownType: { + description: 'forbidden find wigwags message', + statusCode: 403, + response: createExpectRbacForbidden('wigwags'), + }, + filterWithNoType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + filterWithUnAllowedType: { + description: 'Bad Request', + statusCode: 400, + response: expectFilterWrongTypeError, + }, }, }); @@ -304,6 +430,31 @@ export default function({ getService }: FtrProviderContext) { statusCode: 400, response: expectTypeRequired, }, + filterWithNotSpaceAwareType: { + description: 'only the globaltype', + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + filterWithHiddenType: { + description: 'forbidden find hiddentype message', + statusCode: 403, + response: createExpectRbacForbidden('hiddentype'), + }, + filterWithUnknownType: { + description: 'forbidden find wigwags message', + statusCode: 403, + response: createExpectRbacForbidden('wigwags'), + }, + filterWithNoType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + filterWithUnAllowedType: { + description: 'Bad Request', + statusCode: 400, + response: expectFilterWrongTypeError, + }, }, }); @@ -346,6 +497,31 @@ export default function({ getService }: FtrProviderContext) { statusCode: 400, response: expectTypeRequired, }, + filterWithNotSpaceAwareType: { + description: 'only the globaltype', + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + filterWithHiddenType: { + description: 'forbidden find hiddentype message', + statusCode: 403, + response: createExpectRbacForbidden('hiddentype'), + }, + filterWithUnknownType: { + description: 'forbidden find wigwags message', + statusCode: 403, + response: createExpectRbacForbidden('wigwags'), + }, + filterWithNoType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + filterWithUnAllowedType: { + description: 'Bad Request', + statusCode: 400, + response: expectFilterWrongTypeError, + }, }, }); @@ -388,6 +564,31 @@ export default function({ getService }: FtrProviderContext) { statusCode: 400, response: expectTypeRequired, }, + filterWithNotSpaceAwareType: { + description: 'only the globaltype', + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + filterWithHiddenType: { + description: 'forbidden find hiddentype message', + statusCode: 403, + response: createExpectRbacForbidden('hiddentype'), + }, + filterWithUnknownType: { + description: 'forbidden find wigwags message', + statusCode: 403, + response: createExpectRbacForbidden('wigwags'), + }, + filterWithNoType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + filterWithUnAllowedType: { + description: 'Bad Request', + statusCode: 400, + response: expectFilterWrongTypeError, + }, }, }); @@ -430,6 +631,31 @@ export default function({ getService }: FtrProviderContext) { statusCode: 400, response: expectTypeRequired, }, + filterWithNotSpaceAwareType: { + description: 'only the globaltype', + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + filterWithHiddenType: { + description: 'forbidden find hiddentype message', + statusCode: 403, + response: createExpectRbacForbidden('hiddentype'), + }, + filterWithUnknownType: { + description: 'forbidden find wigwags message', + statusCode: 403, + response: createExpectRbacForbidden('wigwags'), + }, + filterWithNoType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + filterWithUnAllowedType: { + description: 'Bad Request', + statusCode: 400, + response: expectFilterWrongTypeError, + }, }, }); @@ -472,6 +698,31 @@ export default function({ getService }: FtrProviderContext) { statusCode: 400, response: expectTypeRequired, }, + filterWithNotSpaceAwareType: { + description: 'forbidden login and find globaltype message', + statusCode: 403, + response: createExpectRbacForbidden('globaltype'), + }, + filterWithHiddenType: { + description: 'forbidden find hiddentype message', + statusCode: 403, + response: createExpectRbacForbidden('hiddentype'), + }, + filterWithUnknownType: { + description: 'forbidden find wigwags message', + statusCode: 403, + response: createExpectRbacForbidden('wigwags'), + }, + filterWithNoType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + filterWithUnAllowedType: { + description: 'forbidden', + statusCode: 403, + response: createExpectRbacForbidden('globaltype'), + }, }, }); }); diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/find.ts b/x-pack/test/saved_object_api_integration/security_only/apis/find.ts index d17dbe6e7b1ed..64d85a199e7bc 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/find.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/find.ts @@ -17,6 +17,7 @@ export default function({ getService }: FtrProviderContext) { createExpectEmpty, createExpectRbacForbidden, createExpectVisualizationResults, + expectFilterWrongTypeError, expectNotSpaceAwareResults, expectTypeRequired, findTest, @@ -60,6 +61,31 @@ export default function({ getService }: FtrProviderContext) { statusCode: 400, response: expectTypeRequired, }, + filterWithNotSpaceAwareType: { + description: 'forbidden login and find globaltype message', + statusCode: 403, + response: createExpectRbacForbidden('globaltype'), + }, + filterWithHiddenType: { + description: 'forbidden login and find hiddentype message', + statusCode: 403, + response: createExpectRbacForbidden('hiddentype'), + }, + filterWithUnknownType: { + description: 'forbidden find wigwags message', + statusCode: 403, + response: createExpectRbacForbidden('wigwags'), + }, + filterWithNoType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + filterWithUnAllowedType: { + description: 'forbidden', + statusCode: 403, + response: createExpectRbacForbidden('globaltype'), + }, }, }); @@ -101,6 +127,31 @@ export default function({ getService }: FtrProviderContext) { statusCode: 400, response: expectTypeRequired, }, + filterWithNotSpaceAwareType: { + description: 'only the globaltype', + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + filterWithHiddenType: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(1, 20, 0), + }, + filterWithUnknownType: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(1, 20, 0), + }, + filterWithNoType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + filterWithUnAllowedType: { + description: 'Bad Request', + statusCode: 400, + response: expectFilterWrongTypeError, + }, }, }); @@ -142,6 +193,31 @@ export default function({ getService }: FtrProviderContext) { statusCode: 400, response: expectTypeRequired, }, + filterWithNotSpaceAwareType: { + description: 'forbidden login and find globaltype message', + statusCode: 403, + response: createExpectRbacForbidden('globaltype'), + }, + filterWithHiddenType: { + description: 'forbidden login and find hiddentype message', + statusCode: 403, + response: createExpectRbacForbidden('hiddentype'), + }, + filterWithUnknownType: { + description: 'forbidden find wigwags message', + statusCode: 403, + response: createExpectRbacForbidden('wigwags'), + }, + filterWithNoType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + filterWithUnAllowedType: { + description: 'forbidden', + statusCode: 403, + response: createExpectRbacForbidden('globaltype'), + }, }, }); @@ -183,6 +259,31 @@ export default function({ getService }: FtrProviderContext) { statusCode: 400, response: expectTypeRequired, }, + filterWithNotSpaceAwareType: { + description: 'only the globaltype', + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + filterWithHiddenType: { + description: 'forbidden find hiddentype message', + statusCode: 403, + response: createExpectRbacForbidden('hiddentype'), + }, + filterWithUnknownType: { + description: 'forbidden find wigwags message', + statusCode: 403, + response: createExpectRbacForbidden('wigwags'), + }, + filterWithNoType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + filterWithUnAllowedType: { + description: 'Bad Request', + statusCode: 400, + response: expectFilterWrongTypeError, + }, }, }); @@ -224,6 +325,31 @@ export default function({ getService }: FtrProviderContext) { statusCode: 400, response: expectTypeRequired, }, + filterWithNotSpaceAwareType: { + description: 'only the globaltype', + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + filterWithHiddenType: { + description: 'forbidden find hiddentype message', + statusCode: 403, + response: createExpectRbacForbidden('hiddentype'), + }, + filterWithUnknownType: { + description: 'forbidden find wigwags message', + statusCode: 403, + response: createExpectRbacForbidden('wigwags'), + }, + filterWithNoType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + filterWithUnAllowedType: { + description: 'Bad Request', + statusCode: 400, + response: expectFilterWrongTypeError, + }, }, }); @@ -265,6 +391,31 @@ export default function({ getService }: FtrProviderContext) { statusCode: 400, response: expectTypeRequired, }, + filterWithNotSpaceAwareType: { + description: 'only the globaltype', + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + filterWithHiddenType: { + description: 'forbidden find hiddentype message', + statusCode: 403, + response: createExpectRbacForbidden('hiddentype'), + }, + filterWithUnknownType: { + description: 'forbidden find wigwags message', + statusCode: 403, + response: createExpectRbacForbidden('wigwags'), + }, + filterWithNoType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + filterWithUnAllowedType: { + description: 'Bad Request', + statusCode: 400, + response: expectFilterWrongTypeError, + }, }, }); @@ -306,6 +457,31 @@ export default function({ getService }: FtrProviderContext) { statusCode: 400, response: expectTypeRequired, }, + filterWithNotSpaceAwareType: { + description: 'only the globaltype', + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + filterWithHiddenType: { + description: 'forbidden find hiddentype message', + statusCode: 403, + response: createExpectRbacForbidden('hiddentype'), + }, + filterWithUnknownType: { + description: 'forbidden find wigwags message', + statusCode: 403, + response: createExpectRbacForbidden('wigwags'), + }, + filterWithNoType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + filterWithUnAllowedType: { + description: 'Bad Request', + statusCode: 400, + response: expectFilterWrongTypeError, + }, }, }); @@ -347,6 +523,31 @@ export default function({ getService }: FtrProviderContext) { statusCode: 400, response: expectTypeRequired, }, + filterWithNotSpaceAwareType: { + description: 'only the globaltype', + statusCode: 403, + response: createExpectRbacForbidden('globaltype'), + }, + filterWithHiddenType: { + description: 'forbidden find hiddentype message', + statusCode: 403, + response: createExpectRbacForbidden('hiddentype'), + }, + filterWithUnknownType: { + description: 'forbidden find wigwags message', + statusCode: 403, + response: createExpectRbacForbidden('wigwags'), + }, + filterWithNoType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + filterWithUnAllowedType: { + description: 'forbidden', + statusCode: 403, + response: createExpectRbacForbidden('globaltype'), + }, }, }); @@ -388,6 +589,31 @@ export default function({ getService }: FtrProviderContext) { statusCode: 400, response: expectTypeRequired, }, + filterWithNotSpaceAwareType: { + description: 'only the globaltype', + statusCode: 403, + response: createExpectRbacForbidden('globaltype'), + }, + filterWithHiddenType: { + description: 'forbidden find hiddentype message', + statusCode: 403, + response: createExpectRbacForbidden('hiddentype'), + }, + filterWithUnknownType: { + description: 'forbidden find wigwags message', + statusCode: 403, + response: createExpectRbacForbidden('wigwags'), + }, + filterWithNoType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + filterWithUnAllowedType: { + description: 'forbidden', + statusCode: 403, + response: createExpectRbacForbidden('globaltype'), + }, }, }); @@ -429,6 +655,31 @@ export default function({ getService }: FtrProviderContext) { statusCode: 400, response: expectTypeRequired, }, + filterWithNotSpaceAwareType: { + description: 'only the globaltype', + statusCode: 403, + response: createExpectRbacForbidden('globaltype'), + }, + filterWithHiddenType: { + description: 'forbidden find hiddentype message', + statusCode: 403, + response: createExpectRbacForbidden('hiddentype'), + }, + filterWithUnknownType: { + description: 'forbidden find wigwags message', + statusCode: 403, + response: createExpectRbacForbidden('wigwags'), + }, + filterWithNoType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + filterWithUnAllowedType: { + description: 'forbidden', + statusCode: 403, + response: createExpectRbacForbidden('globaltype'), + }, }, }); @@ -470,6 +721,31 @@ export default function({ getService }: FtrProviderContext) { statusCode: 400, response: expectTypeRequired, }, + filterWithNotSpaceAwareType: { + description: 'only the globaltype', + statusCode: 403, + response: createExpectRbacForbidden('globaltype'), + }, + filterWithHiddenType: { + description: 'forbidden find hiddentype message', + statusCode: 403, + response: createExpectRbacForbidden('hiddentype'), + }, + filterWithUnknownType: { + description: 'forbidden find wigwags message', + statusCode: 403, + response: createExpectRbacForbidden('wigwags'), + }, + filterWithNoType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + filterWithUnAllowedType: { + description: 'forbidden', + statusCode: 403, + response: createExpectRbacForbidden('globaltype'), + }, }, }); }); diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/find.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/find.ts index 69a2690c61978..a07d3edf834e9 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/find.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/find.ts @@ -15,6 +15,7 @@ export default function({ getService }: FtrProviderContext) { const { createExpectEmpty, createExpectVisualizationResults, + expectFilterWrongTypeError, expectNotSpaceAwareResults, expectTypeRequired, findTest, @@ -59,6 +60,31 @@ export default function({ getService }: FtrProviderContext) { statusCode: 400, response: expectTypeRequired, }, + filterWithNotSpaceAwareType: { + description: 'only the visualization', + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + filterWithHiddenType: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(1, 20, 0), + }, + filterWithUnknownType: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(1, 20, 0), + }, + filterWithNoType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + filterWithUnAllowedType: { + description: 'Bad Request', + statusCode: 400, + response: expectFilterWrongTypeError, + }, }, }); @@ -100,6 +126,31 @@ export default function({ getService }: FtrProviderContext) { statusCode: 400, response: expectTypeRequired, }, + filterWithNotSpaceAwareType: { + description: 'only the visualization', + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + filterWithHiddenType: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(1, 20, 0), + }, + filterWithUnknownType: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(1, 20, 0), + }, + filterWithNoType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + filterWithUnAllowedType: { + description: 'Bad Request', + statusCode: 400, + response: expectFilterWrongTypeError, + }, }, }); }); From d4321f5aa71c7889e5a5e4e2202a073b96ee86a6 Mon Sep 17 00:00:00 2001 From: Michael Marcialis Date: Wed, 2 Oct 2019 19:47:17 -0400 Subject: [PATCH 08/19] [SIEM] Update Settings Text (#47147) * update settings text * correct ml casing --- x-pack/legacy/plugins/siem/index.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/x-pack/legacy/plugins/siem/index.ts b/x-pack/legacy/plugins/siem/index.ts index 4c0997e1d6181..c3e2c2b0e119d 100644 --- a/x-pack/legacy/plugins/siem/index.ts +++ b/x-pack/legacy/plugins/siem/index.ts @@ -65,14 +65,15 @@ export function siem(kibana: any) { [DEFAULT_SIEM_REFRESH_INTERVAL]: { type: 'json', name: i18n.translate('xpack.siem.uiSettings.defaultRefreshIntervalLabel', { - defaultMessage: 'Time picker refresh interval', + defaultMessage: 'Time filter refresh interval', }), value: `{ "pause": ${DEFAULT_INTERVAL_PAUSE}, "value": ${DEFAULT_INTERVAL_VALUE} }`, description: i18n.translate('xpack.siem.uiSettings.defaultRefreshIntervalDescription', { - defaultMessage: "The SIEM timefilter's default refresh interval", + defaultMessage: + '

Default refresh interval for the SIEM time filter, in milliseconds.

', }), category: ['siem'], requiresPageReload: true, @@ -80,39 +81,39 @@ export function siem(kibana: any) { [DEFAULT_SIEM_TIME_RANGE]: { type: 'json', name: i18n.translate('xpack.siem.uiSettings.defaultTimeRangeLabel', { - defaultMessage: 'Time picker defaults', + defaultMessage: 'Time filter period', }), value: `{ "from": "${DEFAULT_FROM}", "to": "${DEFAULT_TO}" }`, description: i18n.translate('xpack.siem.uiSettings.defaultTimeRangeDescription', { - defaultMessage: - 'The SIEM timefilter selection to use when Kibana is started without one', + defaultMessage: '

Default period of time in the SIEM time filter.

', }), category: ['siem'], requiresPageReload: true, }, [DEFAULT_INDEX_KEY]: { name: i18n.translate('xpack.siem.uiSettings.defaultIndexLabel', { - defaultMessage: 'Default index', + defaultMessage: 'Elasticsearch indices', }), value: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], description: i18n.translate('xpack.siem.uiSettings.defaultIndexDescription', { - defaultMessage: 'Default Elasticsearch index to search', + defaultMessage: + '

Comma-delimited list of Elasticsearch indices from which the SIEM app collects events.

', }), category: ['siem'], requiresPageReload: true, }, [DEFAULT_ANOMALY_SCORE]: { name: i18n.translate('xpack.siem.uiSettings.defaultAnomalyScoreLabel', { - defaultMessage: 'Default anomaly threshold', + defaultMessage: 'Anomaly threshold', }), value: 50, type: 'number', description: i18n.translate('xpack.siem.uiSettings.defaultAnomalyScoreDescription', { defaultMessage: - 'Default anomaly score threshold to exceed before showing anomalies. Valid values are between 0 and 100', + '

Value above which Machine Learning job anomalies are displayed in the SIEM app.

Valid values: 0 to 100.

', }), category: ['siem'], requiresPageReload: true, From f61601cc2d40c8431ce9ac2e177601e1dc716696 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Thu, 3 Oct 2019 03:13:36 +0100 Subject: [PATCH 09/19] chore(NA): fix logic behind cleaning x-pack node modules on build (#47091) --- package.json | 2 -- packages/kbn-babel-code-parser/src/can_require.js | 12 ++++++------ packages/kbn-babel-code-parser/src/code_parser.js | 2 +- packages/kbn-babel-code-parser/src/strategies.js | 8 ++++++-- .../kbn-babel-code-parser/src/strategies.test.js | 6 +++--- x-pack/package.json | 2 ++ 6 files changed, 18 insertions(+), 14 deletions(-) diff --git a/package.json b/package.json index be43e242ce569..8aff95748560d 100644 --- a/package.json +++ b/package.json @@ -160,7 +160,6 @@ "expiry-js": "0.1.7", "file-loader": "4.2.0", "font-awesome": "4.7.0", - "fp-ts": "^2.0.5", "getos": "^3.1.0", "glob": "^7.1.2", "glob-all": "^3.1.0", @@ -177,7 +176,6 @@ "https-proxy-agent": "^2.2.2", "inert": "^5.1.0", "inline-style": "^2.0.0", - "io-ts": "^2.0.1", "joi": "^13.5.2", "jquery": "^3.4.1", "js-yaml": "3.13.1", diff --git a/packages/kbn-babel-code-parser/src/can_require.js b/packages/kbn-babel-code-parser/src/can_require.js index e590c249e9806..4d85910abe6ed 100644 --- a/packages/kbn-babel-code-parser/src/can_require.js +++ b/packages/kbn-babel-code-parser/src/can_require.js @@ -17,18 +17,18 @@ * under the License. */ -export function canRequire(cwd, entry) { +export function canRequire(entry, cwd = require.resolve.paths(entry) || []) { try { // We will try to test if we can resolve // this entry through the require.resolve // setting as the start looking path the - // given cwd. Require.resolve will keep + // given cwd. That cwd variable could be + // a path or an array of paths + // from where Require.resolve will keep // looking recursively as normal starting - // from that location. + // from those locations. return require.resolve(entry, { - paths: [ - cwd - ] + paths: [].concat(cwd) }); } catch (e) { return false; diff --git a/packages/kbn-babel-code-parser/src/code_parser.js b/packages/kbn-babel-code-parser/src/code_parser.js index 8d76b1032561a..0f53bd249bb5c 100644 --- a/packages/kbn-babel-code-parser/src/code_parser.js +++ b/packages/kbn-babel-code-parser/src/code_parser.js @@ -79,7 +79,7 @@ export async function parseEntries(cwd, entries, strategy, results, wasParsed = const sanitizedCwd = cwd || process.cwd(); // Test each entry against canRequire function - const entriesQueue = entries.map(entry => canRequire(sanitizedCwd, entry)); + const entriesQueue = entries.map(entry => canRequire(entry)); while(entriesQueue.length) { // Get the first element in the queue as diff --git a/packages/kbn-babel-code-parser/src/strategies.js b/packages/kbn-babel-code-parser/src/strategies.js index 317ded014210b..89621bc53bd53 100644 --- a/packages/kbn-babel-code-parser/src/strategies.js +++ b/packages/kbn-babel-code-parser/src/strategies.js @@ -62,8 +62,12 @@ export async function dependenciesParseStrategy(cwd, parseSingleFile, mainEntry, // new dependencies return dependencies.reduce((filteredEntries, entry) => { const absEntryPath = resolve(cwd, dirname(mainEntry), entry); - const requiredPath = canRequire(cwd, absEntryPath); - const requiredRelativePath = canRequire(cwd, entry); + + // NOTE: cwd for following canRequires is absEntryPath + // because we should start looking from there + const requiredPath = canRequire(absEntryPath, absEntryPath); + const requiredRelativePath = canRequire(entry, absEntryPath); + const isRelativeFile = !isAbsolute(entry); const isNodeModuleDep = isRelativeFile && !requiredPath && requiredRelativePath; const isNewEntry = isRelativeFile && requiredPath; diff --git a/packages/kbn-babel-code-parser/src/strategies.test.js b/packages/kbn-babel-code-parser/src/strategies.test.js index 5a84edf560af1..d7caa8b95d4a2 100644 --- a/packages/kbn-babel-code-parser/src/strategies.test.js +++ b/packages/kbn-babel-code-parser/src/strategies.test.js @@ -59,8 +59,8 @@ describe('Code Parser Strategies', () => { cb(null, `require('dep_from_node_modules')`); }); - canRequire.mockImplementation((mockCwd, entry) => { - if (entry === `${mockCwd}dep1/dep_from_node_modules`) { + canRequire.mockImplementation((entry, cwd) => { + if (entry === `${cwd}dep1/dep_from_node_modules`) { return false; } @@ -78,7 +78,7 @@ describe('Code Parser Strategies', () => { cb(null, `require('./relative_dep')`); }); - canRequire.mockImplementation((mockCwd, entry) => { + canRequire.mockImplementation((entry) => { if (entry === `${mockCwd}dep1/relative_dep`) { return `${entry}/index.js`; } diff --git a/x-pack/package.json b/x-pack/package.json index 5402e41855451..5a27764bdf9ee 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -241,6 +241,7 @@ "file-type": "^10.9.0", "font-awesome": "4.7.0", "formsy-react": "^1.1.5", + "fp-ts": "^2.0.5", "geojson-rewind": "^0.3.1", "get-port": "4.2.0", "getos": "^3.1.0", @@ -262,6 +263,7 @@ "immer": "^1.5.0", "inline-style": "^2.0.0", "intl": "^1.2.5", + "io-ts": "^2.0.1", "isbinaryfile": "4.0.2", "isomorphic-git": "0.55.5", "joi": "^13.5.2", From 27a53d166fe834cc9106807045c39e9362423e30 Mon Sep 17 00:00:00 2001 From: Tim Roes Date: Thu, 3 Oct 2019 10:15:31 +0200 Subject: [PATCH 10/19] Add TypeScript rules to STYLEGUIDE [skip ci] (#47125) * Add TypeScript rules to STYLEGUIDE * Update STYLEGUIDE.md Co-Authored-By: Court Ewing --- STYLEGUIDE.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/STYLEGUIDE.md b/STYLEGUIDE.md index 152a0e2c48871..5fd3ef5e8ff4b 100644 --- a/STYLEGUIDE.md +++ b/STYLEGUIDE.md @@ -141,6 +141,39 @@ function addBar(foos, foo) { } ``` +### Avoid `any` whenever possible + +Since TypeScript 3.0 and the introduction of the +[`unknown` type](https://mariusschulz.com/blog/the-unknown-type-in-typescript) there are rarely any +reasons to use `any` as a type. Nearly all places of former `any` usage can be replace by either a +generic or `unknown` (in cases the type is really not known). + +You should always prefer using those mechanisms over using `any`, since they are stricter typed and +less likely to introduce bugs in the future due to insufficient types. + +If you’re not having `any` in your plugin or are starting a new plugin, you should enable the +[`@typescript-eslint/no-explicit-any`](https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/no-explicit-any.md) +linting rule for your plugin via the [`.eslintrc.js`](https://github.com/elastic/kibana/blob/master/.eslintrc.js) config. + +### Avoid non-null assertions + +You should try avoiding non-null assertions (`!.`) wherever possible. By using them you tell +TypeScript, that something is not null even though by it’s type it could be. Usage of non-null +assertions is most often a side-effect of you actually checked that the variable is not `null` +but TypeScript doesn’t correctly carry on that information till the usage of the variable. + +In most cases it’s possible to replace the non-null assertion by structuring your code/checks slightly different +or using [user defined type guards](https://www.typescriptlang.org/docs/handbook/advanced-types.html#user-defined-type-guards) +to properly tell TypeScript what type a variable has. + +Using non-null assertion increases the risk for future bugs. In case the condition under which we assumed that the +variable can’t be null has changed (potentially even due to changes in compeltely different files), the non-null +assertion would now wrongly disable proper type checking for us. + +If you’re not using non-null assertions in your plugin or are starting a new plugin, consider enabling the +[`@typescript-eslint/no-non-null-assertion`](https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/no-non-null-assertion.md) +linting rule for you plugin in the [`.eslintrc.js`](https://github.com/elastic/kibana/blob/master/.eslintrc.js) config. + ### Return/throw early from functions To avoid deep nesting of if-statements, always return a function's value as early From cae19e80aeafbbba5b99b593a59f8037172f2f5f Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Thu, 3 Oct 2019 10:44:21 +0200 Subject: [PATCH 11/19] =?UTF-8?q?refactor:=20=F0=9F=92=A1=20convert=20Inte?= =?UTF-8?q?rpreter=20.js=20->=20.ts=20(#44545)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 💡 convert Interpreter .js -> .ts * fix: 🐛 fix TypeScript type errors * test: 💍 remove old snapshot --- ...ibana.test.js.snap => kibana.test.ts.snap} | 0 .../functions/__tests__/{font.js => font.ts} | 10 ++--- .../public/functions/{clog.js => clog.ts} | 4 +- .../public/functions/{index.js => index.ts} | 9 ++++- .../{kibana.test.js => kibana.test.ts} | 6 +-- .../public/functions/{kibana.js => kibana.ts} | 6 +-- .../{kibana_context.js => kibana_context.ts} | 19 ++++------ .../{vis_dimension.js => vis_dimension.ts} | 32 ++++++++-------- .../{visualization.js => visualization.ts} | 27 +++++++------- ...nterpreter.test.js => interpreter.test.ts} | 15 ++++---- .../public/{interpreter.js => interpreter.ts} | 10 +++-- ...{render_function.js => render_function.ts} | 4 +- ...gistry.js => render_functions_registry.ts} | 6 +-- .../{visualization.js => visualization.ts} | 13 ++++--- ...{create_handlers.js => create_handlers.ts} | 4 +- ...{create_handlers.js => create_handlers.ts} | 4 +- .../server/routes/{index.js => index.ts} | 2 +- ...erver_functions.js => server_functions.ts} | 37 ++++++++++--------- .../{test_helpers.js => test_helpers.ts} | 5 ++- .../public/markdown_fn.test.ts | 2 +- .../public/metric_vis_fn.test.ts | 2 +- .../public/table_vis_fn.test.ts | 4 +- .../public/tag_cloud_fn.test.ts | 2 +- 23 files changed, 118 insertions(+), 105 deletions(-) rename src/legacy/core_plugins/interpreter/public/functions/__snapshots__/{kibana.test.js.snap => kibana.test.ts.snap} (100%) rename src/legacy/core_plugins/interpreter/public/functions/__tests__/{font.js => font.ts} (96%) rename src/legacy/core_plugins/interpreter/public/functions/{clog.js => clog.ts} (91%) rename src/legacy/core_plugins/interpreter/public/functions/{index.js => index.ts} (92%) rename src/legacy/core_plugins/interpreter/public/functions/{kibana.test.js => kibana.test.ts} (97%) rename src/legacy/core_plugins/interpreter/public/functions/{kibana.js => kibana.ts} (93%) rename src/legacy/core_plugins/interpreter/public/functions/{kibana_context.js => kibana_context.ts} (87%) rename src/legacy/core_plugins/interpreter/public/functions/{vis_dimension.js => vis_dimension.ts} (74%) rename src/legacy/core_plugins/interpreter/public/functions/{visualization.js => visualization.ts} (91%) rename src/legacy/core_plugins/interpreter/public/{interpreter.test.js => interpreter.test.ts} (96%) rename src/legacy/core_plugins/interpreter/public/{interpreter.js => interpreter.ts} (87%) rename src/legacy/core_plugins/interpreter/public/lib/{render_function.js => render_function.ts} (92%) rename src/legacy/core_plugins/interpreter/public/lib/{render_functions_registry.js => render_functions_registry.ts} (88%) rename src/legacy/core_plugins/interpreter/public/renderers/{visualization.js => visualization.ts} (84%) rename src/legacy/core_plugins/interpreter/server/lib/__tests__/{create_handlers.js => create_handlers.ts} (95%) rename src/legacy/core_plugins/interpreter/server/lib/{create_handlers.js => create_handlers.ts} (88%) rename src/legacy/core_plugins/interpreter/server/routes/{index.js => index.ts} (95%) rename src/legacy/core_plugins/interpreter/server/routes/{server_functions.js => server_functions.ts} (84%) rename src/legacy/core_plugins/interpreter/{test_helpers.js => test_helpers.ts} (86%) diff --git a/src/legacy/core_plugins/interpreter/public/functions/__snapshots__/kibana.test.js.snap b/src/legacy/core_plugins/interpreter/public/functions/__snapshots__/kibana.test.ts.snap similarity index 100% rename from src/legacy/core_plugins/interpreter/public/functions/__snapshots__/kibana.test.js.snap rename to src/legacy/core_plugins/interpreter/public/functions/__snapshots__/kibana.test.ts.snap diff --git a/src/legacy/core_plugins/interpreter/public/functions/__tests__/font.js b/src/legacy/core_plugins/interpreter/public/functions/__tests__/font.ts similarity index 96% rename from src/legacy/core_plugins/interpreter/public/functions/__tests__/font.js rename to src/legacy/core_plugins/interpreter/public/functions/__tests__/font.ts index 4a7ebc1522f2a..8bf8052fee3b7 100644 --- a/src/legacy/core_plugins/interpreter/public/functions/__tests__/font.js +++ b/src/legacy/core_plugins/interpreter/public/functions/__tests__/font.ts @@ -23,13 +23,13 @@ import { font } from '../font'; import { functionWrapper } from '../../../test_helpers'; describe('font', () => { - const fn = functionWrapper(font); + const fn: any = functionWrapper(font); describe('default output', () => { const result = fn(null); it('returns a style', () => { - expect(result) + (expect as any)(result) .to.have.property('type', 'style') .and.to.have.property('spec') .and.to.have.property('css'); @@ -40,8 +40,8 @@ describe('font', () => { describe('size', () => { it('sets font size', () => { const result = fn(null, { size: 20 }); - expect(result.spec).to.have.property('fontSize', '20px'); - expect(result.css).to.contain('font-size:20px'); + (expect as any)(result.spec).to.have.property('fontSize', '20px'); + (expect as any)(result.css).to.contain('font-size:20px'); }); it('defaults to 14px', () => { @@ -110,7 +110,7 @@ describe('font', () => { expect(result.css).to.contain('font-weight:400'); }); - it('defaults to \'normal\'', () => { + it("defaults to 'normal'", () => { const result = fn(null); expect(result.spec).to.have.property('fontWeight', 'normal'); expect(result.css).to.contain('font-weight:normal'); diff --git a/src/legacy/core_plugins/interpreter/public/functions/clog.js b/src/legacy/core_plugins/interpreter/public/functions/clog.ts similarity index 91% rename from src/legacy/core_plugins/interpreter/public/functions/clog.js rename to src/legacy/core_plugins/interpreter/public/functions/clog.ts index 634d166f5f0bb..4867726a42d72 100644 --- a/src/legacy/core_plugins/interpreter/public/functions/clog.js +++ b/src/legacy/core_plugins/interpreter/public/functions/clog.ts @@ -20,8 +20,8 @@ export const clog = () => ({ name: 'clog', help: 'Outputs the context to the console', - fn: context => { - console.log(context); //eslint-disable-line no-console + fn: (context: any) => { + console.log(context); // eslint-disable-line no-console return context; }, }); diff --git a/src/legacy/core_plugins/interpreter/public/functions/index.js b/src/legacy/core_plugins/interpreter/public/functions/index.ts similarity index 92% rename from src/legacy/core_plugins/interpreter/public/functions/index.js rename to src/legacy/core_plugins/interpreter/public/functions/index.ts index 38c3920f91bd2..d86f033acb3d1 100644 --- a/src/legacy/core_plugins/interpreter/public/functions/index.js +++ b/src/legacy/core_plugins/interpreter/public/functions/index.ts @@ -27,5 +27,12 @@ import { visualization } from './visualization'; import { visDimension } from './vis_dimension'; export const functions = [ - clog, esaggs, font, kibana, kibanaContext, range, visualization, visDimension, + clog, + esaggs, + font, + kibana, + kibanaContext, + range, + visualization, + visDimension, ]; diff --git a/src/legacy/core_plugins/interpreter/public/functions/kibana.test.js b/src/legacy/core_plugins/interpreter/public/functions/kibana.test.ts similarity index 97% rename from src/legacy/core_plugins/interpreter/public/functions/kibana.test.js rename to src/legacy/core_plugins/interpreter/public/functions/kibana.test.ts index 4757b9b12b50d..9f80449ac36be 100644 --- a/src/legacy/core_plugins/interpreter/public/functions/kibana.test.js +++ b/src/legacy/core_plugins/interpreter/public/functions/kibana.test.ts @@ -22,9 +22,9 @@ import { kibana } from './kibana'; describe('interpreter/functions#kibana', () => { const fn = functionWrapper(kibana); - let context; - let initialContext; - let handlers; + let context: any; + let initialContext: any; + let handlers: any; beforeEach(() => { context = { timeRange: { from: '0', to: '1' } }; diff --git a/src/legacy/core_plugins/interpreter/public/functions/kibana.js b/src/legacy/core_plugins/interpreter/public/functions/kibana.ts similarity index 93% rename from src/legacy/core_plugins/interpreter/public/functions/kibana.js rename to src/legacy/core_plugins/interpreter/public/functions/kibana.ts index e0817d8e04b02..37ff337f58b8d 100644 --- a/src/legacy/core_plugins/interpreter/public/functions/kibana.js +++ b/src/legacy/core_plugins/interpreter/public/functions/kibana.ts @@ -24,10 +24,10 @@ export const kibana = () => ({ type: 'kibana_context', context: {}, help: i18n.translate('interpreter.functions.kibana.help', { - defaultMessage: 'Gets kibana global context' + defaultMessage: 'Gets kibana global context', }), args: {}, - fn(context, args, handlers) { + fn(context: any, args: any, handlers: any) { const initialContext = handlers.getInitialContext ? handlers.getInitialContext() : {}; if (context.query) { @@ -45,7 +45,7 @@ export const kibana = () => ({ type: 'kibana_context', query: initialContext.query, filters: initialContext.filters, - timeRange: timeRange, + timeRange, }; }, }); diff --git a/src/legacy/core_plugins/interpreter/public/functions/kibana_context.js b/src/legacy/core_plugins/interpreter/public/functions/kibana_context.ts similarity index 87% rename from src/legacy/core_plugins/interpreter/public/functions/kibana_context.js rename to src/legacy/core_plugins/interpreter/public/functions/kibana_context.ts index 7b7294a87831d..2f2241a367094 100644 --- a/src/legacy/core_plugins/interpreter/public/functions/kibana_context.js +++ b/src/legacy/core_plugins/interpreter/public/functions/kibana_context.ts @@ -24,13 +24,10 @@ export const kibanaContext = () => ({ name: 'kibana_context', type: 'kibana_context', context: { - types: [ - 'kibana_context', - 'null', - ], + types: ['kibana_context', 'null'], }, help: i18n.translate('interpreter.functions.kibana_context.help', { - defaultMessage: 'Updates kibana global context' + defaultMessage: 'Updates kibana global context', }), args: { q: { @@ -49,11 +46,11 @@ export const kibanaContext = () => ({ savedSearchId: { types: ['string', 'null'], default: null, - } + }, }, - async fn(context, args) { + async fn(context: any, args: any) { const $injector = await chrome.dangerouslyGetActiveInjector(); - const savedSearches = $injector.get('savedSearches'); + const savedSearches = $injector.get('savedSearches') as any; const queryArg = args.q ? JSON.parse(args.q) : []; let queries = Array.isArray(queryArg) ? queryArg : [queryArg]; let filters = args.filters ? JSON.parse(args.filters) : []; @@ -71,7 +68,7 @@ export const kibanaContext = () => ({ } if (context.filters) { - filters = filters.concat(context.filters).filter(f => !f.meta.disabled); + filters = filters.concat(context.filters).filter((f: any) => !f.meta.disabled); } const timeRange = args.timeRange ? JSON.parse(args.timeRange) : context.timeRange; @@ -79,8 +76,8 @@ export const kibanaContext = () => ({ return { type: 'kibana_context', query: queries, - filters: filters, - timeRange: timeRange, + filters, + timeRange, }; }, }); diff --git a/src/legacy/core_plugins/interpreter/public/functions/vis_dimension.js b/src/legacy/core_plugins/interpreter/public/functions/vis_dimension.ts similarity index 74% rename from src/legacy/core_plugins/interpreter/public/functions/vis_dimension.js rename to src/legacy/core_plugins/interpreter/public/functions/vis_dimension.ts index e1a6c41198bad..19503dbe03ae9 100644 --- a/src/legacy/core_plugins/interpreter/public/functions/vis_dimension.js +++ b/src/legacy/core_plugins/interpreter/public/functions/vis_dimension.ts @@ -22,48 +22,48 @@ import { i18n } from '@kbn/i18n'; export const visDimension = () => ({ name: 'visdimension', help: i18n.translate('interpreter.function.visDimension.help', { - defaultMessage: 'Generates visConfig dimension object' + defaultMessage: 'Generates visConfig dimension object', }), type: 'vis_dimension', context: { - types: [ - 'kibana_datatable' - ], + types: ['kibana_datatable'], }, args: { accessor: { types: ['string', 'number'], aliases: ['_'], help: i18n.translate('interpreter.function.visDimension.accessor.help', { - defaultMessage: 'Column in your dataset to use (either column index or column name)' + defaultMessage: 'Column in your dataset to use (either column index or column name)', }), }, format: { types: ['string'], - default: 'string' + default: 'string', }, formatParams: { types: ['string'], default: '"{}"', - } + }, }, - fn: (context, args) => { - const accessor = Number.isInteger(args.accessor) ? - args.accessor : - context.columns.find(c => c.id === args.accessor); + fn: (context: any, args: any) => { + const accessor = Number.isInteger(args.accessor) + ? args.accessor + : context.columns.find((c: any) => c.id === args.accessor); if (accessor === undefined) { - throw new Error(i18n.translate('interpreter.function.visDimension.error.accessor', { - defaultMessage: 'Column name provided is invalid' - })); + throw new Error( + i18n.translate('interpreter.function.visDimension.error.accessor', { + defaultMessage: 'Column name provided is invalid', + }) + ); } return { type: 'vis_dimension', - accessor: accessor, + accessor, format: { id: args.format, params: JSON.parse(args.formatParams), - } + }, }; }, }); diff --git a/src/legacy/core_plugins/interpreter/public/functions/visualization.js b/src/legacy/core_plugins/interpreter/public/functions/visualization.ts similarity index 91% rename from src/legacy/core_plugins/interpreter/public/functions/visualization.js rename to src/legacy/core_plugins/interpreter/public/functions/visualization.ts index 7dceeaf684354..94be78befd3d0 100644 --- a/src/legacy/core_plugins/interpreter/public/functions/visualization.js +++ b/src/legacy/core_plugins/interpreter/public/functions/visualization.ts @@ -20,17 +20,16 @@ import { get } from 'lodash'; import { i18n } from '@kbn/i18n'; import chrome from 'ui/chrome'; -import { setup as data } from '../../../data/public/legacy'; -import { start as visualizations } from '../../../visualizations/public/legacy'; - import { FilterBarQueryFilterProvider } from 'ui/filter_manager/query_filter'; import { PersistedState } from 'ui/persisted_state'; +import { setup as data } from '../../../data/public/legacy'; +import { start as visualizations } from '../../../visualizations/public/legacy'; export const visualization = () => ({ name: 'visualization', type: 'render', help: i18n.translate('interpreter.functions.visualization.help', { - defaultMessage: 'A simple visualization' + defaultMessage: 'A simple visualization', }), args: { index: { @@ -60,17 +59,17 @@ export const visualization = () => ({ uiState: { types: ['string'], default: '"{}"', - } + }, }, - async fn(context, args, handlers) { + async fn(context: any, args: any, handlers: any) { const $injector = await chrome.dangerouslyGetActiveInjector(); - const Private = $injector.get('Private'); + const Private = $injector.get('Private') as any; const { indexPatterns } = data.indexPatterns; const queryFilter = Private(FilterBarQueryFilterProvider); const visConfigParams = JSON.parse(args.visConfig); const schemas = JSON.parse(args.schemas); - const visType = visualizations.types.get(args.type || 'histogram'); + const visType = visualizations.types.get(args.type || 'histogram') as any; const indexPattern = args.index ? await indexPatterns.get(args.index) : null; const uiStateParams = JSON.parse(args.uiState); @@ -85,7 +84,7 @@ export const visualization = () => ({ timeRange: get(context, 'timeRange', null), query: get(context, 'query', null), filters: get(context, 'filters', null), - uiState: uiState, + uiState, inspectorAdapters: handlers.inspectorAdapters, queryFilter, forceFetch: true, @@ -95,14 +94,14 @@ export const visualization = () => ({ if (typeof visType.responseHandler === 'function') { if (context.columns) { // assign schemas to aggConfigs - context.columns.forEach(column => { + context.columns.forEach((column: any) => { if (column.aggConfig) { column.aggConfig.aggConfigs.schemas = visType.schemas.all; } }); Object.keys(schemas).forEach(key => { - schemas[key].forEach(i => { + schemas[key].forEach((i: any) => { if (context.columns[i] && context.columns[i].aggConfig) { context.columns[i].aggConfig.schema = key; } @@ -119,8 +118,8 @@ export const visualization = () => ({ value: { visData: context, visType: args.type, - visConfig: visConfigParams - } + visConfig: visConfigParams, + }, }; - } + }, }); diff --git a/src/legacy/core_plugins/interpreter/public/interpreter.test.js b/src/legacy/core_plugins/interpreter/public/interpreter.test.ts similarity index 96% rename from src/legacy/core_plugins/interpreter/public/interpreter.test.js rename to src/legacy/core_plugins/interpreter/public/interpreter.test.ts index bd7dc0a47c124..1de1e8c0cc059 100644 --- a/src/legacy/core_plugins/interpreter/public/interpreter.test.js +++ b/src/legacy/core_plugins/interpreter/public/interpreter.test.ts @@ -24,9 +24,9 @@ jest.mock('ui/new_platform', () => ({ injectedMetadata: { getKibanaVersion: () => '8.0.0', getBasePath: () => '/lol', - } - } - } + }, + }, + }, })); jest.mock('uiExports/interpreter'); @@ -38,7 +38,7 @@ jest.mock('@kbn/interpreter/common', () => ({ const mockInterpreter = { interpreter: { interpretAst: jest.fn(), - } + }, }; jest.mock('./lib/interpreter', () => ({ initializeInterpreter: jest.fn().mockReturnValue(Promise.resolve(mockInterpreter)), @@ -57,9 +57,9 @@ jest.mock('./functions', () => ({ functions: [{}, {}, {}] })); jest.mock('./renderers/visualization', () => ({ visualization: {} })); describe('interpreter/interpreter', () => { - let getInterpreter; - let interpretAst; - let initializeInterpreter; + let getInterpreter: any; + let interpretAst: any; + let initializeInterpreter: any; beforeEach(() => { jest.clearAllMocks(); @@ -117,5 +117,4 @@ describe('interpreter/interpreter', () => { expect(mockInterpreter.interpreter.interpretAst).toHaveBeenCalledTimes(2); }); }); - }); diff --git a/src/legacy/core_plugins/interpreter/public/interpreter.js b/src/legacy/core_plugins/interpreter/public/interpreter.ts similarity index 87% rename from src/legacy/core_plugins/interpreter/public/interpreter.js rename to src/legacy/core_plugins/interpreter/public/interpreter.ts index 84e05bb10d9fa..8ba82d5daf759 100644 --- a/src/legacy/core_plugins/interpreter/public/interpreter.js +++ b/src/legacy/core_plugins/interpreter/public/interpreter.ts @@ -18,6 +18,7 @@ */ import 'uiExports/interpreter'; +// @ts-ignore import { register, registryFactory } from '@kbn/interpreter/common'; import { initializeInterpreter } from './lib/interpreter'; import { registries } from './registries'; @@ -27,7 +28,10 @@ import { typeSpecs } from '../../../../plugins/expressions/common'; // Expose kbnInterpreter.register(specs) and kbnInterpreter.registries() globally so that plugins // can register without a transpile step. -global.kbnInterpreter = Object.assign(global.kbnInterpreter || {}, registryFactory(registries)); +(global as any).kbnInterpreter = Object.assign( + (global as any).kbnInterpreter || {}, + registryFactory(registries) +); register(registries, { types: typeSpecs, @@ -35,7 +39,7 @@ register(registries, { renderers: [visualization], }); -let interpreterPromise; +let interpreterPromise: Promise | undefined; export const getInterpreter = async () => { if (!interpreterPromise) { @@ -44,7 +48,7 @@ export const getInterpreter = async () => { return await interpreterPromise; }; -export const interpretAst = async (...params) => { +export const interpretAst = async (...params: any) => { const { interpreter } = await getInterpreter(); return await interpreter.interpretAst(...params); }; diff --git a/src/legacy/core_plugins/interpreter/public/lib/render_function.js b/src/legacy/core_plugins/interpreter/public/lib/render_function.ts similarity index 92% rename from src/legacy/core_plugins/interpreter/public/lib/render_function.js rename to src/legacy/core_plugins/interpreter/public/lib/render_function.ts index 04aa05951be70..76d1f58b66195 100644 --- a/src/legacy/core_plugins/interpreter/public/lib/render_function.js +++ b/src/legacy/core_plugins/interpreter/public/lib/render_function.ts @@ -17,7 +17,7 @@ * under the License. */ -export function RenderFunction(config) { +export function RenderFunction(this: any, config: any) { // This must match the name of the function that is used to create the `type: render` object this.name = config.name; @@ -36,7 +36,7 @@ export function RenderFunction(config) { // the function called to render the data this.render = config.render || - function render(domNode, data, done) { + function render(domNode: any, data: any, done: any) { done(); }; } diff --git a/src/legacy/core_plugins/interpreter/public/lib/render_functions_registry.js b/src/legacy/core_plugins/interpreter/public/lib/render_functions_registry.ts similarity index 88% rename from src/legacy/core_plugins/interpreter/public/lib/render_functions_registry.js rename to src/legacy/core_plugins/interpreter/public/lib/render_functions_registry.ts index 60e823baf0fa7..427e7f7454c24 100644 --- a/src/legacy/core_plugins/interpreter/public/lib/render_functions_registry.js +++ b/src/legacy/core_plugins/interpreter/public/lib/render_functions_registry.ts @@ -20,9 +20,9 @@ import { Registry } from '@kbn/interpreter/common'; import { RenderFunction } from './render_function'; -class RenderFunctionsRegistry extends Registry { - wrapper(obj) { - return new RenderFunction(obj); +class RenderFunctionsRegistry extends Registry { + wrapper(obj: any) { + return new (RenderFunction as any)(obj); } } diff --git a/src/legacy/core_plugins/interpreter/public/renderers/visualization.js b/src/legacy/core_plugins/interpreter/public/renderers/visualization.ts similarity index 84% rename from src/legacy/core_plugins/interpreter/public/renderers/visualization.js rename to src/legacy/core_plugins/interpreter/public/renderers/visualization.ts index 38fe02436380c..960e925b13221 100644 --- a/src/legacy/core_plugins/interpreter/public/renderers/visualization.js +++ b/src/legacy/core_plugins/interpreter/public/renderers/visualization.ts @@ -19,17 +19,18 @@ import chrome from 'ui/chrome'; import { visualizationLoader } from 'ui/visualize/loader/visualization_loader'; +// @ts-ignore import { VisProvider } from 'ui/visualize/loader/vis'; export const visualization = () => ({ name: 'visualization', displayName: 'visualization', reuseDomNode: true, - render: async (domNode, config, handlers) => { + render: async (domNode: HTMLElement, config: any, handlers: any) => { const { visData, visConfig, params } = config; const visType = config.visType || visConfig.type; const $injector = await chrome.dangerouslyGetActiveInjector(); - const Private = $injector.get('Private'); + const Private = $injector.get('Private') as any; const Vis = Private(VisProvider); if (handlers.vis) { @@ -49,8 +50,10 @@ export const visualization = () => ({ handlers.onDestroy(() => visualizationLoader.destroy()); - await visualizationLoader.render(domNode, handlers.vis, visData, handlers.vis.params, uiState, params).then(() => { - if (handlers.done) handlers.done(); - }); + await visualizationLoader + .render(domNode, handlers.vis, visData, handlers.vis.params, uiState, params) + .then(() => { + if (handlers.done) handlers.done(); + }); }, }); diff --git a/src/legacy/core_plugins/interpreter/server/lib/__tests__/create_handlers.js b/src/legacy/core_plugins/interpreter/server/lib/__tests__/create_handlers.ts similarity index 95% rename from src/legacy/core_plugins/interpreter/server/lib/__tests__/create_handlers.js rename to src/legacy/core_plugins/interpreter/server/lib/__tests__/create_handlers.ts index a6e0e13049e1c..0088663080774 100644 --- a/src/legacy/core_plugins/interpreter/server/lib/__tests__/create_handlers.js +++ b/src/legacy/core_plugins/interpreter/server/lib/__tests__/create_handlers.ts @@ -28,13 +28,13 @@ const mockServer = { plugins: { elasticsearch: { getCluster: () => ({ - callWithRequest: (...args) => Promise.resolve(args), + callWithRequest: (...args: any) => Promise.resolve(args), }), }, }, config: () => ({ has: () => false, - get: val => val, + get: (val: any) => val, }), info: { uri: 'serveruri', diff --git a/src/legacy/core_plugins/interpreter/server/lib/create_handlers.js b/src/legacy/core_plugins/interpreter/server/lib/create_handlers.ts similarity index 88% rename from src/legacy/core_plugins/interpreter/server/lib/create_handlers.js rename to src/legacy/core_plugins/interpreter/server/lib/create_handlers.ts index d4ea9b3dc6180..6e295d0aecaa5 100644 --- a/src/legacy/core_plugins/interpreter/server/lib/create_handlers.js +++ b/src/legacy/core_plugins/interpreter/server/lib/create_handlers.ts @@ -17,7 +17,7 @@ * under the License. */ -export const createHandlers = (request, server) => { +export const createHandlers = (request: any, server: any) => { const { callWithRequest } = server.plugins.elasticsearch.getCluster('data'); const config = server.config(); @@ -27,6 +27,6 @@ export const createHandlers = (request, server) => { config.has('server.rewriteBasePath') && config.get('server.rewriteBasePath') ? `${server.info.uri}${config.get('server.basePath')}` : server.info.uri, - elasticsearchClient: async (...args) => callWithRequest(request, ...args), + elasticsearchClient: async (...args: any) => callWithRequest(request, ...args), }; }; diff --git a/src/legacy/core_plugins/interpreter/server/routes/index.js b/src/legacy/core_plugins/interpreter/server/routes/index.ts similarity index 95% rename from src/legacy/core_plugins/interpreter/server/routes/index.js rename to src/legacy/core_plugins/interpreter/server/routes/index.ts index 9140f93a9bde6..50385147dd38e 100644 --- a/src/legacy/core_plugins/interpreter/server/routes/index.js +++ b/src/legacy/core_plugins/interpreter/server/routes/index.ts @@ -19,6 +19,6 @@ import { registerServerFunctions } from './server_functions'; -export function routes(server) { +export function routes(server: any) { registerServerFunctions(server); } diff --git a/src/legacy/core_plugins/interpreter/server/routes/server_functions.js b/src/legacy/core_plugins/interpreter/server/routes/server_functions.ts similarity index 84% rename from src/legacy/core_plugins/interpreter/server/routes/server_functions.js rename to src/legacy/core_plugins/interpreter/server/routes/server_functions.ts index b64a9af006e41..740b046610d9e 100644 --- a/src/legacy/core_plugins/interpreter/server/routes/server_functions.js +++ b/src/legacy/core_plugins/interpreter/server/routes/server_functions.ts @@ -18,16 +18,16 @@ */ import Boom from 'boom'; +import Joi from 'joi'; import { serializeProvider, API_ROUTE } from '../../common'; import { createHandlers } from '../lib/create_handlers'; -import Joi from 'joi'; /** * Register the Canvas function endopints. * * @param {*} server - The Kibana server */ -export function registerServerFunctions(server) { +export function registerServerFunctions(server: any) { getServerFunctions(server); runServerFunctions(server); } @@ -37,7 +37,7 @@ export function registerServerFunctions(server) { * * @param {*} server - The Kibana server */ -function runServerFunctions(server) { +function runServerFunctions(server: any) { server.route({ method: 'POST', path: `${API_ROUTE}/fns`, @@ -48,19 +48,20 @@ function runServerFunctions(server) { }, validate: { payload: Joi.object({ - functions: Joi.array().items( - Joi.object() - .keys({ + functions: Joi.array() + .items( + Joi.object().keys({ id: Joi.number().required(), functionName: Joi.string().required(), args: Joi.object().default({}), context: Joi.any().default(null), - }), - ).required(), + }) + ) + .required(), }).required(), }, }, - async handler(req) { + async handler(req: any) { const handlers = await createHandlers(req, server); const { functions } = req.payload; @@ -73,19 +74,19 @@ function runServerFunctions(server) { // Send the initial headers. res.writeHead(200, { 'Content-Type': 'text/plain', - 'Connection': 'keep-alive', + Connection: 'keep-alive', 'Transfer-Encoding': 'chunked', 'Cache-Control': 'no-cache', }); // Write a length-delimited response - const streamResult = (result) => { + const streamResult = (result: any) => { const payload = JSON.stringify(result) + '\n'; res.write(`${payload.length}:${payload}`); }; // Tries to run an interpreter function, and ensures a consistent error payload on failure. - const tryFunction = async (id, fnCall) => { + const tryFunction = async (id: any, fnCall: any) => { try { const result = await runFunction(server, handlers, fnCall); @@ -96,7 +97,7 @@ function runServerFunctions(server) { return { id, statusCode: 200, result }; } catch (err) { if (Boom.isBoom(err)) { - return batchError(id, err.output.payload, err.statusCode); + return batchError(id, err.output.payload, (err as any).statusCode); } else if (err instanceof Error) { return batchError(id, err.message); } @@ -107,7 +108,9 @@ function runServerFunctions(server) { }; // Process each function individually, and stream the responses back to the client - await Promise.all(functions.map(({ id, ...fnCall }) => tryFunction(id, fnCall).then(streamResult))); + await Promise.all( + functions.map(({ id, ...fnCall }: any) => tryFunction(id, fnCall).then(streamResult)) + ); // All of the responses have been written, so we can close the response. res.end(); @@ -118,7 +121,7 @@ function runServerFunctions(server) { /** * A helper function for bundling up errors. */ -function batchError(id, message, statusCode = 500) { +function batchError(id: any, message: any, statusCode = 500) { return { id, statusCode, @@ -130,7 +133,7 @@ function batchError(id, message, statusCode = 500) { * Register the endpoint that returns the list of server-only functions. * @param {*} server - The Kibana server */ -function getServerFunctions(server) { +function getServerFunctions(server: any) { server.route({ method: 'GET', path: `${API_ROUTE}/fns`, @@ -147,7 +150,7 @@ function getServerFunctions(server) { * @param {*} handlers - The Canvas handlers * @param {*} fnCall - Describes the function being run `{ functionName, args, context }` */ -async function runFunction(server, handlers, fnCall) { +async function runFunction(server: any, handlers: any, fnCall: any) { const registries = server.plugins.interpreter.registries(); const { functionName, args, context } = fnCall; const types = registries.types.toJS(); diff --git a/src/legacy/core_plugins/interpreter/test_helpers.js b/src/legacy/core_plugins/interpreter/test_helpers.ts similarity index 86% rename from src/legacy/core_plugins/interpreter/test_helpers.js rename to src/legacy/core_plugins/interpreter/test_helpers.ts index e743b8a09280e..741cd83bb47fe 100644 --- a/src/legacy/core_plugins/interpreter/test_helpers.js +++ b/src/legacy/core_plugins/interpreter/test_helpers.ts @@ -21,8 +21,9 @@ import { mapValues } from 'lodash'; // Takes a function spec and passes in default args, // overriding with any provided args. -export const functionWrapper = fnSpec => { +export const functionWrapper = (fnSpec: any) => { const spec = fnSpec(); const defaultArgs = mapValues(spec.args, argSpec => argSpec.default); - return (context, args, handlers) => spec.fn(context, { ...defaultArgs, ...args }, handlers); + return (context: any, args: any, handlers: any) => + spec.fn(context, { ...defaultArgs, ...args }, handlers); }; diff --git a/src/legacy/core_plugins/vis_type_markdown/public/markdown_fn.test.ts b/src/legacy/core_plugins/vis_type_markdown/public/markdown_fn.test.ts index 28021a763b287..009797905701c 100644 --- a/src/legacy/core_plugins/vis_type_markdown/public/markdown_fn.test.ts +++ b/src/legacy/core_plugins/vis_type_markdown/public/markdown_fn.test.ts @@ -30,7 +30,7 @@ describe('interpreter/functions#markdown', () => { }; it('returns an object with the correct structure', async () => { - const actual = await fn(undefined, args); + const actual = await fn(undefined, args, undefined); expect(actual).toMatchSnapshot(); }); }); diff --git a/src/legacy/core_plugins/vis_type_metric/public/metric_vis_fn.test.ts b/src/legacy/core_plugins/vis_type_metric/public/metric_vis_fn.test.ts index 5fe2ac7b7fdf0..fee6dec641842 100644 --- a/src/legacy/core_plugins/vis_type_metric/public/metric_vis_fn.test.ts +++ b/src/legacy/core_plugins/vis_type_metric/public/metric_vis_fn.test.ts @@ -67,7 +67,7 @@ describe('interpreter/functions#metric', () => { }; it('returns an object with the correct structure', () => { - const actual = fn(context, args); + const actual = fn(context, args, undefined); expect(actual).toMatchSnapshot(); }); diff --git a/src/legacy/core_plugins/vis_type_table/public/table_vis_fn.test.ts b/src/legacy/core_plugins/vis_type_table/public/table_vis_fn.test.ts index 2bdebc8a9d19e..1c1b808ffb014 100644 --- a/src/legacy/core_plugins/vis_type_table/public/table_vis_fn.test.ts +++ b/src/legacy/core_plugins/vis_type_table/public/table_vis_fn.test.ts @@ -76,12 +76,12 @@ describe('interpreter/functions#table', () => { }); it('returns an object with the correct structure', async () => { - const actual = await fn(context, { visConfig: JSON.stringify(visConfig) }); + const actual = await fn(context, { visConfig: JSON.stringify(visConfig) }, undefined); expect(actual).toMatchSnapshot(); }); it('calls response handler with correct values', async () => { - await fn(context, { visConfig: JSON.stringify(visConfig) }); + await fn(context, { visConfig: JSON.stringify(visConfig) }, undefined); expect(mockResponseHandler).toHaveBeenCalledTimes(1); expect(mockResponseHandler).toHaveBeenCalledWith(context, visConfig.dimensions); }); diff --git a/src/legacy/core_plugins/vis_type_tagcloud/public/tag_cloud_fn.test.ts b/src/legacy/core_plugins/vis_type_tagcloud/public/tag_cloud_fn.test.ts index d14871c6bd337..0365f7840cac4 100644 --- a/src/legacy/core_plugins/vis_type_tagcloud/public/tag_cloud_fn.test.ts +++ b/src/legacy/core_plugins/vis_type_tagcloud/public/tag_cloud_fn.test.ts @@ -39,7 +39,7 @@ describe('interpreter/functions#tagcloud', () => { }; it('returns an object with the correct structure', () => { - const actual = fn(context, visConfig); + const actual = fn(context, visConfig, undefined); expect(actual).toMatchSnapshot(); }); }); From e322acceab83c444843447f0e55dc3c798d155e6 Mon Sep 17 00:00:00 2001 From: Shahzad Date: Thu, 3 Oct 2019 13:57:31 +0500 Subject: [PATCH 12/19] [Uptime] Change default status filter in ping list to all on monitor page (#47108) * change default status filter in ping list to all * update snaps --- .../plugins/uptime/common/constants/client_defaults.ts | 2 +- .../__tests__/__snapshots__/use_url_params.test.tsx.snap | 2 +- .../__snapshots__/get_supported_url_params.test.ts.snap | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/x-pack/legacy/plugins/uptime/common/constants/client_defaults.ts b/x-pack/legacy/plugins/uptime/common/constants/client_defaults.ts index 21e866e991944..66ac571e2b7a5 100644 --- a/x-pack/legacy/plugins/uptime/common/constants/client_defaults.ts +++ b/x-pack/legacy/plugins/uptime/common/constants/client_defaults.ts @@ -31,6 +31,6 @@ export const CLIENT_DEFAULTS = { MONITOR_LIST_SORT_DIRECTION: 'asc', MONITOR_LIST_SORT_FIELD: 'monitor_id', SEARCH: '', - SELECTED_PING_LIST_STATUS: 'down', + SELECTED_PING_LIST_STATUS: '', STATUS_FILTER: '', }; diff --git a/x-pack/legacy/plugins/uptime/public/hooks/__tests__/__snapshots__/use_url_params.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/hooks/__tests__/__snapshots__/use_url_params.test.tsx.snap index 4b45ed20d5d83..5794169d75597 100644 --- a/x-pack/legacy/plugins/uptime/public/hooks/__tests__/__snapshots__/use_url_params.test.tsx.snap +++ b/x-pack/legacy/plugins/uptime/public/hooks/__tests__/__snapshots__/use_url_params.test.tsx.snap @@ -5,7 +5,7 @@ exports[`useUrlParams gets the expected values using the context 1`] = ` hook={[Function]} >
- {"absoluteDateRangeStart":20,"absoluteDateRangeEnd":20,"autorefreshInterval":60000,"autorefreshIsPaused":false,"dateRangeStart":"now-19d","dateRangeEnd":"now-1m","filters":"","search":"","selectedPingStatus":"down","statusFilter":""} + {"absoluteDateRangeStart":20,"absoluteDateRangeEnd":20,"autorefreshInterval":60000,"autorefreshIsPaused":false,"dateRangeStart":"now-19d","dateRangeEnd":"now-1m","filters":"","search":"","selectedPingStatus":"","statusFilter":""}
- - diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job/wizard/steps/index_or_search/paginated_selectable_list/paginated_selectable_list.js b/x-pack/legacy/plugins/ml/public/jobs/new_job/wizard/steps/index_or_search/paginated_selectable_list/paginated_selectable_list.js deleted file mode 100644 index 784bf84322dd3..0000000000000 --- a/x-pack/legacy/plugins/ml/public/jobs/new_job/wizard/steps/index_or_search/paginated_selectable_list/paginated_selectable_list.js +++ /dev/null @@ -1,87 +0,0 @@ -/* - * 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 _ from 'lodash'; -import { uiModules } from 'ui/modules'; -import 'ui/directives/paginate'; -import 'ui/directives/kbn_href'; -import paginatedSelectableListTemplate from './paginated_selectable_list.html'; - -const module = uiModules.get('kibana'); - -function throwError(message) { - throw new Error(message); -} - -module.directive('paginatedSelectableList', function () { - - return { - restrict: 'E', - scope: { - perPage: '=?', - list: '=', - listProperty: '@', - userMakeUrl: '=?', - userOnSelect: '=?', - disableAutoFocus: '=' - }, - template: paginatedSelectableListTemplate, - controller: function ($scope, $filter) { - function calculateHitsByQuery() { - $scope.hitsByQuery = $filter('filter')($scope.hits, $scope.query); - } - - // Should specify either user-make-url or user-on-select - if (!$scope.userMakeUrl && !$scope.userOnSelect) { - throwError('paginatedSelectableList directive expects a makeUrl or onSelect function'); - } - - // Should specify either user-make-url or user-on-select, but not both. - if ($scope.userMakeUrl && $scope.userOnSelect) { - throwError('paginatedSelectableList directive expects a makeUrl or onSelect attribute but not both'); - } - - $scope.perPage = $scope.perPage || 10; - $scope.hits = $scope.list = _.sortBy($scope.list, $scope.accessor); - $scope.$watchGroup(['hits', 'query'], calculateHitsByQuery); - calculateHitsByQuery(); - $scope.hitCount = $scope.hits.length; - - /** - * Boolean that keeps track of whether hits are sorted ascending (true) - * or descending (false) - * * @type {Boolean} - */ - $scope.isAscending = true; - - /** - * Sorts saved object finder hits either ascending or descending - * @param {Array} hits Array of saved finder object hits - * @return {Array} Array sorted either ascending or descending - */ - $scope.sortHits = function (hits) { - const sortedList = _.sortBy(hits, $scope.accessor); - - $scope.isAscending = !$scope.isAscending; - $scope.hits = $scope.isAscending ? sortedList : sortedList.reverse(); - }; - - $scope.makeUrl = function (hit) { - return $scope.userMakeUrl(hit); - }; - - $scope.onSelect = function (hit, $event) { - return $scope.userOnSelect(hit, $event); - }; - - $scope.accessor = function (val) { - const prop = $scope.listProperty; - return prop ? _.get(val, prop) : val; - }; - } - }; -}); diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/index.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/index.ts index 2366f2c655000..f0061b1b9847e 100644 --- a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/index.ts +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/index.ts @@ -8,3 +8,5 @@ import './pages/new_job/route'; import './pages/new_job/directive'; import './pages/job_type/route'; import './pages/job_type/directive'; +import './pages/index_or_search/route'; +import './pages/index_or_search/directive'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/index_or_search/__test__/directive.js b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/index_or_search/__test__/directive.js new file mode 100644 index 0000000000000..bd63a16abfacd --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/index_or_search/__test__/directive.js @@ -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 ngMock from 'ng_mock'; +import expect from '@kbn/expect'; +import sinon from 'sinon'; + +// Import this way to be able to stub/mock functions later on in the tests using sinon. +import * as indexUtils from 'plugins/ml/util/index_utils'; + +describe('ML - Index or Saved Search selection directive', () => { + let $scope; + let $compile; + let $element; + + beforeEach(ngMock.module('kibana')); + beforeEach(() => { + ngMock.inject(function ($injector) { + $compile = $injector.get('$compile'); + const $rootScope = $injector.get('$rootScope'); + $scope = $rootScope.$new(); + }); + }); + + afterEach(() => { + $scope.$destroy(); + }); + + it('Initialize Index or Saved Search selection directive', done => { + sinon.stub(indexUtils, 'timeBasedIndexCheck').callsFake(() => false); + ngMock.inject(function () { + expect(() => { + $element = $compile('')($scope); + }).to.not.throwError(); + + // directive has scope: false + const scope = $element.isolateScope(); + expect(scope).to.eql(undefined); + done(); + }); + }); +}); diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/index_or_search/directive.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/index_or_search/directive.tsx new file mode 100644 index 0000000000000..7f3edf0896840 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/index_or_search/directive.tsx @@ -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 React from 'react'; +import ReactDOM from 'react-dom'; + +// @ts-ignore +import { uiModules } from 'ui/modules'; +const module = uiModules.get('apps/ml', ['react']); +import { timefilter } from 'ui/timefilter'; + +import { I18nContext } from 'ui/i18n'; +import { InjectorService } from '../../../../../common/types/angular'; +import { Page } from './page'; + +module.directive('mlIndexOrSearch', ($injector: InjectorService) => { + return { + scope: {}, + restrict: 'E', + link: async (scope: ng.IScope, element: ng.IAugmentedJQuery) => { + // remove time picker from top of page + timefilter.disableTimeRangeSelector(); + timefilter.disableAutoRefreshSelector(); + + const $route = $injector.get('$route'); + const { nextStepPath } = $route.current.locals; + + ReactDOM.render( + {React.createElement(Page, { nextStepPath })}, + element[0] + ); + + element.on('$destroy', () => { + ReactDOM.unmountComponentAtNode(element[0]); + scope.$destroy(); + }); + }, + }; +}); diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/index_or_search/page.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/index_or_search/page.tsx new file mode 100644 index 0000000000000..68013bd243a91 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/index_or_search/page.tsx @@ -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 React, { FC } from 'react'; +import { + EuiPage, + EuiPageBody, + EuiTitle, + EuiPageHeader, + EuiPageHeaderSection, + EuiPageContent, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { SavedObjectFinder } from 'ui/saved_objects/components/saved_object_finder'; + +export interface PageProps { + nextStepPath: string; +} + +export const Page: FC = ({ nextStepPath }) => { + const RESULTS_PER_PAGE = 20; + + const onObjectSelection = (id: string, type: string) => { + window.location.href = `${nextStepPath}?${ + type === 'index-pattern' ? 'index' : 'savedSearchId' + }=${encodeURIComponent(id)}`; + }; + + return ( + + + + + +

+ +

+
+
+
+ + 'search', + name: i18n.translate( + 'xpack.ml.newJob.wizard.searchSelection.savedObjectType.search', + { + defaultMessage: 'Saved search', + } + ), + }, + { + type: 'index-pattern', + getIconForSavedObject: () => 'indexPatternApp', + name: i18n.translate( + 'xpack.ml.newJob.wizard.searchSelection.savedObjectType.indexPattern', + { + defaultMessage: 'Index pattern', + } + ), + }, + ]} + fixedPageSize={RESULTS_PER_PAGE} + /> + +
+
+ ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/index_or_search/route.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/index_or_search/route.ts new file mode 100644 index 0000000000000..1ed12577960c3 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/index_or_search/route.ts @@ -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. + */ + +// @ts-ignore +import { checkMlNodesAvailable } from 'plugins/ml/ml_nodes_check/check_ml_nodes'; +// @ts-ignore +import { preConfiguredJobRedirect } from 'plugins/ml/jobs/new_job/wizard/preconfigured_job_redirect'; +import uiRoutes from 'ui/routes'; +// @ts-ignore +import { checkLicenseExpired, checkBasicLicense } from '../../../../license/check_license'; +import { loadIndexPatterns } from '../../../../util/index_utils'; +import { + checkCreateJobsPrivilege, + checkFindFileStructurePrivilege, +} from '../../../../privilege/check_privilege'; +import { + getCreateJobBreadcrumbs, + getDataVisualizerIndexOrSearchBreadcrumbs, +} from '../../../breadcrumbs'; + +uiRoutes.when('/jobs/new_job', { + redirectTo: '/jobs/new_job/step/index_or_search', +}); + +uiRoutes.when('/jobs/new_job/step/index_or_search', { + template: '', + k7Breadcrumbs: getCreateJobBreadcrumbs, + resolve: { + CheckLicense: checkLicenseExpired, + privileges: checkCreateJobsPrivilege, + indexPatterns: loadIndexPatterns, + preConfiguredJobRedirect, + checkMlNodesAvailable, + nextStepPath: () => '#/jobs/new_job/step/job_type', + }, +}); + +uiRoutes.when('/datavisualizer_index_select', { + template: '', + k7Breadcrumbs: getDataVisualizerIndexOrSearchBreadcrumbs, + resolve: { + CheckLicense: checkBasicLicense, + privileges: checkFindFileStructurePrivilege, + indexPatterns: loadIndexPatterns, + nextStepPath: () => '#jobs/new_job/datavisualizer', + }, +}); diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 97b509f0e3ffe..d055e77606587 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -7175,8 +7175,6 @@ "xpack.ml.newJob.simple.watcher.email.timeLabel": "時間", "xpack.ml.newJob.simple.watcher.email.topInfluencersLabel": "トップ影響因子:", "xpack.ml.newJob.simple.watcher.email.topRecordsLabel": "トップの記録:", - "xpack.ml.newJob.wizard.createFromNewSearchTitle": "新規検索からインデックスを選択", - "xpack.ml.newJob.wizard.createFromSavedSearchTitle": "または保存された検索から", "xpack.ml.newJob.wizard.jobType.advancedAriaLabel": "高度なジョブ", "xpack.ml.newJob.wizard.jobType.advancedDescription": "より高度なユースケースでは、ジョブの作成にすべてのオプションを使用します。", "xpack.ml.newJob.wizard.jobType.advancedTitle": "高度な設定", @@ -7205,7 +7203,6 @@ "xpack.ml.newJob.wizard.jobType.useSuppliedConfigurationTitle": "提供された構成を使用", "xpack.ml.newJob.wizard.jobType.useWizardDescription": "ウィザードの 1 つを使用し、データの異常を検知する機械学習ジョブを作成します。", "xpack.ml.newJob.wizard.jobType.useWizardTitle": "ウィザードを使用", - "xpack.ml.newJob.wizard.savedSearchesTooltip": "保存された検索", "xpack.ml.privilege.licenseHasExpiredTooltip": "ご使用のライセンスは期限切れです。", "xpack.ml.privilege.noPermission.createCalendarsTooltip": "カレンダーを作成するパーミッションがありません。", "xpack.ml.privilege.noPermission.createDataFrameTransformTooltip": "データフレーム変換を作成するパーミッションがありません。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index aaced82cb5b14..6ca82b053ba66 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -7177,8 +7177,6 @@ "xpack.ml.newJob.simple.watcher.email.timeLabel": "时间", "xpack.ml.newJob.simple.watcher.email.topInfluencersLabel": "排在前面的影响因素:", "xpack.ml.newJob.simple.watcher.email.topRecordsLabel": "排在前面的记录:", - "xpack.ml.newJob.wizard.createFromNewSearchTitle": "基于“新搜索”,选择“索引”", - "xpack.ml.newJob.wizard.createFromSavedSearchTitle": "或者,基于“已保存的搜索”", "xpack.ml.newJob.wizard.jobType.advancedAriaLabel": "高级作业", "xpack.ml.newJob.wizard.jobType.advancedDescription": "使用全部选项为更高级的用例创建作业。", "xpack.ml.newJob.wizard.jobType.advancedTitle": "高级", @@ -7207,7 +7205,6 @@ "xpack.ml.newJob.wizard.jobType.useSuppliedConfigurationTitle": "使用提供的配置", "xpack.ml.newJob.wizard.jobType.useWizardDescription": "使用其中一个向导创建 Machine Learning 作业,以查找数据中的异常。", "xpack.ml.newJob.wizard.jobType.useWizardTitle": "使用向导", - "xpack.ml.newJob.wizard.savedSearchesTooltip": "已保存的搜索", "xpack.ml.privilege.licenseHasExpiredTooltip": "您的许可证已过期。", "xpack.ml.privilege.noPermission.createCalendarsTooltip": "您没有权限创建日历。", "xpack.ml.privilege.noPermission.createDataFrameTransformTooltip": "您无权创建数据帧转换。", diff --git a/x-pack/test/functional/services/machine_learning/job_source_selection.ts b/x-pack/test/functional/services/machine_learning/job_source_selection.ts index 89fae06fd33ce..81f2de3a47e79 100644 --- a/x-pack/test/functional/services/machine_learning/job_source_selection.ts +++ b/x-pack/test/functional/services/machine_learning/job_source_selection.ts @@ -11,8 +11,7 @@ export function MachineLearningJobSourceSelectionProvider({ getService }: FtrPro return { async selectSourceIndexPattern(indexPattern: string) { - const subj = 'paginatedListItem-' + indexPattern; - await testSubjects.clickWhenNotDisabled(subj); + await testSubjects.clickWhenNotDisabled(`savedObjectTitle${indexPattern}`); await testSubjects.existOrFail('mlPageJobTypeSelection'); }, }; From 127eab03201f5805c789ab9976560afb9b316737 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Thu, 3 Oct 2019 11:13:42 +0200 Subject: [PATCH 14/19] [Graph] Empty workspace overlay (#45547) --- .../public/angular/graph_client_workspace.js | 9 +- .../graph/public/angular/templates/index.html | 4 +- x-pack/legacy/plugins/graph/public/app.js | 30 +++- .../graph/public/components/_index.scss | 2 + .../public/components/_source_modal.scss | 4 + .../plugins/graph/public/components/app.tsx | 63 +++++--- .../components/field_manager/field_icon.tsx | 2 +- .../field_manager/field_manager.test.tsx | 27 +++- .../field_manager/field_manager.tsx | 30 ++-- .../components/field_manager/field_picker.tsx | 12 +- .../guidance_panel/_guidance_panel.scss | 47 ++++++ .../components/guidance_panel/_index.scss | 1 + .../guidance_panel/guidance_panel.tsx | 143 ++++++++++++++++++ .../public/components/guidance_panel/index.ts | 7 + .../public/components/search_bar.test.tsx | 3 + .../graph/public/components/search_bar.tsx | 117 +++++++------- .../graph/public/components/source_modal.tsx | 4 +- .../public/services/fetch_top_nodes.test.ts | 97 ++++++++++++ .../graph/public/services/fetch_top_nodes.ts | 112 ++++++++++++++ .../graph/public/types/workspace_state.ts | 42 ++--- 20 files changed, 622 insertions(+), 134 deletions(-) create mode 100644 x-pack/legacy/plugins/graph/public/components/_source_modal.scss create mode 100644 x-pack/legacy/plugins/graph/public/components/guidance_panel/_guidance_panel.scss create mode 100644 x-pack/legacy/plugins/graph/public/components/guidance_panel/_index.scss create mode 100644 x-pack/legacy/plugins/graph/public/components/guidance_panel/guidance_panel.tsx create mode 100644 x-pack/legacy/plugins/graph/public/components/guidance_panel/index.ts create mode 100644 x-pack/legacy/plugins/graph/public/services/fetch_top_nodes.test.ts create mode 100644 x-pack/legacy/plugins/graph/public/services/fetch_top_nodes.ts diff --git a/x-pack/legacy/plugins/graph/public/angular/graph_client_workspace.js b/x-pack/legacy/plugins/graph/public/angular/graph_client_workspace.js index db427fd11c765..96be9eed2b467 100644 --- a/x-pack/legacy/plugins/graph/public/angular/graph_client_workspace.js +++ b/x-pack/legacy/plugins/graph/public/angular/graph_client_workspace.js @@ -1200,11 +1200,14 @@ module.exports = (function () { } - //Add missing links between existing nodes - this.fillInGraph = function () { + /** + * Add missing links between existing nodes + * @param maxNewEdges Max number of new edges added. Avoid adding too many new edges + * at once into the graph otherwise disorientating + */ + this.fillInGraph = function (maxNewEdges = 10) { let nodesForLinking = self.getSelectedOrAllTopNodes(); - const maxNewEdges = 10; // Avoid adding too many new edges at once into the graph otherwise disorientating const maxNumVerticesSearchable = 100; diff --git a/x-pack/legacy/plugins/graph/public/angular/templates/index.html b/x-pack/legacy/plugins/graph/public/angular/templates/index.html index 3ed9b390c6a78..07b57ee322548 100644 --- a/x-pack/legacy/plugins/graph/public/angular/templates/index.html +++ b/x-pack/legacy/plugins/graph/public/angular/templates/index.html @@ -12,15 +12,17 @@ on-index-pattern-selected="uiSelectIndex" on-query-submit="submit" is-loading="loading" + is-initialized="!!workspace || savedWorkspace.id" initial-query="initialQuery" state="reduxState" dispatch="reduxDispatch" + on-fill-workspace="fillWorkspace" autocomplete-start="autocompleteStart" core-start="coreStart" store="store" > -
+
{ + try { + const fields = selectedFieldsSelector(store.getState()); + const topTermNodes = await fetchTopNodes( + npStart.core.http.post, + $scope.selectedIndex.title, + fields + ); + initWorkspaceIfRequired(); + $scope.workspace.mergeGraph({ + nodes: topTermNodes, + edges: [] + }); + $scope.workspace.fillInGraph(fields.length * 10); + } catch (e) { + toastNotifications.addDanger({ + title: i18n.translate( + 'xpack.graph.fillWorkspaceError', + { defaultMessage: 'Fetching top terms failed: {message}', values: { message: e.message } } + ), + }); + } + }; + $scope.submit = function (searchTerm) { initWorkspaceIfRequired(); const numHops = 2; @@ -846,9 +873,6 @@ app.controller('graphuiPlugin', function ( } else { $route.current.locals.SavedWorkspacesProvider.get().then(function (newWorkspace) { $scope.savedWorkspace = newWorkspace; - openSourceModal(npStart.core, indexPattern => { - $scope.indexSelected(indexPattern); - }); }); } diff --git a/x-pack/legacy/plugins/graph/public/components/_index.scss b/x-pack/legacy/plugins/graph/public/components/_index.scss index 85bbf4fcc3ade..a06209e7e4d34 100644 --- a/x-pack/legacy/plugins/graph/public/components/_index.scss +++ b/x-pack/legacy/plugins/graph/public/components/_index.scss @@ -1,5 +1,7 @@ @import './app'; @import './search_bar'; +@import './source_modal'; +@import './guidance_panel/index'; @import './graph_visualization/index'; @import './venn_diagram/index'; @import './settings/index'; diff --git a/x-pack/legacy/plugins/graph/public/components/_source_modal.scss b/x-pack/legacy/plugins/graph/public/components/_source_modal.scss new file mode 100644 index 0000000000000..fbc293442f331 --- /dev/null +++ b/x-pack/legacy/plugins/graph/public/components/_source_modal.scss @@ -0,0 +1,4 @@ +.gphSourceModal { + width: 720px; + min-height: 530px; +} \ No newline at end of file diff --git a/x-pack/legacy/plugins/graph/public/components/app.tsx b/x-pack/legacy/plugins/graph/public/components/app.tsx index 907e7e4cecdcd..894c6b9ef45ac 100644 --- a/x-pack/legacy/plugins/graph/public/components/app.tsx +++ b/x-pack/legacy/plugins/graph/public/components/app.tsx @@ -5,12 +5,16 @@ */ import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import React from 'react'; +import React, { useState } from 'react'; +import { I18nProvider } from '@kbn/i18n/react'; import { Storage } from 'ui/storage'; import { CoreStart } from 'kibana/public'; import { AutocompletePublicPluginStart } from 'src/plugins/data/public'; import { FieldManagerProps, FieldManager } from './field_manager'; import { SearchBarProps, SearchBar } from './search_bar'; +import { GuidancePanel } from './guidance_panel'; +import { selectedFieldsSelector } from '../state_management'; +import { openSourceModal } from '../services/source_modal'; import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public'; @@ -18,28 +22,47 @@ export interface GraphAppProps extends FieldManagerProps, SearchBarProps { coreStart: CoreStart; autocompleteStart: AutocompletePublicPluginStart; store: Storage; + onFillWorkspace: () => void; + isInitialized: boolean; } export function GraphApp(props: GraphAppProps) { + const [pickerOpen, setPickerOpen] = useState(false); + return ( - -
- - - - - - - - -
-
+ + +
+ + + + + + + + +
+ {!props.isInitialized && ( + 0} + onFillWorkspace={props.onFillWorkspace} + onOpenFieldPicker={() => { + setPickerOpen(true); + }} + onOpenDatasourcePicker={() => { + openSourceModal(props.coreStart, props.onIndexPatternSelected); + }} + /> + )} +
+
); } diff --git a/x-pack/legacy/plugins/graph/public/components/field_manager/field_icon.tsx b/x-pack/legacy/plugins/graph/public/components/field_manager/field_icon.tsx index 93561e1722936..429eec19a47fa 100644 --- a/x-pack/legacy/plugins/graph/public/components/field_manager/field_icon.tsx +++ b/x-pack/legacy/plugins/graph/public/components/field_manager/field_icon.tsx @@ -16,7 +16,7 @@ function getIconForDataType(dataType: string) { boolean: 'invert', date: 'calendar', geo_point: 'globe', - ip: 'link', + ip: 'storage', }; return icons[dataType] || ICON_TYPES.find(t => t === dataType) || 'document'; } diff --git a/x-pack/legacy/plugins/graph/public/components/field_manager/field_manager.test.tsx b/x-pack/legacy/plugins/graph/public/components/field_manager/field_manager.test.tsx index 32cc546a3ad0c..fb715e759c62d 100644 --- a/x-pack/legacy/plugins/graph/public/components/field_manager/field_manager.test.tsx +++ b/x-pack/legacy/plugins/graph/public/components/field_manager/field_manager.test.tsx @@ -18,6 +18,7 @@ describe('field_manager', () => { let store: GraphStore; let instance: ShallowWrapper; let dispatchSpy: jest.Mock; + let openSpy: jest.Mock; beforeEach(() => { store = createGraphStore(); @@ -52,8 +53,16 @@ describe('field_manager', () => { ); dispatchSpy = jest.fn(store.dispatch); - - instance = shallow(); + openSpy = jest.fn(); + + instance = shallow( + + ); }); function update() { @@ -80,13 +89,19 @@ describe('field_manager', () => { }); it('should select fields from picker', () => { - const fieldPicker = instance.find(FieldPicker).dive(); - act(() => { - (fieldPicker.find(EuiPopover).prop('button')! as ReactElement).props.onClick(); + (instance + .find(FieldPicker) + .dive() + .find(EuiPopover) + .prop('button')! as ReactElement).props.onClick(); }); - fieldPicker.update(); + expect(openSpy).toHaveBeenCalled(); + + instance.setProps({ pickerOpen: true }); + + const fieldPicker = instance.find(FieldPicker).dive(); expect( fieldPicker diff --git a/x-pack/legacy/plugins/graph/public/components/field_manager/field_manager.tsx b/x-pack/legacy/plugins/graph/public/components/field_manager/field_manager.tsx index 7f89b555c9f7a..e44ad248e279d 100644 --- a/x-pack/legacy/plugins/graph/public/components/field_manager/field_manager.tsx +++ b/x-pack/legacy/plugins/graph/public/components/field_manager/field_manager.tsx @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { I18nProvider } from '@kbn/i18n/react'; import React from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { bindActionCreators } from 'redux'; @@ -24,9 +23,11 @@ import { export interface FieldManagerProps { state: GraphState; dispatch: GraphDispatch; + pickerOpen: boolean; + setPickerOpen: (open: boolean) => void; } -export function FieldManager({ state, dispatch }: FieldManagerProps) { +export function FieldManager({ state, dispatch, pickerOpen, setPickerOpen }: FieldManagerProps) { const fieldMap = fieldMapSelector(state); const allFields = fieldsSelector(state); const selectedFields = selectedFieldsSelector(state); @@ -41,17 +42,20 @@ export function FieldManager({ state, dispatch }: FieldManagerProps) { ); return ( - - - {selectedFields.map(field => ( - - - - ))} - - + + {selectedFields.map(field => ( + + - - + ))} + + + + ); } diff --git a/x-pack/legacy/plugins/graph/public/components/field_manager/field_picker.tsx b/x-pack/legacy/plugins/graph/public/components/field_manager/field_picker.tsx index b1ddce4fa1744..8ef566e881989 100644 --- a/x-pack/legacy/plugins/graph/public/components/field_manager/field_picker.tsx +++ b/x-pack/legacy/plugins/graph/public/components/field_manager/field_picker.tsx @@ -16,11 +16,17 @@ export interface FieldPickerProps { fieldMap: Record; selectField: (fieldName: string) => void; deselectField: (fieldName: string) => void; + open: boolean; + setOpen: (open: boolean) => void; } -export function FieldPicker({ fieldMap, selectField, deselectField }: FieldPickerProps) { - const [open, setOpen] = useState(false); - +export function FieldPicker({ + fieldMap, + selectField, + deselectField, + open, + setOpen, +}: FieldPickerProps) { const allFields = Object.values(fieldMap); const unselectedFields = allFields.filter(field => !field.selected); const hasSelectedFields = unselectedFields.length < allFields.length; diff --git a/x-pack/legacy/plugins/graph/public/components/guidance_panel/_guidance_panel.scss b/x-pack/legacy/plugins/graph/public/components/guidance_panel/_guidance_panel.scss new file mode 100644 index 0000000000000..f1c332eba1aa8 --- /dev/null +++ b/x-pack/legacy/plugins/graph/public/components/guidance_panel/_guidance_panel.scss @@ -0,0 +1,47 @@ +.gphGuidancePanel { + max-width: 580px; + margin: $euiSizeL 0; +} + +.gphGuidancePanel__list { + list-style: none; + margin: 0; + padding: 0; +} + +.gphGuidancePanel__item { + display: block; + max-width: 420px; + position: relative; + padding-left: $euiSizeXL; + margin-bottom: $euiSizeL; + + button { + // make buttons wrap lines like regular text + display: contents; + } +} + +.gphGuidancePanel__item--disabled { + color: $euiColorDarkShade; + pointer-events: none; + + button { + color: $euiColorDarkShade !important; + } +} + +.gphGuidancePanel__itemIcon { + position: absolute; + left: 0; + top: -($euiSizeXS / 2); + width: $euiSizeL; + height: $euiSizeL; + padding: $euiSizeXS; + + &--done { + background-color: $euiColorSecondary; + color: $euiColorEmptyShade; + border-radius: 50%; + } +} diff --git a/x-pack/legacy/plugins/graph/public/components/guidance_panel/_index.scss b/x-pack/legacy/plugins/graph/public/components/guidance_panel/_index.scss new file mode 100644 index 0000000000000..65c71cc17ba35 --- /dev/null +++ b/x-pack/legacy/plugins/graph/public/components/guidance_panel/_index.scss @@ -0,0 +1 @@ +@import './_guidance_panel'; \ No newline at end of file diff --git a/x-pack/legacy/plugins/graph/public/components/guidance_panel/guidance_panel.tsx b/x-pack/legacy/plugins/graph/public/components/guidance_panel/guidance_panel.tsx new file mode 100644 index 0000000000000..62d8bbb03bc3f --- /dev/null +++ b/x-pack/legacy/plugins/graph/public/components/guidance_panel/guidance_panel.tsx @@ -0,0 +1,143 @@ +/* + * 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 React, { ReactNode } from 'react'; +import { EuiPanel, EuiFlexGroup, EuiFlexItem, EuiIcon, EuiText, EuiLink } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import classNames from 'classnames'; +import { FormattedMessage } from '@kbn/i18n/react'; + +export interface GuidancePanelProps { + onFillWorkspace: () => void; + onOpenFieldPicker: () => void; + onOpenDatasourcePicker: () => void; + hasDatasource: boolean; + hasFields: boolean; +} + +function ListItem({ + children, + state, +}: { + state: 'done' | 'active' | 'disabled'; + children: ReactNode; +}) { + return ( +
  • + {state !== 'disabled' && ( + + + + )} + {children} +
  • + ); +} + +export function GuidancePanel(props: GuidancePanelProps) { + const { + onFillWorkspace, + onOpenFieldPicker, + onOpenDatasourcePicker, + hasDatasource, + hasFields, + } = props; + + return ( + + + + + + + + + +

    + {i18n.translate('xpack.graph.guidancePanel.title', { + defaultMessage: "Let's get started!", + })} +

    +
    +
    + +
      + + + {i18n.translate( + 'xpack.graph.guidancePanel.datasourceItem.indexPatternButtonLabel', + { + defaultMessage: 'index pattern', + } + )} + + ), + }} + /> + + + + {i18n.translate( + 'xpack.graph.guidancePanel.fieldsItem.fieldsButtonLabel', + { + defaultMessage: 'Select fields', + } + )} + + ), + }} + /> + + + + {i18n.translate( + 'xpack.graph.guidancePanel.nodesItem.topTermsButtonLabel', + { + defaultMessage: 'show correlations of the top terms', + } + )} + + ), + }} + /> + +
    +
    +
    +
    +
    +
    + ); +} diff --git a/x-pack/legacy/plugins/graph/public/components/guidance_panel/index.ts b/x-pack/legacy/plugins/graph/public/components/guidance_panel/index.ts new file mode 100644 index 0000000000000..8704eb2eb6761 --- /dev/null +++ b/x-pack/legacy/plugins/graph/public/components/guidance_panel/index.ts @@ -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 './guidance_panel'; diff --git a/x-pack/legacy/plugins/graph/public/components/search_bar.test.tsx b/x-pack/legacy/plugins/graph/public/components/search_bar.test.tsx index 80b1c3c343942..dbad0e01078fd 100644 --- a/x-pack/legacy/plugins/graph/public/components/search_bar.test.tsx +++ b/x-pack/legacy/plugins/graph/public/components/search_bar.test.tsx @@ -51,6 +51,7 @@ describe('search_bar', () => { onIndexPatternSelected: () => {}, onQuerySubmit: querySubmit, currentIndexPattern: { title: 'Testpattern' } as IndexPattern, + coreStart: {} as CoreStart, }) ); act(() => { @@ -72,6 +73,7 @@ describe('search_bar', () => { onIndexPatternSelected: () => {}, onQuerySubmit: querySubmit, currentIndexPattern: { title: 'Testpattern', fields: [{ name: 'test' }] } as IndexPattern, + coreStart: {} as CoreStart, }) ); act(() => { @@ -97,6 +99,7 @@ describe('search_bar', () => { onIndexPatternSelected: indexPatternSelected, onQuerySubmit: () => {}, currentIndexPattern: { title: 'Testpattern' } as IndexPattern, + coreStart: {} as CoreStart, }) ); diff --git a/x-pack/legacy/plugins/graph/public/components/search_bar.tsx b/x-pack/legacy/plugins/graph/public/components/search_bar.tsx index 226f6f829d8a4..18eca326776f5 100644 --- a/x-pack/legacy/plugins/graph/public/components/search_bar.tsx +++ b/x-pack/legacy/plugins/graph/public/components/search_bar.tsx @@ -8,9 +8,9 @@ import { EuiFlexGroup, EuiFlexItem, EuiButton, EuiButtonEmpty, EuiToolTip } from import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; -import { I18nProvider } from '@kbn/i18n/react'; import { fromKueryExpression, toElasticsearchQuery } from '@kbn/es-query'; import { IDataPluginServices } from 'src/legacy/core_plugins/data/public/types'; +import { CoreStart } from 'kibana/public'; import { QueryBarInput, Query, @@ -21,6 +21,7 @@ import { openSourceModal } from '../services/source_modal'; import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; export interface SearchBarProps { + coreStart: CoreStart; isLoading: boolean; currentIndexPattern?: IndexPattern; initialQuery?: string; @@ -54,71 +55,61 @@ export function SearchBar(props: SearchBarProps) { } = props; const [query, setQuery] = useState({ language: 'kuery', query: initialQuery || '' }); const kibana = useKibana(); - const { overlays, uiSettings, savedObjects } = kibana.services; + const { overlays } = kibana.services; if (!overlays) return null; return ( - -
    { - e.preventDefault(); - if (!isLoading && currentIndexPattern) { - onQuerySubmit(queryToString(query, currentIndexPattern)); - } - }} - > - - - { + e.preventDefault(); + if (!isLoading && currentIndexPattern) { + onQuerySubmit(queryToString(query, currentIndexPattern)); + } + }} + > + + + + { + openSourceModal(props.coreStart, onIndexPatternSelected); + }} > - { - openSourceModal( - { - overlays, - savedObjects, - uiSettings, - }, - onIndexPatternSelected - ); - }} - > - {currentIndexPattern - ? currentIndexPattern.title - : // This branch will be shown if the user exits the - // initial picker modal - i18n.translate('xpack.graph.bar.pickSourceLabel', { - defaultMessage: 'Click here to pick a data source', - })} - - - } - onChange={setQuery} - /> - - - - {i18n.translate('xpack.graph.bar.exploreLabel', { defaultMessage: 'Explore' })} - - - - -
    + {currentIndexPattern + ? currentIndexPattern.title + : // This branch will be shown if the user exits the + // initial picker modal + i18n.translate('xpack.graph.bar.pickSourceLabel', { + defaultMessage: 'Click here to pick a data source', + })} + + + } + onChange={setQuery} + /> + + + + {i18n.translate('xpack.graph.bar.exploreLabel', { defaultMessage: 'Explore' })} + + + + ); } diff --git a/x-pack/legacy/plugins/graph/public/components/source_modal.tsx b/x-pack/legacy/plugins/graph/public/components/source_modal.tsx index 4c3b3c8be9110..5829370b030e6 100644 --- a/x-pack/legacy/plugins/graph/public/components/source_modal.tsx +++ b/x-pack/legacy/plugins/graph/public/components/source_modal.tsx @@ -12,7 +12,7 @@ import { SourcePicker, SourcePickerProps } from './source_picker'; export function SourceModal(props: SourcePickerProps) { return ( - <> +
    - +
    ); } diff --git a/x-pack/legacy/plugins/graph/public/services/fetch_top_nodes.test.ts b/x-pack/legacy/plugins/graph/public/services/fetch_top_nodes.test.ts new file mode 100644 index 0000000000000..0a0fc8cae5d26 --- /dev/null +++ b/x-pack/legacy/plugins/graph/public/services/fetch_top_nodes.test.ts @@ -0,0 +1,97 @@ +/* + * 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 { getSuitableIcon } from '../helpers/style_choices'; +import { fetchTopNodes } from './fetch_top_nodes'; + +const icon = getSuitableIcon(''); + +describe('fetch_top_nodes', () => { + it('should build terms agg', async () => { + const postMock = jest.fn(() => Promise.resolve({ resp: {} })); + await fetchTopNodes(postMock, 'test', [ + { color: '', hopSize: 5, icon, name: 'field1', selected: false, type: 'string' }, + { color: '', hopSize: 5, icon, name: 'field2', selected: false, type: 'string' }, + ]); + expect(postMock).toHaveBeenCalledWith('../api/graph/searchProxy', { + body: JSON.stringify({ + index: 'test', + body: { + size: 0, + aggs: { + sample: { + sampler: { + shard_size: 5000, + }, + aggs: { + top_values_field1: { + terms: { + field: 'field1', + size: 10, + }, + }, + top_values_field2: { + terms: { + field: 'field2', + size: 10, + }, + }, + }, + }, + }, + }, + }), + }); + }); + + it('should map result to nodes', async () => { + const postMock = jest.fn(() => + Promise.resolve({ + resp: { + aggregations: { + sample: { + top_values_field1: { + buckets: [{ key: 'A' }, { key: 'B' }], + }, + top_values_field2: { + buckets: [{ key: 'C' }, { key: 'D' }], + }, + }, + }, + }, + }) + ); + const result = await fetchTopNodes(postMock, 'test', [ + { color: 'red', hopSize: 5, icon, name: 'field1', selected: false, type: 'string' }, + { color: 'blue', hopSize: 5, icon, name: 'field2', selected: false, type: 'string' }, + ]); + expect(result.length).toEqual(4); + expect(result[0]).toEqual({ + color: 'red', + data: { + field: 'field1', + term: 'A', + }, + field: 'field1', + icon, + id: '', + label: 'A', + term: 'A', + }); + expect(result[2]).toEqual({ + color: 'blue', + data: { + field: 'field2', + term: 'C', + }, + field: 'field2', + icon, + id: '', + label: 'C', + term: 'C', + }); + }); +}); diff --git a/x-pack/legacy/plugins/graph/public/services/fetch_top_nodes.ts b/x-pack/legacy/plugins/graph/public/services/fetch_top_nodes.ts new file mode 100644 index 0000000000000..87b33cbe35f82 --- /dev/null +++ b/x-pack/legacy/plugins/graph/public/services/fetch_top_nodes.ts @@ -0,0 +1,112 @@ +/* + * 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 { CoreStart } from 'src/core/public'; +import { WorkspaceField, ServerResultNode } from '../types'; + +const DEFAULT_SHARD_SIZE = 5000; + +function createSamplerSearchBody(aggs: object, shardSize: number = DEFAULT_SHARD_SIZE) { + return { + size: 0, + aggs: { + sample: { + sampler: { + shard_size: shardSize, + }, + aggs, + }, + }, + }; +} + +function createTopTermsAggName(fieldName: string) { + return `top_values_${fieldName}`; +} + +function createTopTermsSubAgg(field: string, size: number = 10) { + return { + [createTopTermsAggName(field)]: { + terms: { + field, + size, + }, + }, + }; +} + +// TODO use elasticsearch types here +interface TopTermsAggResponse { + aggregations?: { + sample: Record< + string, + { + buckets: Array<{ key: string; doc_count: number }>; + } + >; + }; +} + +function getTopTermsResult(response: TopTermsAggResponse, fieldName: string) { + if (!response.aggregations) { + return []; + } + return response.aggregations.sample[createTopTermsAggName(fieldName)].buckets.map( + bucket => bucket.key + ); +} + +export function createServerResultNode( + fieldName: string, + term: string, + allFields: WorkspaceField[] +): ServerResultNode { + const field = allFields.find(({ name }) => name === fieldName); + + if (!field) { + throw new Error('Invariant error: field not found'); + } + + return { + field: fieldName, + term, + id: '', + color: field.color, + icon: field.icon, + data: { + field: fieldName, + term, + }, + label: term, + }; +} + +export async function fetchTopNodes( + post: CoreStart['http']['post'], + index: string, + fields: WorkspaceField[] +) { + const aggs = fields + .map(({ name }) => name) + .map(fieldName => createTopTermsSubAgg(fieldName)) + .reduce((allAggs, subAgg) => ({ ...allAggs, ...subAgg })); + const body = createSamplerSearchBody(aggs); + + const response: TopTermsAggResponse = (await post('../api/graph/searchProxy', { + body: JSON.stringify({ index, body }), + })).resp; + + const nodes: ServerResultNode[] = []; + + fields.forEach(({ name }) => { + const topTerms = getTopTermsResult(response, name); + const fieldNodes = topTerms.map(term => createServerResultNode(name, term, fields)); + + nodes.push(...fieldNodes); + }); + + return nodes; +} diff --git a/x-pack/legacy/plugins/graph/public/types/workspace_state.ts b/x-pack/legacy/plugins/graph/public/types/workspace_state.ts index 54666c48161e6..fab093535cb63 100644 --- a/x-pack/legacy/plugins/graph/public/types/workspace_state.ts +++ b/x-pack/legacy/plugins/graph/public/types/workspace_state.ts @@ -41,27 +41,31 @@ export interface WorkspaceEdge { isSelected?: boolean; } -export interface GraphData { - nodes: Array<{ +export interface ServerResultNode { + field: string; + term: string; + id: string; + label: string; + color: string; + icon: FontawesomeIcon; + data: { field: string; term: string; - id: string; - label: string; - color: string; - icon: FontawesomeIcon; - data: { - field: string; - term: string; - }; - }>; - edges: Array<{ - source: number; - target: number; - weight: number; - width: number; - doc_count?: number; - inferred: boolean; - }>; + }; +} + +export interface ServerResultEdge { + source: number; + target: number; + weight: number; + width: number; + doc_count?: number; + inferred: boolean; +} + +export interface GraphData { + nodes: ServerResultNode[]; + edges: ServerResultEdge[]; } export interface Workspace { From ea18949ce72713b8406df72f492a5fe14081817c Mon Sep 17 00:00:00 2001 From: Michail Yasonik Date: Thu, 3 Oct 2019 15:00:12 +0530 Subject: [PATCH 15/19] Variety of quick a11y fixes (#46569) Focusing on heading structure and page layout for Home, Discover, Dashboard, and Visualize. This is progress on #37539 --- src/core/public/chrome/ui/header/header.tsx | 14 +-- .../query_bar_input.test.tsx.snap | 33 ++----- .../query_bar/components/query_bar_input.tsx | 43 ++++---- .../components/options/gauge/labels_panel.tsx | 4 +- .../components/options/gauge/ranges_panel.tsx | 4 +- .../components/options/gauge/style_panel.tsx | 4 +- .../category_axis_panel.test.tsx.snap | 4 +- .../value_axes_panel.test.tsx.snap | 4 +- .../metrics_axes/category_axis_panel.tsx | 4 +- .../options/metrics_axes/series_panel.tsx | 4 +- .../options/metrics_axes/value_axes_panel.tsx | 4 +- .../public/components/options/pie.tsx | 8 +- .../options/point_series/grid_panel.tsx | 4 +- .../options/point_series/point_series.tsx | 4 +- .../options/point_series/threshold_panel.tsx | 4 +- .../public/dashboard/dashboard_app.html | 1 + .../field_chooser/field_chooser.html | 2 +- .../__snapshots__/no_results.test.js.snap | 4 +- .../public/discover/directives/no_results.js | 4 +- .../kibana/public/discover/index.html | 10 +- .../public/visualize/editor/editor.html | 10 ++ .../public/markdown_options.tsx | 4 +- .../public/chrome/directives/kbn_chrome.html | 4 +- .../__snapshots__/agg_group.test.tsx.snap | 4 +- .../editors/default/components/agg_group.tsx | 2 +- .../public/vis/editors/default/sidebar.html | 4 +- .../lib/containers/embeddable_child_panel.tsx | 2 +- .../public/lib/panel/embeddable_panel.tsx | 14 ++- .../lib/panel/panel_header/panel_header.tsx | 97 +++++++++++-------- .../panel/panel_header/panel_options_menu.tsx | 22 ++++- .../public/context_menu/open_context_menu.tsx | 1 - .../translations/translations/ja-JP.json | 2 - .../translations/translations/zh-CN.json | 2 - 33 files changed, 186 insertions(+), 145 deletions(-) diff --git a/src/core/public/chrome/ui/header/header.tsx b/src/core/public/chrome/ui/header/header.tsx index afd9f8e4a3820..f24b0ed1681aa 100644 --- a/src/core/public/chrome/ui/header/header.tsx +++ b/src/core/public/chrome/ui/header/header.tsx @@ -19,7 +19,7 @@ import Url from 'url'; -import React, { Component, createRef, Fragment } from 'react'; +import React, { Component, createRef } from 'react'; import * as Rx from 'rxjs'; import { @@ -376,7 +376,7 @@ class HeaderUI extends Component { ]; return ( - +
    @@ -407,11 +407,13 @@ class HeaderUI extends Component { isLocked={isLocked} onIsLockedUpdate={onIsLockedUpdate} > - - - + - +
    ); } diff --git a/src/legacy/core_plugins/data/public/query/query_bar/components/__snapshots__/query_bar_input.test.tsx.snap b/src/legacy/core_plugins/data/public/query/query_bar/components/__snapshots__/query_bar_input.test.tsx.snap index da756275a83e9..6fdbf4fce4553 100644 --- a/src/legacy/core_plugins/data/public/query/query_bar/components/__snapshots__/query_bar_input.test.tsx.snap +++ b/src/legacy/core_plugins/data/public/query/query_bar/components/__snapshots__/query_bar_input.test.tsx.snap @@ -583,10 +583,9 @@ exports[`QueryBarInput Should disable autoFocus on EuiFieldText when disableAuto onOutsideClick={[Function]} >
    } - aria-activedescendant="" aria-autocomplete="list" - aria-controls="kbnTypeahead__items" - aria-label="You are on search box of Another Screen page. Start typing to search and filter the test" + aria-label="Start typing to search and filter the test page" autoComplete="off" autoFocus={false} compressed={false} @@ -651,10 +648,8 @@ exports[`QueryBarInput Should disable autoFocus on EuiFieldText when disableAuto >
    } - aria-activedescendant="" aria-autocomplete="list" - aria-controls="kbnTypeahead__items" - aria-label="You are on search box of Another Screen page. Start typing to search and filter the test" + aria-label="Start typing to search and filter the test page" autoComplete="off" autoFocus={true} compressed={false} @@ -1425,10 +1417,8 @@ exports[`QueryBarInput Should pass the query language to the language switcher 1 >
    } - aria-activedescendant="" aria-autocomplete="list" - aria-controls="kbnTypeahead__items" - aria-label="You are on search box of Another Screen page. Start typing to search and filter the test" + aria-label="Start typing to search and filter the test page" autoComplete="off" autoFocus={true} compressed={false} @@ -2199,10 +2186,8 @@ exports[`QueryBarInput Should render the given query 1`] = ` > { } public render() { + const isSuggestionsVisible = this.state.isSuggestionsVisible && { + 'aria-controls': 'kbnTypeahead__items', + 'aria-owns': 'kbnTypeahead__items', + }; + const ariaCombobox = { ...isSuggestionsVisible, role: 'combobox' }; + return (
    { }} autoComplete="off" spellCheck={false} - aria-label={ - this.props.screenTitle - ? this.props.intl.formatMessage( - { - id: 'data.query.queryBar.searchInputAriaLabel', - defaultMessage: - 'You are on search box of {previouslyTranslatedPageTitle} page. Start typing to search and filter the {pageType}', - }, - { - previouslyTranslatedPageTitle: this.props.screenTitle, - pageType: this.services.appName, - } - ) - : undefined - } + aria-label={i18n.translate('data.query.queryBar.searchInputAriaLabel', { + defaultMessage: 'Start typing to search and filter the {pageType} page', + values: { pageType: this.services.appName }, + })} type="text" aria-autocomplete="list" - aria-controls="kbnTypeahead__items" + aria-controls={this.state.isSuggestionsVisible ? 'kbnTypeahead__items' : undefined} aria-activedescendant={ - this.state.isSuggestionsVisible ? 'suggestion-' + this.state.index : '' + this.state.isSuggestionsVisible && typeof this.state.index === 'number' + ? `suggestion-${this.state.index}` + : undefined } role="textbox" prepend={this.props.prepend} diff --git a/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/gauge/labels_panel.tsx b/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/gauge/labels_panel.tsx index a24a37ca971d5..b96132fa29380 100644 --- a/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/gauge/labels_panel.tsx +++ b/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/gauge/labels_panel.tsx @@ -29,12 +29,12 @@ function LabelsPanel({ stateParams, setValue, setGaugeValue }: GaugeOptionsInter return ( -

    +

    -

    +

    diff --git a/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/gauge/ranges_panel.tsx b/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/gauge/ranges_panel.tsx index 4abfb2e604b1d..4e3b511782c9e 100644 --- a/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/gauge/ranges_panel.tsx +++ b/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/gauge/ranges_panel.tsx @@ -38,12 +38,12 @@ function RangesPanel({ return ( -

    +

    -

    +

    diff --git a/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/gauge/style_panel.tsx b/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/gauge/style_panel.tsx index f606080afbdb2..a76171673d9a8 100644 --- a/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/gauge/style_panel.tsx +++ b/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/gauge/style_panel.tsx @@ -34,12 +34,12 @@ function StylePanel({ aggs, setGaugeValue, stateParams, vis }: GaugeOptionsInter return ( -

    +

    -

    +

    diff --git a/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/metrics_axes/__snapshots__/category_axis_panel.test.tsx.snap b/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/metrics_axes/__snapshots__/category_axis_panel.test.tsx.snap index 6eef5047634f4..d88654cfdc0c4 100644 --- a/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/metrics_axes/__snapshots__/category_axis_panel.test.tsx.snap +++ b/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/metrics_axes/__snapshots__/category_axis_panel.test.tsx.snap @@ -7,13 +7,13 @@ exports[`CategoryAxisPanel component should init with the default set of props 1 -

    +

    -

    +

    -

    +

    -

    +

    -

    +

    -

    +

    diff --git a/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/metrics_axes/series_panel.tsx b/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/metrics_axes/series_panel.tsx index 434202d64d6c3..5a455f4adde31 100644 --- a/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/metrics_axes/series_panel.tsx +++ b/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/metrics_axes/series_panel.tsx @@ -38,12 +38,12 @@ function SeriesPanel(props: SeriesPanelProps) { return ( -

    +

    -

    +
    diff --git a/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/metrics_axes/value_axes_panel.tsx b/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/metrics_axes/value_axes_panel.tsx index 34a0d2cd981c5..eb0ab4333af59 100644 --- a/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/metrics_axes/value_axes_panel.tsx +++ b/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/metrics_axes/value_axes_panel.tsx @@ -109,12 +109,12 @@ function ValueAxesPanel(props: ValueAxesPanelProps) { -

    +

    -

    +
    diff --git a/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/pie.tsx b/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/pie.tsx index 982c7265d5494..53dde185ec09f 100644 --- a/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/pie.tsx +++ b/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/pie.tsx @@ -36,12 +36,12 @@ function PieOptions(props: VisOptionsProps) { <> -

    +

    -

    +
    ) { -
    +

    -

    +
    -

    +

    -

    +
    diff --git a/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/point_series/point_series.tsx b/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/point_series/point_series.tsx index 11034f7f7335e..8e3f66d12b9bd 100644 --- a/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/point_series/point_series.tsx +++ b/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/point_series/point_series.tsx @@ -34,12 +34,12 @@ function PointSeriesOptions(props: VisOptionsProps) { <> -

    +

    -

    +
    diff --git a/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/point_series/threshold_panel.tsx b/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/point_series/threshold_panel.tsx index 9877b84345a1f..49e56e377a8d5 100644 --- a/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/point_series/threshold_panel.tsx +++ b/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/options/point_series/threshold_panel.tsx @@ -42,12 +42,12 @@ function ThresholdPanel({ stateParams, setValue, vis }: VisOptionsProps -

    +

    -

    +
    diff --git a/src/legacy/core_plugins/kibana/public/dashboard/dashboard_app.html b/src/legacy/core_plugins/kibana/public/dashboard/dashboard_app.html index 5ceb28e6b225b..39db357a69321 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/dashboard_app.html +++ b/src/legacy/core_plugins/kibana/public/dashboard/dashboard_app.html @@ -120,6 +120,7 @@
    +

    {{screenTitle}}

    diff --git a/src/legacy/core_plugins/kibana/public/discover/components/field_chooser/field_chooser.html b/src/legacy/core_plugins/kibana/public/discover/components/field_chooser/field_chooser.html index 2043dc44c147e..d1a75adac5b82 100644 --- a/src/legacy/core_plugins/kibana/public/discover/components/field_chooser/field_chooser.html +++ b/src/legacy/core_plugins/kibana/public/discover/components/field_chooser/field_chooser.html @@ -84,7 +84,7 @@ i18n-default-message="Selected fields" > -
      +
        -

        Expand your time range -

        +

        One or more of the indices you’re looking at contains a date field. Your query may not match anything in the current time range, or there may not be any data at all in the currently selected time range. You can try changing the time range to one which contains data.

        diff --git a/src/legacy/core_plugins/kibana/public/discover/directives/no_results.js b/src/legacy/core_plugins/kibana/public/discover/directives/no_results.js index 9f57c49977f5a..5f6d32681b50e 100644 --- a/src/legacy/core_plugins/kibana/public/discover/directives/no_results.js +++ b/src/legacy/core_plugins/kibana/public/discover/directives/no_results.js @@ -119,12 +119,12 @@ export class DiscoverNoResults extends Component { -

        +

        -

        +

        - +

        {{screenTitle}}

        + +

        +

        +

        -

        Markdown

        +

        + +

        diff --git a/src/legacy/ui/public/chrome/directives/kbn_chrome.html b/src/legacy/ui/public/chrome/directives/kbn_chrome.html index 541082e68de58..ced89287d310f 100644 --- a/src/legacy/ui/public/chrome/directives/kbn_chrome.html +++ b/src/legacy/ui/public/chrome/directives/kbn_chrome.html @@ -1,9 +1,9 @@
        -
        + >
        diff --git a/src/legacy/ui/public/vis/editors/default/components/__snapshots__/agg_group.test.tsx.snap b/src/legacy/ui/public/vis/editors/default/components/__snapshots__/agg_group.test.tsx.snap index 813b7978d2667..29af0887db2b8 100644 --- a/src/legacy/ui/public/vis/editors/default/components/__snapshots__/agg_group.test.tsx.snap +++ b/src/legacy/ui/public/vis/editors/default/components/__snapshots__/agg_group.test.tsx.snap @@ -10,9 +10,9 @@ exports[`DefaultEditorAgg component should init with the default set of props 1` -
        +

        Metrics -

        +
        -
        {groupNameLabel}
        +

        {groupNameLabel}

        diff --git a/src/legacy/ui/public/vis/editors/default/sidebar.html b/src/legacy/ui/public/vis/editors/default/sidebar.html index 0434534bddbfd..b0a03e461fc1c 100644 --- a/src/legacy/ui/public/vis/editors/default/sidebar.html +++ b/src/legacy/ui/public/vis/editors/default/sidebar.html @@ -7,7 +7,7 @@ ng-keydown="submitEditorWithKeyboard($event)" > -
        {{ vis.indexPattern.title }} -
        +