diff --git a/packages/kbn-crypto/.babelrc b/packages/kbn-crypto/.babelrc new file mode 100644 index 0000000000000..7da72d1779128 --- /dev/null +++ b/packages/kbn-crypto/.babelrc @@ -0,0 +1,3 @@ +{ + "presets": ["@kbn/babel-preset/node_preset"] +} diff --git a/packages/kbn-crypto/BUILD.bazel b/packages/kbn-crypto/BUILD.bazel index 36b61d0fb046b..0f35aab461078 100644 --- a/packages/kbn-crypto/BUILD.bazel +++ b/packages/kbn-crypto/BUILD.bazel @@ -1,6 +1,7 @@ load("@npm//@bazel/typescript:index.bzl", "ts_config", "ts_project") load("@build_bazel_rules_nodejs//:index.bzl", "js_library", "pkg_npm") +load("//src/dev/bazel:index.bzl", "jsts_transpiler") PKG_BASE_NAME = "kbn-crypto" PKG_REQUIRE_NAME = "@kbn/crypto" @@ -26,22 +27,24 @@ NPM_MODULE_EXTRA_FILES = [ "README.md" ] -SRC_DEPS = [ +RUNTIME_DEPS = [ "//packages/kbn-dev-utils", "@npm//node-forge", ] TYPES_DEPS = [ + "//packages/kbn-dev-utils", "@npm//@types/flot", "@npm//@types/jest", "@npm//@types/node", "@npm//@types/node-forge", - "@npm//@types/testing-library__jest-dom", - "@npm//resize-observer-polyfill", - "@npm//@emotion/react", ] -DEPS = SRC_DEPS + TYPES_DEPS +jsts_transpiler( + name = "target_node", + srcs = SRCS, + build_pkg_name = package_name(), +) ts_config( name = "tsconfig", @@ -53,13 +56,14 @@ ts_config( ) ts_project( - name = "tsc", + name = "tsc_types", args = ['--pretty'], srcs = SRCS, - deps = DEPS, + deps = TYPES_DEPS, declaration = True, declaration_map = True, - out_dir = "target", + emit_declaration_only = True, + out_dir = "target_types", source_map = True, root_dir = "src", tsconfig = ":tsconfig", @@ -68,7 +72,7 @@ ts_project( js_library( name = PKG_BASE_NAME, srcs = NPM_MODULE_EXTRA_FILES, - deps = DEPS + [":tsc"], + deps = RUNTIME_DEPS + [":target_node", ":tsc_types"], package_name = PKG_REQUIRE_NAME, visibility = ["//visibility:public"], ) diff --git a/packages/kbn-crypto/package.json b/packages/kbn-crypto/package.json index bbeb57e5b7cca..8fa6cd3c232fa 100644 --- a/packages/kbn-crypto/package.json +++ b/packages/kbn-crypto/package.json @@ -3,6 +3,6 @@ "version": "1.0.0", "private": true, "license": "SSPL-1.0 OR Elastic License 2.0", - "main": "./target/index.js", - "types": "./target/index.d.ts" + "main": "./target_node/index.js", + "types": "./target_types/index.d.ts" } diff --git a/packages/kbn-crypto/tsconfig.json b/packages/kbn-crypto/tsconfig.json index af1a7c75c8e99..0863fc3f530de 100644 --- a/packages/kbn-crypto/tsconfig.json +++ b/packages/kbn-crypto/tsconfig.json @@ -1,9 +1,10 @@ { "extends": "../../tsconfig.bazel.json", "compilerOptions": { - "outDir": "./target/types", "declaration": true, "declarationMap": true, + "emitDeclarationOnly": true, + "outDir": "./target_types", "rootDir": "src", "sourceMap": true, "sourceRoot": "../../../../packages/kbn-crypto/src", diff --git a/packages/kbn-es-query/src/es_query/build_es_query.ts b/packages/kbn-es-query/src/es_query/build_es_query.ts index 955af1e4c185f..c01b11f580ba6 100644 --- a/packages/kbn-es-query/src/es_query/build_es_query.ts +++ b/packages/kbn-es-query/src/es_query/build_es_query.ts @@ -12,17 +12,17 @@ import { buildQueryFromFilters } from './from_filters'; import { buildQueryFromLucene } from './from_lucene'; import { Filter, Query } from '../filters'; import { IndexPatternBase } from './types'; +import { KueryQueryOptions } from '../kuery'; /** * Configurations to be used while constructing an ES query. * @public */ -export interface EsQueryConfig { +export type EsQueryConfig = KueryQueryOptions & { allowLeadingWildcards: boolean; queryStringOptions: Record; ignoreFilterIfFieldNotInIndex: boolean; - dateFormatTZ?: string; -} +}; function removeMatchAll(filters: T[]) { return filters.filter( @@ -59,7 +59,8 @@ export function buildEsQuery( indexPattern, queriesByLanguage.kuery, config.allowLeadingWildcards, - config.dateFormatTZ + config.dateFormatTZ, + config.filtersInMustClause ); const luceneQuery = buildQueryFromLucene( queriesByLanguage.lucene, diff --git a/packages/kbn-es-query/src/es_query/from_kuery.ts b/packages/kbn-es-query/src/es_query/from_kuery.ts index 87382585181f8..bf66057e49327 100644 --- a/packages/kbn-es-query/src/es_query/from_kuery.ts +++ b/packages/kbn-es-query/src/es_query/from_kuery.ts @@ -15,13 +15,14 @@ export function buildQueryFromKuery( indexPattern: IndexPatternBase | undefined, queries: Query[] = [], allowLeadingWildcards: boolean = false, - dateFormatTZ?: string + dateFormatTZ?: string, + filtersInMustClause: boolean = false ) { const queryASTs = queries.map((query) => { return fromKueryExpression(query.query, { allowLeadingWildcards }); }); - return buildQuery(indexPattern, queryASTs, { dateFormatTZ }); + return buildQuery(indexPattern, queryASTs, { dateFormatTZ, filtersInMustClause }); } function buildQuery( diff --git a/packages/kbn-es-query/src/kuery/functions/and.test.ts b/packages/kbn-es-query/src/kuery/functions/and.test.ts index 1e6797485c964..239342bdc0a1c 100644 --- a/packages/kbn-es-query/src/kuery/functions/and.test.ts +++ b/packages/kbn-es-query/src/kuery/functions/and.test.ts @@ -55,6 +55,24 @@ describe('kuery functions', () => { ) ); }); + + test("should wrap subqueries in an ES bool query's must clause for scoring if enabled", () => { + const node = nodeTypes.function.buildNode('and', [childNode1, childNode2]); + const result = and.toElasticsearchQuery(node, indexPattern, { + filtersInMustClause: true, + }); + + expect(result).toHaveProperty('bool'); + expect(Object.keys(result).length).toBe(1); + expect(result.bool).toHaveProperty('must'); + expect(Object.keys(result.bool).length).toBe(1); + + expect(result.bool.must).toEqual( + [childNode1, childNode2].map((childNode) => + ast.toElasticsearchQuery(childNode, indexPattern) + ) + ); + }); }); }); }); diff --git a/packages/kbn-es-query/src/kuery/functions/and.ts b/packages/kbn-es-query/src/kuery/functions/and.ts index 239dd67e73d10..98788ac07b715 100644 --- a/packages/kbn-es-query/src/kuery/functions/and.ts +++ b/packages/kbn-es-query/src/kuery/functions/and.ts @@ -7,7 +7,7 @@ */ import * as ast from '../ast'; -import { IndexPatternBase, KueryNode } from '../..'; +import { IndexPatternBase, KueryNode, KueryQueryOptions } from '../..'; export function buildNodeParams(children: KueryNode[]) { return { @@ -18,14 +18,16 @@ export function buildNodeParams(children: KueryNode[]) { export function toElasticsearchQuery( node: KueryNode, indexPattern?: IndexPatternBase, - config: Record = {}, + config: KueryQueryOptions = {}, context: Record = {} ) { + const { filtersInMustClause } = config; const children = node.arguments || []; + const key = filtersInMustClause ? 'must' : 'filter'; return { bool: { - filter: children.map((child: KueryNode) => { + [key]: children.map((child: KueryNode) => { return ast.toElasticsearchQuery(child, indexPattern, config, context); }), }, diff --git a/packages/kbn-es-query/src/kuery/index.ts b/packages/kbn-es-query/src/kuery/index.ts index 7796785f85394..dd1e39307b27e 100644 --- a/packages/kbn-es-query/src/kuery/index.ts +++ b/packages/kbn-es-query/src/kuery/index.ts @@ -9,4 +9,4 @@ export { KQLSyntaxError } from './kuery_syntax_error'; export { nodeTypes, nodeBuilder } from './node_types'; export { fromKueryExpression, toElasticsearchQuery } from './ast'; -export { DslQuery, KueryNode } from './types'; +export { DslQuery, KueryNode, KueryQueryOptions } from './types'; diff --git a/packages/kbn-es-query/src/kuery/types.ts b/packages/kbn-es-query/src/kuery/types.ts index f188eab61c546..59c48f21425bc 100644 --- a/packages/kbn-es-query/src/kuery/types.ts +++ b/packages/kbn-es-query/src/kuery/types.ts @@ -32,3 +32,9 @@ export interface KueryParseOptions { } export { nodeTypes } from './node_types'; + +/** @public */ +export interface KueryQueryOptions { + filtersInMustClause?: boolean; + dateFormatTZ?: string; +} diff --git a/packages/kbn-securitysolution-list-utils/src/autocomplete_operators/index.ts b/packages/kbn-securitysolution-list-utils/src/autocomplete_operators/index.ts index 051c359dc4612..101076bdfcfff 100644 --- a/packages/kbn-securitysolution-list-utils/src/autocomplete_operators/index.ts +++ b/packages/kbn-securitysolution-list-utils/src/autocomplete_operators/index.ts @@ -85,6 +85,13 @@ export const isNotInListOperator: OperatorOption = { value: 'is_not_in_list', }; +export const EVENT_FILTERS_OPERATORS: OperatorOption[] = [ + isOperator, + isNotOperator, + isOneOfOperator, + isNotOneOfOperator, +]; + export const EXCEPTION_OPERATORS: OperatorOption[] = [ isOperator, isNotOperator, diff --git a/src/dev/typescript/run_type_check_cli.ts b/src/dev/typescript/run_type_check_cli.ts index 1bf31a6c5bac0..6a28631322857 100644 --- a/src/dev/typescript/run_type_check_cli.ts +++ b/src/dev/typescript/run_type_check_cli.ts @@ -37,19 +37,34 @@ export async function runTypeCheckCli() { : undefined; const projects = PROJECTS.filter((p) => { - return ( - !p.disableTypeCheck && - (!projectFilter || p.tsConfigPath === projectFilter) && - !p.isCompositeProject() - ); + return !p.disableTypeCheck && (!projectFilter || p.tsConfigPath === projectFilter); }); if (!projects.length) { - throw createFailError(`Unable to find project at ${flags.project}`); + if (projectFilter) { + throw createFailError(`Unable to find project at ${flags.project}`); + } else { + throw createFailError(`Unable to find projects to type-check`); + } + } + + const nonCompositeProjects = projects.filter((p) => !p.isCompositeProject()); + if (!nonCompositeProjects.length) { + if (projectFilter) { + log.success( + `${flags.project} is a composite project so its types are validated by scripts/build_ts_refs` + ); + } else { + log.success( + `All projects are composite so their types are validated by scripts/build_ts_refs` + ); + } + + return; } const concurrency = Math.min(4, Math.round((Os.cpus() || []).length / 2) || 1) || 1; - log.info('running type check in', projects.length, 'non-composite projects'); + log.info('running type check in', nonCompositeProjects.length, 'non-composite projects'); const tscArgs = [ ...['--emitDeclarationOnly', 'false'], @@ -61,7 +76,7 @@ export async function runTypeCheckCli() { ]; const failureCount = await lastValueFrom( - Rx.from(projects).pipe( + Rx.from(nonCompositeProjects).pipe( mergeMap(async (p) => { const relativePath = Path.relative(process.cwd(), p.tsConfigPath); diff --git a/src/plugins/data/common/search/search_source/search_source.test.ts b/src/plugins/data/common/search/search_source/search_source.test.ts index 90f5ff331b971..4e62b49938ade 100644 --- a/src/plugins/data/common/search/search_source/search_source.test.ts +++ b/src/plugins/data/common/search/search_source/search_source.test.ts @@ -359,6 +359,69 @@ describe('SearchSource', () => { expect(request.fields).toEqual(['*']); expect(request._source).toEqual(false); }); + + test('includes queries in the "filter" clause by default', async () => { + searchSource.setField('query', { + query: 'agent.keyword : "Mozilla" ', + language: 'kuery', + }); + const request = searchSource.getSearchRequestBody(); + expect(request.query).toMatchInlineSnapshot(` + Object { + "bool": Object { + "filter": Array [ + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "match_phrase": Object { + "agent.keyword": "Mozilla", + }, + }, + ], + }, + }, + ], + "must": Array [], + "must_not": Array [], + "should": Array [], + }, + } + `); + }); + + test('includes queries in the "must" clause if sorting by _score', async () => { + searchSource.setField('query', { + query: 'agent.keyword : "Mozilla" ', + language: 'kuery', + }); + searchSource.setField('sort', [{ _score: SortDirection.asc }]); + const request = searchSource.getSearchRequestBody(); + expect(request.query).toMatchInlineSnapshot(` + Object { + "bool": Object { + "filter": Array [], + "must": Array [ + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "match_phrase": Object { + "agent.keyword": "Mozilla", + }, + }, + ], + }, + }, + ], + "must_not": Array [], + "should": Array [], + }, + } + `); + }); }); describe('source filters handling', () => { @@ -943,27 +1006,27 @@ describe('SearchSource', () => { expect(next).toBeCalledTimes(2); expect(complete).toBeCalledTimes(1); expect(next.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Object { - "isPartial": true, - "isRunning": true, - "rawResponse": Object { - "test": 1, - }, - }, - ] - `); + Array [ + Object { + "isPartial": true, + "isRunning": true, + "rawResponse": Object { + "test": 1, + }, + }, + ] + `); expect(next.mock.calls[1]).toMatchInlineSnapshot(` - Array [ - Object { - "isPartial": false, - "isRunning": false, - "rawResponse": Object { - "test": 2, - }, - }, - ] - `); + Array [ + Object { + "isPartial": false, + "isRunning": false, + "rawResponse": Object { + "test": 2, + }, + }, + ] + `); }); test('shareReplays result', async () => { diff --git a/src/plugins/data/common/search/search_source/search_source.ts b/src/plugins/data/common/search/search_source/search_source.ts index c72976e3412a6..f2b801ebac29f 100644 --- a/src/plugins/data/common/search/search_source/search_source.ts +++ b/src/plugins/data/common/search/search_source/search_source.ts @@ -79,6 +79,7 @@ import { IIndexPattern, IndexPattern, IndexPatternField } from '../../index_patt import { AggConfigs, ES_SEARCH_STRATEGY, + EsQuerySortValue, IEsSearchResponse, ISearchGeneric, ISearchOptions, @@ -833,7 +834,14 @@ export class SearchSource { body.fields = filteredDocvalueFields; } - const esQueryConfigs = getEsQueryConfig({ get: getConfig }); + // If sorting by _score, build queries in the "must" clause instead of "filter" clause to enable scoring + const filtersInMustClause = (body.sort ?? []).some((sort: EsQuerySortValue[]) => + sort.hasOwnProperty('_score') + ); + const esQueryConfigs = { + ...getEsQueryConfig({ get: getConfig }), + filtersInMustClause, + }; body.query = buildEsQuery(index, query, filters, esQueryConfigs); if (highlightAll && body.query) { diff --git a/src/plugins/discover/public/application/apps/main/components/doc_table/components/table_header/score_sort_warning.tsx b/src/plugins/discover/public/application/apps/main/components/doc_table/components/table_header/score_sort_warning.tsx new file mode 100644 index 0000000000000..f2b086b84a260 --- /dev/null +++ b/src/plugins/discover/public/application/apps/main/components/doc_table/components/table_header/score_sort_warning.tsx @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { EuiIconTip } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +export function DocViewTableScoreSortWarning() { + const tooltipContent = i18n.translate('discover.docViews.table.scoreSortWarningTooltip', { + defaultMessage: 'In order to retrieve values for _score, you must sort by it.', + }); + + return ; +} diff --git a/src/plugins/discover/public/application/apps/main/components/doc_table/components/table_header/table_header_column.tsx b/src/plugins/discover/public/application/apps/main/components/doc_table/components/table_header/table_header_column.tsx index e4cbac052ca67..fc7b41c43049b 100644 --- a/src/plugins/discover/public/application/apps/main/components/doc_table/components/table_header/table_header_column.tsx +++ b/src/plugins/discover/public/application/apps/main/components/doc_table/components/table_header/table_header_column.tsx @@ -10,6 +10,7 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; import { SortOrder } from './helpers'; +import { DocViewTableScoreSortWarning } from './score_sort_warning'; interface Props { colLeftIdx: number; // idx of the column to the left, -1 if moving is not possible @@ -64,6 +65,10 @@ export function TableHeaderColumn({ const curColSort = sortOrder.find((pair) => pair[0] === name); const curColSortDir = (curColSort && curColSort[1]) || ''; + // If this is the _score column, and _score is not one of the columns inside the sort, show a + // warning that the _score will not be retrieved from Elasticsearch + const showScoreSortWarning = name === '_score' && !curColSort; + const handleChangeSortOrder = () => { if (!onChangeSortOrder) return; @@ -177,6 +182,7 @@ export function TableHeaderColumn({ return ( + {showScoreSortWarning && } {displayName} {buttons .filter((button) => button.active) diff --git a/src/plugins/kibana_react/public/toolbar_button/toolbar_button.scss b/src/plugins/kibana_react/public/toolbar_button/toolbar_button.scss index 8a4545672de3c..0b5152bd99bbf 100644 --- a/src/plugins/kibana_react/public/toolbar_button/toolbar_button.scss +++ b/src/plugins/kibana_react/public/toolbar_button/toolbar_button.scss @@ -58,6 +58,10 @@ font-weight: $euiFontWeightBold; } +.kbnToolbarButton--normal { + font-weight: $euiFontWeightRegular; +} + .kbnToolbarButton--s { box-shadow: none !important; // sass-lint:disable-line no-important font-size: $euiFontSizeS; diff --git a/src/plugins/vis_type_timeseries/common/constants.ts b/src/plugins/vis_type_timeseries/common/constants.ts index 1debfaf951e99..bddbf095e895e 100644 --- a/src/plugins/vis_type_timeseries/common/constants.ts +++ b/src/plugins/vis_type_timeseries/common/constants.ts @@ -14,3 +14,4 @@ export const ROUTES = { FIELDS: '/api/metrics/fields', }; export const USE_KIBANA_INDEXES_KEY = 'use_kibana_indexes'; +export const TSVB_DEFAULT_COLOR = '#68BC00'; diff --git a/src/plugins/vis_type_timeseries/common/types/panel_model.ts b/src/plugins/vis_type_timeseries/common/types/panel_model.ts index 7eea4e64e7c6f..2ac9125534ac7 100644 --- a/src/plugins/vis_type_timeseries/common/types/panel_model.ts +++ b/src/plugins/vis_type_timeseries/common/types/panel_model.ts @@ -24,6 +24,7 @@ interface Percentile { shade?: number | string; value?: number | string; percentile?: string; + color?: string; } export interface Metric { @@ -52,6 +53,7 @@ export interface Metric { type: string; value?: string; values?: string[]; + colors?: string[]; size?: string | number; agg_with?: string; order?: string; diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/histogram_support.test.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/histogram_support.test.js index c536856327f28..c4a49a393acd6 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/aggs/histogram_support.test.js +++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/histogram_support.test.js @@ -27,7 +27,7 @@ const runTest = (aggType, name, test, additionalProps = {}) => { ...additionalProps, }; const series = { ...SERIES, metrics: [metric] }; - const panel = { ...PANEL, series }; + const panel = PANEL; it(name, () => { const wrapper = mountWithIntl( diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile.js index 45bb5387c5cd3..94adb37de156b 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile.js +++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile.js @@ -102,7 +102,13 @@ export function PercentileAgg(props) { /> } > - + diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile_rank/multi_value_row.test.tsx b/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile_rank/multi_value_row.test.tsx new file mode 100644 index 0000000000000..7b08715ba1a93 --- /dev/null +++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile_rank/multi_value_row.test.tsx @@ -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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { shallowWithIntl } from '@kbn/test/jest'; +import { MultiValueRow } from './multi_value_row'; +import { ColorPicker } from '../../color_picker'; + +describe('MultiValueRow', () => { + const model = { + id: 95, + value: '95', + color: '#00028', + }; + const props = { + model, + enableColorPicker: true, + onChange: jest.fn(), + onDelete: jest.fn(), + onAdd: jest.fn(), + disableAdd: false, + disableDelete: false, + }; + + const wrapper = shallowWithIntl(); + + it('displays a color picker if the enableColorPicker prop is true', () => { + expect(wrapper.find(ColorPicker).length).toEqual(1); + }); + + it('not displays a color picker if the enableColorPicker prop is false', () => { + const newWrapper = shallowWithIntl(); + expect(newWrapper.find(ColorPicker).length).toEqual(0); + }); + + it('sets the picker color to the model color', () => { + expect(wrapper.find(ColorPicker).prop('value')).toEqual('#00028'); + }); + + it('should have called the onChange function on color change', () => { + wrapper.find(ColorPicker).simulate('change'); + expect(props.onChange).toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile_rank/multi_value_row.tsx b/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile_rank/multi_value_row.tsx index 8fa65e6ce40db..d1174ce95367c 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile_rank/multi_value_row.tsx +++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile_rank/multi_value_row.tsx @@ -10,17 +10,21 @@ import React, { ChangeEvent } from 'react'; import { get } from 'lodash'; import { EuiFieldNumber, EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui'; +import { TSVB_DEFAULT_COLOR } from '../../../../../common/constants'; import { AddDeleteButtons } from '../../add_delete_buttons'; +import { ColorPicker, ColorProps } from '../../color_picker'; interface MultiValueRowProps { model: { id: number; value: string; + color: string; }; disableAdd: boolean; disableDelete: boolean; - onChange: ({ value, id }: { id: number; value: string }) => void; + enableColorPicker: boolean; + onChange: ({ value, id, color }: { id: number; value: string; color: string }) => void; onDelete: (model: { id: number; value: string }) => void; onAdd: () => void; } @@ -32,6 +36,7 @@ export const MultiValueRow = ({ onAdd, disableAdd, disableDelete, + enableColorPicker, }: MultiValueRowProps) => { const onFieldNumberChange = (event: ChangeEvent) => onChange({ @@ -39,9 +44,25 @@ export const MultiValueRow = ({ value: get(event, 'target.value'), }); + const onColorPickerChange = (props: ColorProps) => + onChange({ + ...model, + color: props?.color || TSVB_DEFAULT_COLOR, + }); + return ( + {enableColorPicker && ( + + + + )} { const { panel, fields, indexPattern } = props; - const defaults = { values: [''] }; + const defaults = { values: [''], colors: [TSVB_DEFAULT_COLOR] }; const model = { ...defaults, ...props.model }; const htmlId = htmlIdGenerator(); @@ -56,11 +59,17 @@ export const PercentileRankAgg = (props: PercentileRankAggProps) => { const handleChange = createChangeHandler(props.onChange, model); const handleSelectChange = createSelectHandler(handleChange); const handleNumberChange = createNumberHandler(handleChange); + const percentileRankSeries = + panel.series.find((s) => s.id === props.series.id) || panel.series[0]; + // If the series is grouped by, then these colors are not respected, no need to display the color picker */ + const isGroupedBy = panel.series.length > 0 && percentileRankSeries.split_mode !== 'everything'; + const enableColorPicker = !isGroupedBy && !['table', 'metric', 'markdown'].includes(panel.type); - const handlePercentileRankValuesChange = (values: Metric['values']) => { + const handlePercentileRankValuesChange = (values: Metric['values'], colors: Metric['colors']) => { handleChange({ ...model, values, + colors, }); }; return ( @@ -119,8 +128,10 @@ export const PercentileRankAgg = (props: PercentileRankAggProps) => { disableAdd={isTablePanel} disableDelete={isTablePanel} showOnlyLastRow={isTablePanel} - model={model.values!} + values={model.values!} + colors={model.colors!} onChange={handlePercentileRankValuesChange} + enableColorPicker={enableColorPicker} /> diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile_rank/percentile_rank_values.tsx b/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile_rank/percentile_rank_values.tsx index 2441611b87d31..f3eb290f77a08 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile_rank/percentile_rank_values.tsx +++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile_rank/percentile_rank_values.tsx @@ -10,35 +10,43 @@ import React from 'react'; import { last } from 'lodash'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { TSVB_DEFAULT_COLOR } from '../../../../../common/constants'; import { MultiValueRow } from './multi_value_row'; interface PercentileRankValuesProps { - model: Array; + values: string[]; + colors: string[]; disableDelete: boolean; disableAdd: boolean; showOnlyLastRow: boolean; - onChange: (values: any[]) => void; + enableColorPicker: boolean; + onChange: (values: string[], colors: string[]) => void; } export const PercentileRankValues = (props: PercentileRankValuesProps) => { - const model = props.model || []; - const { onChange, disableAdd, disableDelete, showOnlyLastRow } = props; + const values = props.values || []; + const colors = props.colors || []; + const { onChange, disableAdd, disableDelete, showOnlyLastRow, enableColorPicker } = props; - const onChangeValue = ({ value, id }: { value: string; id: number }) => { - model[id] = value; + const onChangeValue = ({ value, id, color }: { value: string; id: number; color: string }) => { + values[id] = value; + colors[id] = color; - onChange(model); + onChange(values, colors); }; const onDeleteValue = ({ id }: { id: number }) => - onChange(model.filter((item, currentIndex) => id !== currentIndex)); - const onAddValue = () => onChange([...model, '']); + onChange( + values.filter((item, currentIndex) => id !== currentIndex), + colors.filter((item, currentIndex) => id !== currentIndex) + ); + const onAddValue = () => onChange([...values, ''], [...colors, TSVB_DEFAULT_COLOR]); const renderRow = ({ rowModel, disableDeleteRow, disableAddRow, }: { - rowModel: { id: number; value: string }; + rowModel: { id: number; value: string; color: string }; disableDeleteRow: boolean; disableAddRow: boolean; }) => ( @@ -50,6 +58,7 @@ export const PercentileRankValues = (props: PercentileRankValuesProps) => { disableDelete={disableDeleteRow} disableAdd={disableAddRow} model={rowModel} + enableColorPicker={enableColorPicker} /> ); @@ -59,19 +68,21 @@ export const PercentileRankValues = (props: PercentileRankValuesProps) => { {showOnlyLastRow && renderRow({ rowModel: { - id: model.length - 1, - value: last(model) || '', + id: values.length - 1, + value: last(values) || '', + color: last(colors) || TSVB_DEFAULT_COLOR, }, disableAddRow: true, disableDeleteRow: true, })} {!showOnlyLastRow && - model.map((value, id, array) => + values.map((value, id, array) => renderRow({ rowModel: { id, value: value || '', + color: colors[id] || TSVB_DEFAULT_COLOR, }, disableAddRow: disableAdd, disableDeleteRow: disableDelete || array.length < 2, diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile_ui.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile_ui.js index 5b8b56849fcda..bfd41b9cdfafc 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile_ui.js +++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile_ui.js @@ -10,6 +10,7 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { i18n } from '@kbn/i18n'; import _ from 'lodash'; +import { TSVB_DEFAULT_COLOR } from '../../../../common/constants'; import { collectionActions } from '../lib/collection_actions'; import { AddDeleteButtons } from '../add_delete_buttons'; import uuid from 'uuid'; @@ -23,10 +24,11 @@ import { EuiFlexGrid, EuiPanel, } from '@elastic/eui'; +import { ColorPicker } from '../color_picker'; import { FormattedMessage } from '@kbn/i18n/react'; export const newPercentile = (opts) => { - return _.assign({ id: uuid.v1(), mode: 'line', shade: 0.2 }, opts); + return _.assign({ id: uuid.v1(), mode: 'line', shade: 0.2, color: TSVB_DEFAULT_COLOR }, opts); }; export class Percentiles extends Component { @@ -39,11 +41,20 @@ export class Percentiles extends Component { }; } + handleColorChange(item) { + return (val) => { + const handleChange = collectionActions.handleChange.bind(null, this.props); + handleChange(_.assign({}, item, val)); + }; + } + renderRow = (row, i, items) => { - const defaults = { value: '', percentile: '', shade: '' }; + const defaults = { value: '', percentile: '', shade: '', color: TSVB_DEFAULT_COLOR }; const model = { ...defaults, ...row }; - const { panel } = this.props; + const { panel, seriesId } = this.props; const flexItemStyle = { minWidth: 100 }; + const percentileSeries = panel.series.find((s) => s.id === seriesId) || panel.series[0]; + const isGroupedBy = panel.series.length > 0 && percentileSeries.split_mode !== 'everything'; const percentileFieldNumber = ( @@ -106,7 +117,19 @@ export class Percentiles extends Component { - + + {/* If the series is grouped by, then these colors are not respected, + no need to display the color picker */} + {!isGroupedBy && !['table', 'metric', 'markdown'].includes(panel.type) && ( + + + + )} {percentileFieldNumber} { + const props = { + name: 'percentiles', + model: { + values: ['100', '200'], + colors: ['#00028', 'rgba(96,146,192,1)'], + percentiles: [ + { + id: 'ece1c4b0-fb4b-11eb-a845-3de627f78e15', + mode: 'line', + shade: 0.2, + color: '#00028', + value: 50, + }, + ], + }, + panel: { + time_range_mode: 'entire_time_range', + series: [ + { + axis_position: 'right', + chart_type: 'line', + color: '#68BC00', + fill: 0.5, + formatter: 'number', + id: '64e4b07a-206e-4a0d-87e1-d6f5864f4acb', + label: '', + line_width: 1, + metrics: [ + { + values: ['100', '200'], + colors: ['#68BC00', 'rgba(96,146,192,1)'], + field: 'AvgTicketPrice', + id: 'a64ed16c-c642-4705-8045-350206595530', + type: 'percentile', + percentiles: [ + { + id: 'ece1c4b0-fb4b-11eb-a845-3de627f78e15', + mode: 'line', + shade: 0.2, + color: '#68BC00', + value: 50, + }, + ], + }, + ], + palette: { + name: 'default', + type: 'palette', + }, + point_size: 1, + separate_axis: 0, + split_mode: 'everything', + stacked: 'none', + type: 'timeseries', + }, + ], + show_grid: 1, + show_legend: 1, + time_field: '', + tooltip_mode: 'show_all', + type: 'timeseries', + use_kibana_indexes: true, + }, + seriesId: '64e4b07a-206e-4a0d-87e1-d6f5864f4acb', + id: 'iecdd7ef1-fb4b-11eb-8db9-69be3a5b3be0', + onBlur: jest.fn(), + onChange: jest.fn(), + onFocus: jest.fn(), + }; + + const wrapper = shallowWithIntl(); + + it('displays a color picker if is not grouped by', () => { + expect(wrapper.find(ColorPicker).length).toEqual(1); + }); + + it('sets the picker color to the model color', () => { + expect(wrapper.find(ColorPicker).prop('value')).toEqual('#00028'); + }); + + it('should have called the onChange function on color change', () => { + wrapper.find(ColorPicker).simulate('change'); + expect(props.onChange).toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/vis_type_timeseries/public/application/components/color_picker.tsx b/src/plugins/vis_type_timeseries/public/application/components/color_picker.tsx index 280e4eda33899..fbfec01121036 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/color_picker.tsx +++ b/src/plugins/vis_type_timeseries/public/application/components/color_picker.tsx @@ -20,7 +20,7 @@ import { i18n } from '@kbn/i18n'; const COMMAS_NUMS_ONLY_RE = /[^0-9,]/g; -interface ColorProps { +export interface ColorProps { [key: string]: string | null; } diff --git a/src/plugins/vis_type_timeseries/public/metrics_type.ts b/src/plugins/vis_type_timeseries/public/metrics_type.ts index a2efe39b2c7f0..364de9c6b4245 100644 --- a/src/plugins/vis_type_timeseries/public/metrics_type.ts +++ b/src/plugins/vis_type_timeseries/public/metrics_type.ts @@ -11,6 +11,7 @@ import uuid from 'uuid/v4'; import { TSVB_EDITOR_NAME } from './application/editor_controller'; import { PANEL_TYPES, TOOLTIP_MODES } from '../common/enums'; import { isStringTypeIndexPattern } from '../common/index_patterns_utils'; +import { TSVB_DEFAULT_COLOR } from '../common/constants'; import { toExpressionAst } from './to_ast'; import { VIS_EVENT_TO_TRIGGER, VisGroups, VisParams } from '../../visualizations/public'; import { getDataStart } from './services'; @@ -30,7 +31,7 @@ export const metricsVisDefinition = { series: [ { id: uuid(), - color: '#68BC00', + color: TSVB_DEFAULT_COLOR, split_mode: 'everything', palette: { type: 'palette', diff --git a/src/plugins/vis_type_timeseries/public/test_utils/index.ts b/src/plugins/vis_type_timeseries/public/test_utils/index.ts index d5121237cd2a7..b88c765baf3a3 100644 --- a/src/plugins/vis_type_timeseries/public/test_utils/index.ts +++ b/src/plugins/vis_type_timeseries/public/test_utils/index.ts @@ -35,5 +35,5 @@ export const SERIES = { export const PANEL = { type: 'timeseries', index_pattern: INDEX_PATTERN, - series: SERIES, + series: [SERIES], }; diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/percentile.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/percentile.js index 5eec0f8f2c6f6..b7e0026132af3 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/percentile.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/percentile.js @@ -38,7 +38,10 @@ export function percentile(resp, panel, series, meta, extractFields) { if (percentile.mode === 'band') { results.push({ id, - color: split.color, + color: + series.split_mode === 'everything' && percentile.color + ? percentile.color + : split.color, label: split.label, data, lines: { @@ -60,8 +63,11 @@ export function percentile(resp, panel, series, meta, extractFields) { const decoration = getDefaultDecoration(series); results.push({ id, - color: split.color, - label: `${split.label} (${percentileValue})`, + color: + series.split_mode === 'everything' && percentile.color + ? percentile.color + : split.color, + label: `(${percentileValue}) ${split.label}`, data, ...decoration, }); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/percentile.test.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/percentile.test.js index 9174876c768c5..de304913d6c69 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/percentile.test.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/percentile.test.js @@ -31,7 +31,7 @@ describe('percentile(resp, panel, series)', () => { type: 'percentile', field: 'cpu', percentiles: [ - { id: '10-90', mode: 'band', value: 10, percentile: 90, shade: 0.2 }, + { id: '10-90', mode: 'band', value: 10, percentile: 90, shade: 0.2, color: '#000028' }, { id: '50', mode: 'line', value: 50 }, ], }, @@ -84,7 +84,7 @@ describe('percentile(resp, panel, series)', () => { expect(results).toHaveLength(2); expect(results[0]).toHaveProperty('id', 'test:10-90'); - expect(results[0]).toHaveProperty('color', 'rgb(255, 0, 0)'); + expect(results[0]).toHaveProperty('color', '#000028'); expect(results[0]).toHaveProperty('label', 'Percentile of cpu'); expect(results[0]).toHaveProperty('lines'); expect(results[0].lines).toEqual({ @@ -102,7 +102,7 @@ describe('percentile(resp, panel, series)', () => { expect(results[1]).toHaveProperty('id', 'test:50'); expect(results[1]).toHaveProperty('color', 'rgb(255, 0, 0)'); - expect(results[1]).toHaveProperty('label', 'Percentile of cpu (50)'); + expect(results[1]).toHaveProperty('label', '(50) Percentile of cpu'); expect(results[1]).toHaveProperty('stack', false); expect(results[1]).toHaveProperty('lines'); expect(results[1].lines).toEqual({ diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/percentile_rank.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/percentile_rank.js index 96b004d4b539e..7203be4d2feb6 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/percentile_rank.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/percentile_rank.js @@ -34,8 +34,11 @@ export function percentileRank(resp, panel, series, meta, extractFields) { results.push({ data, id: `${split.id}:${percentileRank}:${index}`, - label: `${split.label} (${percentileRank || 0})`, - color: split.color, + label: `(${percentileRank || 0}) ${split.label}`, + color: + series.split_mode === 'everything' && metric.colors + ? metric.colors[index] + : split.color, ...getDefaultDecoration(series), }); }); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/percentile_rank.test.ts b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/percentile_rank.test.ts new file mode 100644 index 0000000000000..c1e5bd006ef68 --- /dev/null +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/percentile_rank.test.ts @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +// @ts-expect-error no typed yet +import { percentileRank } from './percentile_rank'; +import type { Panel, Series } from '../../../../../common/types'; + +describe('percentile_rank(resp, panel, series, meta, extractFields)', () => { + let panel: Panel; + let series: Series; + let resp: unknown; + beforeEach(() => { + panel = { + time_field: 'timestamp', + } as Panel; + series = ({ + chart_type: 'line', + stacked: 'stacked', + line_width: 1, + point_size: 1, + fill: 0, + color: 'rgb(255, 0, 0)', + id: 'test', + split_mode: 'everything', + metrics: [ + { + id: 'pct_rank', + type: 'percentile_rank', + field: 'cpu', + values: ['1000', '500'], + colors: ['#000028', '#0000FF'], + }, + ], + } as unknown) as Series; + resp = { + aggregations: { + test: { + timeseries: { + buckets: [ + { + key: 1, + pct_rank: { + values: { '500.0': 1, '1000.0': 2 }, + }, + }, + { + key: 2, + pct_rank: { + values: { '500.0': 3, '1000.0': 1 }, + }, + }, + ], + }, + }, + }, + }; + }); + + test('calls next when finished', async () => { + const next = jest.fn(); + + await percentileRank(resp, panel, series, {})(next)([]); + + expect(next.mock.calls.length).toEqual(1); + }); + + test('creates a series', async () => { + const next = (results: unknown) => results; + const results = await percentileRank(resp, panel, series, {})(next)([]); + + expect(results).toHaveLength(2); + + expect(results[0]).toHaveProperty('id', 'test:1000:0'); + expect(results[0]).toHaveProperty('color', '#000028'); + expect(results[0]).toHaveProperty('label', '(1000) Percentile Rank of cpu'); + expect(results[0].data).toEqual([ + [1, 2], + [2, 1], + ]); + + expect(results[1]).toHaveProperty('id', 'test:500:1'); + expect(results[1]).toHaveProperty('color', '#0000FF'); + expect(results[1]).toHaveProperty('label', '(500) Percentile Rank of cpu'); + expect(results[1].data).toEqual([ + [1, 1], + [2, 3], + ]); + }); +}); diff --git a/x-pack/examples/embedded_lens_example/public/app.tsx b/x-pack/examples/embedded_lens_example/public/app.tsx index bf43e200b902d..58c932c3ca164 100644 --- a/x-pack/examples/embedded_lens_example/public/app.tsx +++ b/x-pack/examples/embedded_lens_example/public/app.tsx @@ -67,6 +67,7 @@ function getLensAttributes( { accessors: ['col2'], layerId: 'layer1', + layerType: 'data', seriesType: 'bar_stacked', xAccessor: 'col1', yConfig: [{ forAccessor: 'col2', color }], diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/field_data_row/action_menu/lens_utils.ts b/x-pack/plugins/data_visualizer/public/application/common/components/field_data_row/action_menu/lens_utils.ts index 4d90defc668a4..0b0c0e8fe3f09 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/field_data_row/action_menu/lens_utils.ts +++ b/x-pack/plugins/data_visualizer/public/application/common/components/field_data_row/action_menu/lens_utils.ts @@ -55,6 +55,7 @@ export function getNumberSettings(item: FieldVisConfig, defaultIndexPattern: Ind const layer: XYLayerConfig = { accessors: ['col2'], layerId: 'layer1', + layerType: 'data', seriesType: 'bar', xAccessor: 'col1', }; @@ -86,6 +87,7 @@ export function getNumberSettings(item: FieldVisConfig, defaultIndexPattern: Ind const layer: XYLayerConfig = { accessors: ['col2'], layerId: 'layer1', + layerType: 'data', seriesType: 'line', xAccessor: 'col1', }; @@ -115,6 +117,7 @@ export function getDateSettings(item: FieldVisConfig) { const layer: XYLayerConfig = { accessors: ['col2'], layerId: 'layer1', + layerType: 'data', seriesType: 'line', xAccessor: 'col1', }; @@ -147,6 +150,7 @@ export function getKeywordSettings(item: FieldVisConfig) { const layer: XYLayerConfig = { accessors: ['col2'], layerId: 'layer1', + layerType: 'data', seriesType: 'bar', xAccessor: 'col1', }; @@ -179,6 +183,7 @@ export function getBooleanSettings(item: FieldVisConfig) { const layer: XYLayerConfig = { accessors: ['col2'], layerId: 'layer1', + layerType: 'data', seriesType: 'bar', xAccessor: 'col1', }; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/constants/actions.ts b/x-pack/plugins/enterprise_search/public/applications/shared/constants/actions.ts index e6511947d2506..6579e911cc19b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/constants/actions.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/constants/actions.ts @@ -44,3 +44,8 @@ export const CLOSE_BUTTON_LABEL = i18n.translate( 'xpack.enterpriseSearch.actions.closeButtonLabel', { defaultMessage: 'Close' } ); + +export const RESET_DEFAULT_BUTTON_LABEL = i18n.translate( + 'xpack.enterpriseSearch.actions.resetDefaultButtonLabel', + { defaultMessage: 'Reset to default' } +); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/index.ts index e9ebc791622d9..fb9846dbccde8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/index.ts @@ -8,3 +8,4 @@ export { toSentenceSerial } from './to_sentence_serial'; export { getAsLocalDateTimeString } from './get_as_local_datetime_string'; export { mimeType } from './mime_types'; +export { readUploadedFileAsBase64 } from './read_uploaded_file_as_base64'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/read_uploaded_file_as_base64.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/read_uploaded_file_as_base64.test.ts new file mode 100644 index 0000000000000..9f612a7432ec5 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/read_uploaded_file_as_base64.test.ts @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { readUploadedFileAsBase64 } from './'; + +describe('readUploadedFileAsBase64', () => { + it('reads a file and returns base64 string', async () => { + const file = new File(['a mock file'], 'mockFile.png', { type: 'img/png' }); + const text = await readUploadedFileAsBase64(file); + expect(text).toEqual('YSBtb2NrIGZpbGU='); + }); + + it('throws an error if the file cannot be read', async () => { + const badFile = ('causes an error' as unknown) as File; + await expect(readUploadedFileAsBase64(badFile)).rejects.toThrow(); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/read_uploaded_file_as_base64.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/read_uploaded_file_as_base64.ts new file mode 100644 index 0000000000000..d9f6d177cf9cd --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/read_uploaded_file_as_base64.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const readUploadedFileAsBase64 = (fileInput: File): Promise => { + const reader = new FileReader(); + + return new Promise((resolve, reject) => { + reader.onload = () => { + // We need to split off the prefix from the DataUrl and only pass the base64 string + // before: 'data:image/png;base64,encodedData==' + // after: 'encodedData==' + const base64 = (reader.result as string).split(',')[1]; + resolve(base64); + }; + try { + reader.readAsDataURL(fileInput); + } catch { + reader.abort(); + reject(new Error()); + } + }); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/branding_section.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/branding_section.test.tsx new file mode 100644 index 0000000000000..0f96b76130b4f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/branding_section.test.tsx @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { shallow, mount } from 'enzyme'; + +import { EuiFilePicker, EuiConfirmModal } from '@elastic/eui'; +import { nextTick } from '@kbn/test/jest'; + +jest.mock('../../../utils', () => ({ + readUploadedFileAsBase64: jest.fn(({ img }) => img), +})); +import { readUploadedFileAsBase64 } from '../../../utils'; + +import { RESET_IMAGE_TITLE } from '../constants'; + +import { BrandingSection, defaultLogo } from './branding_section'; + +describe('BrandingSection', () => { + const stageImage = jest.fn(); + const saveImage = jest.fn(); + const resetImage = jest.fn(); + + const props = { + image: 'foo', + imageType: 'logo' as 'logo', + description: 'logo test', + helpText: 'this is a logo', + stageImage, + saveImage, + resetImage, + }; + + it('renders logo', () => { + const wrapper = mount(); + + expect(wrapper.find(EuiFilePicker)).toHaveLength(1); + }); + + it('renders icon copy', () => { + const wrapper = shallow(); + wrapper.find('[data-test-subj="ResetImageButton"]').simulate('click'); + + expect(wrapper.find(EuiConfirmModal).prop('title')).toEqual(RESET_IMAGE_TITLE); + }); + + it('renders default Workplace Search logo', () => { + const wrapper = shallow(); + + expect(wrapper.find('img').prop('src')).toContain(defaultLogo); + }); + + describe('resetConfirmModal', () => { + it('calls method and hides modal when modal confirmed', () => { + const wrapper = shallow(); + wrapper.find('[data-test-subj="ResetImageButton"]').simulate('click'); + wrapper.find(EuiConfirmModal).prop('onConfirm')!({} as any); + + expect(wrapper.find(EuiConfirmModal)).toHaveLength(0); + expect(resetImage).toHaveBeenCalled(); + }); + }); + + describe('handleUpload', () => { + it('handles empty files', () => { + const wrapper = shallow(); + wrapper.find(EuiFilePicker).prop('onChange')!([] as any); + + expect(stageImage).toHaveBeenCalledWith(null); + }); + + it('handles image', async () => { + const wrapper = shallow(); + wrapper.find(EuiFilePicker).prop('onChange')!(['foo'] as any); + + expect(readUploadedFileAsBase64).toHaveBeenCalledWith('foo'); + await nextTick(); + expect(stageImage).toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/branding_section.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/branding_section.tsx new file mode 100644 index 0000000000000..776e72c4026cf --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/branding_section.tsx @@ -0,0 +1,152 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState, useEffect } from 'react'; + +import { + EuiButton, + EuiConfirmModal, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiFilePicker, + EuiText, + EuiSpacer, +} from '@elastic/eui'; + +import { + SAVE_BUTTON_LABEL, + CANCEL_BUTTON_LABEL, + RESET_DEFAULT_BUTTON_LABEL, +} from '../../../../shared/constants'; +import { readUploadedFileAsBase64 } from '../../../utils'; + +import { + LOGO_TEXT, + ICON_TEXT, + RESET_IMAGE_TITLE, + RESET_LOGO_DESCRIPTION, + RESET_ICON_DESCRIPTION, + RESET_IMAGE_CONFIRMATION_TEXT, + ORGANIZATION_LABEL, + BRAND_TEXT, +} from '../constants'; + +export const defaultLogo = + 'iVBORw0KGgoAAAANSUhEUgAAAMMAAAAeCAMAAACmAVppAAABp1BMVEUAAAAmLjf/xRPwTpglLjf/xhIlLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjcwMTslLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjclLjf+xBMlLjclLjclLjclLjclLjf/xxBUOFP+wRclLjf+xxb/0w3wTpgkLkP+xRM6ME3wTphKPEnxU5PwT5f/yhDwTpj/xxD/yBJQLF/wTpjyWY7/zQw5I1z/0Aj3SKT/zg//zg38syyoOYfhTZL/0QT+xRP/Uqr/UqtBMFD+xBV6SllaOVY7J1VXM1v/yhH/1wYlLjf+xRPwTpgzN0HvTpc1OEH+xBMuNj7/UaX/UKEXMzQQMzH4TpvwS5swNkArNj4nNTv/UqflTZPdTJA6OEQiNDr/yQ7zT5q9SIB1P19nPlhMOkz/UqbUTIvSS4oFLTD1hLkfAAAAbXRSTlMADfLy4wwCKflGIPzzaF0k8BEFlMd/G9rNFAjosWJWNC8s1LZ4bey9q6SZclHewJxlQDkLoIqDfE09So4Y6MSniIaFy8G8h04Q/vb29ObitpyQiodmXlZUVDssJSQfHQj+7Ovi4caspKFzbGw11xUNcgAABZRJREFUWMPVmIeT0kAUh180IoQOJyAgvQt4dLD33nvvXX8ed/beu3+0bzcJtjiDjuMM38xluU12932b3U2ytGu+ZM8RGrFl0zzJqgU0GczoPHq0l3QWXH79+vYtyaQ4zJ8x2U+C0xtumcybPIeZw/zv8fO3Jtph2wmim7cn2mF29uIZoqO3J9lh5tnnjZxx4PbkOsw+e/H4wVXO2WTpoCgBIyUz/QnrPGopNhoTZWHaT2MTUAI/OczePTt3//Gd60Rb51k5OOyqKLLS56oS03at+zUEl8tCIuNaOKZBxQmgHKIx6bl6PzrM3pt9eX9ueGfuGNENKwc/0OTEAywjxo4q/YwfsHDwIT2eQgaYqgOxxTQea9H50eHhvfcP5obD4ZPdnLfKaj5kkeNjEKhxkoQ9Sj9iI8V0+GHwqBjvPuSQ8RKFwmjTeCzCItPBGElv798ZMo/vHCLaZ+WwFFk+huGE1/wnN6VmPZxGl63QSoUGSYdBOe6n9opWJxzp2UwHW66urs6RIFkJhyspYhZ3Mmq5QQZxTMvT5aV81ILhWrsp+4Mbqef5R7rsaa5WNSJ3US26pcN0qliL902HN3ffPRhKnm4k2mLlkIY9QF6sXga3aDBP/ghgB8pyELkAj3QYgLunBYTBTEV1B60G+CC9+5Bw6Joqy7tJJ4iplaO2fPJUlcyScaIqnAC8lIUgKxyKEFQNh4czH17pDk92RumklQPFMKAlyHtRInJxZW2++baBj2NXfCg0Qq0oQCFgKYkMV7PVLKCnOyxFRqOQCgf5nVgXjQYBogiCAY4MxiT2OuEMeuRkCKjYbOO2nArlENFIK6BJDqCe0riqWDOQ9CHHDugqoSKmDId7z18+HepsV2jrDiuHZRxdiSuDi7yIURTQiLilDNmcSMo5XUipQoEUOxycJKDqDooMrYQ8ublJplKyebkgs54zdZKyh0tp4nCLeoMeo2Qdbs4sEFNAn4+Nspt68iov7H/gkECJfIjSFAIJVGiAmhzUAJHemYrL7uRrxC/wdSQ0zTldDcZjwBJqs6OOG7VyPLsmgjVk4s2XAHuKowvzqXIYK0Ylpw0xDbCN5nRQz/iDseSHmhK9mENiPRJURUTOOenAccoRBKhe3UGeMx1SqpgcGXhoDf/p5MHKTsTUzfQdoSyH2tVPqWqekqJkJMb2DtT5fOo7B7nKLwTGn9NiABdFL7KICj8l4SPjXpoOdiwPIqw7LBYB6Q4aZdDWAtThSIKyb6nlt3kQp+8IrFtk0+vz0TSCZBDGMi5ZGjks1msmxf/NYey1VYrrsarAau5kn+zSCocSNRwAMfPbYlRhhb7UiKtDZIdNxjNNy1GIciQFZ0CB3c+Znm5KdwDkk38dIqQhJkfbIs0GEFMbOVBEPtk69hXfHMZ+xjFNQCUZNnpyNiPn4N9J8o8cFEqLsdtyOVFJBIHlQsrLUyg+6Ef4jIgh7EmEUReGsSWNtYCDJNNAyZ3PAgniEVfzNCqi1gjKzX5Gzge5GnCCYH89MKD1aP/oMHvv+Zz5rnHwd++tPlT0yY2kSLtgfFUZfNp0IDeQIhQWgVlkvGukVQC1Kbj5FqwGU/fLdYdxLSGDHgR2MecDcTCFPlEyBiBT5JLLESGB2wnAyTWtlatB2nSQo+nF8P7cq2tEC+b9ziGVWClv+3KHuY6s9YhgbI7lLZk4xJBpeNIBOGlhN7eQmEFfYT13x00rEyES57vdhlFfrrNkJY0ILel2+QEhSfbWehS57uU707Lk4mrSuMy9Oa+J1hOi41oczMhh5tmLuS9XLN69/wI/0KL/BzuYEh8/XfpH30ByVP0/2GFkceFffYvKL4n/gPWewPF/syeg/B8F672ZU+duTfD3tLlHtur1xDn8sld5Smz0TdZepcWe8cENk7Vn/BXafhbMBIo0xQAAAABJRU5ErkJggg=='; +const defaultIcon = + 'iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAMAAABEpIrGAAAA/1BMVEUAAADwTpj+xRT+xRTwTpjwTpj+xRTwTpj+wxT+xRT/yBMSHkn8yxL+wxX+xRT+xRT/1QwzN0E6OkH/xxPwTpjwTpjwTpj/xBQsMUPwTpj/UK3/yRMWHkvwTpj/zg7wTpj/0A3wTpjwTpgRIEf/0Qx/P2P/yBMuM0I1OEH+xRQuM0L+xRQuM0LntRr+xRT+xRT+xBQ1JlZjPVdaUDwtMEUbJkYbJEj+xRTwTpg0N0E2N0LuTZX/U6z/Uqf9UaFkPVYRMjD/UqnzTpgKMS0BMCn/UaL3T53gTJGwRn2jRHRdPFUtNj4qNjwmNToALyfKSojISoeJQWhtPlsFKTP/yxKq4k7GAAAAN3RSTlMA29vt7fPy6uPQdjYd/aSVBfHs49nPwq+nlIuEU084MichEAoK/vPXz6iempOSjn9kY1w0LBcVaxnnyQAAASFJREFUOMuVk3lbgkAQh6cIxQq0u6zM7vs+cHchRbE7O7//Z+nng60PDuDj+9/MvMCyM0O0YE4Ac35lkzTTp3M5A+QKCPK1HuY69bjY+3UjDERjNc1GVD9zNeNxIb+FeOfYZYJmEXHFzhBUGYnVdEHde1fILHFB1+uNG5zCYoKuh2L2jqhqJwnqwfsOpRQHyE0mCU3vqyOkEOIESYsLyv9svUoB5BRewYVm8NJCvcsymsGF9uP7m4iY2SYqMMF/aoh/8I1DLjz3hTWi4ogC/4Qz9JCj/6byP7IvCle925Fd4yj5qtGsoB7C2I83i7f7Fiew0wfm55qoZKWOXDu4zBo5UMbz50PGvop85uKUigMCXz0nJrDlja2OQcnrX3H0+v8BzVCfXpvPH1sAAAAASUVORK5CYII='; + +interface Props { + imageType: 'logo' | 'icon'; + description: string; + helpText: string; + image?: string | null; + stagedImage?: string | null; + stageImage(image: string | null): void; + saveImage(): void; + resetImage(): void; +} + +export const BrandingSection: React.FC = ({ + imageType, + description, + helpText, + image, + stagedImage, + stageImage, + saveImage, + resetImage, +}) => { + const [resetConfirmModalVisible, setVisible] = useState(false); + const [imageUploadKey, setKey] = useState(1); + const showDeleteModal = () => setVisible(true); + const closeDeleteModal = () => setVisible(false); + const isLogo = imageType === 'logo'; + const imageText = isLogo ? LOGO_TEXT : ICON_TEXT; + const defaultImage = isLogo ? defaultLogo : defaultIcon; + + const handleUpload = async (files: FileList | null) => { + if (!files || files.length < 1) { + return stageImage(null); + } + const file = files[0]; + const img = await readUploadedFileAsBase64(file); + stageImage(img); + }; + + const resetConfirmModal = ( + { + resetImage(); + closeDeleteModal(); + }} + cancelButtonText={CANCEL_BUTTON_LABEL} + confirmButtonText={RESET_DEFAULT_BUTTON_LABEL} + buttonColor="danger" + defaultFocusedButton="confirm" + > + <> +

{isLogo ? RESET_LOGO_DESCRIPTION : RESET_ICON_DESCRIPTION}

+

{RESET_IMAGE_CONFIRMATION_TEXT}

+ +
+ ); + + // EUI currently does not support clearing an upload input programatically, so we can render a new + // one each time the image is changed. + useEffect(() => { + setKey(imageUploadKey + 1); + }, [image]); + + return ( + <> + + {description} + + } + > + <> + + {`${BRAND_TEXT} + + + + + + + + + {SAVE_BUTTON_LABEL} + + + + {image && ( + + {RESET_DEFAULT_BUTTON_LABEL} + + )} + + + + {resetConfirmModalVisible && resetConfirmModal} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/customize.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/customize.test.tsx index 15d0db4c415d0..9b17ec560ba51 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/customize.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/customize.test.tsx @@ -17,6 +17,7 @@ import { EuiFieldText } from '@elastic/eui'; import { ContentSection } from '../../../components/shared/content_section'; +import { BrandingSection } from './branding_section'; import { Customize } from './customize'; describe('Customize', () => { @@ -32,6 +33,7 @@ describe('Customize', () => { const wrapper = shallow(); expect(wrapper.find(ContentSection)).toHaveLength(1); + expect(wrapper.find(BrandingSection)).toHaveLength(2); }); it('handles input change', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/customize.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/customize.tsx index 98662585ce330..be4be08f54ebd 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/customize.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/customize.tsx @@ -9,7 +9,14 @@ import React, { FormEvent } from 'react'; import { useActions, useValues } from 'kea'; -import { EuiButton, EuiFieldText, EuiFlexGroup, EuiFlexItem, EuiFormRow } from '@elastic/eui'; +import { + EuiButton, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiSpacer, +} from '@elastic/eui'; import { WorkplaceSearchPageTemplate } from '../../../components/layout'; import { ContentSection } from '../../../components/shared/content_section'; @@ -20,11 +27,25 @@ import { CUSTOMIZE_NAME_LABEL, CUSTOMIZE_NAME_BUTTON, } from '../../../constants'; +import { LOGO_DESCRIPTION, LOGO_HELP_TEXT, ICON_DESCRIPTION, ICON_HELP_TEXT } from '../constants'; import { SettingsLogic } from '../settings_logic'; +import { BrandingSection } from './branding_section'; + export const Customize: React.FC = () => { - const { onOrgNameInputChange, updateOrgName } = useActions(SettingsLogic); - const { orgNameInputValue } = useValues(SettingsLogic); + const { + onOrgNameInputChange, + updateOrgName, + setStagedIcon, + setStagedLogo, + updateOrgLogo, + updateOrgIcon, + resetOrgLogo, + resetOrgIcon, + } = useActions(SettingsLogic); + const { dataLoading, orgNameInputValue, icon, stagedIcon, logo, stagedLogo } = useValues( + SettingsLogic + ); const handleSubmit = (e: FormEvent) => { e.preventDefault(); @@ -38,6 +59,7 @@ export const Customize: React.FC = () => { pageTitle: CUSTOMIZE_HEADER_TITLE, description: CUSTOMIZE_HEADER_DESCRIPTION, }} + isLoading={dataLoading} >
@@ -63,6 +85,28 @@ export const Customize: React.FC = () => {
+ + + + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/constants.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/constants.ts new file mode 100644 index 0000000000000..1bcd038947117 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/constants.ts @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const LOGO_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.settings.logoText', + { + defaultMessage: 'logo', + } +); + +export const ICON_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.settings.iconText', + { + defaultMessage: 'icon', + } +); + +export const RESET_IMAGE_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.settings.resetImageTitle', + { + defaultMessage: 'Reset to default branding', + } +); + +export const RESET_LOGO_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.settings.resetLogoDescription', + { + defaultMessage: "You're about to reset the logo to the default Workplace Search branding.", + } +); + +export const RESET_ICON_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.settings.resetIconDescription', + { + defaultMessage: "You're about to reset the icon to the default Workplace Search branding.", + } +); + +export const RESET_IMAGE_CONFIRMATION_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.settings.resetImageConfirmationText', + { + defaultMessage: 'Are you sure you want to do this?', + } +); + +export const ORGANIZATION_LABEL = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.settings.organizationLabel', + { + defaultMessage: 'Organization', + } +); + +export const BRAND_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.settings.brandText', + { + defaultMessage: 'Brand', + } +); + +export const LOGO_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.settings.logoDescription', + { + defaultMessage: 'Used as the main visual branding element across prebuilt search applications', + } +); + +export const LOGO_HELP_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.settings.logoHelpText', + { + defaultMessage: 'Maximum file size is 2MB. Only PNG files are supported.', + } +); + +export const ICON_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.settings.iconDescription', + { + defaultMessage: 'Used as the branding element for smaller screen sizes and browser icons', + } +); + +export const ICON_HELP_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.settings.iconHelpText', + { + defaultMessage: + 'Maximum file size is 2MB and recommended aspect ratio is 1:1. Only PNG files are supported.', + } +); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_logic.test.ts index 0aef84ccf20e2..005f2f016d561 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_logic.test.ts @@ -25,7 +25,7 @@ describe('SettingsLogic', () => { const { clearFlashMessages, flashAPIErrors, - setSuccessMessage, + flashSuccessToast, setQueuedSuccessMessage, } = mockFlashMessageHelpers; const { mount } = new LogicMounter(SettingsLogic); @@ -35,8 +35,12 @@ describe('SettingsLogic', () => { connectors: [], orgNameInputValue: '', oauthApplication: null, + icon: null, + stagedIcon: null, + logo: null, + stagedLogo: null, }; - const serverProps = { organizationName: ORG_NAME, oauthApplication }; + const serverProps = { organizationName: ORG_NAME, oauthApplication, logo: null, icon: null }; beforeEach(() => { jest.clearAllMocks(); @@ -79,6 +83,34 @@ describe('SettingsLogic', () => { expect(SettingsLogic.values.oauthApplication).toEqual(oauthApplication); }); + it('setIcon', () => { + SettingsLogic.actions.setStagedIcon('stagedIcon'); + SettingsLogic.actions.setIcon('icon'); + + expect(SettingsLogic.values.icon).toEqual('icon'); + expect(SettingsLogic.values.stagedIcon).toEqual(null); + }); + + it('setStagedIcon', () => { + SettingsLogic.actions.setStagedIcon('stagedIcon'); + + expect(SettingsLogic.values.stagedIcon).toEqual('stagedIcon'); + }); + + it('setLogo', () => { + SettingsLogic.actions.setStagedLogo('stagedLogo'); + SettingsLogic.actions.setLogo('logo'); + + expect(SettingsLogic.values.logo).toEqual('logo'); + expect(SettingsLogic.values.stagedLogo).toEqual(null); + }); + + it('setStagedLogo', () => { + SettingsLogic.actions.setStagedLogo('stagedLogo'); + + expect(SettingsLogic.values.stagedLogo).toEqual('stagedLogo'); + }); + it('setUpdatedOauthApplication', () => { SettingsLogic.actions.setUpdatedOauthApplication({ oauthApplication }); @@ -143,7 +175,7 @@ describe('SettingsLogic', () => { body: JSON.stringify({ name: NAME }), }); await nextTick(); - expect(setSuccessMessage).toHaveBeenCalledWith(ORG_UPDATED_MESSAGE); + expect(flashSuccessToast).toHaveBeenCalledWith(ORG_UPDATED_MESSAGE); expect(setUpdatedNameSpy).toHaveBeenCalledWith({ organizationName: NAME }); }); @@ -156,6 +188,80 @@ describe('SettingsLogic', () => { }); }); + describe('updateOrgIcon', () => { + it('calls API and sets values', async () => { + const ICON = 'icon'; + SettingsLogic.actions.setStagedIcon(ICON); + const setIconSpy = jest.spyOn(SettingsLogic.actions, 'setIcon'); + http.put.mockReturnValue(Promise.resolve({ icon: ICON })); + + SettingsLogic.actions.updateOrgIcon(); + + expect(http.put).toHaveBeenCalledWith('/api/workplace_search/org/settings/upload_images', { + body: JSON.stringify({ icon: ICON }), + }); + await nextTick(); + expect(flashSuccessToast).toHaveBeenCalledWith(ORG_UPDATED_MESSAGE); + expect(setIconSpy).toHaveBeenCalledWith(ICON); + }); + + it('handles error', async () => { + http.put.mockReturnValue(Promise.reject('this is an error')); + SettingsLogic.actions.updateOrgIcon(); + + await nextTick(); + expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); + }); + }); + + describe('updateOrgLogo', () => { + it('calls API and sets values', async () => { + const LOGO = 'logo'; + SettingsLogic.actions.setStagedLogo(LOGO); + const setLogoSpy = jest.spyOn(SettingsLogic.actions, 'setLogo'); + http.put.mockReturnValue(Promise.resolve({ logo: LOGO })); + + SettingsLogic.actions.updateOrgLogo(); + + expect(http.put).toHaveBeenCalledWith('/api/workplace_search/org/settings/upload_images', { + body: JSON.stringify({ logo: LOGO }), + }); + await nextTick(); + expect(flashSuccessToast).toHaveBeenCalledWith(ORG_UPDATED_MESSAGE); + expect(setLogoSpy).toHaveBeenCalledWith(LOGO); + }); + + it('handles error', async () => { + http.put.mockReturnValue(Promise.reject('this is an error')); + SettingsLogic.actions.updateOrgLogo(); + + await nextTick(); + expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); + }); + }); + + it('resetOrgLogo', () => { + const updateOrgLogoSpy = jest.spyOn(SettingsLogic.actions, 'updateOrgLogo'); + SettingsLogic.actions.setStagedLogo('stagedLogo'); + SettingsLogic.actions.setLogo('logo'); + SettingsLogic.actions.resetOrgLogo(); + + expect(SettingsLogic.values.logo).toEqual(null); + expect(SettingsLogic.values.stagedLogo).toEqual(null); + expect(updateOrgLogoSpy).toHaveBeenCalled(); + }); + + it('resetOrgIcon', () => { + const updateOrgIconSpy = jest.spyOn(SettingsLogic.actions, 'updateOrgIcon'); + SettingsLogic.actions.setStagedIcon('stagedIcon'); + SettingsLogic.actions.setIcon('icon'); + SettingsLogic.actions.resetOrgIcon(); + + expect(SettingsLogic.values.icon).toEqual(null); + expect(SettingsLogic.values.stagedIcon).toEqual(null); + expect(updateOrgIconSpy).toHaveBeenCalled(); + }); + describe('updateOauthApplication', () => { it('calls API and sets values', async () => { const { name, redirectUri, confidential } = oauthApplication; @@ -179,7 +285,7 @@ describe('SettingsLogic', () => { ); await nextTick(); expect(setUpdatedOauthApplicationSpy).toHaveBeenCalledWith({ oauthApplication }); - expect(setSuccessMessage).toHaveBeenCalledWith(OAUTH_APP_UPDATED_MESSAGE); + expect(flashSuccessToast).toHaveBeenCalledWith(OAUTH_APP_UPDATED_MESSAGE); }); it('handles error', async () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_logic.ts index e07adbde15939..65a2cdf8c3f30 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_logic.ts @@ -12,7 +12,7 @@ import { i18n } from '@kbn/i18n'; import { clearFlashMessages, setQueuedSuccessMessage, - setSuccessMessage, + flashSuccessToast, flashAPIErrors, } from '../../../shared/flash_messages'; import { HttpLogic } from '../../../shared/http'; @@ -34,6 +34,8 @@ interface IOauthApplication { export interface SettingsServerProps { organizationName: string; oauthApplication: IOauthApplication; + logo: string | null; + icon: string | null; } interface SettingsActions { @@ -41,6 +43,10 @@ interface SettingsActions { onOrgNameInputChange(orgNameInputValue: string): string; setUpdatedName({ organizationName }: { organizationName: string }): string; setServerProps(props: SettingsServerProps): SettingsServerProps; + setIcon(icon: string | null): string | null; + setStagedIcon(stagedIcon: string | null): string | null; + setLogo(logo: string | null): string | null; + setStagedLogo(stagedLogo: string | null): string | null; setOauthApplication(oauthApplication: IOauthApplication): IOauthApplication; setUpdatedOauthApplication({ oauthApplication, @@ -52,6 +58,10 @@ interface SettingsActions { initializeConnectors(): void; updateOauthApplication(): void; updateOrgName(): void; + updateOrgLogo(): void; + updateOrgIcon(): void; + resetOrgLogo(): void; + resetOrgIcon(): void; deleteSourceConfig( serviceType: string, name: string @@ -66,14 +76,24 @@ interface SettingsValues { connectors: Connector[]; orgNameInputValue: string; oauthApplication: IOauthApplication | null; + logo: string | null; + icon: string | null; + stagedLogo: string | null; + stagedIcon: string | null; } +const imageRoute = '/api/workplace_search/org/settings/upload_images'; + export const SettingsLogic = kea>({ actions: { onInitializeConnectors: (connectors: Connector[]) => connectors, onOrgNameInputChange: (orgNameInputValue: string) => orgNameInputValue, setUpdatedName: ({ organizationName }) => organizationName, setServerProps: (props: SettingsServerProps) => props, + setIcon: (icon) => icon, + setStagedIcon: (stagedIcon) => stagedIcon, + setLogo: (logo) => logo, + setStagedLogo: (stagedLogo) => stagedLogo, setOauthApplication: (oauthApplication: IOauthApplication) => oauthApplication, setUpdatedOauthApplication: ({ oauthApplication }: { oauthApplication: IOauthApplication }) => oauthApplication, @@ -81,6 +101,10 @@ export const SettingsLogic = kea> initializeSettings: () => true, initializeConnectors: () => true, updateOrgName: () => true, + updateOrgLogo: () => true, + updateOrgIcon: () => true, + resetOrgLogo: () => true, + resetOrgIcon: () => true, updateOauthApplication: () => true, deleteSourceConfig: (serviceType: string, name: string) => ({ serviceType, @@ -113,10 +137,43 @@ export const SettingsLogic = kea> dataLoading: [ true, { + setServerProps: () => false, onInitializeConnectors: () => false, resetSettingsState: () => true, }, ], + logo: [ + null, + { + setServerProps: (_, { logo }) => logo, + setLogo: (_, logo) => logo, + resetOrgLogo: () => null, + }, + ], + stagedLogo: [ + null, + { + setStagedLogo: (_, stagedLogo) => stagedLogo, + resetOrgLogo: () => null, + setLogo: () => null, + }, + ], + icon: [ + null, + { + setServerProps: (_, { icon }) => icon, + setIcon: (_, icon) => icon, + resetOrgIcon: () => null, + }, + ], + stagedIcon: [ + null, + { + setStagedIcon: (_, stagedIcon) => stagedIcon, + resetOrgIcon: () => null, + setIcon: () => null, + }, + ], }, listeners: ({ actions, values }) => ({ initializeSettings: async () => { @@ -150,12 +207,38 @@ export const SettingsLogic = kea> try { const response = await http.put(route, { body }); actions.setUpdatedName(response); - setSuccessMessage(ORG_UPDATED_MESSAGE); + flashSuccessToast(ORG_UPDATED_MESSAGE); AppLogic.actions.setOrgName(name); } catch (e) { flashAPIErrors(e); } }, + updateOrgLogo: async () => { + const { http } = HttpLogic.values; + const { stagedLogo: logo } = values; + const body = JSON.stringify({ logo }); + + try { + const response = await http.put(imageRoute, { body }); + actions.setLogo(response.logo); + flashSuccessToast(ORG_UPDATED_MESSAGE); + } catch (e) { + flashAPIErrors(e); + } + }, + updateOrgIcon: async () => { + const { http } = HttpLogic.values; + const { stagedIcon: icon } = values; + const body = JSON.stringify({ icon }); + + try { + const response = await http.put(imageRoute, { body }); + actions.setIcon(response.icon); + flashSuccessToast(ORG_UPDATED_MESSAGE); + } catch (e) { + flashAPIErrors(e); + } + }, updateOauthApplication: async () => { const { http } = HttpLogic.values; const route = '/api/workplace_search/org/settings/oauth_application'; @@ -170,7 +253,7 @@ export const SettingsLogic = kea> try { const response = await http.put(route, { body }); actions.setUpdatedOauthApplication(response); - setSuccessMessage(OAUTH_APP_UPDATED_MESSAGE); + flashSuccessToast(OAUTH_APP_UPDATED_MESSAGE); } catch (e) { flashAPIErrors(e); } @@ -195,5 +278,11 @@ export const SettingsLogic = kea> resetSettingsState: () => { clearFlashMessages(); }, + resetOrgLogo: () => { + actions.updateOrgLogo(); + }, + resetOrgIcon: () => { + actions.updateOrgIcon(); + }, }), }); diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/settings.test.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/settings.test.ts index 00a5b6c75df0a..858bd71c50c44 100644 --- a/x-pack/plugins/enterprise_search/server/routes/workplace_search/settings.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/settings.test.ts @@ -10,6 +10,7 @@ import { MockRouter, mockRequestHandler, mockDependencies } from '../../__mocks_ import { registerOrgSettingsRoute, registerOrgSettingsCustomizeRoute, + registerOrgSettingsUploadImagesRoute, registerOrgSettingsOauthApplicationRoute, } from './settings'; @@ -67,6 +68,36 @@ describe('settings routes', () => { }); }); + describe('PUT /api/workplace_search/org/settings/upload_images', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ + method: 'put', + path: '/api/workplace_search/org/settings/upload_images', + }); + + registerOrgSettingsUploadImagesRoute({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request handler', () => { + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/ws/org/settings/upload_images', + }); + }); + + describe('validates', () => { + it('correctly', () => { + const request = { body: { logo: 'foo', icon: null } }; + mockRouter.shouldValidate(request); + }); + }); + }); + describe('PUT /api/workplace_search/org/settings/oauth_application', () => { let mockRouter: MockRouter; diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/settings.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/settings.ts index bd8b5388625c6..aa8651f74bec5 100644 --- a/x-pack/plugins/enterprise_search/server/routes/workplace_search/settings.ts +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/settings.ts @@ -43,6 +43,26 @@ export function registerOrgSettingsCustomizeRoute({ ); } +export function registerOrgSettingsUploadImagesRoute({ + router, + enterpriseSearchRequestHandler, +}: RouteDependencies) { + router.put( + { + path: '/api/workplace_search/org/settings/upload_images', + validate: { + body: schema.object({ + logo: schema.maybe(schema.nullable(schema.string())), + icon: schema.maybe(schema.nullable(schema.string())), + }), + }, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/ws/org/settings/upload_images', + }) + ); +} + export function registerOrgSettingsOauthApplicationRoute({ router, enterpriseSearchRequestHandler, @@ -69,5 +89,6 @@ export function registerOrgSettingsOauthApplicationRoute({ export const registerSettingsRoutes = (dependencies: RouteDependencies) => { registerOrgSettingsRoute(dependencies); registerOrgSettingsCustomizeRoute(dependencies); + registerOrgSettingsUploadImagesRoute(dependencies); registerOrgSettingsOauthApplicationRoute(dependencies); }; diff --git a/x-pack/plugins/lens/common/constants.ts b/x-pack/plugins/lens/common/constants.ts index 26edf66130ee3..bba3ac7e8a9ca 100644 --- a/x-pack/plugins/lens/common/constants.ts +++ b/x-pack/plugins/lens/common/constants.ts @@ -7,6 +7,7 @@ import rison from 'rison-node'; import type { TimeRange } from '../../../../src/plugins/data/common/query'; +import { LayerType } from './types'; export const PLUGIN_ID = 'lens'; export const APP_ID = 'lens'; @@ -16,6 +17,8 @@ export const NOT_INTERNATIONALIZED_PRODUCT_NAME = 'Lens Visualizations'; export const BASE_API_URL = '/api/lens'; export const LENS_EDIT_BY_VALUE = 'edit_by_value'; +export const layerTypes: Record = { DATA: 'data', THRESHOLD: 'threshold' }; + export function getBasePath() { return `#/`; } diff --git a/x-pack/plugins/lens/common/expressions/metric_chart/metric_chart.ts b/x-pack/plugins/lens/common/expressions/metric_chart/metric_chart.ts index 53ed7c8da32eb..6c05502bb2b03 100644 --- a/x-pack/plugins/lens/common/expressions/metric_chart/metric_chart.ts +++ b/x-pack/plugins/lens/common/expressions/metric_chart/metric_chart.ts @@ -23,7 +23,7 @@ export interface MetricRender { export const metricChart: ExpressionFunctionDefinition< 'lens_metric_chart', LensMultiTable, - Omit, + Omit, MetricRender > = { name: 'lens_metric_chart', diff --git a/x-pack/plugins/lens/common/expressions/metric_chart/types.ts b/x-pack/plugins/lens/common/expressions/metric_chart/types.ts index c182b19f3ced5..65a72632a5491 100644 --- a/x-pack/plugins/lens/common/expressions/metric_chart/types.ts +++ b/x-pack/plugins/lens/common/expressions/metric_chart/types.ts @@ -5,9 +5,12 @@ * 2.0. */ +import { LayerType } from '../../types'; + export interface MetricState { layerId: string; accessor?: string; + layerType: LayerType; } export interface MetricConfig extends MetricState { diff --git a/x-pack/plugins/lens/common/expressions/pie_chart/types.ts b/x-pack/plugins/lens/common/expressions/pie_chart/types.ts index e377272322950..213651134d98a 100644 --- a/x-pack/plugins/lens/common/expressions/pie_chart/types.ts +++ b/x-pack/plugins/lens/common/expressions/pie_chart/types.ts @@ -6,7 +6,7 @@ */ import type { PaletteOutput } from '../../../../../../src/plugins/charts/common'; -import type { LensMultiTable } from '../../types'; +import type { LensMultiTable, LayerType } from '../../types'; export interface SharedPieLayerState { groups: string[]; @@ -21,6 +21,7 @@ export interface SharedPieLayerState { export type PieLayerState = SharedPieLayerState & { layerId: string; + layerType: LayerType; }; export interface PieVisualizationState { diff --git a/x-pack/plugins/lens/common/expressions/xy_chart/layer_config.ts b/x-pack/plugins/lens/common/expressions/xy_chart/layer_config.ts index f3baf242425f5..ff3d50a13a06d 100644 --- a/x-pack/plugins/lens/common/expressions/xy_chart/layer_config.ts +++ b/x-pack/plugins/lens/common/expressions/xy_chart/layer_config.ts @@ -7,6 +7,8 @@ import type { PaletteOutput } from '../../../../../../src/plugins/charts/common'; import type { ExpressionFunctionDefinition } from '../../../../../../src/plugins/expressions/common'; +import type { LayerType } from '../../types'; +import { layerTypes } from '../../constants'; import { axisConfig, YConfig } from './axis_config'; import type { SeriesType } from './series_type'; @@ -19,6 +21,7 @@ export interface XYLayerConfig { seriesType: SeriesType; splitAccessor?: string; palette?: PaletteOutput; + layerType: LayerType; } export interface ValidLayer extends XYLayerConfig { @@ -57,6 +60,7 @@ export const layerConfig: ExpressionFunctionDefinition< types: ['string'], help: '', }, + layerType: { types: ['string'], options: Object.values(layerTypes), help: '' }, seriesType: { types: ['string'], options: [ diff --git a/x-pack/plugins/lens/common/types.ts b/x-pack/plugins/lens/common/types.ts index 06fa31b87ce64..f5f10887dee1d 100644 --- a/x-pack/plugins/lens/common/types.ts +++ b/x-pack/plugins/lens/common/types.ts @@ -57,3 +57,5 @@ export interface CustomPaletteParams { } export type RequiredPaletteParamTypes = Required; + +export type LayerType = 'data' | 'threshold'; diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.test.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.test.tsx index ea8914e9078c4..ba4ca284fe26e 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.test.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.test.tsx @@ -16,6 +16,7 @@ import { chartPluginMock } from 'src/plugins/charts/public/mocks'; import { PaletteRegistry } from 'src/plugins/charts/public'; import { act } from 'react-dom/test-utils'; import { PalettePanelContainer } from '../../shared_components'; +import { layerTypes } from '../../../common'; describe('data table dimension editor', () => { let frame: FramePublicAPI; @@ -28,6 +29,7 @@ describe('data table dimension editor', () => { function testState(): DatatableVisualizationState { return { layerId: 'first', + layerType: layerTypes.DATA, columns: [ { columnId: 'foo', diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx index 1e4b1cfa6069d..64d5a6f8f25a6 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx @@ -17,6 +17,7 @@ import { VisualizationDimensionGroupConfig, } from '../types'; import { chartPluginMock } from 'src/plugins/charts/public/mocks'; +import { layerTypes } from '../../common'; function mockFrame(): FramePublicAPI { return { @@ -34,6 +35,7 @@ describe('Datatable Visualization', () => { it('should initialize from the empty state', () => { expect(datatableVisualization.initialize(() => 'aaa', undefined)).toEqual({ layerId: 'aaa', + layerType: layerTypes.DATA, columns: [], }); }); @@ -41,6 +43,7 @@ describe('Datatable Visualization', () => { it('should initialize from a persisted state', () => { const expectedState: DatatableVisualizationState = { layerId: 'foo', + layerType: layerTypes.DATA, columns: [{ columnId: 'saved' }], }; expect(datatableVisualization.initialize(() => 'foo', expectedState)).toEqual(expectedState); @@ -51,6 +54,7 @@ describe('Datatable Visualization', () => { it('return the layer ids', () => { const state: DatatableVisualizationState = { layerId: 'baz', + layerType: layerTypes.DATA, columns: [{ columnId: 'a' }, { columnId: 'b' }, { columnId: 'c' }], }; expect(datatableVisualization.getLayerIds(state)).toEqual(['baz']); @@ -61,15 +65,35 @@ describe('Datatable Visualization', () => { it('should reset the layer', () => { const state: DatatableVisualizationState = { layerId: 'baz', + layerType: layerTypes.DATA, columns: [{ columnId: 'a' }, { columnId: 'b' }, { columnId: 'c' }], }; expect(datatableVisualization.clearLayer(state, 'baz')).toMatchObject({ layerId: 'baz', + layerType: layerTypes.DATA, columns: [], }); }); }); + describe('#getSupportedLayers', () => { + it('should return a single layer type', () => { + expect(datatableVisualization.getSupportedLayers()).toHaveLength(1); + }); + }); + + describe('#getLayerType', () => { + it('should return the type only if the layer is in the state', () => { + const state: DatatableVisualizationState = { + layerId: 'baz', + layerType: layerTypes.DATA, + columns: [{ columnId: 'a' }, { columnId: 'b' }, { columnId: 'c' }], + }; + expect(datatableVisualization.getLayerType('baz', state)).toEqual(layerTypes.DATA); + expect(datatableVisualization.getLayerType('foo', state)).toBeUndefined(); + }); + }); + describe('#getSuggestions', () => { function numCol(columnId: string): TableSuggestionColumn { return { @@ -97,6 +121,7 @@ describe('Datatable Visualization', () => { const suggestions = datatableVisualization.getSuggestions({ state: { layerId: 'first', + layerType: layerTypes.DATA, columns: [{ columnId: 'col1' }], }, table: { @@ -115,6 +140,7 @@ describe('Datatable Visualization', () => { const suggestions = datatableVisualization.getSuggestions({ state: { layerId: 'first', + layerType: layerTypes.DATA, columns: [ { columnId: 'col1', width: 123 }, { columnId: 'col2', hidden: true }, @@ -149,6 +175,7 @@ describe('Datatable Visualization', () => { const suggestions = datatableVisualization.getSuggestions({ state: { layerId: 'first', + layerType: layerTypes.DATA, columns: [{ columnId: 'col1' }], }, table: { @@ -167,6 +194,7 @@ describe('Datatable Visualization', () => { const suggestions = datatableVisualization.getSuggestions({ state: { layerId: 'first', + layerType: layerTypes.DATA, columns: [{ columnId: 'col1' }], }, table: { @@ -185,6 +213,7 @@ describe('Datatable Visualization', () => { const suggestions = datatableVisualization.getSuggestions({ state: { layerId: 'older', + layerType: layerTypes.DATA, columns: [{ columnId: 'col1' }], }, table: { @@ -225,6 +254,7 @@ describe('Datatable Visualization', () => { layerId: 'first', state: { layerId: 'first', + layerType: layerTypes.DATA, columns: [], }, frame, @@ -240,6 +270,7 @@ describe('Datatable Visualization', () => { layerId: 'first', state: { layerId: 'first', + layerType: layerTypes.DATA, columns: [], }, frame, @@ -279,6 +310,7 @@ describe('Datatable Visualization', () => { layerId: 'first', state: { layerId: 'first', + layerType: layerTypes.DATA, columns: [], }, frame, @@ -313,6 +345,7 @@ describe('Datatable Visualization', () => { layerId: 'a', state: { layerId: 'a', + layerType: layerTypes.DATA, columns: [{ columnId: 'b' }, { columnId: 'c' }], }, frame, @@ -327,28 +360,37 @@ describe('Datatable Visualization', () => { datatableVisualization.removeDimension({ prevState: { layerId: 'layer1', + layerType: layerTypes.DATA, columns: [{ columnId: 'b' }, { columnId: 'c' }], }, layerId: 'layer1', columnId: 'b', + frame: mockFrame(), }) ).toEqual({ layerId: 'layer1', + layerType: layerTypes.DATA, columns: [{ columnId: 'c' }], }); }); it('should handle correctly the sorting state on removing dimension', () => { - const state = { layerId: 'layer1', columns: [{ columnId: 'b' }, { columnId: 'c' }] }; + const state = { + layerId: 'layer1', + layerType: layerTypes.DATA, + columns: [{ columnId: 'b' }, { columnId: 'c' }], + }; expect( datatableVisualization.removeDimension({ prevState: { ...state, sorting: { columnId: 'b', direction: 'asc' } }, layerId: 'layer1', columnId: 'b', + frame: mockFrame(), }) ).toEqual({ sorting: undefined, layerId: 'layer1', + layerType: layerTypes.DATA, columns: [{ columnId: 'c' }], }); @@ -357,10 +399,12 @@ describe('Datatable Visualization', () => { prevState: { ...state, sorting: { columnId: 'c', direction: 'asc' } }, layerId: 'layer1', columnId: 'b', + frame: mockFrame(), }) ).toEqual({ sorting: { columnId: 'c', direction: 'asc' }, layerId: 'layer1', + layerType: layerTypes.DATA, columns: [{ columnId: 'c' }], }); }); @@ -370,13 +414,19 @@ describe('Datatable Visualization', () => { it('allows columns to be added', () => { expect( datatableVisualization.setDimension({ - prevState: { layerId: 'layer1', columns: [{ columnId: 'b' }, { columnId: 'c' }] }, + prevState: { + layerId: 'layer1', + layerType: layerTypes.DATA, + columns: [{ columnId: 'b' }, { columnId: 'c' }], + }, layerId: 'layer1', columnId: 'd', groupId: '', + frame: mockFrame(), }) ).toEqual({ layerId: 'layer1', + layerType: layerTypes.DATA, columns: [{ columnId: 'b' }, { columnId: 'c' }, { columnId: 'd', isTransposed: false }], }); }); @@ -384,13 +434,19 @@ describe('Datatable Visualization', () => { it('does not set a duplicate dimension', () => { expect( datatableVisualization.setDimension({ - prevState: { layerId: 'layer1', columns: [{ columnId: 'b' }, { columnId: 'c' }] }, + prevState: { + layerId: 'layer1', + layerType: layerTypes.DATA, + columns: [{ columnId: 'b' }, { columnId: 'c' }], + }, layerId: 'layer1', columnId: 'b', groupId: '', + frame: mockFrame(), }) ).toEqual({ layerId: 'layer1', + layerType: layerTypes.DATA, columns: [{ columnId: 'b', isTransposed: false }, { columnId: 'c' }], }); }); @@ -409,7 +465,11 @@ describe('Datatable Visualization', () => { }); const expression = datatableVisualization.toExpression( - { layerId: 'a', columns: [{ columnId: 'b' }, { columnId: 'c' }] }, + { + layerId: 'a', + layerType: layerTypes.DATA, + columns: [{ columnId: 'b' }, { columnId: 'c' }], + }, frame.datasourceLayers ) as Ast; @@ -460,7 +520,11 @@ describe('Datatable Visualization', () => { }); const expression = datatableVisualization.toExpression( - { layerId: 'a', columns: [{ columnId: 'b' }, { columnId: 'c' }] }, + { + layerId: 'a', + layerType: layerTypes.DATA, + columns: [{ columnId: 'b' }, { columnId: 'c' }], + }, frame.datasourceLayers ); @@ -482,6 +546,7 @@ describe('Datatable Visualization', () => { const error = datatableVisualization.getErrorMessages({ layerId: 'a', + layerType: layerTypes.DATA, columns: [{ columnId: 'b' }, { columnId: 'c' }], }); @@ -501,6 +566,7 @@ describe('Datatable Visualization', () => { const error = datatableVisualization.getErrorMessages({ layerId: 'a', + layerType: layerTypes.DATA, columns: [{ columnId: 'b' }, { columnId: 'c' }], }); @@ -512,6 +578,7 @@ describe('Datatable Visualization', () => { it('should add a sort column to the state', () => { const currentState: DatatableVisualizationState = { layerId: 'foo', + layerType: layerTypes.DATA, columns: [{ columnId: 'saved' }], }; expect( @@ -531,6 +598,7 @@ describe('Datatable Visualization', () => { it('should add a custom width to a column in the state', () => { const currentState: DatatableVisualizationState = { layerId: 'foo', + layerType: layerTypes.DATA, columns: [{ columnId: 'saved' }], }; expect( @@ -547,6 +615,7 @@ describe('Datatable Visualization', () => { it('should clear custom width value for the column from the state', () => { const currentState: DatatableVisualizationState = { layerId: 'foo', + layerType: layerTypes.DATA, columns: [{ columnId: 'saved', width: 5000 }], }; expect( diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx index 691fce0ed70d2..807d32a245834 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx @@ -21,12 +21,14 @@ import { LensIconChartDatatable } from '../assets/chart_datatable'; import { TableDimensionEditor } from './components/dimension_editor'; import { CUSTOM_PALETTE } from '../shared_components/coloring/constants'; import { getStopsForFixedMode } from '../shared_components'; +import { LayerType, layerTypes } from '../../common'; import { getDefaultSummaryLabel } from '../../common/expressions'; import type { ColumnState, SortingState } from '../../common/expressions'; export interface DatatableVisualizationState { columns: ColumnState[]; layerId: string; + layerType: LayerType; sorting?: SortingState; } @@ -82,6 +84,7 @@ export const getDatatableVisualization = ({ state || { columns: [], layerId: addNewLayer(), + layerType: layerTypes.DATA, } ); }, @@ -141,6 +144,7 @@ export const getDatatableVisualization = ({ state: { ...(state || {}), layerId: table.layerId, + layerType: layerTypes.DATA, columns: table.columns.map((col, columnIndex) => ({ ...(oldColumnSettings[col.columnId] || {}), isTransposed: usesTransposing && columnIndex < lastTransposedColumnIndex, @@ -296,6 +300,23 @@ export const getDatatableVisualization = ({ ); }, + getSupportedLayers() { + return [ + { + type: layerTypes.DATA, + label: i18n.translate('xpack.lens.datatable.addLayer', { + defaultMessage: 'Add visualization layer', + }), + }, + ]; + }, + + getLayerType(layerId, state) { + if (state?.layerId === layerId) { + return state.layerType; + } + }, + toExpression(state, datasourceLayers, { title, description } = {}): Ast | null { const { sortedColumns, datasource } = getDataSourceAndSortedColumns(state, datasourceLayers, state.layerId) || {}; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/add_layer.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/add_layer.tsx new file mode 100644 index 0000000000000..0259acc4dcca1 --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/add_layer.tsx @@ -0,0 +1,128 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState } from 'react'; +import { + EuiToolTip, + EuiButton, + EuiPopover, + EuiContextMenuItem, + EuiContextMenuPanel, + EuiIcon, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { LayerType, layerTypes } from '../../../../common'; +import type { FramePublicAPI, Visualization } from '../../../types'; + +interface AddLayerButtonProps { + visualization: Visualization; + visualizationState: unknown; + onAddLayerClick: (layerType: LayerType) => void; + layersMeta: Pick; +} + +export function getLayerType(visualization: Visualization, state: unknown, layerId: string) { + return visualization.getLayerType(layerId, state) || layerTypes.DATA; +} + +export function AddLayerButton({ + visualization, + visualizationState, + onAddLayerClick, + layersMeta, +}: AddLayerButtonProps) { + const [showLayersChoice, toggleLayersChoice] = useState(false); + + const hasMultipleLayers = Boolean(visualization.appendLayer && visualizationState); + if (!hasMultipleLayers) { + return null; + } + const supportedLayers = visualization.getSupportedLayers?.(visualizationState, layersMeta); + if (supportedLayers?.length === 1) { + return ( + + onAddLayerClick(supportedLayers[0].type)} + iconType="layers" + > + {i18n.translate('xpack.lens.configPanel.addLayerButton', { + defaultMessage: 'Add layer', + })} + + + ); + } + return ( + toggleLayersChoice(!showLayersChoice)} + iconType="layers" + > + {i18n.translate('xpack.lens.configPanel.addLayerButton', { + defaultMessage: 'Add layer', + })} + + } + isOpen={showLayersChoice} + closePopover={() => toggleLayersChoice(false)} + panelPaddingSize="none" + > + { + return ( + { + onAddLayerClick(type); + toggleLayersChoice(false); + }} + icon={icon && } + disabled={disabled} + toolTipContent={tooltipContent} + > + {label} + + ); + })} + /> + + ); +} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/empty_dimension_button.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/empty_dimension_button.tsx index cb72b986430d6..122f888e009d6 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/empty_dimension_button.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/empty_dimension_button.tsx @@ -108,13 +108,13 @@ export function EmptyDimensionButton({ diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.scss index 6629b44075831..0d51108fb2dcb 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.scss +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.scss @@ -1,7 +1,3 @@ -.lnsConfigPanel__addLayerBtnWrapper { - padding-bottom: $euiSize; -} - .lnsConfigPanel__addLayerBtn { @include kbnThemeStyle('v7') { // sass-lint:disable-block no-important diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx index 804f73b5d5fec..f7fe2beefa963 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx @@ -8,8 +8,7 @@ import './config_panel.scss'; import React, { useMemo, memo } from 'react'; -import { EuiFlexItem, EuiToolTip, EuiButton, EuiForm } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; +import { EuiForm } from '@elastic/eui'; import { mapValues } from 'lodash'; import { Visualization } from '../../../types'; import { LayerPanel } from './layer_panel'; @@ -26,7 +25,9 @@ import { setToggleFullscreen, useLensSelector, selectVisualization, + VisualizationState, } from '../../../state_management'; +import { AddLayerButton } from './add_layer'; export const ConfigPanelWrapper = memo(function ConfigPanelWrapper(props: ConfigPanelWrapperProps) { const visualization = useLensSelector(selectVisualization); @@ -39,6 +40,18 @@ export const ConfigPanelWrapper = memo(function ConfigPanelWrapper(props: Config ) : null; }); +function getRemoveOperation( + activeVisualization: Visualization, + visualizationState: VisualizationState['state'], + layerId: string, + layerCount: number +) { + if (activeVisualization.getRemoveOperation) { + return activeVisualization.getRemoveOperation(visualizationState, layerId); + } + // fallback to generic count check + return layerCount === 1 ? 'clear' : 'remove'; +} export function LayerPanels( props: ConfigPanelWrapperProps & { activeVisualization: Visualization; @@ -104,6 +117,10 @@ export function LayerPanels( typeof newDatasourceState === 'function' ? newDatasourceState(prevState.datasourceStates[datasourceId].state) : newDatasourceState; + const updatedVisualizationState = + typeof newVisualizationState === 'function' + ? newVisualizationState(prevState.visualization.state) + : newVisualizationState; return { ...prevState, datasourceStates: { @@ -115,7 +132,7 @@ export function LayerPanels( }, visualization: { ...prevState.visualization, - state: newVisualizationState, + state: updatedVisualizationState, }, stagedPreview: undefined, }; @@ -152,15 +169,26 @@ export function LayerPanels( updateDatasource={updateDatasource} updateDatasourceAsync={updateDatasourceAsync} updateAll={updateAll} - isOnlyLayer={layerIds.length === 1} + isOnlyLayer={ + getRemoveOperation( + activeVisualization, + visualization.state, + layerId, + layerIds.length + ) === 'clear' + } onRemoveLayer={() => { dispatchLens( updateState({ subType: 'REMOVE_OR_CLEAR_LAYER', updater: (state) => { - const isOnlyLayer = activeVisualization - .getLayerIds(state.visualization.state) - .every((id) => id === layerId); + const isOnlyLayer = + getRemoveOperation( + activeVisualization, + state.visualization.state, + layerId, + layerIds.length + ) === 'clear'; return { ...state, @@ -195,51 +223,30 @@ export function LayerPanels( /> ) : null )} - {activeVisualization.appendLayer && visualization.state && ( - - - { - const id = generateId(); - dispatchLens( - updateState({ - subType: 'ADD_LAYER', - updater: (state) => - appendLayer({ - activeVisualization, - generateId: () => id, - trackUiEvent, - activeDatasource: datasourceMap[activeDatasourceId!], - state, - }), - }) - ); - setNextFocusedLayerId(id); - }} - iconType="plusInCircleFilled" - /> - - - )} + { + const id = generateId(); + dispatchLens( + updateState({ + subType: 'ADD_LAYER', + updater: (state) => + appendLayer({ + activeVisualization, + generateId: () => id, + trackUiEvent, + activeDatasource: datasourceMap[activeDatasourceId!], + state, + layerType, + }), + }) + ); + + setNextFocusedLayerId(id); + }} + /> ); } diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions.test.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions.test.ts index ad15be170e631..967e6e47c55f0 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions.test.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions.test.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { layerTypes } from '../../../../common'; import { initialState } from '../../../state_management/lens_slice'; import { removeLayer, appendLayer } from './layer_actions'; @@ -119,6 +120,7 @@ describe('appendLayer', () => { generateId: () => 'foo', state, trackUiEvent, + layerType: layerTypes.DATA, }); expect(newState.visualization.state).toEqual(['layer1', 'layer2', 'foo']); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions.ts index 328a868cfb893..c0f0847e8ff5c 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions.ts @@ -6,6 +6,7 @@ */ import { mapValues } from 'lodash'; +import type { LayerType } from '../../../../common'; import { LensAppState } from '../../../state_management'; import { Datasource, Visualization } from '../../../types'; @@ -24,6 +25,7 @@ interface AppendLayerOptions { generateId: () => string; activeDatasource: Pick; activeVisualization: Pick; + layerType: LayerType; } export function removeLayer(opts: RemoveLayerOptions): LensAppState { @@ -62,6 +64,7 @@ export function appendLayer({ state, generateId, activeDatasource, + layerType, }: AppendLayerOptions): LensAppState { trackUiEvent('layer_added'); @@ -85,7 +88,7 @@ export function appendLayer({ }, visualization: { ...state.visualization, - state: activeVisualization.appendLayer(state.visualization.state, layerId), + state: activeVisualization.appendLayer(state.visualization.state, layerId, layerType), }, stagedPreview: undefined, }; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.scss index fd37a7bada02f..7a1cbb8237f50 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.scss +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.scss @@ -1,7 +1,7 @@ @import '../../../mixins'; .lnsLayerPanel { - margin-bottom: $euiSizeS; + margin-bottom: $euiSize; // disable focus ring for mouse clicks, leave it for keyboard users &:focus:not(:focus-visible) { @@ -9,26 +9,41 @@ } } -.lnsLayerPanel__sourceFlexItem { - max-width: calc(100% - #{$euiSize * 3.625}); +.lnsLayerPanel__layerHeader { + padding: $euiSize; + border-bottom: $euiBorderThin; +} + +// fixes truncation for too long chart switcher labels +.lnsLayerPanel__layerSettingsWrapper { + min-width: 0; } -.lnsLayerPanel__settingsFlexItem:empty + .lnsLayerPanel__sourceFlexItem { - max-width: calc(100% - #{$euiSizeS}); +.lnsLayerPanel__settingsStaticHeader { + padding-left: $euiSizeXS; } -.lnsLayerPanel__settingsFlexItem:empty { - margin: 0; +.lnsLayerPanel__settingsStaticHeaderIcon { + margin-right: $euiSizeS; + vertical-align: inherit; +} + +.lnsLayerPanel__settingsStaticHeaderTitle { + display: inline; } .lnsLayerPanel__row { background: $euiColorLightestShade; - padding: $euiSizeS 0; - border-radius: $euiBorderRadius; + padding: $euiSize; - // Add margin to the top of the next same panel + // Add border to the top of the next same panel & + & { - margin-top: $euiSize; + border-top: $euiBorderThin; + margin-top: 0; + } + &:last-child { + border-bottom-right-radius: $euiBorderRadius; + border-bottom-left-radius: $euiBorderRadius; } } @@ -45,10 +60,6 @@ padding: 0; } -.lnsLayerPanel__groupLabel { - padding: 0 $euiSizeS; -} - .lnsLayerPanel__error { padding: 0 $euiSizeS; } @@ -76,7 +87,7 @@ } .lnsLayerPanel__dimensionContainer { - margin: 0 $euiSizeS $euiSizeS; + margin: 0 0 $euiSizeS; position: relative; &:last-child { @@ -93,6 +104,7 @@ padding: $euiSizeS; min-height: $euiSizeXXL - 2; word-break: break-word; + font-weight: $euiFontWeightRegular; } .lnsLayerPanel__triggerTextLabel { diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx index 12f27b5bfba10..13b7b8cfecf56 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import { EuiFormRow } from '@elastic/eui'; -import { Visualization } from '../../../types'; +import { FramePublicAPI, Visualization } from '../../../types'; import { LayerPanel } from './layer_panel'; import { ChildDragDropProvider, DragDrop } from '../../../drag_drop'; import { coreMock } from '../../../../../../../src/core/public/mocks'; @@ -56,9 +56,10 @@ describe('LayerPanel', () => { let mockVisualization2: jest.Mocked; let mockDatasource: DatasourceMock; + let frame: FramePublicAPI; function getDefaultProps() { - const frame = createMockFramePublicAPI(); + frame = createMockFramePublicAPI(); frame.datasourceLayers = { first: mockDatasource.publicAPIMock, }; @@ -119,27 +120,27 @@ describe('LayerPanel', () => { describe('layer reset and remove', () => { it('should show the reset button when single layer', async () => { const { instance } = await mountWithProvider(); - expect(instance.find('[data-test-subj="lnsLayerRemove"]').first().text()).toContain( - 'Reset layer' - ); + expect( + instance.find('[data-test-subj="lnsLayerRemove"]').first().props()['aria-label'] + ).toContain('Reset layer'); }); it('should show the delete button when multiple layers', async () => { const { instance } = await mountWithProvider( ); - expect(instance.find('[data-test-subj="lnsLayerRemove"]').first().text()).toContain( - 'Delete layer' - ); + expect( + instance.find('[data-test-subj="lnsLayerRemove"]').first().props()['aria-label'] + ).toContain('Delete layer'); }); it('should show to reset visualization for visualizations only allowing a single layer', async () => { const layerPanelAttributes = getDefaultProps(); delete layerPanelAttributes.activeVisualization.removeLayer; const { instance } = await mountWithProvider(); - expect(instance.find('[data-test-subj="lnsLayerRemove"]').first().text()).toContain( - 'Reset visualization' - ); + expect( + instance.find('[data-test-subj="lnsLayerRemove"]').first().props()['aria-label'] + ).toContain('Reset visualization'); }); it('should call the clear callback', async () => { @@ -901,12 +902,14 @@ describe('LayerPanel', () => { droppedItem: draggingOperation, }) ); - expect(mockVis.setDimension).toHaveBeenCalledWith({ - columnId: 'c', - groupId: 'b', - layerId: 'first', - prevState: 'state', - }); + expect(mockVis.setDimension).toHaveBeenCalledWith( + expect.objectContaining({ + columnId: 'c', + groupId: 'b', + layerId: 'first', + prevState: 'state', + }) + ); expect(mockVis.removeDimension).toHaveBeenCalledWith( expect.objectContaining({ columnId: 'a', diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx index d0a6830aa178a..c729885fef8a9 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx @@ -124,7 +124,7 @@ export function LayerPanel( dateRange, }; - const { groups } = useMemo( + const { groups, supportStaticValue } = useMemo( () => activeVisualization.getConfiguration(layerVisualizationConfigProps), // eslint-disable-next-line react-hooks/exhaustive-deps [ @@ -194,6 +194,7 @@ export function LayerPanel( layerId: targetLayerId, prevState: props.visualizationState, previousColumn: typeof droppedItem.column === 'string' ? droppedItem.column : undefined, + frame: framePublicAPI, }); if (typeof dropResult === 'object') { @@ -203,6 +204,7 @@ export function LayerPanel( columnId: dropResult.deleted, layerId: targetLayerId, prevState: newVisState, + frame: framePublicAPI, }) ); } else { @@ -211,6 +213,7 @@ export function LayerPanel( } }; }, [ + framePublicAPI, groups, layerDatasourceOnDrop, props.visualizationState, @@ -242,6 +245,7 @@ export function LayerPanel( layerId, columnId: activeId, prevState: visualizationState, + frame: framePublicAPI, }) ); } @@ -254,6 +258,7 @@ export function LayerPanel( groupId: activeGroup.groupId, columnId: activeId, prevState: visualizationState, + frame: framePublicAPI, }) ); setActiveDimension({ ...activeDimension, isNew: false }); @@ -272,6 +277,7 @@ export function LayerPanel( updateAll, updateDatasourceAsync, visualizationState, + framePublicAPI, ] ); @@ -283,60 +289,72 @@ export function LayerPanel( className="lnsLayerPanel" style={{ visibility: isDimensionPanelOpen ? 'hidden' : 'visible' }} > - - - - - - + +
+ + + + + + + + + {layerDatasource && ( - - { - const newState = - typeof updater === 'function' ? updater(layerDatasourceState) : updater; - // Look for removed columns - const nextPublicAPI = layerDatasource.getPublicAPI({ - state: newState, + { + const newState = + typeof updater === 'function' ? updater(layerDatasourceState) : updater; + // Look for removed columns + const nextPublicAPI = layerDatasource.getPublicAPI({ + state: newState, + layerId, + }); + const nextTable = new Set( + nextPublicAPI.getTableSpec().map(({ columnId }) => columnId) + ); + const removed = datasourcePublicAPI + .getTableSpec() + .map(({ columnId }) => columnId) + .filter((columnId) => !nextTable.has(columnId)); + let nextVisState = props.visualizationState; + removed.forEach((columnId) => { + nextVisState = activeVisualization.removeDimension({ layerId, + columnId, + prevState: nextVisState, + frame: framePublicAPI, }); - const nextTable = new Set( - nextPublicAPI.getTableSpec().map(({ columnId }) => columnId) - ); - const removed = datasourcePublicAPI - .getTableSpec() - .map(({ columnId }) => columnId) - .filter((columnId) => !nextTable.has(columnId)); - let nextVisState = props.visualizationState; - removed.forEach((columnId) => { - nextVisState = activeVisualization.removeDimension({ - layerId, - columnId, - prevState: nextVisState, - }); - }); + }); - props.updateAll(datasourceId, newState, nextVisState); - }, - }} - /> - + props.updateAll(datasourceId, newState, nextVisState); + }, + }} + /> )} - - - +
{groups.map((group, groupIndex) => { const isMissing = !isEmptyLayer && group.required && group.accessors.length === 0; @@ -349,7 +367,7 @@ export function LayerPanel( } fullWidth label={ -
+
{group.groupLabel} {group.groupTooltip && ( <> @@ -429,6 +447,7 @@ export function LayerPanel( layerId, columnId: id, prevState: props.visualizationState, + frame: framePublicAPI, }) ); removeButtonRef(id); @@ -462,7 +481,7 @@ export function LayerPanel( setActiveDimension({ activeGroup: group, activeId: id, - isNew: true, + isNew: !supportStaticValue, }); }} onDrop={onDrop} @@ -472,19 +491,6 @@ export function LayerPanel( ); })} - - - - - - - - @@ -532,6 +538,7 @@ export function LayerPanel( toggleFullscreen, isFullscreen, setState: updateDataLayerState, + layerType: activeVisualization.getLayerType(layerId, visualizationState), }} /> )} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_settings.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_settings.tsx index 2d421965a633a..467b1ecfe1b5b 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_settings.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_settings.tsx @@ -5,12 +5,10 @@ * 2.0. */ -import React, { useState } from 'react'; -import { EuiPopover, EuiToolTip } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiTitle } from '@elastic/eui'; import { NativeRenderer } from '../../../native_renderer'; import { Visualization, VisualizationLayerWidgetProps } from '../../../types'; -import { ToolbarButton } from '../../../../../../../src/plugins/kibana_react/public'; export function LayerSettings({ layerId, @@ -21,56 +19,34 @@ export function LayerSettings({ activeVisualization: Visualization; layerConfigProps: VisualizationLayerWidgetProps; }) { - const [isOpen, setIsOpen] = useState(false); + const description = activeVisualization.getDescription(layerConfigProps.state); - if (!activeVisualization.renderLayerContextMenu) { - return null; - } - - const a11yText = (chartType?: string) => { - if (chartType) { - return i18n.translate('xpack.lens.editLayerSettingsChartType', { - defaultMessage: 'Edit layer settings, {chartType}', - values: { - chartType, - }, - }); + if (!activeVisualization.renderLayerHeader) { + if (!description) { + return null; } - return i18n.translate('xpack.lens.editLayerSettings', { - defaultMessage: 'Edit layer settings', - }); - }; + return ( + + {description.icon && ( + + {' '} + + )} + + +
{description.label}
+
+
+
+ ); + } - const contextMenuIcon = activeVisualization.getLayerContextMenuIcon?.(layerConfigProps); return ( - - setIsOpen(!isOpen)} - data-test-subj="lns_layer_settings" - /> - - } - isOpen={isOpen} - closePopover={() => setIsOpen(false)} - anchorPosition="downLeft" - > - - + ); } diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/remove_layer_button.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/remove_layer_button.tsx index cca8cc88c6ab1..fbc498b729d2a 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/remove_layer_button.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/remove_layer_button.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { EuiButtonEmpty } from '@elastic/eui'; +import { EuiButtonIcon } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { Visualization } from '../../../types'; @@ -22,40 +22,31 @@ export function RemoveLayerButton({ activeVisualization: Visualization; }) { let ariaLabel; - let componentText; if (!activeVisualization.removeLayer) { ariaLabel = i18n.translate('xpack.lens.resetVisualizationAriaLabel', { defaultMessage: 'Reset visualization', }); - componentText = i18n.translate('xpack.lens.resetVisualization', { - defaultMessage: 'Reset visualization', - }); } else if (isOnlyLayer) { ariaLabel = i18n.translate('xpack.lens.resetLayerAriaLabel', { defaultMessage: 'Reset layer {index}', values: { index: layerIndex + 1 }, }); - componentText = i18n.translate('xpack.lens.resetLayer', { - defaultMessage: 'Reset layer', - }); } else { ariaLabel = i18n.translate('xpack.lens.deleteLayerAriaLabel', { defaultMessage: `Delete layer {index}`, values: { index: layerIndex + 1 }, }); - componentText = i18n.translate('xpack.lens.deleteLayer', { - defaultMessage: `Delete layer`, - }); } return ( - { // If we don't blur the remove / clear button, it remains focused // which is a strange UX in this case. e.target.blur doesn't work @@ -69,8 +60,6 @@ export function RemoveLayerButton({ onRemoveLayer(); }} - > - {componentText} - + /> ); } diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/frame_layout.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/frame_layout.scss index 2d86b37669ed0..91793d1f6cb71 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/frame_layout.scss +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/frame_layout.scss @@ -107,7 +107,7 @@ a tilemap in an iframe: https://github.com/elastic/kibana/issues/16457 */ .lnsConfigPanel { @include euiScrollBar; - padding: $euiSize $euiSizeXS $euiSize $euiSize; + padding: $euiSize $euiSizeXS $euiSizeXL $euiSize; overflow-x: hidden; overflow-y: scroll; padding-left: $euiFormMaxWidth + $euiSize; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts index 65cd5ae35c6fe..2f3fe3795a881 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { flatten } from 'lodash'; import { Ast } from '@kbn/interpreter/common'; import { IconType } from '@elastic/eui/src/components/icon/icon'; import { Datatable } from 'src/plugins/expressions'; @@ -22,6 +21,8 @@ import { VisualizationMap, } from '../../types'; import { DragDropIdentifier } from '../../drag_drop'; +import { LayerType, layerTypes } from '../../../common'; +import { getLayerType } from './config_panel/add_layer'; import { LensDispatch, selectSuggestion, @@ -80,58 +81,88 @@ export function getSuggestions({ ([datasourceId]) => datasourceStates[datasourceId] && !datasourceStates[datasourceId].isLoading ); + const layerTypesMap = datasources.reduce((memo, [datasourceId, datasource]) => { + const datasourceState = datasourceStates[datasourceId].state; + if (!activeVisualizationId || !datasourceState || !visualizationMap[activeVisualizationId]) { + return memo; + } + const layers = datasource.getLayers(datasourceState); + for (const layerId of layers) { + const type = getLayerType( + visualizationMap[activeVisualizationId], + visualizationState, + layerId + ); + memo[layerId] = type; + } + return memo; + }, {} as Record); + + const isLayerSupportedByVisualization = (layerId: string, supportedTypes: LayerType[]) => + supportedTypes.includes(layerTypesMap[layerId] ?? layerTypes.DATA); + // Collect all table suggestions from available datasources - const datasourceTableSuggestions = flatten( - datasources.map(([datasourceId, datasource]) => { - const datasourceState = datasourceStates[datasourceId].state; - let dataSourceSuggestions; - if (visualizeTriggerFieldContext) { - dataSourceSuggestions = datasource.getDatasourceSuggestionsForVisualizeField( - datasourceState, - visualizeTriggerFieldContext.indexPatternId, - visualizeTriggerFieldContext.fieldName - ); - } else if (field) { - dataSourceSuggestions = datasource.getDatasourceSuggestionsForField(datasourceState, field); - } else { - dataSourceSuggestions = datasource.getDatasourceSuggestionsFromCurrentState( - datasourceState, - activeData - ); - } - return dataSourceSuggestions.map((suggestion) => ({ ...suggestion, datasourceId })); - }) - ); + const datasourceTableSuggestions = datasources.flatMap(([datasourceId, datasource]) => { + const datasourceState = datasourceStates[datasourceId].state; + let dataSourceSuggestions; + if (visualizeTriggerFieldContext) { + dataSourceSuggestions = datasource.getDatasourceSuggestionsForVisualizeField( + datasourceState, + visualizeTriggerFieldContext.indexPatternId, + visualizeTriggerFieldContext.fieldName + ); + } else if (field) { + dataSourceSuggestions = datasource.getDatasourceSuggestionsForField(datasourceState, field); + } else { + dataSourceSuggestions = datasource.getDatasourceSuggestionsFromCurrentState( + datasourceState, + activeData + ); + } + return dataSourceSuggestions.map((suggestion) => ({ ...suggestion, datasourceId })); + }); // Pass all table suggestions to all visualization extensions to get visualization suggestions // and rank them by score - return flatten( - Object.entries(visualizationMap).map(([visualizationId, visualization]) => - flatten( - datasourceTableSuggestions.map((datasourceSuggestion) => { + return Object.entries(visualizationMap) + .flatMap(([visualizationId, visualization]) => { + const supportedLayerTypes = visualization.getSupportedLayers().map(({ type }) => type); + return datasourceTableSuggestions + .filter((datasourceSuggestion) => { + const filteredCount = datasourceSuggestion.keptLayerIds.filter((layerId) => + isLayerSupportedByVisualization(layerId, supportedLayerTypes) + ).length; + // make it pass either suggestions with some ids left after filtering + // or suggestion with already 0 ids before the filtering (testing purposes) + return filteredCount || filteredCount === datasourceSuggestion.keptLayerIds.length; + }) + .flatMap((datasourceSuggestion) => { const table = datasourceSuggestion.table; const currentVisualizationState = visualizationId === activeVisualizationId ? visualizationState : undefined; const palette = mainPalette || - (activeVisualizationId && - visualizationMap[activeVisualizationId] && - visualizationMap[activeVisualizationId].getMainPalette - ? visualizationMap[activeVisualizationId].getMainPalette!(visualizationState) + (activeVisualizationId && visualizationMap[activeVisualizationId]?.getMainPalette + ? visualizationMap[activeVisualizationId].getMainPalette?.(visualizationState) : undefined); + return getVisualizationSuggestions( visualization, table, visualizationId, - datasourceSuggestion, + { + ...datasourceSuggestion, + keptLayerIds: datasourceSuggestion.keptLayerIds.filter((layerId) => + isLayerSupportedByVisualization(layerId, supportedLayerTypes) + ), + }, currentVisualizationState, subVisualizationId, palette ); - }) - ) - ) - ).sort((a, b) => b.score - a.score); + }); + }) + .sort((a, b) => b.score - a.score); } export function getVisualizeFieldSuggestions({ diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx index e2036e556a551..010e4d73c4791 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx @@ -136,21 +136,22 @@ export const ChartSwitch = memo(function ChartSwitch(props: Props) { ); } layerIds.forEach((layerId) => { - const layerDatasourceId = Object.entries(props.datasourceMap).find( - ([datasourceId, datasource]) => { + const [layerDatasourceId] = + Object.entries(props.datasourceMap).find(([datasourceId, datasource]) => { return ( datasourceStates[datasourceId] && datasource.getLayers(datasourceStates[datasourceId].state).includes(layerId) ); - } - )![0]; - dispatchLens( - updateLayer({ - layerId, - datasourceId: layerDatasourceId, - updater: props.datasourceMap[layerDatasourceId].removeLayer, - }) - ); + }) ?? []; + if (layerDatasourceId) { + dispatchLens( + updateLayer({ + layerId, + datasourceId: layerDatasourceId, + updater: props.datasourceMap[layerDatasourceId].removeLayer, + }) + ); + } }); } diff --git a/x-pack/plugins/lens/public/heatmap_visualization/suggestions.test.ts b/x-pack/plugins/lens/public/heatmap_visualization/suggestions.test.ts index d7443ea8fe43d..e9f8acad7f82d 100644 --- a/x-pack/plugins/lens/public/heatmap_visualization/suggestions.test.ts +++ b/x-pack/plugins/lens/public/heatmap_visualization/suggestions.test.ts @@ -9,6 +9,7 @@ import { Position } from '@elastic/charts'; import { getSuggestions } from './suggestions'; import type { HeatmapVisualizationState } from './types'; import { HEATMAP_GRID_FUNCTION, LEGEND_FUNCTION } from './constants'; +import { layerTypes } from '../../common'; describe('heatmap suggestions', () => { describe('rejects suggestions', () => { @@ -24,6 +25,7 @@ describe('heatmap suggestions', () => { state: { shape: 'heatmap', layerId: 'first', + layerType: layerTypes.DATA, } as HeatmapVisualizationState, keptLayerIds: ['first'], }) @@ -78,6 +80,7 @@ describe('heatmap suggestions', () => { }, state: { layerId: 'first', + layerType: layerTypes.DATA, } as HeatmapVisualizationState, keptLayerIds: ['first'], }) @@ -96,6 +99,7 @@ describe('heatmap suggestions', () => { state: { shape: 'heatmap', layerId: 'first', + layerType: layerTypes.DATA, xAccessor: 'some-field', } as HeatmapVisualizationState, keptLayerIds: ['first'], @@ -116,6 +120,7 @@ describe('heatmap suggestions', () => { }, state: { layerId: 'first', + layerType: layerTypes.DATA, } as HeatmapVisualizationState, keptLayerIds: ['first'], }) @@ -123,6 +128,7 @@ describe('heatmap suggestions', () => { { state: { layerId: 'first', + layerType: layerTypes.DATA, shape: 'heatmap', gridConfig: { type: HEATMAP_GRID_FUNCTION, @@ -164,6 +170,7 @@ describe('heatmap suggestions', () => { }, state: { layerId: 'first', + layerType: layerTypes.DATA, } as HeatmapVisualizationState, keptLayerIds: ['first'], }) @@ -171,6 +178,7 @@ describe('heatmap suggestions', () => { { state: { layerId: 'first', + layerType: layerTypes.DATA, shape: 'heatmap', xAccessor: 'test-column', gridConfig: { @@ -225,6 +233,7 @@ describe('heatmap suggestions', () => { }, state: { layerId: 'first', + layerType: layerTypes.DATA, } as HeatmapVisualizationState, keptLayerIds: ['first'], }) @@ -232,6 +241,7 @@ describe('heatmap suggestions', () => { { state: { layerId: 'first', + layerType: layerTypes.DATA, shape: 'heatmap', xAccessor: 'date-column', valueAccessor: 'metric-column', @@ -295,6 +305,7 @@ describe('heatmap suggestions', () => { }, state: { layerId: 'first', + layerType: layerTypes.DATA, } as HeatmapVisualizationState, keptLayerIds: ['first'], }) @@ -302,6 +313,7 @@ describe('heatmap suggestions', () => { { state: { layerId: 'first', + layerType: layerTypes.DATA, shape: 'heatmap', xAccessor: 'date-column', yAccessor: 'group-column', diff --git a/x-pack/plugins/lens/public/heatmap_visualization/suggestions.ts b/x-pack/plugins/lens/public/heatmap_visualization/suggestions.ts index 3f27d5e81b507..ebe93419edce6 100644 --- a/x-pack/plugins/lens/public/heatmap_visualization/suggestions.ts +++ b/x-pack/plugins/lens/public/heatmap_visualization/suggestions.ts @@ -11,6 +11,7 @@ import { i18n } from '@kbn/i18n'; import type { Visualization } from '../types'; import type { HeatmapVisualizationState } from './types'; import { CHART_SHAPES, HEATMAP_GRID_FUNCTION, LEGEND_FUNCTION } from './constants'; +import { layerTypes } from '../../common'; export const getSuggestions: Visualization['getSuggestions'] = ({ table, @@ -59,6 +60,7 @@ export const getSuggestions: Visualization['getSugges const newState: HeatmapVisualizationState = { shape: CHART_SHAPES.HEATMAP, layerId: table.layerId, + layerType: layerTypes.DATA, legend: { isVisible: state?.legend?.isVisible ?? true, position: state?.legend?.position ?? Position.Right, diff --git a/x-pack/plugins/lens/public/heatmap_visualization/types.ts b/x-pack/plugins/lens/public/heatmap_visualization/types.ts index 0cf830bea609a..5515d77d1a8ab 100644 --- a/x-pack/plugins/lens/public/heatmap_visualization/types.ts +++ b/x-pack/plugins/lens/public/heatmap_visualization/types.ts @@ -7,7 +7,7 @@ import type { PaletteOutput } from '../../../../../src/plugins/charts/common'; import type { LensBrushEvent, LensFilterEvent } from '../types'; -import type { LensMultiTable, FormatFactory, CustomPaletteParams } from '../../common'; +import type { LensMultiTable, FormatFactory, CustomPaletteParams, LayerType } from '../../common'; import type { HeatmapGridConfigResult, HeatmapLegendConfigResult } from '../../common/expressions'; import { CHART_SHAPES, LENS_HEATMAP_RENDERER } from './constants'; import type { ChartsPluginSetup, PaletteRegistry } from '../../../../../src/plugins/charts/public'; @@ -25,6 +25,7 @@ export interface SharedHeatmapLayerState { export type HeatmapLayerState = SharedHeatmapLayerState & { layerId: string; + layerType: LayerType; }; export type HeatmapVisualizationState = HeatmapLayerState & { diff --git a/x-pack/plugins/lens/public/heatmap_visualization/visualization.test.ts b/x-pack/plugins/lens/public/heatmap_visualization/visualization.test.ts index 6cbe27fbf323f..bceeeebb5e140 100644 --- a/x-pack/plugins/lens/public/heatmap_visualization/visualization.test.ts +++ b/x-pack/plugins/lens/public/heatmap_visualization/visualization.test.ts @@ -22,10 +22,12 @@ import { Position } from '@elastic/charts'; import type { HeatmapVisualizationState } from './types'; import type { DatasourcePublicAPI, Operation } from '../types'; import { chartPluginMock } from 'src/plugins/charts/public/mocks'; +import { layerTypes } from '../../common'; function exampleState(): HeatmapVisualizationState { return { layerId: 'test-layer', + layerType: layerTypes.DATA, legend: { isVisible: true, position: Position.Right, @@ -54,6 +56,7 @@ describe('heatmap', () => { test('returns a default state', () => { expect(getHeatmapVisualization({ paletteService }).initialize(() => 'l1')).toEqual({ layerId: 'l1', + layerType: layerTypes.DATA, title: 'Empty Heatmap chart', shape: CHART_SHAPES.HEATMAP, legend: { @@ -214,6 +217,7 @@ describe('heatmap', () => { layerId: 'first', columnId: 'new-x-accessor', groupId: 'x', + frame, }) ).toEqual({ ...prevState, @@ -236,6 +240,7 @@ describe('heatmap', () => { prevState, layerId: 'first', columnId: 'x-accessor', + frame, }) ).toEqual({ ...exampleState(), @@ -244,6 +249,31 @@ describe('heatmap', () => { }); }); + describe('#getSupportedLayers', () => { + it('should return a single layer type', () => { + expect( + getHeatmapVisualization({ + paletteService, + }).getSupportedLayers() + ).toHaveLength(1); + }); + }); + + describe('#getLayerType', () => { + it('should return the type only if the layer is in the state', () => { + const state: HeatmapVisualizationState = { + ...exampleState(), + xAccessor: 'x-accessor', + valueAccessor: 'value-accessor', + }; + const instance = getHeatmapVisualization({ + paletteService, + }); + expect(instance.getLayerType('test-layer', state)).toEqual(layerTypes.DATA); + expect(instance.getLayerType('foo', state)).toBeUndefined(); + }); + }); + describe('#toExpression', () => { let datasourceLayers: Record; diff --git a/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx b/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx index 716792805e1b5..5405cff6ed1db 100644 --- a/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx @@ -31,6 +31,7 @@ import { CUSTOM_PALETTE, getStopsForFixedMode } from '../shared_components'; import { HeatmapDimensionEditor } from './dimension_editor'; import { getSafePaletteParams } from './utils'; import type { CustomPaletteParams } from '../../common'; +import { layerTypes } from '../../common'; const groupLabelForBar = i18n.translate('xpack.lens.heatmapVisualization.heatmapGroupLabel', { defaultMessage: 'Heatmap', @@ -63,7 +64,7 @@ export const isCellValueSupported = (op: OperationMetadata) => { return !isBucketed(op) && (op.scale === 'ordinal' || op.scale === 'ratio') && isNumericMetric(op); }; -function getInitialState(): Omit { +function getInitialState(): Omit { return { shape: CHART_SHAPES.HEATMAP, legend: { @@ -138,6 +139,7 @@ export const getHeatmapVisualization = ({ return ( state || { layerId: addNewLayer(), + layerType: layerTypes.DATA, title: 'Empty Heatmap chart', ...getInitialState(), } @@ -263,6 +265,23 @@ export const getHeatmapVisualization = ({ ); }, + getSupportedLayers() { + return [ + { + type: layerTypes.DATA, + label: i18n.translate('xpack.lens.heatmap.addLayer', { + defaultMessage: 'Add visualization layer', + }), + }, + ]; + }, + + getLayerType(layerId, state) { + if (state?.layerId === layerId) { + return state.layerType; + } + }, + toExpression(state, datasourceLayers, attributes): Ast | null { const datasource = datasourceLayers[state.layerId]; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.scss b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.scss index d3320714a65cd..0303e6549d8df 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.scss +++ b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.scss @@ -45,4 +45,15 @@ .lnsChangeIndexPatternPopover { width: 320px; +} + +.lnsChangeIndexPatternPopover__trigger { + padding: 0 $euiSize; +} + +.lnsLayerPanelChartSwitch_title { + font-weight: 600; + display: inline; + vertical-align: middle; + padding-left: 8px; } \ No newline at end of file diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx index 05100567c1b03..0faaa1f342eeb 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx @@ -96,6 +96,7 @@ export function DimensionEditor(props: DimensionEditorProps) { dimensionGroups, toggleFullscreen, isFullscreen, + layerType, } = props; const services = { data: props.data, @@ -186,7 +187,8 @@ export function DimensionEditor(props: DimensionEditorProps) { definition.getDisabledStatus && definition.getDisabledStatus( state.indexPatterns[state.currentIndexPatternId], - state.layers[layerId] + state.layers[layerId], + layerType ), }; }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx index b6d3a230d06f5..6d96b853ab239 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx @@ -36,6 +36,7 @@ import { Filtering, setFilter } from './filtering'; import { TimeShift } from './time_shift'; import { DimensionEditor } from './dimension_editor'; import { AdvancedOptions } from './advanced_options'; +import { layerTypes } from '../../../common'; jest.mock('../loader'); jest.mock('../query_input', () => ({ @@ -184,6 +185,7 @@ describe('IndexPatternDimensionEditorPanel', () => { dateRange: { fromDate: 'now-1d', toDate: 'now' }, columnId: 'col1', layerId: 'first', + layerType: layerTypes.DATA, uniqueLabel: 'stuff', filterOperations: () => true, storage: {} as IStorageWrapper, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/droppable.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/droppable.test.ts index 56d255ec02227..d1082da2beb20 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/droppable.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/droppable.test.ts @@ -263,6 +263,7 @@ describe('IndexPatternDimensionEditorPanel', () => { dateRange: { fromDate: 'now-1d', toDate: 'now' }, columnId: 'col1', layerId: 'first', + layerType: 'data', uniqueLabel: 'stuff', groupId: 'group1', filterOperations: () => true, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/counter_rate.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/counter_rate.tsx index e6cba7ac9dce0..8cc6139fedc0a 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/counter_rate.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/counter_rate.tsx @@ -14,6 +14,7 @@ import { checkForDateHistogram, dateBasedOperationToExpression, hasDateField, + checkForDataLayerType, } from './utils'; import { DEFAULT_TIME_SCALE } from '../../time_scale_utils'; import { OperationDefinition } from '..'; @@ -111,13 +112,18 @@ export const counterRateOperation: OperationDefinition< }) ); }, - getDisabledStatus(indexPattern, layer) { - return checkForDateHistogram( - layer, - i18n.translate('xpack.lens.indexPattern.counterRate', { - defaultMessage: 'Counter rate', - }) - )?.join(', '); + getDisabledStatus(indexPattern, layer, layerType) { + const opName = i18n.translate('xpack.lens.indexPattern.counterRate', { + defaultMessage: 'Counter rate', + }); + if (layerType) { + const dataLayerErrors = checkForDataLayerType(layerType, opName); + if (dataLayerErrors) { + return dataLayerErrors.join(', '); + } + } + + return checkForDateHistogram(layer, opName)?.join(', '); }, timeScalingMode: 'mandatory', filterable: true, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/cumulative_sum.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/cumulative_sum.tsx index 9c8437140f793..a59491cfc8a6b 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/cumulative_sum.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/cumulative_sum.tsx @@ -14,6 +14,7 @@ import { dateBasedOperationToExpression, hasDateField, buildLabelFunction, + checkForDataLayerType, } from './utils'; import { OperationDefinition } from '..'; import { getFormatFromPreviousColumn, getFilter } from '../helpers'; @@ -108,13 +109,17 @@ export const cumulativeSumOperation: OperationDefinition< }) ); }, - getDisabledStatus(indexPattern, layer) { - return checkForDateHistogram( - layer, - i18n.translate('xpack.lens.indexPattern.cumulativeSum', { - defaultMessage: 'Cumulative sum', - }) - )?.join(', '); + getDisabledStatus(indexPattern, layer, layerType) { + const opName = i18n.translate('xpack.lens.indexPattern.cumulativeSum', { + defaultMessage: 'Cumulative sum', + }); + if (layerType) { + const dataLayerErrors = checkForDataLayerType(layerType, opName); + if (dataLayerErrors) { + return dataLayerErrors.join(', '); + } + } + return checkForDateHistogram(layer, opName)?.join(', '); }, filterable: true, documentation: { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/differences.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/differences.tsx index 8890390378d21..730067e9c5577 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/differences.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/differences.tsx @@ -14,6 +14,7 @@ import { getErrorsForDateReference, dateBasedOperationToExpression, hasDateField, + checkForDataLayerType, } from './utils'; import { adjustTimeScaleOnOtherColumnChange } from '../../time_scale_utils'; import { OperationDefinition } from '..'; @@ -99,13 +100,17 @@ export const derivativeOperation: OperationDefinition< }) ); }, - getDisabledStatus(indexPattern, layer) { - return checkForDateHistogram( - layer, - i18n.translate('xpack.lens.indexPattern.derivative', { - defaultMessage: 'Differences', - }) - )?.join(', '); + getDisabledStatus(indexPattern, layer, layerType) { + const opName = i18n.translate('xpack.lens.indexPattern.derivative', { + defaultMessage: 'Differences', + }); + if (layerType) { + const dataLayerErrors = checkForDataLayerType(layerType, opName); + if (dataLayerErrors) { + return dataLayerErrors.join(', '); + } + } + return checkForDateHistogram(layer, opName)?.join(', '); }, timeScalingMode: 'optional', filterable: true, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/moving_average.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/moving_average.tsx index 72e14cc2ea016..7a26253c41f09 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/moving_average.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/moving_average.tsx @@ -18,6 +18,7 @@ import { getErrorsForDateReference, dateBasedOperationToExpression, hasDateField, + checkForDataLayerType, } from './utils'; import { updateColumnParam } from '../../layer_helpers'; import { getFormatFromPreviousColumn, isValidNumber, getFilter } from '../helpers'; @@ -122,13 +123,17 @@ export const movingAverageOperation: OperationDefinition< ); }, getHelpMessage: () => , - getDisabledStatus(indexPattern, layer) { - return checkForDateHistogram( - layer, - i18n.translate('xpack.lens.indexPattern.movingAverage', { - defaultMessage: 'Moving average', - }) - )?.join(', '); + getDisabledStatus(indexPattern, layer, layerType) { + const opName = i18n.translate('xpack.lens.indexPattern.movingAverage', { + defaultMessage: 'Moving average', + }); + if (layerType) { + const dataLayerErrors = checkForDataLayerType(layerType, opName); + if (dataLayerErrors) { + return dataLayerErrors.join(', '); + } + } + return checkForDateHistogram(layer, opName)?.join(', '); }, timeScalingMode: 'optional', filterable: true, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/utils.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/utils.test.ts index 7a6f96d705b0c..d68fd8b9555f9 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/utils.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/utils.test.ts @@ -5,9 +5,10 @@ * 2.0. */ -import { checkReferences } from './utils'; +import { checkReferences, checkForDataLayerType } from './utils'; import { operationDefinitionMap } from '..'; import { createMockedFullReference } from '../../mocks'; +import { layerTypes } from '../../../../../common'; // Mock prevents issue with circular loading jest.mock('..'); @@ -18,6 +19,14 @@ describe('utils', () => { operationDefinitionMap.testReference = createMockedFullReference(); }); + describe('checkForDataLayerType', () => { + it('should return an error if the layer is of the wrong type', () => { + expect(checkForDataLayerType(layerTypes.THRESHOLD, 'Operation')).toEqual([ + 'Operation is disabled for this type of layer.', + ]); + }); + }); + describe('checkReferences', () => { it('should show an error if the reference is missing', () => { expect( diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/utils.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/utils.ts index 34b33d35d4139..29865ac8d60b8 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/utils.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/utils.ts @@ -8,6 +8,7 @@ import { i18n } from '@kbn/i18n'; import type { ExpressionFunctionAST } from '@kbn/interpreter/common'; import memoizeOne from 'memoize-one'; +import { LayerType, layerTypes } from '../../../../../common'; import type { TimeScaleUnit } from '../../../../../common/expressions'; import type { IndexPattern, IndexPatternLayer } from '../../../types'; import { adjustTimeScaleLabelSuffix } from '../../time_scale_utils'; @@ -24,6 +25,19 @@ export const buildLabelFunction = (ofName: (name?: string) => string) => ( return adjustTimeScaleLabelSuffix(rawLabel, undefined, timeScale, undefined, timeShift); }; +export function checkForDataLayerType(layerType: LayerType, name: string) { + if (layerType === layerTypes.THRESHOLD) { + return [ + i18n.translate('xpack.lens.indexPattern.calculations.layerDataType', { + defaultMessage: '{name} is disabled for this type of layer.', + values: { + name, + }, + }), + ]; + } +} + /** * Checks whether the current layer includes a date histogram and returns an error otherwise */ diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts index a8ab6ef943b64..569045f39877e 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts @@ -53,7 +53,7 @@ import { lastValueOperation, LastValueIndexPatternColumn } from './last_value'; import { FrameDatasourceAPI, OperationMetadata } from '../../../types'; import type { BaseIndexPatternColumn, ReferenceBasedIndexPatternColumn } from './column_types'; import { IndexPattern, IndexPatternField, IndexPatternLayer } from '../../types'; -import { DateRange } from '../../../../common'; +import { DateRange, LayerType } from '../../../../common'; import { ExpressionAstFunction } from '../../../../../../../src/plugins/expressions/public'; import { DataPublicPluginStart } from '../../../../../../../src/plugins/data/public'; import { RangeIndexPatternColumn, rangeOperation } from './ranges'; @@ -259,7 +259,11 @@ interface BaseOperationDefinitionProps { * but disable it from usage, this function returns the string describing * the status. Otherwise it returns undefined */ - getDisabledStatus?: (indexPattern: IndexPattern, layer: IndexPatternLayer) => string | undefined; + getDisabledStatus?: ( + indexPattern: IndexPattern, + layer: IndexPatternLayer, + layerType?: LayerType + ) => string | undefined; /** * Validate that the operation has the right preconditions in the state. For example: * diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/last_value.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/last_value.test.tsx index 2db4d5e4b7742..77af42ab41888 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/last_value.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/last_value.test.tsx @@ -322,15 +322,15 @@ describe('last_value', () => { it('should return disabledStatus if indexPattern does contain date field', () => { const indexPattern = createMockedIndexPattern(); - expect(lastValueOperation.getDisabledStatus!(indexPattern, layer)).toEqual(undefined); + expect(lastValueOperation.getDisabledStatus!(indexPattern, layer, 'data')).toEqual(undefined); const indexPatternWithoutTimeFieldName = { ...indexPattern, timeFieldName: undefined, }; - expect(lastValueOperation.getDisabledStatus!(indexPatternWithoutTimeFieldName, layer)).toEqual( - undefined - ); + expect( + lastValueOperation.getDisabledStatus!(indexPatternWithoutTimeFieldName, layer, 'data') + ).toEqual(undefined); const indexPatternWithoutTimefields = { ...indexPatternWithoutTimeFieldName, @@ -339,7 +339,8 @@ describe('last_value', () => { const disabledStatus = lastValueOperation.getDisabledStatus!( indexPatternWithoutTimefields, - layer + layer, + 'data' ); expect(disabledStatus).toEqual( 'This function requires the presence of a date field in your index' diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts index 232843171016a..11c8206fee021 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts @@ -51,6 +51,7 @@ interface ColumnChange { targetGroup?: string; shouldResetLabel?: boolean; incompleteParams?: ColumnAdvancedParams; + initialParams?: { params: Record }; // TODO: bind this to the op parameter } interface ColumnCopy { @@ -398,6 +399,7 @@ export function replaceColumn({ if (previousDefinition.input === 'managedReference') { // If the transition is incomplete, leave the managed state until it's finished. tempLayer = deleteColumn({ layer: tempLayer, columnId, indexPattern }); + const hypotheticalLayer = insertNewColumn({ layer: tempLayer, columnId, diff --git a/x-pack/plugins/lens/public/metric_visualization/expression.test.tsx b/x-pack/plugins/lens/public/metric_visualization/expression.test.tsx index 10575f37dba6e..36ae3904f073c 100644 --- a/x-pack/plugins/lens/public/metric_visualization/expression.test.tsx +++ b/x-pack/plugins/lens/public/metric_visualization/expression.test.tsx @@ -11,6 +11,7 @@ import React from 'react'; import { shallow } from 'enzyme'; import { createMockExecutionContext } from '../../../../../src/plugins/expressions/common/mocks'; import type { IFieldFormat } from '../../../../../src/plugins/field_formats/common'; +import { layerTypes } from '../../common'; import type { LensMultiTable } from '../../common'; function sampleArgs() { @@ -37,6 +38,7 @@ function sampleArgs() { const args: MetricConfig = { accessor: 'c', layerId: 'l1', + layerType: layerTypes.DATA, title: 'My fanci metric chart', description: 'Fancy chart description', metricTitle: 'My fanci metric chart', @@ -46,6 +48,7 @@ function sampleArgs() { const noAttributesArgs: MetricConfig = { accessor: 'c', layerId: 'l1', + layerType: layerTypes.DATA, title: '', description: '', metricTitle: 'My fanci metric chart', diff --git a/x-pack/plugins/lens/public/metric_visualization/metric_suggestions.test.ts b/x-pack/plugins/lens/public/metric_visualization/metric_suggestions.test.ts index 7a3119d81d65c..82e9a901d5041 100644 --- a/x-pack/plugins/lens/public/metric_visualization/metric_suggestions.test.ts +++ b/x-pack/plugins/lens/public/metric_visualization/metric_suggestions.test.ts @@ -106,6 +106,7 @@ describe('metric_suggestions', () => { "state": Object { "accessor": "bytes", "layerId": "l1", + "layerType": "data", }, "title": "Avg bytes", } diff --git a/x-pack/plugins/lens/public/metric_visualization/metric_suggestions.ts b/x-pack/plugins/lens/public/metric_visualization/metric_suggestions.ts index d07dccb770196..de79f5f0a4cbc 100644 --- a/x-pack/plugins/lens/public/metric_visualization/metric_suggestions.ts +++ b/x-pack/plugins/lens/public/metric_visualization/metric_suggestions.ts @@ -6,7 +6,8 @@ */ import { SuggestionRequest, VisualizationSuggestion, TableSuggestion } from '../types'; -import type { MetricState } from '../../common/expressions'; +import { MetricState } from '../../common/expressions'; +import { layerTypes } from '../../common'; import { LensIconChartMetric } from '../assets/chart_metric'; /** @@ -49,6 +50,7 @@ function getSuggestion(table: TableSuggestion): VisualizationSuggestion { Object { "accessor": undefined, "layerId": "test-id1", + "layerType": "data", } `); }); @@ -62,6 +65,7 @@ describe('metric_visualization', () => { expect(metricVisualization.clearLayer(exampleState(), 'l1')).toEqual({ accessor: undefined, layerId: 'l1', + layerType: layerTypes.DATA, }); }); }); @@ -73,6 +77,7 @@ describe('metric_visualization', () => { state: { accessor: undefined, layerId: 'l1', + layerType: layerTypes.DATA, }, layerId: 'l1', frame: mockFrame(), @@ -92,6 +97,7 @@ describe('metric_visualization', () => { state: { accessor: 'a', layerId: 'l1', + layerType: layerTypes.DATA, }, layerId: 'l1', frame: mockFrame(), @@ -113,14 +119,17 @@ describe('metric_visualization', () => { prevState: { accessor: undefined, layerId: 'l1', + layerType: layerTypes.DATA, }, layerId: 'l1', groupId: '', columnId: 'newDimension', + frame: mockFrame(), }) ).toEqual({ accessor: 'newDimension', layerId: 'l1', + layerType: layerTypes.DATA, }); }); }); @@ -132,17 +141,33 @@ describe('metric_visualization', () => { prevState: { accessor: 'a', layerId: 'l1', + layerType: layerTypes.DATA, }, layerId: 'l1', columnId: 'a', + frame: mockFrame(), }) ).toEqual({ accessor: undefined, layerId: 'l1', + layerType: layerTypes.DATA, }); }); }); + describe('#getSupportedLayers', () => { + it('should return a single layer type', () => { + expect(metricVisualization.getSupportedLayers()).toHaveLength(1); + }); + }); + + describe('#getLayerType', () => { + it('should return the type only if the layer is in the state', () => { + expect(metricVisualization.getLayerType('l1', exampleState())).toEqual(layerTypes.DATA); + expect(metricVisualization.getLayerType('foo', exampleState())).toBeUndefined(); + }); + }); + describe('#toExpression', () => { it('should map to a valid AST', () => { const datasource: DatasourcePublicAPI = { diff --git a/x-pack/plugins/lens/public/metric_visualization/visualization.tsx b/x-pack/plugins/lens/public/metric_visualization/visualization.tsx index d312030b5a490..72aa3550e30dd 100644 --- a/x-pack/plugins/lens/public/metric_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/metric_visualization/visualization.tsx @@ -11,6 +11,7 @@ import { getSuggestions } from './metric_suggestions'; import { LensIconChartMetric } from '../assets/chart_metric'; import { Visualization, OperationMetadata, DatasourcePublicAPI } from '../types'; import type { MetricState } from '../../common/expressions'; +import { layerTypes } from '../../common'; const toExpression = ( state: MetricState, @@ -90,6 +91,7 @@ export const metricVisualization: Visualization = { state || { layerId: addNewLayer(), accessor: undefined, + layerType: layerTypes.DATA, } ); }, @@ -109,6 +111,23 @@ export const metricVisualization: Visualization = { }; }, + getSupportedLayers() { + return [ + { + type: layerTypes.DATA, + label: i18n.translate('xpack.lens.metric.addLayer', { + defaultMessage: 'Add visualization layer', + }), + }, + ]; + }, + + getLayerType(layerId, state) { + if (state?.layerId === layerId) { + return state.layerType; + } + }, + toExpression, toPreviewExpression: (state, datasourceLayers) => toExpression(state, datasourceLayers, { mode: 'reduced' }), diff --git a/x-pack/plugins/lens/public/mocks.tsx b/x-pack/plugins/lens/public/mocks.tsx index 611b50b413b71..03f03e2f3826c 100644 --- a/x-pack/plugins/lens/public/mocks.tsx +++ b/x-pack/plugins/lens/public/mocks.tsx @@ -20,8 +20,8 @@ import { DeepPartial } from '@reduxjs/toolkit'; import { LensPublicStart } from '.'; import { visualizationTypes } from './xy_visualization/types'; import { navigationPluginMock } from '../../../../src/plugins/navigation/public/mocks'; -import type { LensAppServices } from './app_plugin/types'; -import { DOC_TYPE } from '../common'; +import { LensAppServices } from './app_plugin/types'; +import { DOC_TYPE, layerTypes } from '../common'; import { DataPublicPluginStart, esFilters, UI_SETTINGS } from '../../../../src/plugins/data/public'; import { dashboardPluginMock } from '../../../../src/plugins/dashboard/public/mocks'; import type { @@ -63,6 +63,8 @@ export function createMockVisualization(): jest.Mocked { clearLayer: jest.fn((state, _layerId) => state), removeLayer: jest.fn(), getLayerIds: jest.fn((_state) => ['layer1']), + getSupportedLayers: jest.fn(() => [{ type: layerTypes.DATA, label: 'Data Layer' }]), + getLayerType: jest.fn((_state, _layerId) => layerTypes.DATA), visualizationTypes: [ { icon: 'empty', diff --git a/x-pack/plugins/lens/public/pie_visualization/suggestions.test.ts b/x-pack/plugins/lens/public/pie_visualization/suggestions.test.ts index 36470fa3d74cf..affc74d8b70cd 100644 --- a/x-pack/plugins/lens/public/pie_visualization/suggestions.test.ts +++ b/x-pack/plugins/lens/public/pie_visualization/suggestions.test.ts @@ -8,7 +8,8 @@ import { PaletteOutput } from 'src/plugins/charts/public'; import { DataType, SuggestionRequest } from '../types'; import { suggestions } from './suggestions'; -import type { PieVisualizationState } from '../../common/expressions'; +import { PieVisualizationState } from '../../common/expressions'; +import { layerTypes } from '../../common'; describe('suggestions', () => { describe('pie', () => { @@ -56,6 +57,7 @@ describe('suggestions', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, groups: [], metric: 'a', numberDisplay: 'hidden', @@ -484,6 +486,7 @@ describe('suggestions', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, groups: ['a'], metric: 'b', @@ -505,6 +508,7 @@ describe('suggestions', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, groups: ['a'], metric: 'b', @@ -536,6 +540,7 @@ describe('suggestions', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, groups: [], metric: 'a', @@ -585,6 +590,7 @@ describe('suggestions', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, groups: ['a', 'b'], metric: 'e', numberDisplay: 'value', @@ -633,6 +639,7 @@ describe('suggestions', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, groups: ['a', 'b'], metric: 'e', numberDisplay: 'percent', @@ -669,6 +676,7 @@ describe('suggestions', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, groups: ['a'], metric: 'b', @@ -689,6 +697,7 @@ describe('suggestions', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, groups: ['a'], metric: 'b', diff --git a/x-pack/plugins/lens/public/pie_visualization/suggestions.ts b/x-pack/plugins/lens/public/pie_visualization/suggestions.ts index 22be8e3357bbb..9078e18588a2f 100644 --- a/x-pack/plugins/lens/public/pie_visualization/suggestions.ts +++ b/x-pack/plugins/lens/public/pie_visualization/suggestions.ts @@ -8,6 +8,7 @@ import { partition } from 'lodash'; import { i18n } from '@kbn/i18n'; import type { SuggestionRequest, VisualizationSuggestion } from '../types'; +import { layerTypes } from '../../common'; import type { PieVisualizationState } from '../../common/expressions'; import { CHART_NAMES, MAX_PIE_BUCKETS, MAX_TREEMAP_BUCKETS } from './constants'; @@ -75,6 +76,7 @@ export function suggestions({ layerId: table.layerId, groups: groups.map((col) => col.columnId), metric: metricColumnId, + layerType: layerTypes.DATA, } : { layerId: table.layerId, @@ -84,6 +86,7 @@ export function suggestions({ categoryDisplay: 'default', legendDisplay: 'default', nestedLegend: false, + layerType: layerTypes.DATA, }, ], }, @@ -134,6 +137,7 @@ export function suggestions({ state.layers[0].categoryDisplay === 'inside' ? 'default' : state.layers[0].categoryDisplay, + layerType: layerTypes.DATA, } : { layerId: table.layerId, @@ -143,6 +147,7 @@ export function suggestions({ categoryDisplay: 'default', legendDisplay: 'default', nestedLegend: false, + layerType: layerTypes.DATA, }, ], }, diff --git a/x-pack/plugins/lens/public/pie_visualization/visualization.test.ts b/x-pack/plugins/lens/public/pie_visualization/visualization.test.ts index 07a4161e7d239..cdbd480297627 100644 --- a/x-pack/plugins/lens/public/pie_visualization/visualization.test.ts +++ b/x-pack/plugins/lens/public/pie_visualization/visualization.test.ts @@ -7,7 +7,10 @@ import { getPieVisualization } from './visualization'; import type { PieVisualizationState } from '../../common/expressions'; +import { layerTypes } from '../../common'; import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks'; +import { createMockDatasource, createMockFramePublicAPI } from '../mocks'; +import { FramePublicAPI } from '../types'; jest.mock('../id_generator'); @@ -23,6 +26,7 @@ function getExampleState(): PieVisualizationState { layers: [ { layerId: LAYER_ID, + layerType: layerTypes.DATA, groups: [], metric: undefined, numberDisplay: 'percent', @@ -34,6 +38,16 @@ function getExampleState(): PieVisualizationState { }; } +function mockFrame(): FramePublicAPI { + return { + ...createMockFramePublicAPI(), + datasourceLayers: { + l1: createMockDatasource('l1').publicAPIMock, + l42: createMockDatasource('l42').publicAPIMock, + }, + }; +} + // Just a basic bootstrap here to kickstart the tests describe('pie_visualization', () => { describe('#getErrorMessages', () => { @@ -43,6 +57,20 @@ describe('pie_visualization', () => { expect(error).not.toBeDefined(); }); }); + + describe('#getSupportedLayers', () => { + it('should return a single layer type', () => { + expect(pieVisualization.getSupportedLayers()).toHaveLength(1); + }); + }); + + describe('#getLayerType', () => { + it('should return the type only if the layer is in the state', () => { + expect(pieVisualization.getLayerType(LAYER_ID, getExampleState())).toEqual(layerTypes.DATA); + expect(pieVisualization.getLayerType('foo', getExampleState())).toBeUndefined(); + }); + }); + describe('#setDimension', () => { it('returns expected state', () => { const prevState: PieVisualizationState = { @@ -50,6 +78,7 @@ describe('pie_visualization', () => { { groups: ['a'], layerId: LAYER_ID, + layerType: layerTypes.DATA, numberDisplay: 'percent', categoryDisplay: 'default', legendDisplay: 'default', @@ -64,6 +93,7 @@ describe('pie_visualization', () => { columnId: 'x', layerId: LAYER_ID, groupId: 'a', + frame: mockFrame(), }); expect(setDimensionResult).toEqual( diff --git a/x-pack/plugins/lens/public/pie_visualization/visualization.tsx b/x-pack/plugins/lens/public/pie_visualization/visualization.tsx index 5d75d82220d1f..ea89ef0bfb854 100644 --- a/x-pack/plugins/lens/public/pie_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/visualization.tsx @@ -13,6 +13,7 @@ import type { PaletteRegistry } from 'src/plugins/charts/public'; import type { Visualization, OperationMetadata, AccessorConfig } from '../types'; import { toExpression, toPreviewExpression } from './to_expression'; import type { PieLayerState, PieVisualizationState } from '../../common/expressions'; +import { layerTypes } from '../../common'; import { suggestions } from './suggestions'; import { CHART_NAMES, MAX_PIE_BUCKETS, MAX_TREEMAP_BUCKETS } from './constants'; import { DimensionEditor, PieToolbar } from './toolbar'; @@ -26,6 +27,7 @@ function newLayerState(layerId: string): PieLayerState { categoryDisplay: 'default', legendDisplay: 'default', nestedLegend: false, + layerType: layerTypes.DATA, }; } @@ -231,6 +233,21 @@ export const getPieVisualization = ({ ); }, + getSupportedLayers() { + return [ + { + type: layerTypes.DATA, + label: i18n.translate('xpack.lens.pie.addLayer', { + defaultMessage: 'Add visualization layer', + }), + }, + ]; + }, + + getLayerType(layerId, state) { + return state?.layers.find(({ layerId: id }) => id === layerId)?.layerType; + }, + toExpression: (state, layers, attributes) => toExpression(state, layers, paletteService, attributes), toPreviewExpression: (state, layers) => toPreviewExpression(state, layers, paletteService), diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index db17154e3bbd2..bf576cb65c688 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -18,7 +18,7 @@ import { Datatable, } from '../../../../src/plugins/expressions/public'; import { DraggingIdentifier, DragDropIdentifier, DragContextState } from './drag_drop'; -import { DateRange } from '../common'; +import type { DateRange, LayerType } from '../common'; import { Query, Filter } from '../../../../src/plugins/data/public'; import { VisualizeFieldContext } from '../../../../src/plugins/ui_actions/public'; import { RangeSelectContext, ValueClickContext } from '../../../../src/plugins/embeddable/public'; @@ -175,6 +175,17 @@ export interface Datasource { clearLayer: (state: T, layerId: string) => T; getLayers: (state: T) => string[]; removeColumn: (props: { prevState: T; layerId: string; columnId: string }) => T; + initializeDimension?: ( + state: T, + layerId: string, + value: { + columnId: string; + label: string; + dataType: string; + staticValue?: unknown; + groupId: string; + } + ) => T; renderDataPanel: ( domElement: Element, @@ -320,6 +331,7 @@ export type DatasourceDimensionEditorProps = DatasourceDimensionPro dimensionGroups: VisualizationDimensionGroupConfig[]; toggleFullscreen: () => void; isFullscreen: boolean; + layerType: LayerType | undefined; }; export type DatasourceDimensionTriggerProps = DatasourceDimensionProps; @@ -449,6 +461,7 @@ interface VisualizationDimensionChangeProps { layerId: string; columnId: string; prevState: T; + frame: Pick; } /** @@ -601,20 +614,42 @@ export interface Visualization { /** Optional, if the visualization supports multiple layers */ removeLayer?: (state: T, layerId: string) => T; /** Track added layers in internal state */ - appendLayer?: (state: T, layerId: string) => T; + appendLayer?: (state: T, layerId: string, type: LayerType) => T; + + /** Retrieve a list of supported layer types with initialization data */ + getSupportedLayers: ( + state?: T, + frame?: Pick + ) => Array<{ + type: LayerType; + label: string; + icon?: IconType; + disabled?: boolean; + tooltipContent?: string; + initialDimensions?: Array<{ + groupId: string; + columnId: string; + dataType: string; + label: string; + staticValue: unknown; + }>; + }>; + getLayerType: (layerId: string, state?: T) => LayerType | undefined; + /* returns the type of removal operation to perform for the specific layer in the current state */ + getRemoveOperation?: (state: T, layerId: string) => 'remove' | 'clear'; /** * For consistency across different visualizations, the dimension configuration UI is standardized */ getConfiguration: ( props: VisualizationConfigProps - ) => { groups: VisualizationDimensionGroupConfig[] }; + ) => { groups: VisualizationDimensionGroupConfig[]; supportStaticValue?: boolean }; /** - * Popover contents that open when the user clicks the contextMenuIcon. This can be used - * for extra configurability, such as for styling the legend or axis + * Header rendered as layer title This can be used for both static and dynamic content lioke + * for extra configurability, such as for switch chart type */ - renderLayerContextMenu?: ( + renderLayerHeader?: ( domElement: Element, props: VisualizationLayerWidgetProps ) => ((cleanupElement: Element) => void) | void; @@ -626,14 +661,6 @@ export interface Visualization { domElement: Element, props: VisualizationToolbarProps ) => ((cleanupElement: Element) => void) | void; - /** - * Visualizations can provide a custom icon which will open a layer-specific popover - * If no icon is provided, gear icon is default - */ - getLayerContextMenuIcon?: (opts: { - state: T; - layerId: string; - }) => { icon: IconType | 'gear'; label: string } | undefined; /** * The frame is telling the visualization to update or set a dimension based on user interaction diff --git a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap index 3bd0e9354c158..9846e92b07bf8 100644 --- a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap +++ b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap @@ -107,6 +107,9 @@ Object { "layerId": Array [ "first", ], + "layerType": Array [ + "data", + ], "seriesType": Array [ "area", ], diff --git a/x-pack/plugins/lens/public/xy_visualization/axes_configuration.test.ts b/x-pack/plugins/lens/public/xy_visualization/axes_configuration.test.ts index 873827700d6e8..355374165c788 100644 --- a/x-pack/plugins/lens/public/xy_visualization/axes_configuration.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/axes_configuration.test.ts @@ -6,6 +6,7 @@ */ import { LayerArgs } from '../../common/expressions'; +import { layerTypes } from '../../common'; import { Datatable } from '../../../../../src/plugins/expressions/public'; import { getAxesConfiguration } from './axes_configuration'; @@ -220,6 +221,7 @@ describe('axes_configuration', () => { const sampleLayer: LayerArgs = { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'line', xAccessor: 'c', accessors: ['yAccessorId'], diff --git a/x-pack/plugins/lens/public/xy_visualization/axis_settings_popover.test.tsx b/x-pack/plugins/lens/public/xy_visualization/axis_settings_popover.test.tsx index 7609c534711d0..aa287795c8181 100644 --- a/x-pack/plugins/lens/public/xy_visualization/axis_settings_popover.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/axis_settings_popover.test.tsx @@ -9,6 +9,7 @@ import React from 'react'; import { shallowWithIntl as shallow } from '@kbn/test/jest'; import { AxisSettingsPopover, AxisSettingsPopoverProps } from './axis_settings_popover'; import { ToolbarPopover } from '../shared_components'; +import { layerTypes } from '../../common'; describe('Axes Settings', () => { let props: AxisSettingsPopoverProps; @@ -17,6 +18,7 @@ describe('Axes Settings', () => { layers: [ { seriesType: 'bar', + layerType: layerTypes.DATA, layerId: 'first', splitAccessor: 'baz', xAccessor: 'foo', diff --git a/x-pack/plugins/lens/public/xy_visualization/color_assignment.test.ts b/x-pack/plugins/lens/public/xy_visualization/color_assignment.test.ts index 390eded97d705..4157eabfad82d 100644 --- a/x-pack/plugins/lens/public/xy_visualization/color_assignment.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/color_assignment.test.ts @@ -8,6 +8,7 @@ import { getColorAssignments } from './color_assignment'; import type { FormatFactory, LensMultiTable } from '../../common'; import type { LayerArgs } from '../../common/expressions'; +import { layerTypes } from '../../common'; describe('color_assignment', () => { const layers: LayerArgs[] = [ @@ -18,6 +19,7 @@ describe('color_assignment', () => { seriesType: 'bar', palette: { type: 'palette', name: 'palette1' }, layerId: '1', + layerType: layerTypes.DATA, splitAccessor: 'split1', accessors: ['y1', 'y2'], }, @@ -28,6 +30,7 @@ describe('color_assignment', () => { seriesType: 'bar', palette: { type: 'palette', name: 'palette2' }, layerId: '2', + layerType: layerTypes.DATA, splitAccessor: 'split2', accessors: ['y3', 'y4'], }, diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx index 94ed503700042..a41ad59ebee93 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx @@ -24,6 +24,7 @@ import { import { PaletteOutput } from 'src/plugins/charts/public'; import { calculateMinInterval, XYChart, XYChartRenderProps, xyChart } from './expression'; import type { LensMultiTable } from '../../common'; +import { layerTypes } from '../../common'; import { layerConfig, legendConfig, @@ -208,6 +209,7 @@ const dateHistogramData: LensMultiTable = { const dateHistogramLayer: LayerArgs = { layerId: 'timeLayer', + layerType: layerTypes.DATA, hide: false, xAccessor: 'xAccessorId', yScaleType: 'linear', @@ -249,6 +251,7 @@ const createSampleDatatableWithRows = (rows: DatatableRow[]): Datatable => ({ const sampleLayer: LayerArgs = { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'line', xAccessor: 'c', accessors: ['a', 'b'], @@ -345,6 +348,7 @@ describe('xy_expression', () => { test('layerConfig produces the correct arguments', () => { const args: LayerArgs = { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'line', xAccessor: 'c', accessors: ['a', 'b'], @@ -506,6 +510,7 @@ describe('xy_expression', () => { describe('date range', () => { const timeSampleLayer: LayerArgs = { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'line', xAccessor: 'c', accessors: ['a', 'b'], @@ -984,6 +989,7 @@ describe('xy_expression', () => { const numberLayer: LayerArgs = { layerId: 'numberLayer', + layerType: layerTypes.DATA, hide: false, xAccessor: 'xAccessorId', yScaleType: 'linear', @@ -1089,6 +1095,7 @@ describe('xy_expression', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, isHistogram: true, seriesType: 'bar_stacked', xAccessor: 'b', @@ -1177,6 +1184,7 @@ describe('xy_expression', () => { const numberLayer: LayerArgs = { layerId: 'numberLayer', + layerType: layerTypes.DATA, hide: false, xAccessor: 'xAccessorId', yScaleType: 'linear', @@ -1295,6 +1303,7 @@ describe('xy_expression', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'line', xAccessor: 'd', accessors: ['a', 'b'], @@ -2140,6 +2149,7 @@ describe('xy_expression', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'line', xAccessor: 'a', accessors: ['c'], @@ -2152,6 +2162,7 @@ describe('xy_expression', () => { }, { layerId: 'second', + layerType: layerTypes.DATA, seriesType: 'line', xAccessor: 'a', accessors: ['c'], @@ -2228,6 +2239,7 @@ describe('xy_expression', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'line', xAccessor: 'a', accessors: ['c'], @@ -2302,6 +2314,7 @@ describe('xy_expression', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'line', xAccessor: 'a', accessors: ['c'], @@ -2535,6 +2548,7 @@ describe('xy_expression', () => { }; const timeSampleLayer: LayerArgs = { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'line', xAccessor: 'c', accessors: ['a', 'b'], diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.tsx index 9ef87fe4f48d4..83ef8af4bec9c 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.tsx @@ -40,7 +40,8 @@ import { i18n } from '@kbn/i18n'; import { RenderMode } from 'src/plugins/expressions'; import type { ILensInterpreterRenderHandlers, LensFilterEvent, LensBrushEvent } from '../types'; import type { LensMultiTable, FormatFactory } from '../../common'; -import { LayerArgs, SeriesType, XYChartProps } from '../../common/expressions'; +import { layerTypes } from '../../common'; +import type { LayerArgs, SeriesType, XYChartProps } from '../../common/expressions'; import { visualizationTypes } from './types'; import { VisualizationContainer } from '../visualization_container'; import { isHorizontalChart, getSeriesColor } from './state_helpers'; @@ -316,7 +317,7 @@ export function XYChart({ const isHistogramViz = filteredLayers.every((l) => l.isHistogram); const { baseDomain: rawXDomain, extendedDomain: xDomain } = getXDomain( - layers, + filteredLayers, data, minInterval, Boolean(isTimeViz), @@ -842,17 +843,20 @@ export function XYChart({ } function getFilteredLayers(layers: LayerArgs[], data: LensMultiTable) { - return layers.filter(({ layerId, xAccessor, accessors, splitAccessor }) => { - return !( - !accessors.length || - !data.tables[layerId] || - data.tables[layerId].rows.length === 0 || - (xAccessor && - data.tables[layerId].rows.every((row) => typeof row[xAccessor] === 'undefined')) || - // stacked percentage bars have no xAccessors but splitAccessor with undefined values in them when empty - (!xAccessor && - splitAccessor && - data.tables[layerId].rows.every((row) => typeof row[splitAccessor] === 'undefined')) + return layers.filter(({ layerId, xAccessor, accessors, splitAccessor, layerType }) => { + return ( + layerType === layerTypes.DATA && + !( + !accessors.length || + !data.tables[layerId] || + data.tables[layerId].rows.length === 0 || + (xAccessor && + data.tables[layerId].rows.every((row) => typeof row[xAccessor] === 'undefined')) || + // stacked percentage bars have no xAccessors but splitAccessor with undefined values in them when empty + (!xAccessor && + splitAccessor && + data.tables[layerId].rows.every((row) => typeof row[splitAccessor] === 'undefined')) + ) ); }); } diff --git a/x-pack/plugins/lens/public/xy_visualization/get_legend_action.test.tsx b/x-pack/plugins/lens/public/xy_visualization/get_legend_action.test.tsx index e3489ae7808af..700aaf91ad5cb 100644 --- a/x-pack/plugins/lens/public/xy_visualization/get_legend_action.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/get_legend_action.test.tsx @@ -11,12 +11,14 @@ import { EuiPopover } from '@elastic/eui'; import { mountWithIntl } from '@kbn/test/jest'; import { ComponentType, ReactWrapper } from 'enzyme'; import type { LensMultiTable } from '../../common'; +import { layerTypes } from '../../common'; import type { LayerArgs } from '../../common/expressions'; import { getLegendAction } from './get_legend_action'; import { LegendActionPopover } from '../shared_components'; const sampleLayer = { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'line', xAccessor: 'c', accessors: ['a', 'b'], diff --git a/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts b/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts index 621e2897a1059..5ce44db1c4db5 100644 --- a/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts @@ -11,6 +11,7 @@ import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks' import { getXyVisualization } from './xy_visualization'; import { Operation } from '../types'; import { createMockDatasource, createMockFramePublicAPI } from '../mocks'; +import { layerTypes } from '../../common'; import { fieldFormatsServiceMock } from '../../../../../src/plugins/field_formats/public/mocks'; describe('#toExpression', () => { @@ -65,6 +66,7 @@ describe('#toExpression', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'area', splitAccessor: 'd', xAccessor: 'a', @@ -87,6 +89,7 @@ describe('#toExpression', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'area', splitAccessor: 'd', xAccessor: 'a', @@ -108,6 +111,7 @@ describe('#toExpression', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'area', splitAccessor: 'd', xAccessor: 'a', @@ -135,6 +139,7 @@ describe('#toExpression', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'area', splitAccessor: undefined, xAccessor: undefined, @@ -159,6 +164,7 @@ describe('#toExpression', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'area', splitAccessor: undefined, xAccessor: 'a', @@ -180,6 +186,7 @@ describe('#toExpression', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'area', splitAccessor: 'd', xAccessor: 'a', @@ -216,6 +223,7 @@ describe('#toExpression', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'area', splitAccessor: 'd', xAccessor: 'a', @@ -243,6 +251,7 @@ describe('#toExpression', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'area', splitAccessor: 'd', xAccessor: 'a', @@ -268,6 +277,7 @@ describe('#toExpression', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'area', splitAccessor: 'd', xAccessor: 'a', @@ -295,6 +305,7 @@ describe('#toExpression', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'area', splitAccessor: 'd', xAccessor: 'a', diff --git a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts index dfad8334ab76a..3f396df4b99a9 100644 --- a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts +++ b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts @@ -11,7 +11,8 @@ import { PaletteRegistry } from 'src/plugins/charts/public'; import { State } from './types'; import { OperationMetadata, DatasourcePublicAPI } from '../types'; import { getColumnToLabelMap } from './state_helpers'; -import { ValidLayer, XYLayerConfig } from '../../common/expressions'; +import type { ValidLayer, XYLayerConfig } from '../../common/expressions'; +import { layerTypes } from '../../common'; export const getSortedAccessors = (datasource: DatasourcePublicAPI, layer: XYLayerConfig) => { const originalOrder = datasource @@ -325,6 +326,7 @@ export const buildExpression = ( })) : [], seriesType: [layer.seriesType], + layerType: [layer.layerType || layerTypes.DATA], accessors: layer.accessors, columnToLabel: [JSON.stringify(columnToLabel)], ...(layer.palette diff --git a/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/visual_options_popover.test.tsx b/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/visual_options_popover.test.tsx index b4c8e8f40dde7..cd6a20c37dd38 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/visual_options_popover.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/visual_options_popover.test.tsx @@ -15,6 +15,7 @@ import { VisualOptionsPopover } from './visual_options_popover'; import { ToolbarPopover } from '../../shared_components'; import { MissingValuesOptions } from './missing_values_option'; import { FillOpacityOption } from './fill_opacity_option'; +import { layerTypes } from '../../../common'; describe('Visual options popover', () => { let frame: FramePublicAPI; @@ -27,6 +28,7 @@ describe('Visual options popover', () => { layers: [ { seriesType: 'bar', + layerType: layerTypes.DATA, layerId: 'first', splitAccessor: 'baz', xAccessor: 'foo', @@ -231,6 +233,7 @@ describe('Visual options popover', () => { { ...state.layers[0], seriesType: 'bar' }, { seriesType: 'bar', + layerType: layerTypes.DATA, layerId: 'second', splitAccessor: 'baz', xAccessor: 'foo', diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts index ef97e2622ee82..14a13fbb0f3bb 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts @@ -10,6 +10,7 @@ import { Position } from '@elastic/charts'; import { Operation } from '../types'; import type { State } from './types'; import type { SeriesType, XYLayerConfig } from '../../common/expressions'; +import { layerTypes } from '../../common'; import { createMockDatasource, createMockFramePublicAPI } from '../mocks'; import { LensIconChartBar } from '../assets/chart_bar'; import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks'; @@ -23,6 +24,7 @@ function exampleState(): State { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'area', splitAccessor: 'd', xAccessor: 'a', @@ -145,6 +147,7 @@ describe('xy_visualization', () => { Object { "accessors": Array [], "layerId": "l1", + "layerType": "data", "position": "top", "seriesType": "bar_stacked", "showGridlines": false, @@ -174,6 +177,7 @@ describe('xy_visualization', () => { ...exampleState().layers, { layerId: 'second', + layerType: layerTypes.DATA, seriesType: 'area', splitAccessor: 'e', xAccessor: 'f', @@ -188,7 +192,7 @@ describe('xy_visualization', () => { describe('#appendLayer', () => { it('adds a layer', () => { - const layers = xyVisualization.appendLayer!(exampleState(), 'foo').layers; + const layers = xyVisualization.appendLayer!(exampleState(), 'foo', layerTypes.DATA).layers; expect(layers.length).toEqual(exampleState().layers.length + 1); expect(layers[layers.length - 1]).toMatchObject({ layerId: 'foo' }); }); @@ -211,15 +215,61 @@ describe('xy_visualization', () => { }); }); + describe('#getSupportedLayers', () => { + it('should return a single layer type', () => { + expect(xyVisualization.getSupportedLayers()).toHaveLength(1); + }); + + it('should return the icon for the visualization type', () => { + expect(xyVisualization.getSupportedLayers()[0].icon).not.toBeUndefined(); + }); + }); + + describe('#getLayerType', () => { + it('should return the type only if the layer is in the state', () => { + expect(xyVisualization.getLayerType('first', exampleState())).toEqual(layerTypes.DATA); + expect(xyVisualization.getLayerType('foo', exampleState())).toBeUndefined(); + }); + }); + describe('#setDimension', () => { + let mockDatasource: ReturnType; + let frame: ReturnType; + + beforeEach(() => { + frame = createMockFramePublicAPI(); + mockDatasource = createMockDatasource('testDatasource'); + + mockDatasource.publicAPIMock.getTableSpec.mockReturnValue([ + { columnId: 'd' }, + { columnId: 'a' }, + { columnId: 'b' }, + { columnId: 'c' }, + ]); + + frame.datasourceLayers = { + first: mockDatasource.publicAPIMock, + }; + + frame.activeData = { + first: { + type: 'datatable', + rows: [], + columns: [], + }, + }; + }); + it('sets the x axis', () => { expect( xyVisualization.setDimension({ + frame, prevState: { ...exampleState(), layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: undefined, accessors: [], @@ -232,6 +282,7 @@ describe('xy_visualization', () => { }).layers[0] ).toEqual({ layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: 'newCol', accessors: [], @@ -241,11 +292,13 @@ describe('xy_visualization', () => { it('replaces the x axis', () => { expect( xyVisualization.setDimension({ + frame, prevState: { ...exampleState(), layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: 'a', accessors: [], @@ -258,6 +311,7 @@ describe('xy_visualization', () => { }).layers[0] ).toEqual({ layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: 'newCol', accessors: [], @@ -266,14 +320,43 @@ describe('xy_visualization', () => { }); describe('#removeDimension', () => { + let mockDatasource: ReturnType; + let frame: ReturnType; + + beforeEach(() => { + frame = createMockFramePublicAPI(); + mockDatasource = createMockDatasource('testDatasource'); + + mockDatasource.publicAPIMock.getTableSpec.mockReturnValue([ + { columnId: 'd' }, + { columnId: 'a' }, + { columnId: 'b' }, + { columnId: 'c' }, + ]); + + frame.datasourceLayers = { + first: mockDatasource.publicAPIMock, + }; + + frame.activeData = { + first: { + type: 'datatable', + rows: [], + columns: [], + }, + }; + }); + it('removes the x axis', () => { expect( xyVisualization.removeDimension({ + frame, prevState: { ...exampleState(), layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: 'a', accessors: [], @@ -285,6 +368,7 @@ describe('xy_visualization', () => { }).layers[0] ).toEqual({ layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: undefined, accessors: [], @@ -609,6 +693,7 @@ describe('xy_visualization', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: 'a', accessors: [], @@ -624,12 +709,14 @@ describe('xy_visualization', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: 'a', accessors: [], }, { layerId: 'second', + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: 'a', accessors: [], @@ -645,12 +732,14 @@ describe('xy_visualization', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: 'a', accessors: ['a'], }, { layerId: 'second', + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: undefined, accessors: ['a'], @@ -667,6 +756,7 @@ describe('xy_visualization', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: undefined, accessors: [], @@ -682,6 +772,7 @@ describe('xy_visualization', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: undefined, accessors: [], @@ -689,6 +780,7 @@ describe('xy_visualization', () => { }, { layerId: 'second', + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: undefined, accessors: [], @@ -705,12 +797,14 @@ describe('xy_visualization', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: 'a', accessors: [], }, { layerId: 'second', + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: undefined, accessors: ['a'], @@ -731,12 +825,14 @@ describe('xy_visualization', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: 'a', accessors: ['a'], }, { layerId: 'second', + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: undefined, accessors: [], @@ -744,6 +840,7 @@ describe('xy_visualization', () => { }, { layerId: 'third', + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: undefined, accessors: [], @@ -765,18 +862,21 @@ describe('xy_visualization', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: 'a', accessors: [], }, { layerId: 'second', + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: 'a', accessors: ['a'], }, { layerId: 'third', + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: 'a', accessors: ['a'], @@ -799,6 +899,7 @@ describe('xy_visualization', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'area', splitAccessor: 'd', xAccessor: 'a', @@ -846,6 +947,7 @@ describe('xy_visualization', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'area', splitAccessor: 'd', xAccessor: 'a', @@ -853,6 +955,7 @@ describe('xy_visualization', () => { }, { layerId: 'second', + layerType: layerTypes.DATA, seriesType: 'area', splitAccessor: 'd', xAccessor: 'e', @@ -900,6 +1003,7 @@ describe('xy_visualization', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'area', splitAccessor: 'd', xAccessor: 'a', @@ -907,6 +1011,7 @@ describe('xy_visualization', () => { }, { layerId: 'second', + layerType: layerTypes.DATA, seriesType: 'area', splitAccessor: 'd', xAccessor: 'e', @@ -970,6 +1075,7 @@ describe('xy_visualization', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'area', xAccessor: 'a', accessors: ['b'], diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx index 799246ef26b80..0a4b18f554f31 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx @@ -14,7 +14,7 @@ import { i18n } from '@kbn/i18n'; import { PaletteRegistry } from 'src/plugins/charts/public'; import { FieldFormatsStart } from 'src/plugins/field_formats/public'; import { getSuggestions } from './xy_suggestions'; -import { LayerContextMenu, XyToolbar, DimensionEditor } from './xy_config_panel'; +import { XyToolbar, DimensionEditor, LayerHeader } from './xy_config_panel'; import type { Visualization, OperationMetadata, @@ -23,7 +23,8 @@ import type { DatasourcePublicAPI, } from '../types'; import { State, visualizationTypes, XYState } from './types'; -import type { SeriesType, XYLayerConfig } from '../../common/expressions'; +import { SeriesType, XYLayerConfig } from '../../common/expressions'; +import { LayerType, layerTypes } from '../../common'; import { isHorizontalChart } from './state_helpers'; import { toExpression, toPreviewExpression, getSortedAccessors } from './to_expression'; import { LensIconChartBarStacked } from '../assets/chart_bar_stacked'; @@ -101,7 +102,12 @@ export const getXyVisualization = ({ }, getLayerIds(state) { - return state.layers.map((l) => l.layerId); + return getLayersByType(state).map((l) => l.layerId); + }, + + getRemoveOperation(state, layerId) { + const dataLayers = getLayersByType(state, layerTypes.DATA).map((l) => l.layerId); + return dataLayers.includes(layerId) && dataLayers.length === 1 ? 'clear' : 'remove'; }, removeLayer(state, layerId) { @@ -111,7 +117,7 @@ export const getXyVisualization = ({ }; }, - appendLayer(state, layerId) { + appendLayer(state, layerId, layerType) { const usedSeriesTypes = uniq(state.layers.map((layer) => layer.seriesType)); return { ...state, @@ -119,7 +125,8 @@ export const getXyVisualization = ({ ...state.layers, newLayerState( usedSeriesTypes.length === 1 ? usedSeriesTypes[0] : state.preferredSeriesType, - layerId + layerId, + layerType ), ], }; @@ -167,16 +174,35 @@ export const getXyVisualization = ({ position: Position.Top, seriesType: defaultSeriesType, showGridlines: false, + layerType: layerTypes.DATA, }, ], } ); }, + getLayerType(layerId, state) { + return state?.layers.find(({ layerId: id }) => id === layerId)?.layerType; + }, + + getSupportedLayers(state, frame) { + const layers = [ + { + type: layerTypes.DATA, + label: i18n.translate('xpack.lens.xyChart.addDataLayerLabel', { + defaultMessage: 'Add visualization layer', + }), + icon: LensIconChartMixedXy, + }, + ]; + + return layers; + }, + getConfiguration({ state, frame, layerId }) { const layer = state.layers.find((l) => l.layerId === layerId); if (!layer) { - return { groups: [] }; + return { groups: [], supportStaticValue: true }; } const datasource = frame.datasourceLayers[layer.layerId]; @@ -204,6 +230,14 @@ export const getXyVisualization = ({ } const isHorizontal = isHorizontalChart(state.layers); + const isDataLayer = !layer.layerType || layer.layerType === layerTypes.DATA; + + if (!isDataLayer) { + return { + groups: [], + }; + } + return { groups: [ { @@ -261,7 +295,6 @@ export const getXyVisualization = ({ return prevState; } const newLayer = { ...foundLayer }; - if (groupId === 'x') { newLayer.xAccessor = columnId; } @@ -278,7 +311,7 @@ export const getXyVisualization = ({ }; }, - removeDimension({ prevState, layerId, columnId }) { + removeDimension({ prevState, layerId, columnId, frame }) { const foundLayer = prevState.layers.find((l) => l.layerId === layerId); if (!foundLayer) { return prevState; @@ -298,25 +331,18 @@ export const getXyVisualization = ({ newLayer.yConfig = newLayer.yConfig.filter(({ forAccessor }) => forAccessor !== columnId); } - return { - ...prevState, - layers: prevState.layers.map((l) => (l.layerId === layerId ? newLayer : l)), - }; - }, + const newLayers = prevState.layers.map((l) => (l.layerId === layerId ? newLayer : l)); - getLayerContextMenuIcon({ state, layerId }) { - const layer = state.layers.find((l) => l.layerId === layerId); - const visualizationType = visualizationTypes.find((t) => t.id === layer?.seriesType); return { - icon: visualizationType?.icon || 'gear', - label: visualizationType?.label || '', + ...prevState, + layers: newLayers, }; }, - renderLayerContextMenu(domElement, props) { + renderLayerHeader(domElement, props) { render( - + , domElement ); @@ -370,8 +396,9 @@ export const getXyVisualization = ({ // filter out those layers with no accessors at all const filteredLayers = state.layers.filter( - ({ accessors, xAccessor, splitAccessor }: XYLayerConfig) => - accessors.length > 0 || xAccessor != null || splitAccessor != null + ({ accessors, xAccessor, splitAccessor, layerType }: XYLayerConfig) => + layerType === layerTypes.DATA && + (accessors.length > 0 || xAccessor != null || splitAccessor != null) ); for (const [dimension, criteria] of checks) { const result = validateLayersForDimension(dimension, filteredLayers, criteria); @@ -526,11 +553,16 @@ function getMessageIdsForDimension(dimension: string, layers: number[], isHorizo return { shortMessage: '', longMessage: '' }; } -function newLayerState(seriesType: SeriesType, layerId: string): XYLayerConfig { +function newLayerState( + seriesType: SeriesType, + layerId: string, + layerType: LayerType = layerTypes.DATA +): XYLayerConfig { return { layerId, seriesType, accessors: [], + layerType, }; } @@ -603,3 +635,9 @@ function checkScaleOperation( ); }; } + +function getLayersByType(state: State, byType?: string) { + return state.layers.filter(({ layerType = layerTypes.DATA }) => + byType ? layerType === byType : true + ); +} diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx index 9292a8d87bbc4..9ca9021382fda 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx @@ -16,6 +16,7 @@ import { Position } from '@elastic/charts'; import { createMockFramePublicAPI, createMockDatasource } from '../mocks'; import { chartPluginMock } from 'src/plugins/charts/public/mocks'; import { EuiColorPicker } from '@elastic/eui'; +import { layerTypes } from '../../common'; describe('XY Config panels', () => { let frame: FramePublicAPI; @@ -28,6 +29,7 @@ describe('XY Config panels', () => { layers: [ { seriesType: 'bar', + layerType: layerTypes.DATA, layerId: 'first', splitAccessor: 'baz', xAccessor: 'foo', @@ -319,6 +321,7 @@ describe('XY Config panels', () => { layers: [ { seriesType: 'bar', + layerType: layerTypes.DATA, layerId: 'first', splitAccessor: undefined, xAccessor: 'foo', @@ -358,6 +361,7 @@ describe('XY Config panels', () => { layers: [ { seriesType: 'bar', + layerType: layerTypes.DATA, layerId: 'first', splitAccessor: undefined, xAccessor: 'foo', diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx index 129f2df895ef2..c386b22f241d0 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx @@ -20,6 +20,10 @@ import { EuiColorPickerProps, EuiToolTip, EuiIcon, + EuiPopover, + EuiSelectable, + EuiText, + EuiPopoverTitle, } from '@elastic/eui'; import type { PaletteRegistry } from 'src/plugins/charts/public'; import type { @@ -30,7 +34,7 @@ import type { } from '../types'; import { State, visualizationTypes, XYState } from './types'; import type { FormatFactory } from '../../common'; -import type { +import { SeriesType, YAxisMode, AxesSettingsConfig, @@ -45,6 +49,7 @@ import { PalettePicker, TooltipWrapper } from '../shared_components'; import { getAccessorColorConfig, getColorAssignments } from './color_assignment'; import { getScaleType, getSortedAccessors } from './to_expression'; import { VisualOptionsPopover } from './visual_options_popover/visual_options_popover'; +import { ToolbarButton } from '../../../../../src/plugins/kibana_react/public'; type UnwrapArray = T extends Array ? P : T; type AxesSettingsConfigKeys = keyof AxesSettingsConfig; @@ -87,6 +92,90 @@ const legendOptions: Array<{ }, ]; +export function LayerHeader(props: VisualizationLayerWidgetProps) { + const [isPopoverOpen, setPopoverIsOpen] = useState(false); + const { state, layerId } = props; + const horizontalOnly = isHorizontalChart(state.layers); + const index = state.layers.findIndex((l) => l.layerId === layerId); + const layer = state.layers[index]; + if (!layer) { + return null; + } + + const currentVisType = visualizationTypes.find(({ id }) => id === layer.seriesType)!; + + const createTrigger = function () { + return ( + setPopoverIsOpen(!isPopoverOpen)} + fullWidth + size="s" + > + <> + + + {currentVisType.fullLabel || currentVisType.label} + + + + ); + }; + + return ( + <> + setPopoverIsOpen(false)} + display="block" + panelPaddingSize="s" + ownFocus + > + + {i18n.translate('xpack.lens.layerPanel.layerVisualizationType', { + defaultMessage: 'Layer visualization type', + })} + +
+ + singleSelection="always" + options={visualizationTypes + .filter((t) => isHorizontalSeries(t.id as SeriesType) === horizontalOnly) + .map((t) => ({ + value: t.id, + key: t.id, + checked: t.id === currentVisType.id ? 'on' : undefined, + prepend: , + label: t.fullLabel || t.label, + 'data-test-subj': `lnsXY_seriesType-${t.id}`, + }))} + onChange={(newOptions) => { + const chosenType = newOptions.find(({ checked }) => checked === 'on'); + if (!chosenType) { + return; + } + const id = chosenType.value!; + trackUiEvent('xy_change_layer_display'); + props.setState(updateLayer(state, { ...layer, seriesType: id as SeriesType }, index)); + setPopoverIsOpen(false); + }} + > + {(list) => <>{list}} + +
+
+ + ); +} + export function LayerContextMenu(props: VisualizationLayerWidgetProps) { const { state, layerId } = props; const horizontalOnly = isHorizontalChart(state.layers); diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.test.ts b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.test.ts index 924b87647fcee..36e69ab6cbf74 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.test.ts @@ -11,8 +11,9 @@ import { State, XYState, visualizationTypes } from './types'; import { generateId } from '../id_generator'; import { getXyVisualization } from './xy_visualization'; import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks'; +import { PaletteOutput } from 'src/plugins/charts/public'; +import { layerTypes } from '../../common'; import { fieldFormatsServiceMock } from '../../../../../src/plugins/field_formats/public/mocks'; -import type { PaletteOutput } from 'src/plugins/charts/public'; jest.mock('../id_generator'); @@ -157,12 +158,14 @@ describe('xy_suggestions', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'bar', accessors: ['bytes'], splitAccessor: undefined, }, { layerId: 'second', + layerType: layerTypes.DATA, seriesType: 'bar', accessors: ['bytes'], splitAccessor: undefined, @@ -270,6 +273,7 @@ describe('xy_suggestions', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'bar', xAccessor: 'date', accessors: ['bytes'], @@ -311,6 +315,7 @@ describe('xy_suggestions', () => { layers: [ { layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'bar', xAccessor: 'date', accessors: ['bytes'], @@ -318,6 +323,7 @@ describe('xy_suggestions', () => { }, { layerId: 'second', + layerType: layerTypes.DATA, seriesType: 'bar', xAccessor: undefined, accessors: [], @@ -547,6 +553,7 @@ describe('xy_suggestions', () => { { accessors: ['price', 'quantity'], layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'bar', splitAccessor: 'product', xAccessor: 'date', @@ -601,6 +608,7 @@ describe('xy_suggestions', () => { { accessors: [], layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'line', splitAccessor: undefined, xAccessor: '', @@ -639,6 +647,7 @@ describe('xy_suggestions', () => { { accessors: ['price'], layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'bar', splitAccessor: undefined, xAccessor: 'date', @@ -681,6 +690,7 @@ describe('xy_suggestions', () => { { accessors: ['price', 'quantity'], layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'bar', splitAccessor: 'product', xAccessor: 'date', @@ -724,6 +734,7 @@ describe('xy_suggestions', () => { { accessors: ['price', 'quantity'], layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'bar', splitAccessor: 'dummyCol', xAccessor: 'product', @@ -757,6 +768,7 @@ describe('xy_suggestions', () => { { accessors: ['price'], layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'bar', splitAccessor: 'date', xAccessor: 'product', @@ -797,6 +809,7 @@ describe('xy_suggestions', () => { { accessors: ['price', 'quantity'], layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'bar', splitAccessor: 'dummyCol', xAccessor: 'product', @@ -841,6 +854,7 @@ describe('xy_suggestions', () => { { accessors: ['price'], layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'bar', splitAccessor: 'category', xAccessor: 'product', @@ -886,6 +900,7 @@ describe('xy_suggestions', () => { { accessors: ['price', 'quantity'], layerId: 'first', + layerType: layerTypes.DATA, seriesType: 'bar', splitAccessor: 'dummyCol', xAccessor: 'product', diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts index dfa0646404388..2e275c455a4d0 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts +++ b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts @@ -18,6 +18,7 @@ import { } from '../types'; import { State, XYState, visualizationTypes } from './types'; import type { SeriesType, XYLayerConfig } from '../../common/expressions'; +import { layerTypes } from '../../common'; import { getIconForSeries } from './state_helpers'; const columnSortOrder = { @@ -504,6 +505,7 @@ function buildSuggestion({ 'yConfig' in existingLayer && existingLayer.yConfig ? existingLayer.yConfig.filter(({ forAccessor }) => accessors.indexOf(forAccessor) !== -1) : undefined, + layerType: layerTypes.DATA, }; // Maintain consistent order for any layers that were saved diff --git a/x-pack/plugins/lens/server/embeddable/lens_embeddable_factory.ts b/x-pack/plugins/lens/server/embeddable/lens_embeddable_factory.ts index 6f1ec38ea951a..14a9713d8461e 100644 --- a/x-pack/plugins/lens/server/embeddable/lens_embeddable_factory.ts +++ b/x-pack/plugins/lens/server/embeddable/lens_embeddable_factory.ts @@ -11,8 +11,14 @@ import { DOC_TYPE } from '../../common'; import { commonRemoveTimezoneDateHistogramParam, commonRenameOperationsForFormula, + commonUpdateVisLayerType, } from '../migrations/common_migrations'; -import { LensDocShape713, LensDocShapePre712 } from '../migrations/types'; +import { + LensDocShape713, + LensDocShape715, + LensDocShapePre712, + VisStatePre715, +} from '../migrations/types'; export const lensEmbeddableFactory = (): EmbeddableRegistryDefinition => { return { @@ -35,6 +41,14 @@ export const lensEmbeddableFactory = (): EmbeddableRegistryDefinition => { attributes: migratedLensState, } as unknown) as SerializableRecord; }, + '7.15.0': (state) => { + const lensState = (state as unknown) as { attributes: LensDocShape715 }; + const migratedLensState = commonUpdateVisLayerType(lensState.attributes); + return ({ + ...lensState, + attributes: migratedLensState, + } as unknown) as SerializableRecord; + }, }, }; }; diff --git a/x-pack/plugins/lens/server/migrations/common_migrations.ts b/x-pack/plugins/lens/server/migrations/common_migrations.ts index db19de7fd9c07..fda4300e03ea9 100644 --- a/x-pack/plugins/lens/server/migrations/common_migrations.ts +++ b/x-pack/plugins/lens/server/migrations/common_migrations.ts @@ -12,7 +12,11 @@ import { LensDocShapePost712, LensDocShape713, LensDocShape714, + LensDocShape715, + VisStatePost715, + VisStatePre715, } from './types'; +import { layerTypes } from '../../common'; export const commonRenameOperationsForFormula = ( attributes: LensDocShapePre712 @@ -78,3 +82,19 @@ export const commonRemoveTimezoneDateHistogramParam = ( ); return newAttributes as LensDocShapePost712; }; + +export const commonUpdateVisLayerType = ( + attributes: LensDocShape715 +): LensDocShape715 => { + const newAttributes = cloneDeep(attributes); + const visState = (newAttributes as LensDocShape715).state.visualization; + if ('layerId' in visState) { + visState.layerType = layerTypes.DATA; + } + if ('layers' in visState) { + for (const layer of visState.layers) { + layer.layerType = layerTypes.DATA; + } + } + return newAttributes as LensDocShape715; +}; diff --git a/x-pack/plugins/lens/server/migrations/saved_object_migrations.test.ts b/x-pack/plugins/lens/server/migrations/saved_object_migrations.test.ts index 9daae1d184ab6..afc6e6c6a590c 100644 --- a/x-pack/plugins/lens/server/migrations/saved_object_migrations.test.ts +++ b/x-pack/plugins/lens/server/migrations/saved_object_migrations.test.ts @@ -5,12 +5,15 @@ * 2.0. */ +import { cloneDeep } from 'lodash'; import { migrations, LensDocShape } from './saved_object_migrations'; import { SavedObjectMigrationContext, SavedObjectMigrationFn, SavedObjectUnsanitizedDoc, } from 'src/core/server'; +import { LensDocShape715, VisStatePost715, VisStatePre715 } from './types'; +import { layerTypes } from '../../common'; describe('Lens migrations', () => { describe('7.7.0 missing dimensions in XY', () => { @@ -944,4 +947,186 @@ describe('Lens migrations', () => { expect((columns[2] as { params: {} }).params).toEqual({ timeZone: 'do not delete' }); }); }); + + describe('7.15.0 add layer type information', () => { + const context = ({ log: { warning: () => {} } } as unknown) as SavedObjectMigrationContext; + const example = ({ + type: 'lens', + id: 'mocked-saved-object-id', + attributes: { + savedObjectId: '1', + title: 'MyRenamedOps', + description: '', + visualizationType: null, + state: { + datasourceMetaData: { + filterableIndexPatterns: [], + }, + datasourceStates: { + indexpattern: { + currentIndexPatternId: 'logstash-*', + layers: { + '2': { + columns: { + '3': { + label: '@timestamp', + dataType: 'date', + operationType: 'date_histogram', + sourceField: '@timestamp', + isBucketed: true, + scale: 'interval', + params: { interval: 'auto', timeZone: 'Europe/Berlin' }, + }, + '4': { + label: '@timestamp', + dataType: 'date', + operationType: 'date_histogram', + sourceField: '@timestamp', + isBucketed: true, + scale: 'interval', + params: { interval: 'auto' }, + }, + '5': { + label: '@timestamp', + dataType: 'date', + operationType: 'my_unexpected_operation', + isBucketed: true, + scale: 'interval', + params: { timeZone: 'do not delete' }, + }, + }, + columnOrder: ['3', '4', '5'], + }, + }, + }, + }, + visualization: {}, + query: { query: '', language: 'kuery' }, + filters: [], + }, + }, + } as unknown) as SavedObjectUnsanitizedDoc>; + + it('should add the layerType to a XY visualization', () => { + const xyExample = cloneDeep(example); + xyExample.attributes.visualizationType = 'lnsXY'; + (xyExample.attributes as LensDocShape715).state.visualization = ({ + title: 'Empty XY chart', + legend: { isVisible: true, position: 'right' }, + valueLabels: 'hide', + preferredSeriesType: 'bar_stacked', + layers: [ + { + layerId: '1', + accessors: [ + '5fea2a56-7b73-44b5-9a50-7f0c0c4f8fd0', + 'e5efca70-edb5-4d6d-a30a-79384066987e', + '7ffb7bde-4f42-47ab-b74d-1b4fd8393e0f', + ], + position: 'top', + seriesType: 'bar_stacked', + showGridlines: false, + xAccessor: '2e57a41e-5a52-42d3-877f-bd211d903ef8', + }, + { + layerId: '2', + accessors: [ + '5fea2a56-7b73-44b5-9a50-7f0c0c4f8fd0', + 'e5efca70-edb5-4d6d-a30a-79384066987e', + '7ffb7bde-4f42-47ab-b74d-1b4fd8393e0f', + ], + position: 'top', + seriesType: 'bar_stacked', + showGridlines: false, + xAccessor: '2e57a41e-5a52-42d3-877f-bd211d903ef8', + }, + ], + } as unknown) as VisStatePre715; + const result = migrations['7.15.0'](xyExample, context) as ReturnType< + SavedObjectMigrationFn + >; + const state = (result.attributes as LensDocShape715).state.visualization; + if ('layers' in state) { + for (const layer of state.layers) { + expect(layer.layerType).toEqual(layerTypes.DATA); + } + } + }); + + it('should add layer info to a pie visualization', () => { + const pieExample = cloneDeep(example); + pieExample.attributes.visualizationType = 'lnsPie'; + (pieExample.attributes as LensDocShape715).state.visualization = ({ + shape: 'pie', + layers: [ + { + layerId: '1', + groups: [], + metric: undefined, + numberDisplay: 'percent', + categoryDisplay: 'default', + legendDisplay: 'default', + nestedLegend: false, + }, + ], + } as unknown) as VisStatePre715; + const result = migrations['7.15.0'](pieExample, context) as ReturnType< + SavedObjectMigrationFn + >; + const state = (result.attributes as LensDocShape715).state.visualization; + if ('layers' in state) { + for (const layer of state.layers) { + expect(layer.layerType).toEqual(layerTypes.DATA); + } + } + }); + it('should add layer info to a metric visualization', () => { + const metricExample = cloneDeep(example); + metricExample.attributes.visualizationType = 'lnsMetric'; + (metricExample.attributes as LensDocShape715).state.visualization = ({ + layerId: '1', + accessor: undefined, + } as unknown) as VisStatePre715; + const result = migrations['7.15.0'](metricExample, context) as ReturnType< + SavedObjectMigrationFn + >; + const state = (result.attributes as LensDocShape715).state.visualization; + expect('layerType' in state).toEqual(true); + if ('layerType' in state) { + expect(state.layerType).toEqual(layerTypes.DATA); + } + }); + it('should add layer info to a datatable visualization', () => { + const datatableExample = cloneDeep(example); + datatableExample.attributes.visualizationType = 'lnsDatatable'; + (datatableExample.attributes as LensDocShape715).state.visualization = ({ + layerId: '1', + accessor: undefined, + } as unknown) as VisStatePre715; + const result = migrations['7.15.0'](datatableExample, context) as ReturnType< + SavedObjectMigrationFn + >; + const state = (result.attributes as LensDocShape715).state.visualization; + expect('layerType' in state).toEqual(true); + if ('layerType' in state) { + expect(state.layerType).toEqual(layerTypes.DATA); + } + }); + it('should add layer info to a heatmap visualization', () => { + const heatmapExample = cloneDeep(example); + heatmapExample.attributes.visualizationType = 'lnsHeatmap'; + (heatmapExample.attributes as LensDocShape715).state.visualization = ({ + layerId: '1', + accessor: undefined, + } as unknown) as VisStatePre715; + const result = migrations['7.15.0'](heatmapExample, context) as ReturnType< + SavedObjectMigrationFn + >; + const state = (result.attributes as LensDocShape715).state.visualization; + expect('layerType' in state).toEqual(true); + if ('layerType' in state) { + expect(state.layerType).toEqual(layerTypes.DATA); + } + }); + }); }); diff --git a/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts b/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts index efcd6e2e6f342..7d08e76841cf5 100644 --- a/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts +++ b/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts @@ -15,10 +15,19 @@ import { } from 'src/core/server'; import { Query, Filter } from 'src/plugins/data/public'; import { PersistableFilter } from '../../common'; -import { LensDocShapePost712, LensDocShapePre712, LensDocShape713, LensDocShape714 } from './types'; +import { + LensDocShapePost712, + LensDocShapePre712, + LensDocShape713, + LensDocShape714, + LensDocShape715, + VisStatePost715, + VisStatePre715, +} from './types'; import { commonRenameOperationsForFormula, commonRemoveTimezoneDateHistogramParam, + commonUpdateVisLayerType, } from './common_migrations'; interface LensDocShapePre710 { @@ -413,6 +422,14 @@ const removeTimezoneDateHistogramParam: SavedObjectMigrationFn, + LensDocShape715 +> = (doc) => { + const newDoc = cloneDeep(doc); + return { ...newDoc, attributes: commonUpdateVisLayerType(newDoc.attributes) }; +}; + export const migrations: SavedObjectMigrationMap = { '7.7.0': removeInvalidAccessors, // The order of these migrations matter, since the timefield migration relies on the aggConfigs @@ -424,4 +441,5 @@ export const migrations: SavedObjectMigrationMap = { '7.13.0': renameOperationsForFormula, '7.13.1': renameOperationsForFormula, // duplicate this migration in case a broken by value panel is added to the library '7.14.0': removeTimezoneDateHistogramParam, + '7.15.0': addLayerTypeToVisualization, }; diff --git a/x-pack/plugins/lens/server/migrations/types.ts b/x-pack/plugins/lens/server/migrations/types.ts index 035e1a86b86f8..09b460ff8b8cd 100644 --- a/x-pack/plugins/lens/server/migrations/types.ts +++ b/x-pack/plugins/lens/server/migrations/types.ts @@ -6,6 +6,7 @@ */ import { Query, Filter } from 'src/plugins/data/public'; +import type { LayerType } from '../../common'; export type OperationTypePre712 = | 'avg' @@ -152,3 +153,42 @@ export type LensDocShape714 = Omit & { }; }; }; + +interface LayerPre715 { + layerId: string; +} + +export type VisStatePre715 = LayerPre715 | { layers: LayerPre715[] }; + +interface LayerPost715 extends LayerPre715 { + layerType: LayerType; +} + +export type VisStatePost715 = LayerPost715 | { layers: LayerPost715[] }; + +export interface LensDocShape715 { + visualizationType: string | null; + title: string; + expression: string | null; + state: { + datasourceMetaData: { + filterableIndexPatterns: Array<{ id: string; title: string }>; + }; + datasourceStates: { + // This is hardcoded as our only datasource + indexpattern: { + currentIndexPatternId: string; + layers: Record< + string, + { + columnOrder: string[]; + columns: Record>; + } + >; + }; + }; + visualization: VisualizationState; + query: Query; + filters: Filter[]; + }; +} diff --git a/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.tsx b/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.tsx index 19349c4eff9b8..e2650b9c8cfb3 100644 --- a/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.tsx +++ b/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.tsx @@ -65,6 +65,7 @@ export interface EntryItemProps { onlyShowListOperators?: boolean; setErrorsExist: (arg: boolean) => void; isDisabled?: boolean; + operatorsList?: OperatorOption[]; } export const BuilderEntryItem: React.FC = ({ @@ -81,6 +82,7 @@ export const BuilderEntryItem: React.FC = ({ setErrorsExist, showLabel, isDisabled = false, + operatorsList, }): JSX.Element => { const handleError = useCallback( (err: boolean): void => { @@ -194,7 +196,9 @@ export const BuilderEntryItem: React.FC = ({ ); const renderOperatorInput = (isFirst: boolean): JSX.Element => { - const operatorOptions = onlyShowListOperators + const operatorOptions = operatorsList + ? operatorsList + : onlyShowListOperators ? EXCEPTION_OPERATORS_ONLY_LISTS : getOperatorOptions( entry, diff --git a/x-pack/plugins/lists/public/exceptions/components/builder/exception_item_renderer.tsx b/x-pack/plugins/lists/public/exceptions/components/builder/exception_item_renderer.tsx index 04d7606bda23e..4b3f094aa4f22 100644 --- a/x-pack/plugins/lists/public/exceptions/components/builder/exception_item_renderer.tsx +++ b/x-pack/plugins/lists/public/exceptions/components/builder/exception_item_renderer.tsx @@ -15,6 +15,7 @@ import { BuilderEntry, ExceptionsBuilderExceptionItem, FormattedBuilderEntry, + OperatorOption, getFormattedBuilderEntries, getUpdatedEntriesOnDelete, } from '@kbn/securitysolution-list-utils'; @@ -60,6 +61,7 @@ interface BuilderExceptionListItemProps { setErrorsExist: (arg: boolean) => void; onlyShowListOperators?: boolean; isDisabled?: boolean; + operatorsList?: OperatorOption[]; } export const BuilderExceptionListItemComponent = React.memo( @@ -80,6 +82,7 @@ export const BuilderExceptionListItemComponent = React.memo { const handleEntryChange = useCallback( (entry: BuilderEntry, entryIndex: number): void => { @@ -152,6 +155,7 @@ export const BuilderExceptionListItemComponent = React.memo void; ruleName: string; isDisabled?: boolean; + operatorsList?: OperatorOption[]; } export const ExceptionBuilderComponent = ({ @@ -109,6 +111,7 @@ export const ExceptionBuilderComponent = ({ ruleName, isDisabled = false, osTypes, + operatorsList, }: ExceptionBuilderProps): JSX.Element => { const [ { @@ -413,6 +416,7 @@ export const ExceptionBuilderComponent = ({ setErrorsExist={setErrorsExist} osTypes={osTypes} isDisabled={isDisabled} + operatorsList={operatorsList} /> diff --git a/x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/join_editor.tsx b/x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/join_editor.tsx index d07b39be6f6ab..e0d630994566d 100644 --- a/x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/join_editor.tsx +++ b/x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/join_editor.tsx @@ -19,7 +19,6 @@ import { import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -// @ts-expect-error import { Join } from './resources/join'; import { ILayer } from '../../../classes/layers/layer'; import { JoinDescriptor } from '../../../../common/descriptor_types'; diff --git a/x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/__snapshots__/metrics_expression.test.js.snap b/x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/__snapshots__/metrics_expression.test.tsx.snap similarity index 90% rename from x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/__snapshots__/metrics_expression.test.js.snap rename to x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/__snapshots__/metrics_expression.test.tsx.snap index a9a1afabfc193..91eec4d8aac29 100644 --- a/x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/__snapshots__/metrics_expression.test.js.snap +++ b/x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/__snapshots__/metrics_expression.test.tsx.snap @@ -43,13 +43,18 @@ exports[`Should render default props 1`] = ` values={Object {}} /> - - - +
`; @@ -99,12 +104,7 @@ exports[`Should render metrics expression for metrics 1`] = ` void; + onRemove: () => void; + leftFields: JoinField[]; + leftSourceName: string; +} + +interface State { + rightFields: IFieldType[]; + indexPattern?: IndexPattern; + loadError?: string; +} + +export class Join extends Component { + private _isMounted = false; + + state: State = { + rightFields: [], indexPattern: undefined, loadError: undefined, }; @@ -36,7 +61,7 @@ export class Join extends Component { this._isMounted = false; } - async _loadRightFields(indexPatternId) { + async _loadRightFields(indexPatternId: string) { if (!indexPatternId) { return; } @@ -66,21 +91,26 @@ export class Join extends Component { }); } - _onLeftFieldChange = (leftField) => { + _onLeftFieldChange = (leftField: string) => { this.props.onChange({ - leftField: leftField, + leftField, right: this.props.join.right, }); }; - _onRightSourceChange = ({ indexPatternId, indexPatternTitle }) => { + _onRightSourceChange = ({ + indexPatternId, + indexPatternTitle, + }: { + indexPatternId: string; + indexPatternTitle: string; + }) => { this.setState({ - rightFields: undefined, + rightFields: [], loadError: undefined, }); this._loadRightFields(indexPatternId); - // eslint-disable-next-line no-unused-vars - const { term, ...restOfRight } = this.props.join.right; + const { term, ...restOfRight } = this.props.join.right as ESTermSourceDescriptor; this.props.onChange({ leftField: this.props.join.leftField, right: { @@ -88,74 +118,74 @@ export class Join extends Component { indexPatternId, indexPatternTitle, type: SOURCE_TYPES.ES_TERM_SOURCE, - }, + } as ESTermSourceDescriptor, }); }; - _onRightFieldChange = (term) => { + _onRightFieldChange = (term?: string) => { this.props.onChange({ leftField: this.props.join.leftField, right: { ...this.props.join.right, term, - }, + } as ESTermSourceDescriptor, }); }; - _onRightSizeChange = (size) => { + _onRightSizeChange = (size: number) => { this.props.onChange({ leftField: this.props.join.leftField, right: { ...this.props.join.right, size, - }, + } as ESTermSourceDescriptor, }); }; - _onMetricsChange = (metrics) => { + _onMetricsChange = (metrics: AggDescriptor[]) => { this.props.onChange({ leftField: this.props.join.leftField, right: { ...this.props.join.right, metrics, - }, + } as ESTermSourceDescriptor, }); }; - _onWhereQueryChange = (whereQuery) => { + _onWhereQueryChange = (whereQuery?: Query) => { this.props.onChange({ leftField: this.props.join.leftField, right: { ...this.props.join.right, whereQuery, - }, + } as ESTermSourceDescriptor, }); }; - _onApplyGlobalQueryChange = (applyGlobalQuery) => { + _onApplyGlobalQueryChange = (applyGlobalQuery: boolean) => { this.props.onChange({ leftField: this.props.join.leftField, right: { ...this.props.join.right, applyGlobalQuery, - }, + } as ESTermSourceDescriptor, }); }; - _onApplyGlobalTimeChange = (applyGlobalTime) => { + _onApplyGlobalTimeChange = (applyGlobalTime: boolean) => { this.props.onChange({ leftField: this.props.join.leftField, right: { ...this.props.join.right, applyGlobalTime, - }, + } as ESTermSourceDescriptor, }); }; render() { const { join, onRemove, leftFields, leftSourceName } = this.props; const { rightFields, indexPattern } = this.state; - const right = _.get(join, 'right', {}); + const right = _.get(join, 'right', {}) as ESTermSourceDescriptor; const rightSourceName = right.indexPatternTitle ? right.indexPatternTitle : right.indexPatternId; @@ -168,7 +198,7 @@ export class Join extends Component { metricsExpression = ( @@ -176,7 +206,9 @@ export class Join extends Component { ); globalFilterCheckbox = ( diff --git a/x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/join_expression.js b/x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/join_expression.tsx similarity index 87% rename from x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/join_expression.js rename to x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/join_expression.tsx index 58e3e3aac0d6a..f2073a9f6e650 100644 --- a/x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/join_expression.js +++ b/x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/join_expression.tsx @@ -7,30 +7,64 @@ import _ from 'lodash'; import React, { Component } from 'react'; -import PropTypes from 'prop-types'; import { EuiPopover, EuiPopoverTitle, EuiExpression, EuiFormRow, EuiComboBox, + EuiComboBoxOptionOption, EuiFormHelpText, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { IFieldType } from 'src/plugins/data/public'; +import { FormattedMessage } from '@kbn/i18n/react'; import { DEFAULT_MAX_BUCKETS_LIMIT } from '../../../../../common/constants'; import { SingleFieldSelect } from '../../../../components/single_field_select'; import { ValidatedNumberInput } from '../../../../components/validated_number_input'; -import { FormattedMessage } from '@kbn/i18n/react'; import { getTermsFields } from '../../../../index_pattern_util'; import { getIndexPatternService, getIndexPatternSelectComponent, } from '../../../../kibana_services'; +import type { JoinField } from '../join_editor'; + +interface Props { + // Left source props (static - can not change) + leftSourceName?: string; + + // Left field props + leftValue?: string; + leftFields: JoinField[]; + onLeftFieldChange: (leftField: string) => void; -export class JoinExpression extends Component { - state = { + // Right source props + rightSourceIndexPatternId: string; + rightSourceName: string; + onRightSourceChange: ({ + indexPatternId, + indexPatternTitle, + }: { + indexPatternId: string; + indexPatternTitle: string; + }) => void; + + // Right field props + rightValue: string; + rightSize?: number; + rightFields: IFieldType[]; + onRightFieldChange: (term?: string) => void; + onRightSizeChange: (size: number) => void; +} + +interface State { + isPopoverOpen: boolean; +} + +export class JoinExpression extends Component { + state: State = { isPopoverOpen: false, }; @@ -46,7 +80,11 @@ export class JoinExpression extends Component { }); }; - _onRightSourceChange = async (indexPatternId) => { + _onRightSourceChange = async (indexPatternId?: string) => { + if (!indexPatternId || indexPatternId.length === 0) { + return; + } + try { const indexPattern = await getIndexPatternService().get(indexPatternId); this.props.onRightSourceChange({ @@ -58,7 +96,7 @@ export class JoinExpression extends Component { } }; - _onLeftFieldChange = (selectedFields) => { + _onLeftFieldChange = (selectedFields: Array>) => { this.props.onLeftFieldChange(_.get(selectedFields, '[0].value.name', null)); }; @@ -246,7 +284,9 @@ export class JoinExpression extends Component { })} > @@ -263,33 +303,6 @@ export class JoinExpression extends Component { } } -JoinExpression.propTypes = { - // Left source props (static - can not change) - leftSourceName: PropTypes.string, - - // Left field props - leftValue: PropTypes.string, - leftFields: PropTypes.arrayOf( - PropTypes.shape({ - label: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - }) - ), - onLeftFieldChange: PropTypes.func.isRequired, - - // Right source props - rightSourceIndexPatternId: PropTypes.string, - rightSourceName: PropTypes.string, - onRightSourceChange: PropTypes.func.isRequired, - - // Right field props - rightValue: PropTypes.string, - rightSize: PropTypes.number, - rightFields: PropTypes.array, - onRightFieldChange: PropTypes.func.isRequired, - onRightSizeChange: PropTypes.func.isRequired, -}; - function getSelectFieldPlaceholder() { return i18n.translate('xpack.maps.layerPanel.joinExpression.selectFieldPlaceholder', { defaultMessage: 'Select field', diff --git a/x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/metrics_expression.test.js b/x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/metrics_expression.test.tsx similarity index 67% rename from x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/metrics_expression.test.js rename to x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/metrics_expression.test.tsx index 8140d7a36ea9b..aa696383fa37c 100644 --- a/x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/metrics_expression.test.js +++ b/x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/metrics_expression.test.tsx @@ -8,9 +8,12 @@ import React from 'react'; import { shallow } from 'enzyme'; import { MetricsExpression } from './metrics_expression'; +import { AGG_TYPE } from '../../../../../common/constants'; const defaultProps = { onChange: () => {}, + metrics: [{ type: AGG_TYPE.COUNT }], + rightFields: [], }; test('Should render default props', () => { @@ -23,11 +26,10 @@ test('Should render metrics expression for metrics', () => { const component = shallow( ); diff --git a/x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/metrics_expression.js b/x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/metrics_expression.tsx similarity index 80% rename from x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/metrics_expression.js rename to x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/metrics_expression.tsx index 581cb75b4500a..899430f3c2f2d 100644 --- a/x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/metrics_expression.js +++ b/x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/metrics_expression.tsx @@ -6,7 +6,6 @@ */ import React, { Component } from 'react'; -import PropTypes from 'prop-types'; import { i18n } from '@kbn/i18n'; import { EuiPopover, @@ -16,12 +15,24 @@ import { EuiFormHelpText, } from '@elastic/eui'; -import { MetricsEditor } from '../../../../components/metrics_editor'; +import { IFieldType } from 'src/plugins/data/public'; import { FormattedMessage } from '@kbn/i18n/react'; +import { MetricsEditor } from '../../../../components/metrics_editor'; import { AGG_TYPE } from '../../../../../common/constants'; +import { AggDescriptor, FieldedAggDescriptor } from '../../../../../common/descriptor_types'; + +interface Props { + metrics: AggDescriptor[]; + rightFields: IFieldType[]; + onChange: (metrics: AggDescriptor[]) => void; +} -export class MetricsExpression extends Component { - state = { +interface State { + isPopoverOpen: boolean; +} + +export class MetricsExpression extends Component { + state: State = { isPopoverOpen: false, }; @@ -61,23 +72,23 @@ export class MetricsExpression extends Component { render() { const metricExpressions = this.props.metrics - .filter(({ type, field }) => { - if (type === AGG_TYPE.COUNT) { + .filter((metric: AggDescriptor) => { + if (metric.type === AGG_TYPE.COUNT) { return true; } - if (field) { + if ((metric as FieldedAggDescriptor).field) { return true; } return false; }) - .map(({ type, field }) => { + .map((metric: AggDescriptor) => { // do not use metric label so field and aggregation are not obscured. - if (type === AGG_TYPE.COUNT) { - return 'count'; + if (metric.type === AGG_TYPE.COUNT) { + return AGG_TYPE.COUNT; } - return `${type} ${field}`; + return `${metric.type} ${(metric as FieldedAggDescriptor).field}`; }); const useMetricDescription = i18n.translate( 'xpack.maps.layerPanel.metricsExpression.useMetricsDescription', @@ -101,7 +112,7 @@ export class MetricsExpression extends Component { onClick={this._togglePopover} description={useMetricDescription} uppercase={false} - value={metricExpressions.length > 0 ? metricExpressions.join(', ') : 'count'} + value={metricExpressions.length > 0 ? metricExpressions.join(', ') : AGG_TYPE.COUNT} /> } > @@ -124,13 +135,3 @@ export class MetricsExpression extends Component { ); } } - -MetricsExpression.propTypes = { - metrics: PropTypes.array, - rightFields: PropTypes.array, - onChange: PropTypes.func.isRequired, -}; - -MetricsExpression.defaultProps = { - metrics: [{ type: AGG_TYPE.COUNT }], -}; diff --git a/x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/where_expression.js b/x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/where_expression.tsx similarity index 86% rename from x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/where_expression.js rename to x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/where_expression.tsx index 93ff3c95d184e..16cef0d5bdad6 100644 --- a/x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/where_expression.js +++ b/x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/where_expression.tsx @@ -9,10 +9,22 @@ import React, { Component } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiButton, EuiPopover, EuiExpression, EuiFormHelpText } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; +import { IndexPattern, Query } from 'src/plugins/data/public'; +import { APP_ID } from '../../../../../common/constants'; import { getData } from '../../../../kibana_services'; -export class WhereExpression extends Component { - state = { +interface Props { + indexPattern: IndexPattern; + onChange: (whereQuery?: Query) => void; + whereQuery?: Query; +} + +interface State { + isPopoverOpen: boolean; +} + +export class WhereExpression extends Component { + state: State = { isPopoverOpen: false, }; @@ -28,7 +40,7 @@ export class WhereExpression extends Component { }); }; - _onQueryChange = ({ query }) => { + _onQueryChange = ({ query }: { query?: Query }) => { this.props.onChange(query); this._closePopover(); }; @@ -73,6 +85,7 @@ export class WhereExpression extends Component { /> { { accessors: ['y-axis-column-layer0'], layerId: 'layer0', + layerType: 'data', palette: undefined, seriesType: 'line', xAccessor: 'x-axis-column-layer0', @@ -418,6 +419,7 @@ describe('Lens Attribute', () => { { accessors: ['y-axis-column-layer0'], layerId: 'layer0', + layerType: 'data', palette: undefined, seriesType: 'line', splitAccessor: 'breakdown-column-layer0', diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts index dfb17ee470d35..6605a74630e11 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts @@ -621,6 +621,7 @@ export class LensAttributes { ...Object.keys(this.getChildYAxises(layerConfig)), ], layerId: `layer${index}`, + layerType: 'data', seriesType: layerConfig.seriesType || layerConfig.seriesConfig.defaultSeriesType, palette: layerConfig.seriesConfig.palette, yConfig: layerConfig.seriesConfig.yConfig || [ diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute.ts index 569d68ad4ebff..73a722642f69b 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute.ts @@ -154,6 +154,7 @@ export const sampleAttribute = { { accessors: ['y-axis-column-layer0'], layerId: 'layer0', + layerType: 'data', seriesType: 'line', yConfig: [{ forAccessor: 'y-axis-column-layer0' }], xAccessor: 'x-axis-column-layer0', diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_cwv.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_cwv.ts index 2087b85b81886..56ceba8fc52de 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_cwv.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_cwv.ts @@ -113,6 +113,7 @@ export const sampleAttributeCoreWebVital = { { accessors: ['y-axis-column-layer0', 'y-axis-column-1', 'y-axis-column-2'], layerId: 'layer0', + layerType: 'data', seriesType: 'bar_horizontal_percentage_stacked', xAccessor: 'x-axis-column-layer0', yConfig: [ diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_kpi.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_kpi.ts index 7f066caf66bf1..72933573c410b 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_kpi.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_kpi.ts @@ -59,6 +59,7 @@ export const sampleAttributeKpi = { { accessors: ['y-axis-column-layer0'], layerId: 'layer0', + layerType: 'data', seriesType: 'line', yConfig: [{ forAccessor: 'y-axis-column-layer0' }], xAccessor: 'x-axis-column-layer0', diff --git a/x-pack/plugins/observability/public/pages/alerts/index.tsx b/x-pack/plugins/observability/public/pages/alerts/index.tsx index fed9ee0be3a4a..a4bd9db5584ee 100644 --- a/x-pack/plugins/observability/public/pages/alerts/index.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/index.tsx @@ -5,7 +5,14 @@ * 2.0. */ -import { EuiButton, EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiLink, EuiSpacer } from '@elastic/eui'; +import { + EuiButtonEmpty, + EuiCallOut, + EuiFlexGroup, + EuiFlexItem, + EuiLink, + EuiSpacer, +} from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { useCallback, useMemo, useRef } from 'react'; import { useHistory } from 'react-router-dom'; @@ -55,9 +62,7 @@ export function AlertsPage({ routeParams }: AlertsPageProps) { // In a future milestone we'll have a page dedicated to rule management in // observability. For now link to the settings page. - const manageDetectionRulesHref = prepend( - '/app/management/insightsAndAlerting/triggersActions/alerts' - ); + const manageRulesHref = prepend('/app/management/insightsAndAlerting/triggersActions/alerts'); const { data: dynamicIndexPatternResp } = useFetcher(({ signal }) => { return callObservabilityApi({ @@ -116,11 +121,11 @@ export function AlertsPage({ routeParams }: AlertsPageProps) { ), rightSideItems: [ - - {i18n.translate('xpack.observability.alerts.manageDetectionRulesButtonLabel', { - defaultMessage: 'Manage detection rules', + + {i18n.translate('xpack.observability.alerts.manageRulesButtonLabel', { + defaultMessage: 'Manage Rules', })} - , + , ], }} > diff --git a/x-pack/plugins/osquery/public/scheduled_query_groups/scheduled_query_group_queries_table.tsx b/x-pack/plugins/osquery/public/scheduled_query_groups/scheduled_query_group_queries_table.tsx index 01acf2dc0d826..6fcad4f11003f 100644 --- a/x-pack/plugins/osquery/public/scheduled_query_groups/scheduled_query_group_queries_table.tsx +++ b/x-pack/plugins/osquery/public/scheduled_query_groups/scheduled_query_group_queries_table.tsx @@ -80,6 +80,7 @@ function getLensAttributes(actionId: string): TypedLensByValueInput['attributes' legendDisplay: 'default', nestedLegend: false, layerId: 'layer1', + layerType: 'data', metric: 'ed999e9d-204c-465b-897f-fe1a125b39ed', numberDisplay: 'percent', groups: ['8690befd-fd69-4246-af4a-dd485d2a3b38'], diff --git a/x-pack/plugins/security_solution/common/ecs/dll/index.ts b/x-pack/plugins/security_solution/common/ecs/dll/index.ts new file mode 100644 index 0000000000000..0634d29c691cf --- /dev/null +++ b/x-pack/plugins/security_solution/common/ecs/dll/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CodeSignature } from '../file'; +import { ProcessPe } from '../process'; + +export interface DllEcs { + path?: string; + code_signature?: CodeSignature; + pe?: ProcessPe; +} diff --git a/x-pack/plugins/security_solution/common/ecs/index.ts b/x-pack/plugins/security_solution/common/ecs/index.ts index 610a2fd1f6e9e..fbeb323157367 100644 --- a/x-pack/plugins/security_solution/common/ecs/index.ts +++ b/x-pack/plugins/security_solution/common/ecs/index.ts @@ -9,6 +9,7 @@ import { AgentEcs } from './agent'; import { AuditdEcs } from './auditd'; import { DestinationEcs } from './destination'; import { DnsEcs } from './dns'; +import { DllEcs } from './dll'; import { EndgameEcs } from './endgame'; import { EventEcs } from './event'; import { FileEcs } from './file'; @@ -68,4 +69,5 @@ export interface Ecs { // eslint-disable-next-line @typescript-eslint/naming-convention Memory_protection?: MemoryProtection; Target?: Target; + dll?: DllEcs; } diff --git a/x-pack/plugins/security_solution/common/ecs/process/index.ts b/x-pack/plugins/security_solution/common/ecs/process/index.ts index 0eb2400466e64..2a58c6d5b47d0 100644 --- a/x-pack/plugins/security_solution/common/ecs/process/index.ts +++ b/x-pack/plugins/security_solution/common/ecs/process/index.ts @@ -5,14 +5,16 @@ * 2.0. */ -import { Ext } from '../file'; +import { CodeSignature, Ext } from '../file'; export interface ProcessEcs { Ext?: Ext; + command_line?: string[]; entity_id?: string[]; exit_code?: number[]; hash?: ProcessHashData; parent?: ProcessParentData; + code_signature?: CodeSignature; pid?: number[]; name?: string[]; ppid?: number[]; @@ -32,6 +34,7 @@ export interface ProcessHashData { export interface ProcessParentData { name?: string[]; pid?: number[]; + executable?: string[]; } export interface Thread { @@ -39,3 +42,9 @@ export interface Thread { start?: string[]; Ext?: Ext; } +export interface ProcessPe { + original_file_name?: string; + company?: string; + description?: string; + file_version?: string; +} diff --git a/x-pack/plugins/security_solution/common/ecs/registry/index.ts b/x-pack/plugins/security_solution/common/ecs/registry/index.ts index c756fb139199e..6ca6afc10098c 100644 --- a/x-pack/plugins/security_solution/common/ecs/registry/index.ts +++ b/x-pack/plugins/security_solution/common/ecs/registry/index.ts @@ -10,4 +10,9 @@ export interface RegistryEcs { key?: string[]; path?: string[]; value?: string[]; + data?: RegistryEcsData; +} + +export interface RegistryEcsData { + strings?: string[]; } diff --git a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts index 255ab8f0a598c..94fc6be366beb 100644 --- a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts +++ b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts @@ -7,6 +7,7 @@ import uuid from 'uuid'; import seedrandom from 'seedrandom'; +import semverLte from 'semver/functions/lte'; import { assertNever } from '@kbn/std'; import { AlertEvent, @@ -261,6 +262,7 @@ interface HostInfo { state?: { isolation: boolean; }; + capabilities?: string[]; }; } @@ -392,6 +394,7 @@ enum AlertTypes { MALWARE = 'MALWARE', MEMORY_SIGNATURE = 'MEMORY_SIGNATURE', MEMORY_SHELLCODE = 'MEMORY_SHELLCODE', + BEHAVIOR = 'BEHAVIOR', } const alertsDefaultDataStream = { @@ -455,10 +458,13 @@ export class EndpointDocGenerator extends BaseDataGenerator { private createHostData(): HostInfo { const hostName = this.randomHostname(); const isIsolated = this.randomBoolean(0.3); + const agentVersion = this.randomVersion(); + const minCapabilitiesVersion = '7.15.0'; + const capabilities = ['isolation']; return { agent: { - version: this.randomVersion(), + version: agentVersion, id: this.seededUUIDv4(), type: 'endpoint', }, @@ -487,6 +493,7 @@ export class EndpointDocGenerator extends BaseDataGenerator { state: { isolation: isIsolated, }, + capabilities: semverLte(minCapabilitiesVersion, agentVersion) ? capabilities : [], }, }; } @@ -778,11 +785,117 @@ export class EndpointDocGenerator extends BaseDataGenerator { alertsDataStream, alertType, }); + case AlertTypes.BEHAVIOR: + return this.generateBehaviorAlert({ + ts, + entityID, + parentEntityID, + ancestry, + alertsDataStream, + }); default: return assertNever(alertType); } } + /** + * Creates a memory alert from the simulated host represented by this EndpointDocGenerator + * @param ts - Timestamp to put in the event + * @param entityID - entityID of the originating process + * @param parentEntityID - optional entityID of the parent process, if it exists + * @param ancestry - an array of ancestors for the generated alert + * @param alertsDataStream the values to populate the data_stream fields when generating alert documents + */ + public generateBehaviorAlert({ + ts = new Date().getTime(), + entityID = this.randomString(10), + parentEntityID, + ancestry = [], + alertsDataStream = alertsDefaultDataStream, + }: { + ts?: number; + entityID?: string; + parentEntityID?: string; + ancestry?: string[]; + alertsDataStream?: DataStream; + } = {}): AlertEvent { + const processName = this.randomProcessName(); + const newAlert: AlertEvent = { + ...this.commonInfo, + data_stream: alertsDataStream, + '@timestamp': ts, + ecs: { + version: '1.6.0', + }, + rule: { + id: this.randomUUID(), + }, + event: { + action: 'rule_detection', + kind: 'alert', + category: 'behavior', + code: 'behavior', + id: this.seededUUIDv4(), + dataset: 'endpoint.diagnostic.collection', + module: 'endpoint', + type: 'info', + sequence: this.sequence++, + }, + file: { + name: 'fake_behavior.exe', + path: 'C:/fake_behavior.exe', + }, + destination: { + port: 443, + ip: this.randomIP(), + }, + source: { + port: 59406, + ip: this.randomIP(), + }, + network: { + transport: 'tcp', + type: 'ipv4', + direction: 'outgoing', + }, + registry: { + path: + 'HKEY_USERS\\S-1-5-21-2460036010-3910878774-3458087990-1001\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run\\chrome', + value: processName, + data: { + strings: `C:/fake_behavior/${processName}`, + }, + }, + process: { + pid: 2, + name: processName, + entity_id: entityID, + executable: `C:/fake_behavior/${processName}`, + parent: parentEntityID + ? { + entity_id: parentEntityID, + pid: 1, + } + : undefined, + Ext: { + ancestry, + code_signature: [ + { + trusted: false, + subject_name: 'bad signer', + }, + ], + user: 'SYSTEM', + token: { + integrity_level_name: 'high', + elevation_level: 'full', + }, + }, + }, + dll: this.getAlertsDefaultDll(), + }; + return newAlert; + } /** * Returns the default DLLs used in alerts */ diff --git a/x-pack/plugins/security_solution/common/endpoint/service/host_isolation/utils.test.ts b/x-pack/plugins/security_solution/common/endpoint/service/host_isolation/utils.test.ts index 8983f1a99b0cd..8b72fe5deb8f6 100644 --- a/x-pack/plugins/security_solution/common/endpoint/service/host_isolation/utils.test.ts +++ b/x-pack/plugins/security_solution/common/endpoint/service/host_isolation/utils.test.ts @@ -9,58 +9,71 @@ import { isVersionSupported, isOsSupported, isIsolationSupported } from './utils describe('Host Isolation utils isVersionSupported', () => { test.each` - a | b | expected - ${'8.14.0'} | ${'7.13.0'} | ${true} - ${'7.14.0'} | ${'7.13.0'} | ${true} - ${'7.14.1'} | ${'7.14.0'} | ${true} - ${'8.14.0'} | ${'9.14.0'} | ${false} - ${'7.13.0'} | ${'7.14.0'} | ${false} - ${'7.14.0'} | ${'7.14.1'} | ${false} - ${'7.14.0'} | ${'7.14.0'} | ${true} - ${'7.14.0-SNAPSHOT'} | ${'7.14.0'} | ${true} - ${'7.14.0-SNAPSHOT-beta'} | ${'7.14.0'} | ${true} - ${'7.14.0-alpha'} | ${'7.14.0'} | ${true} - ${'8.0.0-SNAPSHOT'} | ${'7.14.0'} | ${true} - ${'8.0.0'} | ${'7.14.0'} | ${true} - `('should validate that version $a is compatible($expected) to $b', ({ a, b, expected }) => { - expect( - isVersionSupported({ - currentVersion: a, - minVersionRequired: b, - }) - ).toEqual(expected); - }); + currentVersion | minVersionRequired | expected + ${'8.14.0'} | ${'7.13.0'} | ${true} + ${'7.14.0'} | ${'7.13.0'} | ${true} + ${'7.14.1'} | ${'7.14.0'} | ${true} + ${'8.14.0'} | ${'9.14.0'} | ${false} + ${'7.13.0'} | ${'7.14.0'} | ${false} + ${'7.14.0'} | ${'7.14.1'} | ${false} + ${'7.14.0'} | ${'7.14.0'} | ${true} + ${'7.14.0-SNAPSHOT'} | ${'7.14.0'} | ${true} + ${'7.14.0-SNAPSHOT-beta'} | ${'7.14.0'} | ${true} + ${'7.14.0-alpha'} | ${'7.14.0'} | ${true} + ${'8.0.0-SNAPSHOT'} | ${'7.14.0'} | ${true} + ${'8.0.0'} | ${'7.14.0'} | ${true} + `( + 'should validate that version $a is compatible($expected) to $b', + ({ currentVersion, minVersionRequired, expected }) => { + expect( + isVersionSupported({ + currentVersion, + minVersionRequired, + }) + ).toEqual(expected); + } + ); }); describe('Host Isolation utils isOsSupported', () => { test.each` - a | b | expected - ${'linux'} | ${['macos', 'linux']} | ${true} - ${'linux'} | ${['macos', 'windows']} | ${false} - `('should validate that os $a is compatible($expected) to $b', ({ a, b, expected }) => { - expect( - isOsSupported({ - currentOs: a, - supportedOss: b, - }) - ).toEqual(expected); - }); + currentOs | supportedOss | expected + ${'linux'} | ${{ macos: true, linux: true }} | ${true} + ${'linux'} | ${{ macos: true, windows: true }} | ${false} + `( + 'should validate that os $a is compatible($expected) to $b', + ({ currentOs, supportedOss, expected }) => { + expect( + isOsSupported({ + currentOs, + supportedOss, + }) + ).toEqual(expected); + } + ); }); describe('Host Isolation utils isIsolationSupported', () => { test.each` - a | b | expected - ${'windows'} | ${'7.14.0'} | ${true} - ${'linux'} | ${'7.13.0'} | ${false} - ${'linux'} | ${'7.14.0'} | ${false} - ${'macos'} | ${'7.13.0'} | ${false} + osName | version | capabilities | expected + ${'windows'} | ${'7.14.0'} | ${[]} | ${true} + ${'linux'} | ${'7.13.0'} | ${['isolation']} | ${false} + ${'linux'} | ${'7.14.0'} | ${['isolation']} | ${false} + ${'macos'} | ${'7.13.0'} | ${['isolation']} | ${false} + ${'linux'} | ${'7.13.0'} | ${['isolation']} | ${false} + ${'windows'} | ${'7.15.0'} | ${[]} | ${false} + ${'macos'} | ${'7.15.0'} | ${[]} | ${false} + ${'linux'} | ${'7.15.0'} | ${['isolation']} | ${true} + ${'macos'} | ${'7.15.0'} | ${['isolation']} | ${true} + ${'linux'} | ${'7.16.0'} | ${['isolation']} | ${true} `( - 'should validate that os $a and version $b supports hostIsolation($expected)', - ({ a, b, expected }) => { + 'should validate that os $a, version $b, and capabilities $c supports hostIsolation($expected)', + ({ osName, version, capabilities, expected }) => { expect( isIsolationSupported({ - osName: a, - version: b, + osName, + version, + capabilities, }) ).toEqual(expected); } diff --git a/x-pack/plugins/security_solution/common/endpoint/service/host_isolation/utils.ts b/x-pack/plugins/security_solution/common/endpoint/service/host_isolation/utils.ts index fd0180b9146e7..d012ddfda15ba 100644 --- a/x-pack/plugins/security_solution/common/endpoint/service/host_isolation/utils.ts +++ b/x-pack/plugins/security_solution/common/endpoint/service/host_isolation/utils.ts @@ -4,39 +4,68 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import semverLt from 'semver/functions/lt'; +import semverLte from 'semver/functions/lte'; +import { ImmutableArray } from '../../types'; + +const minSupportedVersion = '7.14.0'; +const minCapabilitiesVersion = '7.15.0'; +const supportedOssMap = { + macos: true, + windows: true, +}; +const isolationCapability = 'isolation'; + +function parseSemver(semver: string) { + return semver.includes('-') ? semver.substring(0, semver.indexOf('-')) : semver; +} export const isVersionSupported = ({ currentVersion, - minVersionRequired, + minVersionRequired = minSupportedVersion, }: { currentVersion: string; - minVersionRequired: string; + minVersionRequired?: string; }) => { - const parsedCurrentVersion = currentVersion.includes('-') - ? currentVersion.substring(0, currentVersion.indexOf('-')) - : currentVersion; - - return ( - parsedCurrentVersion === minVersionRequired || - semverLt(minVersionRequired, parsedCurrentVersion) - ); + const parsedCurrentVersion = parseSemver(currentVersion); + return semverLte(minVersionRequired, parsedCurrentVersion); }; export const isOsSupported = ({ currentOs, - supportedOss, + supportedOss = supportedOssMap, }: { currentOs: string; - supportedOss: string[]; -}) => { - return supportedOss.some((os) => currentOs === os); -}; + supportedOss?: { [os: string]: boolean }; +}) => !!supportedOss[currentOs]; -export const isIsolationSupported = ({ osName, version }: { osName: string; version: string }) => { +function isCapabilitiesSupported(semver: string): boolean { + const parsedVersion = parseSemver(semver); + // capabilities is only available from 7.15+ + return semverLte(minCapabilitiesVersion, parsedVersion); +} + +function isIsolationSupportedCapabilities(capabilities: ImmutableArray = []): boolean { + return capabilities.includes(isolationCapability); +} + +// capabilities isn't introduced until 7.15 so check the OS for support +function isIsolationSupportedOS(osName: string): boolean { const normalizedOs = osName.toLowerCase(); - return ( - isOsSupported({ currentOs: normalizedOs, supportedOss: ['macos', 'windows'] }) && - isVersionSupported({ currentVersion: version, minVersionRequired: '7.14.0' }) - ); + return isOsSupported({ currentOs: normalizedOs }); +} + +export const isIsolationSupported = ({ + osName, + version, + capabilities, +}: { + osName: string; + version: string; + capabilities?: ImmutableArray; +}): boolean => { + if (!version || !isVersionSupported({ currentVersion: version })) return false; + + return isCapabilitiesSupported(version) + ? isIsolationSupportedCapabilities(capabilities) + : isIsolationSupportedOS(osName); }; diff --git a/x-pack/plugins/security_solution/common/endpoint/types/index.ts b/x-pack/plugins/security_solution/common/endpoint/types/index.ts index d5a8caac1dffe..dde7f7799757c 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/index.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/index.ts @@ -301,6 +301,21 @@ export type AlertEvent = Partial<{ feature: ECSField; self_injection: ECSField; }>; + destination: Partial<{ + port: ECSField; + ip: ECSField; + }>; + source: Partial<{ + port: ECSField; + ip: ECSField; + }>; + registry: Partial<{ + path: ECSField; + value: ECSField; + data: Partial<{ + strings: ECSField; + }>; + }>; Target: Partial<{ process: Partial<{ thread: Partial<{ @@ -359,6 +374,9 @@ export type AlertEvent = Partial<{ }>; }>; }>; + rule: Partial<{ + id: ECSField; + }>; file: Partial<{ owner: ECSField; name: ECSField; @@ -507,6 +525,7 @@ export type HostMetadata = Immutable<{ */ isolation?: boolean; }; + capabilities?: string[]; }; agent: { id: string; @@ -677,6 +696,8 @@ export type SafeEndpointEvent = Partial<{ }>; }>; network: Partial<{ + transport: ECSField; + type: ECSField; direction: ECSField; forwarded_ip: ECSField; }>; diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/alerts_details.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/alerts_details.spec.ts index 10ebae84365f5..f5cbc65effd85 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/alerts_details.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/alerts_details.spec.ts @@ -54,7 +54,7 @@ describe('Alert details with unmapped fields', () => { it('Displays the unmapped field on the table', () => { const expectedUnmmappedField = { - row: 88, + row: 90, field: 'unmapped', text: 'This is the unmapped field', }; diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_rules/custom_query_rule.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_rules/custom_query_rule.spec.ts index 7d833b134ddd7..a6043123ce0a8 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_rules/custom_query_rule.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_rules/custom_query_rule.spec.ts @@ -14,11 +14,9 @@ import { getNewOverrideRule, } from '../../objects/rule'; import { - ALERT_RULE_METHOD, ALERT_RULE_NAME, ALERT_RULE_RISK_SCORE, ALERT_RULE_SEVERITY, - ALERT_RULE_VERSION, NUMBER_OF_ALERTS, } from '../../screens/alerts'; @@ -223,8 +221,6 @@ describe('Custom detection rules creation', () => { cy.get(NUMBER_OF_ALERTS).should(($count) => expect(+$count.text()).to.be.gte(1)); cy.get(ALERT_RULE_NAME).first().should('have.text', this.rule.name); - cy.get(ALERT_RULE_VERSION).first().should('have.text', '1'); - cy.get(ALERT_RULE_METHOD).first().should('have.text', 'query'); cy.get(ALERT_RULE_SEVERITY).first().should('have.text', this.rule.severity.toLowerCase()); cy.get(ALERT_RULE_RISK_SCORE).first().should('have.text', this.rule.riskScore); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_rules/event_correlation_rule.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_rules/event_correlation_rule.spec.ts index 677a9b5546494..e06026ce12c7c 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_rules/event_correlation_rule.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_rules/event_correlation_rule.spec.ts @@ -9,11 +9,9 @@ import { formatMitreAttackDescription } from '../../helpers/rules'; import { getEqlRule, getEqlSequenceRule, getIndexPatterns } from '../../objects/rule'; import { - ALERT_RULE_METHOD, ALERT_RULE_NAME, ALERT_RULE_RISK_SCORE, ALERT_RULE_SEVERITY, - ALERT_RULE_VERSION, NUMBER_OF_ALERTS, } from '../../screens/alerts'; import { @@ -169,8 +167,6 @@ describe('Detection rules, EQL', () => { cy.get(NUMBER_OF_ALERTS).should('have.text', expectedNumberOfAlerts); cy.get(ALERT_RULE_NAME).first().should('have.text', this.rule.name); - cy.get(ALERT_RULE_VERSION).first().should('have.text', '1'); - cy.get(ALERT_RULE_METHOD).first().should('have.text', 'eql'); cy.get(ALERT_RULE_SEVERITY).first().should('have.text', this.rule.severity.toLowerCase()); cy.get(ALERT_RULE_RISK_SCORE).first().should('have.text', this.rule.riskScore); }); @@ -221,8 +217,6 @@ describe('Detection rules, sequence EQL', () => { cy.get(NUMBER_OF_ALERTS).should('have.text', expectedNumberOfSequenceAlerts); cy.get(ALERT_RULE_NAME).first().should('have.text', this.rule.name); - cy.get(ALERT_RULE_VERSION).first().should('have.text', '1'); - cy.get(ALERT_RULE_METHOD).first().should('have.text', 'eql'); cy.get(ALERT_RULE_SEVERITY).first().should('have.text', this.rule.severity.toLowerCase()); cy.get(ALERT_RULE_RISK_SCORE).first().should('have.text', this.rule.riskScore); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_rules/indicator_match_rule.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_rules/indicator_match_rule.spec.ts index 07b40df53e2d5..ff000c105a1b4 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_rules/indicator_match_rule.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_rules/indicator_match_rule.spec.ts @@ -9,11 +9,9 @@ import { formatMitreAttackDescription } from '../../helpers/rules'; import { getIndexPatterns, getNewThreatIndicatorRule } from '../../objects/rule'; import { - ALERT_RULE_METHOD, ALERT_RULE_NAME, ALERT_RULE_RISK_SCORE, ALERT_RULE_SEVERITY, - ALERT_RULE_VERSION, NUMBER_OF_ALERTS, } from '../../screens/alerts'; import { @@ -482,8 +480,6 @@ describe('indicator match', () => { cy.get(NUMBER_OF_ALERTS).should('have.text', expectedNumberOfAlerts); cy.get(ALERT_RULE_NAME).first().should('have.text', getNewThreatIndicatorRule().name); - cy.get(ALERT_RULE_VERSION).first().should('have.text', '1'); - cy.get(ALERT_RULE_METHOD).first().should('have.text', 'threat_match'); cy.get(ALERT_RULE_SEVERITY) .first() .should('have.text', getNewThreatIndicatorRule().severity.toLowerCase()); diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_rules/override.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_rules/override.spec.ts index 24a56dd563e17..24c98aaee8f97 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_rules/override.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_rules/override.spec.ts @@ -16,10 +16,8 @@ import { import { NUMBER_OF_ALERTS, ALERT_RULE_NAME, - ALERT_RULE_METHOD, ALERT_RULE_RISK_SCORE, ALERT_RULE_SEVERITY, - ALERT_RULE_VERSION, } from '../../screens/alerts'; import { @@ -196,8 +194,6 @@ describe('Detection rules, override', () => { cy.get(NUMBER_OF_ALERTS).should(($count) => expect(+$count.text()).to.be.gte(1)); cy.get(ALERT_RULE_NAME).first().should('have.text', 'auditbeat'); - cy.get(ALERT_RULE_VERSION).first().should('have.text', '1'); - cy.get(ALERT_RULE_METHOD).first().should('have.text', 'query'); cy.get(ALERT_RULE_SEVERITY).first().should('have.text', 'critical'); sortRiskScore(); diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_rules/threshold_rule.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_rules/threshold_rule.spec.ts index dba12fb4ab95c..665df89435952 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_rules/threshold_rule.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_rules/threshold_rule.spec.ts @@ -14,11 +14,9 @@ import { } from '../../objects/rule'; import { - ALERT_RULE_METHOD, ALERT_RULE_NAME, ALERT_RULE_RISK_SCORE, ALERT_RULE_SEVERITY, - ALERT_RULE_VERSION, NUMBER_OF_ALERTS, } from '../../screens/alerts'; @@ -179,8 +177,6 @@ describe('Detection rules, threshold', () => { cy.get(NUMBER_OF_ALERTS).should(($count) => expect(+$count.text()).to.be.lt(100)); cy.get(ALERT_RULE_NAME).first().should('have.text', rule.name); - cy.get(ALERT_RULE_VERSION).first().should('have.text', '1'); - cy.get(ALERT_RULE_METHOD).first().should('have.text', 'threshold'); cy.get(ALERT_RULE_SEVERITY).first().should('have.text', rule.severity.toLowerCase()); cy.get(ALERT_RULE_RISK_SCORE).first().should('have.text', rule.riskScore); }); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/exceptionable_endpoint_fields.json b/x-pack/plugins/security_solution/public/common/components/exceptions/exceptionable_endpoint_fields.json index c37be60545ab2..12ee0273f078a 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/exceptionable_endpoint_fields.json +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/exceptionable_endpoint_fields.json @@ -56,6 +56,7 @@ "file.mode", "file.name", "file.owner", + "file.path", "file.pe.company", "file.pe.description", "file.pe.file_version", @@ -76,6 +77,7 @@ "host.os.platform", "host.os.version", "host.type", + "process.command_line", "process.Ext.services", "process.Ext.user", "process.Ext.code_signature", @@ -85,6 +87,7 @@ "process.hash.sha256", "process.hash.sha512", "process.name", + "process.parent.executable", "process.parent.hash.md5", "process.parent.hash.sha1", "process.parent.hash.sha256", @@ -97,11 +100,24 @@ "process.pe.product", "process.pgid", "rule.uuid", + "rule.id", + "source.ip", + "source.port", + "destination.ip", + "destination.port", + "registry.path", + "registry.value", + "registry.data.strings", "user.domain", "user.email", "user.hash", "user.id", "Ransomware.feature", "Memory_protection.feature", - "Memory_protection.self_injection" + "Memory_protection.self_injection", + "dll.path", + "dll.code_signature.subject_name", + "dll.pe.original_file_name", + "dns.question.name", + "dns.question.type" ] diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx index 83006f09a14be..9696604ddf222 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx @@ -1255,4 +1255,346 @@ describe('Exception helpers', () => { ]); }); }); + describe('behavior protection exception items', () => { + test('it should return pre-populated behavior protection items', () => { + const defaultItems = defaultEndpointExceptionItems('list_id', 'my_rule', { + _id: '123', + rule: { + id: '123', + }, + process: { + command_line: 'command_line', + executable: 'some file path', + parent: { + executable: 'parent file path', + }, + code_signature: { + subject_name: 'subject-name', + trusted: 'true', + }, + }, + event: { + code: 'behavior', + }, + file: { + path: 'fake-file-path', + name: 'fake-file-name', + }, + source: { + ip: '0.0.0.0', + }, + destination: { + ip: '0.0.0.0', + }, + registry: { + path: 'registry-path', + value: 'registry-value', + data: { + strings: 'registry-strings', + }, + }, + dll: { + path: 'dll-path', + code_signature: { + subject_name: 'dll-code-signature-subject-name', + trusted: 'false', + }, + pe: { + original_file_name: 'dll-pe-original-file-name', + }, + }, + dns: { + question: { + name: 'dns-question-name', + type: 'dns-question-type', + }, + }, + user: { + id: '0987', + }, + }); + + expect(defaultItems[0].entries).toEqual([ + { + id: '123', + field: 'rule.id', + operator: 'included' as const, + type: 'match' as const, + value: '123', + }, + { + id: '123', + field: 'process.executable.caseless', + operator: 'included' as const, + type: 'match' as const, + value: 'some file path', + }, + { + id: '123', + field: 'process.command_line', + operator: 'included' as const, + type: 'match' as const, + value: 'command_line', + }, + { + id: '123', + field: 'process.parent.executable', + operator: 'included' as const, + type: 'match' as const, + value: 'parent file path', + }, + { + id: '123', + field: 'process.code_signature.subject_name', + operator: 'included' as const, + type: 'match' as const, + value: 'subject-name', + }, + { + id: '123', + field: 'file.path', + operator: 'included' as const, + type: 'match' as const, + value: 'fake-file-path', + }, + { + id: '123', + field: 'file.name', + operator: 'included' as const, + type: 'match' as const, + value: 'fake-file-name', + }, + { + id: '123', + field: 'source.ip', + operator: 'included' as const, + type: 'match' as const, + value: '0.0.0.0', + }, + { + id: '123', + field: 'destination.ip', + operator: 'included' as const, + type: 'match' as const, + value: '0.0.0.0', + }, + { + id: '123', + field: 'registry.path', + operator: 'included' as const, + type: 'match' as const, + value: 'registry-path', + }, + { + id: '123', + field: 'registry.value', + operator: 'included' as const, + type: 'match' as const, + value: 'registry-value', + }, + { + id: '123', + field: 'registry.data.strings', + operator: 'included' as const, + type: 'match' as const, + value: 'registry-strings', + }, + { + id: '123', + field: 'dll.path', + operator: 'included' as const, + type: 'match' as const, + value: 'dll-path', + }, + { + id: '123', + field: 'dll.code_signature.subject_name', + operator: 'included' as const, + type: 'match' as const, + value: 'dll-code-signature-subject-name', + }, + { + id: '123', + field: 'dll.pe.original_file_name', + operator: 'included' as const, + type: 'match' as const, + value: 'dll-pe-original-file-name', + }, + { + id: '123', + field: 'dns.question.name', + operator: 'included' as const, + type: 'match' as const, + value: 'dns-question-name', + }, + { + id: '123', + field: 'dns.question.type', + operator: 'included' as const, + type: 'match' as const, + value: 'dns-question-type', + }, + { + id: '123', + field: 'user.id', + operator: 'included' as const, + type: 'match' as const, + value: '0987', + }, + ]); + }); + test('it should return pre-populated behavior protection fields and skip empty', () => { + const defaultItems = defaultEndpointExceptionItems('list_id', 'my_rule', { + _id: '123', + rule: { + id: '123', + }, + process: { + // command_line: 'command_line', intentionally left commented + executable: 'some file path', + parent: { + executable: 'parent file path', + }, + code_signature: { + subject_name: 'subject-name', + trusted: 'true', + }, + }, + event: { + code: 'behavior', + }, + file: { + // path: 'fake-file-path', intentionally left commented + name: 'fake-file-name', + }, + source: { + ip: '0.0.0.0', + }, + destination: { + ip: '0.0.0.0', + }, + // intentionally left commented + // registry: { + // path: 'registry-path', + // value: 'registry-value', + // data: { + // strings: 'registry-strings', + // }, + // }, + dll: { + path: 'dll-path', + code_signature: { + subject_name: 'dll-code-signature-subject-name', + trusted: 'false', + }, + pe: { + original_file_name: 'dll-pe-original-file-name', + }, + }, + dns: { + question: { + name: 'dns-question-name', + type: 'dns-question-type', + }, + }, + user: { + id: '0987', + }, + }); + + expect(defaultItems[0].entries).toEqual([ + { + id: '123', + field: 'rule.id', + operator: 'included' as const, + type: 'match' as const, + value: '123', + }, + { + id: '123', + field: 'process.executable.caseless', + operator: 'included' as const, + type: 'match' as const, + value: 'some file path', + }, + { + id: '123', + field: 'process.parent.executable', + operator: 'included' as const, + type: 'match' as const, + value: 'parent file path', + }, + { + id: '123', + field: 'process.code_signature.subject_name', + operator: 'included' as const, + type: 'match' as const, + value: 'subject-name', + }, + { + id: '123', + field: 'file.name', + operator: 'included' as const, + type: 'match' as const, + value: 'fake-file-name', + }, + { + id: '123', + field: 'source.ip', + operator: 'included' as const, + type: 'match' as const, + value: '0.0.0.0', + }, + { + id: '123', + field: 'destination.ip', + operator: 'included' as const, + type: 'match' as const, + value: '0.0.0.0', + }, + { + id: '123', + field: 'dll.path', + operator: 'included' as const, + type: 'match' as const, + value: 'dll-path', + }, + { + id: '123', + field: 'dll.code_signature.subject_name', + operator: 'included' as const, + type: 'match' as const, + value: 'dll-code-signature-subject-name', + }, + { + id: '123', + field: 'dll.pe.original_file_name', + operator: 'included' as const, + type: 'match' as const, + value: 'dll-pe-original-file-name', + }, + { + id: '123', + field: 'dns.question.name', + operator: 'included' as const, + type: 'match' as const, + value: 'dns-question-name', + }, + { + id: '123', + field: 'dns.question.type', + operator: 'included' as const, + type: 'match' as const, + value: 'dns-question-type', + }, + { + id: '123', + field: 'user.id', + operator: 'included' as const, + type: 'match' as const, + value: '0987', + }, + ]); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx index 62250a0933ffb..3d219b90a2fc8 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx @@ -655,6 +655,136 @@ export const getPrepopulatedMemoryShellcodeException = ({ }; }; +export const getPrepopulatedBehaviorException = ({ + listId, + ruleName, + eventCode, + listNamespace = 'agnostic', + alertEcsData, +}: { + listId: string; + listNamespace?: NamespaceType; + ruleName: string; + eventCode: string; + alertEcsData: Flattened; +}): ExceptionsBuilderExceptionItem => { + const { process } = alertEcsData; + const entries = filterEmptyExceptionEntries([ + { + field: 'rule.id', + operator: 'included' as const, + type: 'match' as const, + value: alertEcsData.rule?.id ?? '', + }, + { + field: 'process.executable.caseless', + operator: 'included' as const, + type: 'match' as const, + value: process?.executable ?? '', + }, + { + field: 'process.command_line', + operator: 'included' as const, + type: 'match' as const, + value: process?.command_line ?? '', + }, + { + field: 'process.parent.executable', + operator: 'included' as const, + type: 'match' as const, + value: process?.parent?.executable ?? '', + }, + { + field: 'process.code_signature.subject_name', + operator: 'included' as const, + type: 'match' as const, + value: process?.code_signature?.subject_name ?? '', + }, + { + field: 'file.path', + operator: 'included' as const, + type: 'match' as const, + value: alertEcsData.file?.path ?? '', + }, + { + field: 'file.name', + operator: 'included' as const, + type: 'match' as const, + value: alertEcsData.file?.name ?? '', + }, + { + field: 'source.ip', + operator: 'included' as const, + type: 'match' as const, + value: alertEcsData.source?.ip ?? '', + }, + { + field: 'destination.ip', + operator: 'included' as const, + type: 'match' as const, + value: alertEcsData.destination?.ip ?? '', + }, + { + field: 'registry.path', + operator: 'included' as const, + type: 'match' as const, + value: alertEcsData.registry?.path ?? '', + }, + { + field: 'registry.value', + operator: 'included' as const, + type: 'match' as const, + value: alertEcsData.registry?.value ?? '', + }, + { + field: 'registry.data.strings', + operator: 'included' as const, + type: 'match' as const, + value: alertEcsData.registry?.data?.strings ?? '', + }, + { + field: 'dll.path', + operator: 'included' as const, + type: 'match' as const, + value: alertEcsData.dll?.path ?? '', + }, + { + field: 'dll.code_signature.subject_name', + operator: 'included' as const, + type: 'match' as const, + value: alertEcsData.dll?.code_signature?.subject_name ?? '', + }, + { + field: 'dll.pe.original_file_name', + operator: 'included' as const, + type: 'match' as const, + value: alertEcsData.dll?.pe?.original_file_name ?? '', + }, + { + field: 'dns.question.name', + operator: 'included' as const, + type: 'match' as const, + value: alertEcsData.dns?.question?.name ?? '', + }, + { + field: 'dns.question.type', + operator: 'included' as const, + type: 'match' as const, + value: alertEcsData.dns?.question?.type ?? '', + }, + { + field: 'user.id', + operator: 'included' as const, + type: 'match' as const, + value: alertEcsData.user?.id ?? '', + }, + ]); + return { + ...getNewExceptionItem({ listId, namespaceType: listNamespace, ruleName }), + entries: addIdToEntries(entries), + }; +}; + /** * Determines whether or not any entries within the given exceptionItems contain values not in the specified ECS mapping */ @@ -697,6 +827,15 @@ export const defaultEndpointExceptionItems = ( const eventCode = alertEvent?.code ?? ''; switch (eventCode) { + case 'behavior': + return [ + getPrepopulatedBehaviorException({ + listId, + ruleName, + eventCode, + alertEcsData, + }), + ]; case 'memory_signature': return [ getPrepopulatedMemorySignatureException({ diff --git a/x-pack/plugins/security_solution/public/detections/components/host_isolation/helpers.ts b/x-pack/plugins/security_solution/public/detections/components/host_isolation/helpers.ts index aa08db0a23669..453cd5c7006c2 100644 --- a/x-pack/plugins/security_solution/public/detections/components/host_isolation/helpers.ts +++ b/x-pack/plugins/security_solution/public/detections/components/host_isolation/helpers.ts @@ -8,6 +8,19 @@ import { find } from 'lodash/fp'; import type { TimelineEventsDetailsItem } from '../../../../common'; +export const getFieldValues = ( + { + category, + field, + }: { + category: string; + field: string; + }, + data: TimelineEventsDetailsItem[] | null +) => { + return find({ category, field }, data)?.values; +}; + export const getFieldValue = ( { category, @@ -18,6 +31,6 @@ export const getFieldValue = ( }, data: TimelineEventsDetailsItem[] | null ) => { - const currentField = find({ category, field }, data)?.values; + const currentField = getFieldValues({ category, field }, data); return currentField && currentField.length > 0 ? currentField[0] : ''; }; diff --git a/x-pack/plugins/security_solution/public/detections/components/host_isolation/use_host_isolation_action.tsx b/x-pack/plugins/security_solution/public/detections/components/host_isolation/use_host_isolation_action.tsx index d7e54e7f9900b..c36d222d23ba1 100644 --- a/x-pack/plugins/security_solution/public/detections/components/host_isolation/use_host_isolation_action.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/host_isolation/use_host_isolation_action.tsx @@ -12,7 +12,7 @@ import { useIsolationPrivileges } from '../../../common/hooks/endpoint/use_isola import { endpointAlertCheck } from '../../../common/utils/endpoint_alert_check'; import { useHostIsolationStatus } from '../../containers/detection_engine/alerts/use_host_isolation_status'; import { ISOLATE_HOST, UNISOLATE_HOST } from './translations'; -import { getFieldValue } from './helpers'; +import { getFieldValue, getFieldValues } from './helpers'; interface UseHostIsolationActionProps { closePopover: () => void; @@ -46,9 +46,19 @@ export const useHostIsolationAction = ({ [detailsData] ); + const hostCapabilities = useMemo( + () => + getFieldValues( + { category: 'Endpoint', field: 'Endpoint.capabilities' }, + detailsData + ) as string[], + [detailsData] + ); + const isolationSupported = isIsolationSupported({ osName: hostOsFamily, version: agentVersion, + capabilities: hostCapabilities, }); const { diff --git a/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/columns.ts b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/columns.ts index d6d3d829d3be5..89de83ab6e5cf 100644 --- a/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/columns.ts +++ b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/columns.ts @@ -35,18 +35,6 @@ export const columns: Array< initialWidth: DEFAULT_COLUMN_MIN_WIDTH, linkField: 'signal.rule.id', }, - { - columnHeaderType: defaultColumnHeaderType, - displayAsText: i18n.ALERTS_HEADERS_VERSION, - id: 'signal.rule.version', - initialWidth: 95, - }, - { - columnHeaderType: defaultColumnHeaderType, - displayAsText: i18n.ALERTS_HEADERS_METHOD, - id: 'signal.rule.type', - initialWidth: 100, - }, { columnHeaderType: defaultColumnHeaderType, displayAsText: i18n.ALERTS_HEADERS_SEVERITY, @@ -57,31 +45,29 @@ export const columns: Array< columnHeaderType: defaultColumnHeaderType, displayAsText: i18n.ALERTS_HEADERS_RISK_SCORE, id: 'signal.rule.risk_score', - initialWidth: 115, + initialWidth: 100, }, { columnHeaderType: defaultColumnHeaderType, - id: 'event.module', - linkField: 'rule.reference', + displayAsText: i18n.ALERTS_HEADERS_REASON, + id: 'signal.reason', + initialWidth: 450, }, { - aggregatable: true, - category: 'event', columnHeaderType: defaultColumnHeaderType, - id: 'event.action', - type: 'string', + id: 'host.name', }, { columnHeaderType: defaultColumnHeaderType, - id: 'event.category', + id: 'user.name', }, { columnHeaderType: defaultColumnHeaderType, - id: 'host.name', + id: 'process.name', }, { columnHeaderType: defaultColumnHeaderType, - id: 'user.name', + id: 'file.name', }, { columnHeaderType: defaultColumnHeaderType, diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/use_endpoint_action_items.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/use_endpoint_action_items.tsx index 03df5d2bcbac7..34423767578d9 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/use_endpoint_action_items.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/hooks/use_endpoint_action_items.tsx @@ -41,6 +41,7 @@ export const useEndpointActionItems = ( const isolationSupported = isIsolationSupported({ osName: endpointMetadata.host.os.name, version: endpointMetadata.agent.version, + capabilities: endpointMetadata.Endpoint.capabilities, }); const { show, diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/index.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/index.tsx index b5e69f92b960d..a024012b41351 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/index.tsx @@ -19,6 +19,7 @@ import { } from '@elastic/eui'; import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import { EVENT_FILTERS_OPERATORS } from '@kbn/securitysolution-list-utils'; import { OperatingSystem } from '../../../../../../../common/endpoint/types'; import { AddExceptionComments } from '../../../../../../common/components/exceptions/add_exception_comments'; @@ -135,6 +136,7 @@ export const EventFiltersForm: React.FC = memo( idAria: 'alert-exception-builder', onChange: handleOnBuilderChange, listTypeSpecificIndexPatternFilter: filterIndexPatterns, + operatorsList: EVENT_FILTERS_OPERATORS, }), [data, handleOnBuilderChange, http, indexPatterns, exception] ); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/__snapshots__/get_signals_template.test.ts.snap b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/__snapshots__/get_signals_template.test.ts.snap index 9fd3e20f79b43..f07bed9fa556a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/__snapshots__/get_signals_template.test.ts.snap +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/__snapshots__/get_signals_template.test.ts.snap @@ -1639,6 +1639,10 @@ Object { "path": "signal.original_event.provider", "type": "alias", }, + "kibana.alert.original_event.reason": Object { + "path": "signal.original_event.reason", + "type": "alias", + }, "kibana.alert.original_event.risk_score": Object { "path": "signal.original_event.risk_score", "type": "alias", @@ -1671,6 +1675,10 @@ Object { "path": "signal.original_time", "type": "alias", }, + "kibana.alert.reason": Object { + "path": "signal.reason", + "type": "alias", + }, "kibana.alert.risk_score": Object { "path": "signal.rule.risk_score", "type": "alias", @@ -3249,6 +3257,9 @@ Object { "provider": Object { "type": "keyword", }, + "reason": Object { + "type": "keyword", + }, "risk_score": Object { "type": "float", }, @@ -3318,6 +3329,9 @@ Object { }, }, }, + "reason": Object { + "type": "keyword", + }, "rule": Object { "properties": Object { "author": Object { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/signal_aad_mapping.json b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/signal_aad_mapping.json index 066fdbc87f906..68c184b66c562 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/signal_aad_mapping.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/signal_aad_mapping.json @@ -17,6 +17,7 @@ "signal.original_event.module": "kibana.alert.original_event.module", "signal.original_event.outcome": "kibana.alert.original_event.outcome", "signal.original_event.provider": "kibana.alert.original_event.provider", + "signal.original_event.reason": "kibana.alert.original_event.reason", "signal.original_event.risk_score": "kibana.alert.original_event.risk_score", "signal.original_event.risk_score_norm": "kibana.alert.original_event.risk_score_norm", "signal.original_event.sequence": "kibana.alert.original_event.sequence", @@ -25,6 +26,7 @@ "signal.original_event.timezone": "kibana.alert.original_event.timezone", "signal.original_event.type": "kibana.alert.original_event.type", "signal.original_time": "kibana.alert.original_time", + "signal.reason": "kibana.alert.reason", "signal.rule.author": "kibana.alert.rule.author", "signal.rule.building_block_type": "kibana.alert.rule.building_block_type", "signal.rule.created_at": "kibana.alert.rule.created_at", diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/signal_extra_fields.json b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/signal_extra_fields.json index e20aa0ef16df4..7bc20fd540b9b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/signal_extra_fields.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/signal_extra_fields.json @@ -43,6 +43,9 @@ } } }, + "reason": { + "type": "keyword" + }, "rule": { "type": "object", "properties": { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/signals_mapping.json b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/signals_mapping.json index d6a06848592cc..4f754ecd2d33a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/signals_mapping.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/signals_mapping.json @@ -360,6 +360,9 @@ "provider": { "type": "keyword" }, + "reason": { + "type": "keyword" + }, "risk_score": { "type": "float" }, @@ -421,6 +424,9 @@ }, "depth": { "type": "integer" + }, + "reason": { + "type": "keyword" } } } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.test.ts index 4c59063d39e60..09f35e279a244 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.test.ts @@ -6,6 +6,7 @@ */ import { + ALERT_REASON, ALERT_RULE_CONSUMER, ALERT_RULE_NAMESPACE, ALERT_STATUS, @@ -50,8 +51,9 @@ describe('buildAlert', () => { const doc = sampleDocNoSortIdWithTimestamp('d5e8eb51-a6a0-456d-8a15-4b79bfec3d71'); delete doc._source.event; const rule = getRulesSchemaMock(); + const reason = 'alert reasonable reason'; const alert = { - ...buildAlert([doc], rule, SPACE_ID), + ...buildAlert([doc], rule, SPACE_ID, reason), ...additionalAlertFields(doc), }; const timestamp = alert['@timestamp']; @@ -68,6 +70,7 @@ describe('buildAlert', () => { }, ], [ALERT_ORIGINAL_TIME]: '2020-04-20T21:27:45.000Z', + [ALERT_REASON]: 'alert reasonable reason', [ALERT_STATUS]: 'open', [ALERT_WORKFLOW_STATUS]: 'open', ...flattenWithPrefix(ALERT_RULE_NAMESPACE, { @@ -119,8 +122,9 @@ describe('buildAlert', () => { module: 'system', }; const rule = getRulesSchemaMock(); + const reason = 'alert reasonable reason'; const alert = { - ...buildAlert([doc], rule, SPACE_ID), + ...buildAlert([doc], rule, SPACE_ID, reason), ...additionalAlertFields(doc), }; const timestamp = alert['@timestamp']; @@ -143,6 +147,7 @@ describe('buildAlert', () => { kind: 'event', module: 'system', }, + [ALERT_REASON]: 'alert reasonable reason', [ALERT_STATUS]: 'open', [ALERT_WORKFLOW_STATUS]: 'open', ...flattenWithPrefix(ALERT_RULE_NAMESPACE, { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.ts index ec667fa50934b..eea85ba26faf8 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.ts @@ -6,6 +6,7 @@ */ import { + ALERT_REASON, ALERT_RULE_CONSUMER, ALERT_RULE_NAMESPACE, ALERT_STATUS, @@ -92,7 +93,8 @@ export const removeClashes = (doc: SimpleHit) => { export const buildAlert = ( docs: SimpleHit[], rule: RulesSchema, - spaceId: string | null | undefined + spaceId: string | null | undefined, + reason: string ): RACAlert => { const removedClashes = docs.map(removeClashes); const parents = removedClashes.map(buildParent); @@ -110,6 +112,7 @@ export const buildAlert = ( [ALERT_STATUS]: 'open', [ALERT_WORKFLOW_STATUS]: 'open', [ALERT_DEPTH]: depth, + [ALERT_REASON]: reason, ...flattenWithPrefix(ALERT_RULE_NAMESPACE, rule), } as unknown) as RACAlert; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_bulk_body.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_bulk_body.ts index ca5857e0ee395..a67337d3b779d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_bulk_body.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_bulk_body.ts @@ -9,6 +9,7 @@ import { SavedObject } from 'src/core/types'; import { BaseHit } from '../../../../../../common/detection_engine/types'; import type { ConfigType } from '../../../../../config'; import { buildRuleWithOverrides, buildRuleWithoutOverrides } from '../../../signals/build_rule'; +import { BuildReasonMessage } from '../../../signals/reason_formatters'; import { getMergeStrategy } from '../../../signals/source_fields_merging/strategies'; import { AlertAttributes, SignalSource, SignalSourceHit } from '../../../signals/types'; import { RACAlert } from '../../types'; @@ -35,19 +36,23 @@ export const buildBulkBody = ( ruleSO: SavedObject, doc: SignalSourceHit, mergeStrategy: ConfigType['alertMergeStrategy'], - applyOverrides: boolean + applyOverrides: boolean, + buildReasonMessage: BuildReasonMessage ): RACAlert => { const mergedDoc = getMergeStrategy(mergeStrategy)({ doc }); const rule = applyOverrides ? buildRuleWithOverrides(ruleSO, mergedDoc._source ?? {}) : buildRuleWithoutOverrides(ruleSO); const filteredSource = filterSource(mergedDoc); + const timestamp = new Date().toISOString(); + + const reason = buildReasonMessage({ mergedDoc, rule, timestamp }); if (isSourceDoc(mergedDoc)) { return { ...filteredSource, - ...buildAlert([mergedDoc], rule, spaceId), + ...buildAlert([mergedDoc], rule, spaceId, reason), ...additionalAlertFields(mergedDoc), - '@timestamp': new Date().toISOString(), + '@timestamp': timestamp, }; } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/wrap_hits_factory.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/wrap_hits_factory.ts index 0b00b2f656379..62946c52b7f40 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/wrap_hits_factory.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/wrap_hits_factory.ts @@ -24,7 +24,7 @@ export const wrapHitsFactory = ({ ruleSO: SearchAfterAndBulkCreateParams['ruleSO']; mergeStrategy: ConfigType['alertMergeStrategy']; spaceId: string | null | undefined; -}): WrapHits => (events) => { +}): WrapHits => (events, buildReasonMessage) => { try { const wrappedDocs: WrappedRACAlert[] = events.flatMap((doc) => [ { @@ -35,7 +35,14 @@ export const wrapHitsFactory = ({ String(doc._version), ruleSO.attributes.params.ruleId ?? '' ), - _source: buildBulkBody(spaceId, ruleSO, doc as SignalSourceHit, mergeStrategy, true), + _source: buildBulkBody( + spaceId, + ruleSO, + doc as SignalSourceHit, + mergeStrategy, + true, + buildReasonMessage + ), }, ]); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/field_maps/alerts.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/field_maps/alerts.ts index 7ab998fe16074..1c4b7f03fd73f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/field_maps/alerts.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/field_maps/alerts.ts @@ -193,6 +193,11 @@ export const alertsFieldMap: FieldMap = { array: false, required: true, }, + 'kibana.alert.reason': { + type: 'keyword', + array: false, + required: false, + }, 'kibana.alert.threat': { type: 'object', array: false, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.test.ts index 117dcdf0c18da..206f3ae59d246 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.test.ts @@ -37,11 +37,13 @@ describe('buildBulkBody', () => { test('bulk body builds well-defined body', () => { const ruleSO = sampleRuleSO(getQueryRuleParams()); const doc = sampleDocNoSortId(); + const buildReasonMessage = jest.fn().mockReturnValue('reasonable reason'); delete doc._source.source; const fakeSignalSourceHit: SignalHitOptionalTimestamp = buildBulkBody( ruleSO, doc, - 'missingFields' + 'missingFields', + buildReasonMessage ); // Timestamp will potentially always be different so remove it for the test delete fakeSignalSourceHit['@timestamp']; @@ -77,6 +79,7 @@ describe('buildBulkBody', () => { }, ], original_time: '2020-04-20T21:27:45.000Z', + reason: 'reasonable reason', status: 'open', rule: expectedRule(), depth: 1, @@ -91,6 +94,7 @@ describe('buildBulkBody', () => { test('bulk body builds well-defined body with threshold results', () => { const ruleSO = sampleRuleSO(getThresholdRuleParams()); const baseDoc = sampleDocNoSortId(); + const buildReasonMessage = jest.fn().mockReturnValue('reasonable reason'); const doc: SignalSourceHit & { _source: Required['_source'] } = { ...baseDoc, _source: { @@ -109,7 +113,8 @@ describe('buildBulkBody', () => { const fakeSignalSourceHit: SignalHitOptionalTimestamp = buildBulkBody( ruleSO, doc, - 'missingFields' + 'missingFields', + buildReasonMessage ); // Timestamp will potentially always be different so remove it for the test delete fakeSignalSourceHit['@timestamp']; @@ -145,6 +150,7 @@ describe('buildBulkBody', () => { }, ], original_time: '2020-04-20T21:27:45.000Z', + reason: 'reasonable reason', status: 'open', rule: { ...expectedRule(), @@ -181,6 +187,7 @@ describe('buildBulkBody', () => { test('bulk body builds original_event if it exists on the event to begin with', () => { const ruleSO = sampleRuleSO(getQueryRuleParams()); const doc = sampleDocNoSortId(); + const buildReasonMessage = jest.fn().mockReturnValue('reasonable reason'); delete doc._source.source; doc._source.event = { action: 'socket_opened', @@ -191,7 +198,8 @@ describe('buildBulkBody', () => { const fakeSignalSourceHit: SignalHitOptionalTimestamp = buildBulkBody( ruleSO, doc, - 'missingFields' + 'missingFields', + buildReasonMessage ); // Timestamp will potentially always be different so remove it for the test delete fakeSignalSourceHit['@timestamp']; @@ -227,6 +235,7 @@ describe('buildBulkBody', () => { depth: 0, }, ], + reason: 'reasonable reason', ancestors: [ { id: sampleIdGuid, @@ -250,6 +259,7 @@ describe('buildBulkBody', () => { test('bulk body builds original_event if it exists on the event to begin with but no kind information', () => { const ruleSO = sampleRuleSO(getQueryRuleParams()); const doc = sampleDocNoSortId(); + const buildReasonMessage = jest.fn().mockReturnValue('reasonable reason'); delete doc._source.source; doc._source.event = { action: 'socket_opened', @@ -259,7 +269,8 @@ describe('buildBulkBody', () => { const fakeSignalSourceHit: SignalHitOptionalTimestamp = buildBulkBody( ruleSO, doc, - 'missingFields' + 'missingFields', + buildReasonMessage ); // Timestamp will potentially always be different so remove it for the test delete fakeSignalSourceHit['@timestamp']; @@ -303,6 +314,7 @@ describe('buildBulkBody', () => { }, ], original_time: '2020-04-20T21:27:45.000Z', + reason: 'reasonable reason', status: 'open', rule: expectedRule(), depth: 1, @@ -317,6 +329,7 @@ describe('buildBulkBody', () => { test('bulk body builds original_event if it exists on the event to begin with with only kind information', () => { const ruleSO = sampleRuleSO(getQueryRuleParams()); const doc = sampleDocNoSortId(); + const buildReasonMessage = jest.fn().mockReturnValue('reasonable reason'); delete doc._source.source; doc._source.event = { kind: 'event', @@ -324,7 +337,8 @@ describe('buildBulkBody', () => { const fakeSignalSourceHit: SignalHitOptionalTimestamp = buildBulkBody( ruleSO, doc, - 'missingFields' + 'missingFields', + buildReasonMessage ); // Timestamp will potentially always be different so remove it for the test delete fakeSignalSourceHit['@timestamp']; @@ -363,6 +377,7 @@ describe('buildBulkBody', () => { }, ], original_time: '2020-04-20T21:27:45.000Z', + reason: 'reasonable reason', status: 'open', rule: expectedRule(), depth: 1, @@ -377,6 +392,7 @@ describe('buildBulkBody', () => { test('bulk body builds "original_signal" if it exists already as a numeric', () => { const ruleSO = sampleRuleSO(getQueryRuleParams()); const sampleDoc = sampleDocNoSortId(); + const buildReasonMessage = jest.fn().mockReturnValue('reasonable reason'); delete sampleDoc._source.source; const doc = ({ ...sampleDoc, @@ -388,7 +404,8 @@ describe('buildBulkBody', () => { const { '@timestamp': timestamp, ...fakeSignalSourceHit } = buildBulkBody( ruleSO, doc, - 'missingFields' + 'missingFields', + buildReasonMessage ); const expected: Omit & { someKey: string } = { someKey: 'someValue', @@ -423,6 +440,7 @@ describe('buildBulkBody', () => { }, ], original_time: '2020-04-20T21:27:45.000Z', + reason: 'reasonable reason', status: 'open', rule: expectedRule(), depth: 1, @@ -437,6 +455,7 @@ describe('buildBulkBody', () => { test('bulk body builds "original_signal" if it exists already as an object', () => { const ruleSO = sampleRuleSO(getQueryRuleParams()); const sampleDoc = sampleDocNoSortId(); + const buildReasonMessage = jest.fn().mockReturnValue('reasonable reason'); delete sampleDoc._source.source; const doc = ({ ...sampleDoc, @@ -448,7 +467,8 @@ describe('buildBulkBody', () => { const { '@timestamp': timestamp, ...fakeSignalSourceHit } = buildBulkBody( ruleSO, doc, - 'missingFields' + 'missingFields', + buildReasonMessage ); const expected: Omit & { someKey: string } = { someKey: 'someValue', @@ -483,6 +503,7 @@ describe('buildBulkBody', () => { }, ], original_time: '2020-04-20T21:27:45.000Z', + reason: 'reasonable reason', status: 'open', rule: expectedRule(), depth: 1, @@ -504,7 +525,12 @@ describe('buildSignalFromSequence', () => { block2._source.new_key = 'new_key_value'; const blocks = [block1, block2]; const ruleSO = sampleRuleSO(getQueryRuleParams()); - const signal: SignalHitOptionalTimestamp = buildSignalFromSequence(blocks, ruleSO); + const buildReasonMessage = jest.fn().mockReturnValue('reasonable reason'); + const signal: SignalHitOptionalTimestamp = buildSignalFromSequence( + blocks, + ruleSO, + buildReasonMessage + ); // Timestamp will potentially always be different so remove it for the test delete signal['@timestamp']; const expected: Omit & { new_key: string } = { @@ -573,6 +599,7 @@ describe('buildSignalFromSequence', () => { }, ], status: 'open', + reason: 'reasonable reason', rule: expectedRule(), depth: 2, group: { @@ -589,7 +616,12 @@ describe('buildSignalFromSequence', () => { block2._source['@timestamp'] = '2021-05-20T22:28:46+0000'; block2._source.someKey = 'someOtherValue'; const ruleSO = sampleRuleSO(getQueryRuleParams()); - const signal: SignalHitOptionalTimestamp = buildSignalFromSequence([block1, block2], ruleSO); + const buildReasonMessage = jest.fn().mockReturnValue('reasonable reason'); + const signal: SignalHitOptionalTimestamp = buildSignalFromSequence( + [block1, block2], + ruleSO, + buildReasonMessage + ); // Timestamp will potentially always be different so remove it for the test delete signal['@timestamp']; const expected: Omit = { @@ -657,6 +689,7 @@ describe('buildSignalFromSequence', () => { }, ], status: 'open', + reason: 'reasonable reason', rule: expectedRule(), depth: 2, group: { @@ -673,11 +706,13 @@ describe('buildSignalFromEvent', () => { const ancestor = sampleDocWithAncestors().hits.hits[0]; delete ancestor._source.source; const ruleSO = sampleRuleSO(getQueryRuleParams()); + const buildReasonMessage = jest.fn().mockReturnValue('reasonable reason'); const signal: SignalHitOptionalTimestamp = buildSignalFromEvent( ancestor, ruleSO, true, - 'missingFields' + 'missingFields', + buildReasonMessage ); // Timestamp will potentially always be different so remove it for the test @@ -724,6 +759,7 @@ describe('buildSignalFromEvent', () => { }, ], status: 'open', + reason: 'reasonable reason', rule: expectedRule(), depth: 2, }, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.ts index 54a41be5cbade..626dcb2fe83ff 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_bulk_body.ts @@ -22,6 +22,7 @@ import { buildEventTypeSignal } from './build_event_type_signal'; import { EqlSequence } from '../../../../common/detection_engine/types'; import { generateSignalId, wrapBuildingBlocks, wrapSignal } from './utils'; import type { ConfigType } from '../../../config'; +import { BuildReasonMessage } from './reason_formatters'; /** * Formats the search_after result for insertion into the signals index. We first create a @@ -35,12 +36,15 @@ import type { ConfigType } from '../../../config'; export const buildBulkBody = ( ruleSO: SavedObject, doc: SignalSourceHit, - mergeStrategy: ConfigType['alertMergeStrategy'] + mergeStrategy: ConfigType['alertMergeStrategy'], + buildReasonMessage: BuildReasonMessage ): SignalHit => { const mergedDoc = getMergeStrategy(mergeStrategy)({ doc }); const rule = buildRuleWithOverrides(ruleSO, mergedDoc._source ?? {}); + const timestamp = new Date().toISOString(); + const reason = buildReasonMessage({ mergedDoc, rule, timestamp }); const signal: Signal = { - ...buildSignal([mergedDoc], rule), + ...buildSignal([mergedDoc], rule, reason), ...additionalSignalFields(mergedDoc), }; const event = buildEventTypeSignal(mergedDoc); @@ -52,7 +56,7 @@ export const buildBulkBody = ( }; const signalHit: SignalHit = { ...filteredSource, - '@timestamp': new Date().toISOString(), + '@timestamp': timestamp, event, signal, }; @@ -71,11 +75,12 @@ export const buildSignalGroupFromSequence = ( sequence: EqlSequence, ruleSO: SavedObject, outputIndex: string, - mergeStrategy: ConfigType['alertMergeStrategy'] + mergeStrategy: ConfigType['alertMergeStrategy'], + buildReasonMessage: BuildReasonMessage ): WrappedSignalHit[] => { const wrappedBuildingBlocks = wrapBuildingBlocks( sequence.events.map((event) => { - const signal = buildSignalFromEvent(event, ruleSO, false, mergeStrategy); + const signal = buildSignalFromEvent(event, ruleSO, false, mergeStrategy, buildReasonMessage); signal.signal.rule.building_block_type = 'default'; return signal; }), @@ -94,7 +99,7 @@ export const buildSignalGroupFromSequence = ( // we can build the signal that links the building blocks together // and also insert the group id (which is also the "shell" signal _id) in each building block const sequenceSignal = wrapSignal( - buildSignalFromSequence(wrappedBuildingBlocks, ruleSO), + buildSignalFromSequence(wrappedBuildingBlocks, ruleSO, buildReasonMessage), outputIndex ); wrappedBuildingBlocks.forEach((block, idx) => { @@ -111,14 +116,18 @@ export const buildSignalGroupFromSequence = ( export const buildSignalFromSequence = ( events: WrappedSignalHit[], - ruleSO: SavedObject + ruleSO: SavedObject, + buildReasonMessage: BuildReasonMessage ): SignalHit => { const rule = buildRuleWithoutOverrides(ruleSO); - const signal: Signal = buildSignal(events, rule); + const timestamp = new Date().toISOString(); + + const reason = buildReasonMessage({ rule, timestamp }); + const signal: Signal = buildSignal(events, rule, reason); const mergedEvents = objectArrayIntersection(events.map((event) => event._source)); return { ...mergedEvents, - '@timestamp': new Date().toISOString(), + '@timestamp': timestamp, event: { kind: 'signal', }, @@ -137,14 +146,17 @@ export const buildSignalFromEvent = ( event: BaseSignalHit, ruleSO: SavedObject, applyOverrides: boolean, - mergeStrategy: ConfigType['alertMergeStrategy'] + mergeStrategy: ConfigType['alertMergeStrategy'], + buildReasonMessage: BuildReasonMessage ): SignalHit => { const mergedEvent = getMergeStrategy(mergeStrategy)({ doc: event }); const rule = applyOverrides ? buildRuleWithOverrides(ruleSO, mergedEvent._source ?? {}) : buildRuleWithoutOverrides(ruleSO); + const timestamp = new Date().toISOString(); + const reason = buildReasonMessage({ mergedDoc: mergedEvent, rule, timestamp }); const signal: Signal = { - ...buildSignal([mergedEvent], rule), + ...buildSignal([mergedEvent], rule, reason), ...additionalSignalFields(mergedEvent), }; const eventFields = buildEventTypeSignal(mergedEvent); @@ -155,7 +167,7 @@ export const buildSignalFromEvent = ( // TODO: better naming for SignalHit - it's really a new signal to be inserted const signalHit: SignalHit = { ...filteredSource, - '@timestamp': new Date().toISOString(), + '@timestamp': timestamp, event: eventFields, signal, }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_signal.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_signal.test.ts index 8c0790761a5e0..90b9cce9e057d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_signal.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_signal.test.ts @@ -31,8 +31,10 @@ describe('buildSignal', () => { const doc = sampleDocNoSortId('d5e8eb51-a6a0-456d-8a15-4b79bfec3d71'); delete doc._source.event; const rule = getRulesSchemaMock(); + const reason = 'signal reasonable reason'; + const signal = { - ...buildSignal([doc], rule), + ...buildSignal([doc], rule, reason), ...additionalSignalFields(doc), }; const expected: Signal = { @@ -62,6 +64,7 @@ describe('buildSignal', () => { }, ], original_time: '2020-04-20T21:27:45.000Z', + reason: 'signal reasonable reason', status: 'open', rule: { author: [], @@ -112,8 +115,9 @@ describe('buildSignal', () => { module: 'system', }; const rule = getRulesSchemaMock(); + const reason = 'signal reasonable reason'; const signal = { - ...buildSignal([doc], rule), + ...buildSignal([doc], rule, reason), ...additionalSignalFields(doc), }; const expected: Signal = { @@ -143,6 +147,7 @@ describe('buildSignal', () => { }, ], original_time: '2020-04-20T21:27:45.000Z', + reason: 'signal reasonable reason', original_event: { action: 'socket_opened', dataset: 'socket', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_signal.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_signal.ts index a415c83e857c2..962869cc4d61a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_signal.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_signal.ts @@ -77,7 +77,7 @@ export const removeClashes = (doc: BaseSignalHit): BaseSignalHit => { * @param docs The parent signals/events of the new signal to be built. * @param rule The rule that is generating the new signal. */ -export const buildSignal = (docs: BaseSignalHit[], rule: RulesSchema): Signal => { +export const buildSignal = (docs: BaseSignalHit[], rule: RulesSchema, reason: string): Signal => { const _meta = { version: SIGNALS_TEMPLATE_VERSION, }; @@ -94,6 +94,7 @@ export const buildSignal = (docs: BaseSignalHit[], rule: RulesSchema): Signal => ancestors, status: 'open', rule, + reason, depth, }; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/bulk_create_ml_signals.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/bulk_create_ml_signals.ts index ebb4462817eab..be6f4cb8feae5 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/bulk_create_ml_signals.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/bulk_create_ml_signals.ts @@ -19,6 +19,7 @@ import { AnomalyResults, Anomaly } from '../../machine_learning'; import { BuildRuleMessage } from './rule_messages'; import { AlertAttributes, BulkCreate, WrapHits } from './types'; import { MachineLearningRuleParams } from '../schemas/rule_schemas'; +import { buildReasonMessageForMlAlert } from './reason_formatters'; interface BulkCreateMlSignalsParams { someResult: AnomalyResults; @@ -89,6 +90,6 @@ export const bulkCreateMlSignals = async ( const anomalyResults = params.someResult; const ecsResults = transformAnomalyResultsToEcs(anomalyResults); - const wrappedDocs = params.wrapHits(ecsResults.hits.hits); + const wrappedDocs = params.wrapHits(ecsResults.hits.hits, buildReasonMessageForMlAlert); return params.bulkCreate(wrappedDocs); }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/eql.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/eql.ts index 8d19510c63477..9a2805610ca8b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/eql.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/eql.ts @@ -35,6 +35,7 @@ import { } from '../types'; import { createSearchAfterReturnType, makeFloatString } from '../utils'; import { ExperimentalFeatures } from '../../../../../common/experimental_features'; +import { buildReasonMessageForEqlAlert } from '../reason_formatters'; export const eqlExecutor = async ({ rule, @@ -119,9 +120,9 @@ export const eqlExecutor = async ({ result.searchAfterTimes = [eqlSearchDuration]; let newSignals: SimpleHit[] | undefined; if (response.hits.sequences !== undefined) { - newSignals = wrapSequences(response.hits.sequences); + newSignals = wrapSequences(response.hits.sequences, buildReasonMessageForEqlAlert); } else if (response.hits.events !== undefined) { - newSignals = wrapHits(response.hits.events); + newSignals = wrapHits(response.hits.events, buildReasonMessageForEqlAlert); } else { throw new Error( 'eql query response should have either `sequences` or `events` but had neither' diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/query.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/query.ts index f27680315d194..f281475fe59eb 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/query.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/query.ts @@ -22,6 +22,7 @@ import { TelemetryEventsSender } from '../../../telemetry/sender'; import { BuildRuleMessage } from '../rule_messages'; import { QueryRuleParams, SavedQueryRuleParams } from '../../schemas/rule_schemas'; import { ExperimentalFeatures } from '../../../../../common/experimental_features'; +import { buildReasonMessageForQueryAlert } from '../reason_formatters'; export const queryExecutor = async ({ rule, @@ -84,6 +85,7 @@ export const queryExecutor = async ({ signalsIndex: ruleParams.outputIndex, filter: esFilter, pageSize: searchAfterSize, + buildReasonMessage: buildReasonMessageForQueryAlert, buildRuleMessage, bulkCreate, wrapHits, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/reason_formatter.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/reason_formatter.test.ts new file mode 100644 index 0000000000000..e7f4fb41c763b --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/reason_formatter.test.ts @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { buildCommonReasonMessage } from './reason_formatters'; +import { RulesSchema } from '../../../../common/detection_engine/schemas/response/rules_schema'; +import { SignalSourceHit } from './types'; + +describe('reason_formatter', () => { + let rule: RulesSchema; + let mergedDoc: SignalSourceHit; + let timestamp: string; + beforeAll(() => { + rule = { + name: 'What is in a name', + risk_score: 9000, + severity: 'medium', + } as RulesSchema; // Cast here as all fields aren't required + mergedDoc = { + _index: 'some-index', + _id: 'some-id', + fields: { + 'host.name': ['party host'], + 'user.name': ['ferris bueller'], + '@timestamp': '2021-08-11T02:28:59.101Z', + }, + }; + timestamp = '2021-08-11T02:28:59.401Z'; + }); + + describe('buildCommonReasonMessage', () => { + describe('when rule, mergedDoc, and timestamp are provided', () => { + it('should return the full reason message', () => { + expect(buildCommonReasonMessage({ rule, mergedDoc, timestamp })).toEqual( + 'Alert What is in a name created at 2021-08-11T02:28:59.401Z with a medium severity and risk score of 9000 by ferris bueller on party host.' + ); + }); + }); + describe('when rule, mergedDoc, and timestamp are provided and host.name is missing', () => { + it('should return the reason message without the host name', () => { + const updatedMergedDoc = { + ...mergedDoc, + fields: { + ...mergedDoc.fields, + 'host.name': ['-'], + }, + }; + expect(buildCommonReasonMessage({ rule, mergedDoc: updatedMergedDoc, timestamp })).toEqual( + 'Alert What is in a name created at 2021-08-11T02:28:59.401Z with a medium severity and risk score of 9000 by ferris bueller.' + ); + }); + }); + describe('when rule, mergedDoc, and timestamp are provided and user.name is missing', () => { + it('should return the reason message without the user name', () => { + const updatedMergedDoc = { + ...mergedDoc, + fields: { + ...mergedDoc.fields, + 'user.name': ['-'], + }, + }; + expect(buildCommonReasonMessage({ rule, mergedDoc: updatedMergedDoc, timestamp })).toEqual( + 'Alert What is in a name created at 2021-08-11T02:28:59.401Z with a medium severity and risk score of 9000 on party host.' + ); + }); + }); + describe('when only rule and timestamp are provided', () => { + it('should return the reason message without host name or user name', () => { + expect(buildCommonReasonMessage({ rule, timestamp })).toEqual( + 'Alert What is in a name created at 2021-08-11T02:28:59.401Z with a medium severity and risk score of 9000.' + ); + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/reason_formatters.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/reason_formatters.ts new file mode 100644 index 0000000000000..0586462a2a581 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/reason_formatters.ts @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { RulesSchema } from '../../../../common/detection_engine/schemas/response/rules_schema'; +import { SignalSourceHit } from './types'; + +export interface BuildReasonMessageArgs { + rule: RulesSchema; + mergedDoc?: SignalSourceHit; + timestamp: string; +} + +export type BuildReasonMessage = (args: BuildReasonMessageArgs) => string; + +/** + * Currently all security solution rule types share a common reason message string. This function composes that string + * In the future there may be different configurations based on the different rule types, so the plumbing has been put in place + * to more easily allow for this in the future. + * @export buildCommonReasonMessage - is only exported for testing purposes, and only used internally here. + */ +export const buildCommonReasonMessage = ({ + rule, + mergedDoc, + timestamp, +}: BuildReasonMessageArgs) => { + if (!rule) { + // This should never happen, but in case, better to not show a malformed string + return ''; + } + let hostName; + let userName; + if (mergedDoc?.fields) { + hostName = mergedDoc.fields['host.name'] != null ? mergedDoc.fields['host.name'] : hostName; + userName = mergedDoc.fields['user.name'] != null ? mergedDoc.fields['user.name'] : userName; + } + + const isFieldEmpty = (field: string | string[] | undefined | null) => + !field || !field.length || (field.length === 1 && field[0] === '-'); + + return i18n.translate('xpack.securitySolution.detectionEngine.signals.alertReasonDescription', { + defaultMessage: + 'Alert {alertName} created at {timestamp} with a {alertSeverity} severity and risk score of {alertRiskScore}{userName, select, null {} other {{whitespace}by {userName}} }{hostName, select, null {} other {{whitespace}on {hostName}} }.', + values: { + alertName: rule.name, + alertSeverity: rule.severity, + alertRiskScore: rule.risk_score, + hostName: isFieldEmpty(hostName) ? 'null' : hostName, + timestamp, + userName: isFieldEmpty(userName) ? 'null' : userName, + whitespace: ' ', // there isn't support for the unicode /u0020 for whitespace, and leading spaces are deleted, so to prevent double-whitespace explicitly passing the space in. + }, + }); +}; + +export const buildReasonMessageForEqlAlert = (args: BuildReasonMessageArgs) => + buildCommonReasonMessage({ ...args }); + +export const buildReasonMessageForMlAlert = (args: BuildReasonMessageArgs) => + buildCommonReasonMessage({ ...args }); + +export const buildReasonMessageForQueryAlert = (args: BuildReasonMessageArgs) => + buildCommonReasonMessage({ ...args }); + +export const buildReasonMessageForThreatMatchAlert = (args: BuildReasonMessageArgs) => + buildCommonReasonMessage({ ...args }); + +export const buildReasonMessageForThresholdAlert = (args: BuildReasonMessageArgs) => + buildCommonReasonMessage({ ...args }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts index 711db931e9072..8bf0c986b9c25 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts @@ -32,11 +32,13 @@ import { bulkCreateFactory } from './bulk_create_factory'; import { wrapHitsFactory } from './wrap_hits_factory'; import { mockBuildRuleMessage } from './__mocks__/build_rule_message.mock'; import { ResponseError } from '@elastic/elasticsearch/lib/errors'; +import { BuildReasonMessage } from './reason_formatters'; const buildRuleMessage = mockBuildRuleMessage; describe('searchAfterAndBulkCreate', () => { let mockService: AlertServicesMock; + let buildReasonMessage: BuildReasonMessage; let bulkCreate: BulkCreate; let wrapHits: WrapHits; let inputIndexPattern: string[] = []; @@ -48,6 +50,7 @@ describe('searchAfterAndBulkCreate', () => { let tuple: RuleRangeTuple; beforeEach(() => { jest.clearAllMocks(); + buildReasonMessage = jest.fn().mockResolvedValue('some alert reason message'); listClient = listMock.getListClient(); listClient.searchListItemByValues = jest.fn().mockResolvedValue([]); inputIndexPattern = ['auditbeat-*']; @@ -191,6 +194,7 @@ describe('searchAfterAndBulkCreate', () => { signalsIndex: DEFAULT_SIGNALS_INDEX, pageSize: 1, filter: undefined, + buildReasonMessage, buildRuleMessage, bulkCreate, wrapHits, @@ -295,6 +299,7 @@ describe('searchAfterAndBulkCreate', () => { signalsIndex: DEFAULT_SIGNALS_INDEX, pageSize: 1, filter: undefined, + buildReasonMessage, buildRuleMessage, bulkCreate, wrapHits, @@ -373,6 +378,7 @@ describe('searchAfterAndBulkCreate', () => { signalsIndex: DEFAULT_SIGNALS_INDEX, pageSize: 1, filter: undefined, + buildReasonMessage, buildRuleMessage, bulkCreate, wrapHits, @@ -432,6 +438,7 @@ describe('searchAfterAndBulkCreate', () => { signalsIndex: DEFAULT_SIGNALS_INDEX, pageSize: 1, filter: undefined, + buildReasonMessage, buildRuleMessage, bulkCreate, wrapHits, @@ -511,6 +518,7 @@ describe('searchAfterAndBulkCreate', () => { signalsIndex: DEFAULT_SIGNALS_INDEX, pageSize: 1, filter: undefined, + buildReasonMessage, buildRuleMessage, bulkCreate, wrapHits, @@ -566,6 +574,7 @@ describe('searchAfterAndBulkCreate', () => { signalsIndex: DEFAULT_SIGNALS_INDEX, pageSize: 1, filter: undefined, + buildReasonMessage, buildRuleMessage, bulkCreate, wrapHits, @@ -638,6 +647,7 @@ describe('searchAfterAndBulkCreate', () => { signalsIndex: DEFAULT_SIGNALS_INDEX, pageSize: 1, filter: undefined, + buildReasonMessage, buildRuleMessage, bulkCreate, wrapHits, @@ -712,6 +722,7 @@ describe('searchAfterAndBulkCreate', () => { signalsIndex: DEFAULT_SIGNALS_INDEX, pageSize: 1, filter: undefined, + buildReasonMessage, buildRuleMessage, bulkCreate, wrapHits, @@ -763,6 +774,7 @@ describe('searchAfterAndBulkCreate', () => { signalsIndex: DEFAULT_SIGNALS_INDEX, pageSize: 1, filter: undefined, + buildReasonMessage, buildRuleMessage, bulkCreate, wrapHits, @@ -810,6 +822,7 @@ describe('searchAfterAndBulkCreate', () => { signalsIndex: DEFAULT_SIGNALS_INDEX, pageSize: 1, filter: undefined, + buildReasonMessage, buildRuleMessage, bulkCreate, wrapHits, @@ -871,6 +884,7 @@ describe('searchAfterAndBulkCreate', () => { signalsIndex: DEFAULT_SIGNALS_INDEX, pageSize: 1, filter: undefined, + buildReasonMessage, buildRuleMessage, bulkCreate, wrapHits, @@ -997,6 +1011,7 @@ describe('searchAfterAndBulkCreate', () => { signalsIndex: DEFAULT_SIGNALS_INDEX, pageSize: 1, filter: undefined, + buildReasonMessage, buildRuleMessage, bulkCreate, wrapHits, @@ -1093,6 +1108,7 @@ describe('searchAfterAndBulkCreate', () => { signalsIndex: DEFAULT_SIGNALS_INDEX, pageSize: 1, filter: undefined, + buildReasonMessage, buildRuleMessage, bulkCreate, wrapHits, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts index 7b5b61577cf32..8037a9a201510 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts @@ -34,6 +34,7 @@ export const searchAfterAndBulkCreate = async ({ filter, pageSize, buildRuleMessage, + buildReasonMessage, enrichment = identity, bulkCreate, wrapHits, @@ -146,7 +147,7 @@ export const searchAfterAndBulkCreate = async ({ ); } const enrichedEvents = await enrichment(filteredEvents); - const wrappedDocs = wrapHits(enrichedEvents.hits.hits); + const wrappedDocs = wrapHits(enrichedEvents.hits.hits, buildReasonMessage); const { bulkCreateDuration: bulkDuration, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signal.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signal.ts index fb9881b519a16..312d75f7a10cc 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signal.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signal.ts @@ -9,6 +9,7 @@ import { buildThreatMappingFilter } from './build_threat_mapping_filter'; import { getFilter } from '../get_filter'; import { searchAfterAndBulkCreate } from '../search_after_bulk_create'; +import { buildReasonMessageForThreatMatchAlert } from '../reason_formatters'; import { CreateThreatSignalOptions } from './types'; import { SearchAfterAndBulkCreateReturnType } from '../types'; @@ -83,6 +84,7 @@ export const createThreatSignal = async ({ filter: esFilter, pageSize: searchAfterSize, buildRuleMessage, + buildReasonMessage: buildReasonMessageForThreatMatchAlert, enrichment: threatEnrichment, bulkCreate, wrapHits, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/bulk_create_threshold_signals.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/bulk_create_threshold_signals.ts index f56ed3a5e9eb4..afb0353c4ba03 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/bulk_create_threshold_signals.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/bulk_create_threshold_signals.ts @@ -24,6 +24,7 @@ import { getThresholdAggregationParts, getThresholdTermsHash, } from '../utils'; +import { buildReasonMessageForThresholdAlert } from '../reason_formatters'; import type { MultiAggBucket, SignalSource, @@ -248,5 +249,7 @@ export const bulkCreateThresholdSignals = async ( params.thresholdSignalHistory ); - return params.bulkCreate(params.wrapHits(ecsResults.hits.hits)); + return params.bulkCreate( + params.wrapHits(ecsResults.hits.hits, buildReasonMessageForThresholdAlert) + ); }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts index 4da411d0c70a1..89233cf2c8242 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts @@ -35,6 +35,7 @@ import { RuleParams } from '../schemas/rule_schemas'; import { GenericBulkCreateResponse } from './bulk_create_factory'; import { EcsFieldMap } from '../../../../../rule_registry/common/assets/field_maps/ecs_field_map'; import { TypeOfFieldMap } from '../../../../../rule_registry/common/field_map'; +import { BuildReasonMessage } from './reason_formatters'; // used for gap detection code // eslint-disable-next-line @typescript-eslint/naming-convention @@ -238,6 +239,7 @@ export interface Signal { }; original_time?: string; original_event?: SearchTypes; + reason?: string; status: Status; threshold_result?: ThresholdResult; original_signal?: SearchTypes; @@ -286,9 +288,15 @@ export type BulkCreate = (docs: Array>) => Promise; -export type WrapHits = (hits: estypes.SearchHit[]) => SimpleHit[]; +export type WrapHits = ( + hits: Array>, + buildReasonMessage: BuildReasonMessage +) => SimpleHit[]; -export type WrapSequences = (sequences: Array>) => SimpleHit[]; +export type WrapSequences = ( + sequences: Array>, + buildReasonMessage: BuildReasonMessage +) => SimpleHit[]; export interface SearchAfterAndBulkCreateParams { tuple: { @@ -308,6 +316,7 @@ export interface SearchAfterAndBulkCreateParams { pageSize: number; filter: unknown; buildRuleMessage: BuildRuleMessage; + buildReasonMessage: BuildReasonMessage; enrichment?: SignalsEnrichment; bulkCreate: BulkCreate; wrapHits: WrapHits; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/wrap_hits_factory.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/wrap_hits_factory.ts index 5cef740e17895..19bdd58140a33 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/wrap_hits_factory.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/wrap_hits_factory.ts @@ -24,7 +24,7 @@ export const wrapHitsFactory = ({ ruleSO: SearchAfterAndBulkCreateParams['ruleSO']; signalsIndex: string; mergeStrategy: ConfigType['alertMergeStrategy']; -}): WrapHits => (events) => { +}): WrapHits => (events, buildReasonMessage) => { const wrappedDocs: WrappedSignalHit[] = events.flatMap((doc) => [ { _index: signalsIndex, @@ -34,7 +34,7 @@ export const wrapHitsFactory = ({ String(doc._version), ruleSO.attributes.params.ruleId ?? '' ), - _source: buildBulkBody(ruleSO, doc as SignalSourceHit, mergeStrategy), + _source: buildBulkBody(ruleSO, doc as SignalSourceHit, mergeStrategy, buildReasonMessage), }, ]); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/wrap_sequences_factory.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/wrap_sequences_factory.ts index f0b9e64047692..0ca4b9688f971 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/wrap_sequences_factory.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/wrap_sequences_factory.ts @@ -17,11 +17,17 @@ export const wrapSequencesFactory = ({ ruleSO: SearchAfterAndBulkCreateParams['ruleSO']; signalsIndex: string; mergeStrategy: ConfigType['alertMergeStrategy']; -}): WrapSequences => (sequences) => +}): WrapSequences => (sequences, buildReasonMessage) => sequences.reduce( (acc: WrappedSignalHit[], sequence) => [ ...acc, - ...buildSignalGroupFromSequence(sequence, ruleSO, signalsIndex, mergeStrategy), + ...buildSignalGroupFromSequence( + sequence, + ruleSO, + signalsIndex, + mergeStrategy, + buildReasonMessage + ), ], [] ); diff --git a/x-pack/plugins/timelines/common/ecs/ecs_fields/index.ts b/x-pack/plugins/timelines/common/ecs/ecs_fields/index.ts index 292822019fc9c..239e295a1f8b1 100644 --- a/x-pack/plugins/timelines/common/ecs/ecs_fields/index.ts +++ b/x-pack/plugins/timelines/common/ecs/ecs_fields/index.ts @@ -292,6 +292,7 @@ export const systemFieldsMap: Readonly> = { export const signalFieldsMap: Readonly> = { 'signal.original_time': 'signal.original_time', + 'signal.reason': 'signal.reason', 'signal.rule.id': 'signal.rule.id', 'signal.rule.saved_id': 'signal.rule.saved_id', 'signal.rule.timeline_id': 'signal.rule.timeline_id', diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/helpers.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/helpers.tsx index 790414314ecdd..3dea3e71445a1 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/body/helpers.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/body/helpers.tsx @@ -147,6 +147,7 @@ export const allowSorting = ({ 'signal.parent.index', 'signal.parent.rule', 'signal.parent.type', + 'signal.reason', 'signal.rule.created_by', 'signal.rule.description', 'signal.rule.enabled', diff --git a/x-pack/plugins/timelines/server/search_strategy/timeline/factory/events/all/constants.ts b/x-pack/plugins/timelines/server/search_strategy/timeline/factory/events/all/constants.ts index aae68dbcf86d1..9b45a5bebfc21 100644 --- a/x-pack/plugins/timelines/server/search_strategy/timeline/factory/events/all/constants.ts +++ b/x-pack/plugins/timelines/server/search_strategy/timeline/factory/events/all/constants.ts @@ -45,6 +45,7 @@ export const TIMELINE_EVENTS_FIELDS = [ 'signal.status', 'signal.group.id', 'signal.original_time', + 'signal.reason', 'signal.rule.filters', 'signal.rule.from', 'signal.rule.language', diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 9fbfe10f66b20..953b38225cd05 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -13221,7 +13221,6 @@ "xpack.lens.configPanel.selectVisualization": "ビジュアライゼーションを選択してください", "xpack.lens.configure.configurePanelTitle": "{groupLabel}", "xpack.lens.configure.editConfig": "{label}構成の編集", - "xpack.lens.configure.emptyConfig": "フィールドを破棄、またはクリックして追加", "xpack.lens.configure.invalidConfigTooltip": "無効な構成です。", "xpack.lens.configure.invalidConfigTooltipClick": "詳細はクリックしてください。", "xpack.lens.customBucketContainer.dragToReorder": "ドラッグして並べ替え", @@ -13248,7 +13247,6 @@ "xpack.lens.datatypes.number": "数字", "xpack.lens.datatypes.record": "レコード", "xpack.lens.datatypes.string": "文字列", - "xpack.lens.deleteLayer": "レイヤーを削除", "xpack.lens.deleteLayerAriaLabel": "レイヤー {index} を削除", "xpack.lens.dimensionContainer.close": "閉じる", "xpack.lens.dimensionContainer.closeConfiguration": "構成を閉じる", @@ -13300,8 +13298,6 @@ "xpack.lens.dynamicColoring.customPalette.deleteButtonLabel": "削除", "xpack.lens.dynamicColoring.customPalette.sortReason": "新しい経由値{value}のため、色経由点が並べ替えられました", "xpack.lens.dynamicColoring.customPalette.stopAriaLabel": "{index}を停止", - "xpack.lens.editLayerSettings": "レイヤー設定を編集", - "xpack.lens.editLayerSettingsChartType": "レイヤー設定を編集、{chartType}", "xpack.lens.editorFrame.buildExpressionError": "グラフの準備中に予期しないエラーが発生しました", "xpack.lens.editorFrame.colorIndicatorLabel": "このディメンションの色:{hex}", "xpack.lens.editorFrame.dataFailure": "データの読み込み中にエラーが発生しました。", @@ -13620,7 +13616,6 @@ "xpack.lens.indexPattern.ranges.lessThanTooltip": "より小さい", "xpack.lens.indexPattern.records": "記録", "xpack.lens.indexPattern.referenceFunctionPlaceholder": "サブ関数", - "xpack.lens.indexPattern.removeColumnAriaLabel": "フィールドを破棄するか、またはクリックして {groupLabel} に追加します", "xpack.lens.indexPattern.removeColumnLabel": "「{groupLabel}」から構成を削除", "xpack.lens.indexPattern.removeFieldLabel": "インデックスパターンを削除", "xpack.lens.indexPattern.sortField.invalid": "無効なフィールドです。インデックスパターンを確認するか、別のフィールドを選択してください。", @@ -13721,9 +13716,7 @@ "xpack.lens.pieChart.showPercentValuesLabel": "割合を表示", "xpack.lens.pieChart.showTreemapCategoriesLabel": "ラベルを表示", "xpack.lens.pieChart.valuesLabel": "ラベル", - "xpack.lens.resetLayer": "レイヤーをリセット", "xpack.lens.resetLayerAriaLabel": "レイヤー {index} をリセット", - "xpack.lens.resetVisualization": "ビジュアライゼーションをリセット", "xpack.lens.resetVisualizationAriaLabel": "ビジュアライゼーションをリセット", "xpack.lens.searchTitle": "Lens:ビジュアライゼーションを作成", "xpack.lens.section.configPanelLabel": "構成パネル", @@ -13796,7 +13789,6 @@ "xpack.lens.visTypeAlias.type": "レンズ", "xpack.lens.visualizeGeoFieldMessage": "Lensは{fieldType}フィールドを可視化できません", "xpack.lens.xyChart.addLayer": "レイヤーを追加", - "xpack.lens.xyChart.addLayerButton": "レイヤーを追加", "xpack.lens.xyChart.axisExtent.custom": "カスタム", "xpack.lens.xyChart.axisExtent.dataBounds": "データ境界", "xpack.lens.xyChart.axisExtent.disabledDataBoundsMessage": "折れ線グラフのみをデータ境界に合わせることができます", @@ -18340,7 +18332,6 @@ "xpack.monitoring.updateLicenseButtonLabel": "ライセンスを更新", "xpack.monitoring.updateLicenseTitle": "ライセンスの更新", "xpack.monitoring.useAvailableLicenseDescription": "すでに新しいライセンスがある場合は、今すぐアップロードしてください。", - "xpack.observability.alerts.manageDetectionRulesButtonLabel": "検出ルールの管理", "xpack.observability.alerts.searchBarPlaceholder": "\"domain\": \"ecommerce\" AND (\"service.name\":\"ProductCatalogService\" …)", "xpack.observability.alerts.statusFilter.allButtonLabel": "すべて", "xpack.observability.alerts.statusFilter.closedButtonLabel": "終了", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index abedf54509baf..d02e7f025e6e9 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -13561,7 +13561,6 @@ "xpack.lens.configPanel.selectVisualization": "选择可视化", "xpack.lens.configure.configurePanelTitle": "{groupLabel}", "xpack.lens.configure.editConfig": "编辑 {label} 配置", - "xpack.lens.configure.emptyConfig": "放置字段或单击添加", "xpack.lens.configure.invalidConfigTooltip": "配置无效。", "xpack.lens.configure.invalidConfigTooltipClick": "单击了解更多详情。", "xpack.lens.customBucketContainer.dragToReorder": "拖动以重新排序", @@ -13588,7 +13587,6 @@ "xpack.lens.datatypes.number": "数字", "xpack.lens.datatypes.record": "记录", "xpack.lens.datatypes.string": "字符串", - "xpack.lens.deleteLayer": "删除图层", "xpack.lens.deleteLayerAriaLabel": "删除图层 {index}", "xpack.lens.dimensionContainer.close": "关闭", "xpack.lens.dimensionContainer.closeConfiguration": "关闭配置", @@ -13643,8 +13641,6 @@ "xpack.lens.dynamicColoring.customPalette.deleteButtonLabel": "删除", "xpack.lens.dynamicColoring.customPalette.sortReason": "由于新停止值 {value},颜色停止已排序", "xpack.lens.dynamicColoring.customPalette.stopAriaLabel": "停止 {index}", - "xpack.lens.editLayerSettings": "编辑图层设置", - "xpack.lens.editLayerSettingsChartType": "编辑图层设置 {chartType}", "xpack.lens.editorFrame.buildExpressionError": "准备图表时发生意外错误", "xpack.lens.editorFrame.colorIndicatorLabel": "此维度的颜色:{hex}", "xpack.lens.editorFrame.configurationFailureMoreErrors": " +{errors} 个{errors, plural, other {错误}}", @@ -13970,7 +13966,6 @@ "xpack.lens.indexPattern.ranges.lessThanTooltip": "小于", "xpack.lens.indexPattern.records": "记录", "xpack.lens.indexPattern.referenceFunctionPlaceholder": "子函数", - "xpack.lens.indexPattern.removeColumnAriaLabel": "丢弃字段,或单击以添加到 {groupLabel}", "xpack.lens.indexPattern.removeColumnLabel": "从“{groupLabel}”中删除配置", "xpack.lens.indexPattern.removeFieldLabel": "移除索引模式字段", "xpack.lens.indexPattern.sortField.invalid": "字段无效。检查索引模式或选取其他字段。", @@ -14072,9 +14067,7 @@ "xpack.lens.pieChart.showPercentValuesLabel": "显示百分比", "xpack.lens.pieChart.showTreemapCategoriesLabel": "显示标签", "xpack.lens.pieChart.valuesLabel": "标签", - "xpack.lens.resetLayer": "重置图层", "xpack.lens.resetLayerAriaLabel": "重置图层 {index}", - "xpack.lens.resetVisualization": "重置可视化", "xpack.lens.resetVisualizationAriaLabel": "重置可视化", "xpack.lens.searchTitle": "Lens:创建可视化", "xpack.lens.section.configPanelLabel": "配置面板", @@ -14147,7 +14140,6 @@ "xpack.lens.visTypeAlias.type": "Lens", "xpack.lens.visualizeGeoFieldMessage": "Lens 无法可视化 {fieldType} 字段", "xpack.lens.xyChart.addLayer": "添加图层", - "xpack.lens.xyChart.addLayerButton": "添加图层", "xpack.lens.xyChart.axisExtent.custom": "定制", "xpack.lens.xyChart.axisExtent.dataBounds": "数据边界", "xpack.lens.xyChart.axisExtent.disabledDataBoundsMessage": "仅折线图可适应数据边界", @@ -18756,7 +18748,6 @@ "xpack.monitoring.updateLicenseButtonLabel": "更新许可证", "xpack.monitoring.updateLicenseTitle": "更新您的许可证", "xpack.monitoring.useAvailableLicenseDescription": "如果您已经持有新的许可证,请立即上传。", - "xpack.observability.alerts.manageDetectionRulesButtonLabel": "管理检测规则", "xpack.observability.alerts.searchBarPlaceholder": "\"domain\": \"ecommerce\" AND (\"service.name\":\"ProductCatalogService\" …)", "xpack.observability.alerts.statusFilter.allButtonLabel": "全部", "xpack.observability.alerts.statusFilter.closedButtonLabel": "已关闭", diff --git a/x-pack/test/api_integration/apis/maps/migrations.js b/x-pack/test/api_integration/apis/maps/migrations.js index 88e6f0c842598..d121c79f6cfe1 100644 --- a/x-pack/test/api_integration/apis/maps/migrations.js +++ b/x-pack/test/api_integration/apis/maps/migrations.js @@ -76,7 +76,7 @@ export default function ({ getService }) { } expect(panels.length).to.be(1); expect(panels[0].type).to.be('map'); - expect(panels[0].version).to.be('7.14.0'); + expect(panels[0].version).to.be('7.15.0'); }); }); }); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_ml.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_ml.ts index a03bd07c86020..cd209da25e883 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_ml.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_ml.ts @@ -193,6 +193,7 @@ export default ({ getService }: FtrProviderContext) => { index: '.ml-anomalies-custom-linux_anomalous_network_activity_ecs', depth: 0, }, + reason: `Alert Test ML rule created at ${signal._source['@timestamp']} with a critical severity and risk score of 50 by root on mothra.`, original_time: '2020-11-16T22:58:08.000Z', }, all_field_values: [ diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_threat_matching.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_threat_matching.ts index c341761160633..399eafc475a89 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_threat_matching.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_threat_matching.ts @@ -275,6 +275,7 @@ export default ({ getService }: FtrProviderContext) => { depth: 0, }, ], + reason: `Alert Query with a rule id created at ${fullSignal['@timestamp']} with a high severity and risk score of 55 by root on zeek-sensor-amsterdam.`, rule: fullSignal.signal.rule, status: 'open', }, diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/text.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/text.ts index dbffeacb03b77..48832cef27cd9 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/text.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/text.ts @@ -33,7 +33,8 @@ export default ({ getService }: FtrProviderContext) => { const esArchiver = getService('esArchiver'); const es = getService('es'); - describe('Rule exception operators for data type text', () => { + // FLAKY: https://github.com/elastic/kibana/issues/107911 + describe.skip('Rule exception operators for data type text', () => { beforeEach(async () => { await createSignalsIndex(supertest); await createListsIndex(supertest); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/generating_signals.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/generating_signals.ts index 66c94a7317b72..1c1e2b9966b7f 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/generating_signals.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/generating_signals.ts @@ -112,7 +112,8 @@ export default ({ getService }: FtrProviderContext) => { const signalsOpen = await getSignalsByIds(supertest, [id]); const signal = signalsOpen.hits.hits[0]._source?.signal; // remove rule to cut down on touch points for test changes when the rule format changes - const signalNoRule = omit(signal, 'rule'); + // remove reason to avoid failures due to @timestamp mismatches in the reason string + const signalNoRule = omit(signal, ['rule', 'reason']); expect(signalNoRule).eql({ parents: [ @@ -165,7 +166,8 @@ export default ({ getService }: FtrProviderContext) => { const signalsOpen = await getSignalsByIds(supertest, [id]); const signal = signalsOpen.hits.hits[0]._source?.signal; // remove rule to cut down on touch points for test changes when the rule format changes - const signalNoRule = omit(signal, 'rule'); + // remove reason to avoid failures due to @timestamp mismatches in the reason string + const signalNoRule = omit(signal, ['rule', 'reason']); expect(signalNoRule).eql({ parents: [ { @@ -228,7 +230,7 @@ export default ({ getService }: FtrProviderContext) => { const signal = signalsOpen.hits.hits[0]._source?.signal; // remove rule to cut down on touch points for test changes when the rule format changes - const signalNoRule = omit(signal, 'rule'); + const signalNoRule = omit(signal, ['rule', 'reason']); expect(signalNoRule).eql({ parents: [ { @@ -360,6 +362,7 @@ export default ({ getService }: FtrProviderContext) => { }, }, signal: { + reason: `Alert Signal Testing Query created at ${fullSignal['@timestamp']} with a high severity and risk score of 1 on suricata-zeek-sensor-toronto.`, rule: fullSignal.signal.rule, original_time: fullSignal.signal.original_time, status: 'open', @@ -494,6 +497,7 @@ export default ({ getService }: FtrProviderContext) => { }, }, signal: { + reason: `Alert Signal Testing Query created at ${fullSignal['@timestamp']} with a high severity and risk score of 1 on suricata-zeek-sensor-toronto.`, rule: fullSignal.signal.rule, original_time: fullSignal.signal.original_time, status: 'open', @@ -658,6 +662,7 @@ export default ({ getService }: FtrProviderContext) => { }, }, signal: { + reason: `Alert Signal Testing Query created at ${fullSignal['@timestamp']} with a high severity and risk score of 1 by root on zeek-sensor-amsterdam.`, rule: fullSignal.signal.rule, group: fullSignal.signal.group, original_time: fullSignal.signal.original_time, @@ -748,6 +753,7 @@ export default ({ getService }: FtrProviderContext) => { status: 'open', depth: 2, group: source.signal.group, + reason: `Alert Signal Testing Query created at ${source['@timestamp']} with a high severity and risk score of 1.`, rule: source.signal.rule, ancestors: [ { @@ -866,6 +872,7 @@ export default ({ getService }: FtrProviderContext) => { }, ], status: 'open', + reason: `Alert Signal Testing Query created at ${fullSignal['@timestamp']} with a high severity and risk score of 1.`, rule: fullSignal.signal.rule, original_time: fullSignal.signal.original_time, depth: 1, @@ -1003,6 +1010,7 @@ export default ({ getService }: FtrProviderContext) => { }, ], status: 'open', + reason: `Alert Signal Testing Query created at ${fullSignal['@timestamp']} with a high severity and risk score of 1.`, rule: fullSignal.signal.rule, original_time: fullSignal.signal.original_time, depth: 1, @@ -1086,6 +1094,7 @@ export default ({ getService }: FtrProviderContext) => { }, ], status: 'open', + reason: `Alert Signal Testing Query created at ${fullSignal['@timestamp']} with a high severity and risk score of 1.`, rule: fullSignal.signal.rule, original_time: fullSignal.signal.original_time, depth: 1, @@ -1171,7 +1180,8 @@ export default ({ getService }: FtrProviderContext) => { const signalsOpen = await getSignalsByIds(supertest, [id]); const signal = signalsOpen.hits.hits[0]._source?.signal; // remove rule to cut down on touch points for test changes when the rule format changes - const signalNoRule = omit(signal, 'rule'); + // remove reason to avoid failures due to @timestamp mismatches in the reason string + const signalNoRule = omit(signal, ['rule', 'reason']); expect(signalNoRule).eql({ parents: [ { @@ -1228,7 +1238,7 @@ export default ({ getService }: FtrProviderContext) => { const signal = signalsOpen.hits.hits[0]._source?.signal; // remove rule to cut down on touch points for test changes when the rule format changes - const signalNoRule = omit(signal, 'rule'); + const signalNoRule = omit(signal, ['rule', 'reason']); expect(signalNoRule).eql({ parents: [ @@ -1325,7 +1335,8 @@ export default ({ getService }: FtrProviderContext) => { const signalsOpen = await getSignalsByIds(supertest, [id]); const signal = signalsOpen.hits.hits[0]._source?.signal; // remove rule to cut down on touch points for test changes when the rule format changes - const signalNoRule = omit(signal, 'rule'); + // remove reason to avoid failures due to @timestamp mismatches in the reason string + const signalNoRule = omit(signal, ['rule', 'reason']); expect(signalNoRule).eql({ parents: [ { @@ -1387,7 +1398,7 @@ export default ({ getService }: FtrProviderContext) => { const signalsOpen = await getSignalsByRuleIds(supertest, ['signal-on-signal']); const signal = signalsOpen.hits.hits[0]._source?.signal; // remove rule to cut down on touch points for test changes when the rule format changes - const signalNoRule = omit(signal, 'rule'); + const signalNoRule = omit(signal, ['rule', 'reason']); expect(signalNoRule).eql({ parents: [ @@ -1675,6 +1686,7 @@ export default ({ getService }: FtrProviderContext) => { }, ], status: 'open', + reason: `Alert boot created at ${fullSignal['@timestamp']} with a high severity and risk score of 1 on zeek-sensor-amsterdam.`, rule: { ...fullSignal.signal.rule, name: 'boot',