From 73e7db5ead8b1aed47de005f579cecaa4c360c0f Mon Sep 17 00:00:00 2001 From: Spencer Date: Wed, 11 Aug 2021 15:32:28 -0700 Subject: [PATCH 01/12] [scripts/type_check] don't fail if --project is a composite project (#108249) Co-authored-by: spalger --- src/dev/typescript/run_type_check_cli.ts | 31 ++++++++++++++++++------ 1 file changed, 23 insertions(+), 8 deletions(-) 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); From 33cd67a4b0566bd5838b158f548ffd246c13a5b2 Mon Sep 17 00:00:00 2001 From: Scotty Bollinger Date: Wed, 11 Aug 2021 18:07:38 -0500 Subject: [PATCH 02/12] [Workplace Search] Add custom branding controls to Settings (#108235) * Add image upload route * Add base64 converter Loosely based on similar App Search util https://github.com/elastic/kibana/blob/master/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/utils.ts I opted to use this built-in class and strip out what the server does not need. Will have to manually add the prefix back in the template the way eweb does it: https://github.com/elastic/ent-search/blob/master/app/javascript/eweb/components/shared/search_results/ServiceTypeResultIcon.jsx#L7 * Swap out flash messages for success toasts Will be doing this app-wide in a future PR to match App Search * Make propperties optional After the redesign, it was decided that both icons could be uploaded individually. * Add constants * Add BrandingSection component * Add logic for image upload and display * Add BrandingSection to settings view * Fix failing test * Fix some typos in tests --- .../applications/shared/constants/actions.ts | 5 + .../workplace_search/utils/index.ts | 1 + .../read_uploaded_file_as_base64.test.ts | 21 +++ .../utils/read_uploaded_file_as_base64.ts | 26 +++ .../components/branding_section.test.tsx | 86 ++++++++++ .../settings/components/branding_section.tsx | 152 ++++++++++++++++++ .../settings/components/customize.test.tsx | 2 + .../views/settings/components/customize.tsx | 50 +++++- .../views/settings/constants.ts | 93 +++++++++++ .../views/settings/settings_logic.test.ts | 114 ++++++++++++- .../views/settings/settings_logic.ts | 95 ++++++++++- .../routes/workplace_search/settings.test.ts | 31 ++++ .../routes/workplace_search/settings.ts | 21 +++ 13 files changed, 687 insertions(+), 10 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/read_uploaded_file_as_base64.test.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/read_uploaded_file_as_base64.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/branding_section.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/branding_section.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/constants.ts 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); }; From 31b8a8229ca8110ba8a464a714588ec4532fbaca Mon Sep 17 00:00:00 2001 From: spalger Date: Wed, 11 Aug 2021 17:37:33 -0700 Subject: [PATCH 03/12] skip flaky suite (#107911) --- .../tests/exception_operators_data_types/text.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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); From 7860c2aac3962b66730bc09a86a501e5196493b3 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Thu, 12 Aug 2021 03:09:50 +0100 Subject: [PATCH 04/12] chore(NA): moving @kbn/crypto to babel transpiler (#108189) * chore(NA): moving @kbn/crypto to babel transpiler * chore(NA): update configs --- packages/kbn-crypto/.babelrc | 3 +++ packages/kbn-crypto/BUILD.bazel | 22 +++++++++++++--------- packages/kbn-crypto/package.json | 4 ++-- packages/kbn-crypto/tsconfig.json | 3 ++- 4 files changed, 20 insertions(+), 12 deletions(-) create mode 100644 packages/kbn-crypto/.babelrc 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", From a2347b2d7794d473289c58942ca16f3e8b394666 Mon Sep 17 00:00:00 2001 From: Lukas Olson Date: Wed, 11 Aug 2021 21:45:01 -0700 Subject: [PATCH 05/12] Add scoring support to KQL (#103727) * Add ability to generate KQL filters in the "must" clause Also defaults search source to generate filters in the must clause if _score is one of the sort fields * Update docs * Review feedback * Fix tests * update tests * Fix merge error Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../src/es_query/build_es_query.ts | 9 +- .../kbn-es-query/src/es_query/from_kuery.ts | 5 +- .../src/kuery/functions/and.test.ts | 18 +++ .../kbn-es-query/src/kuery/functions/and.ts | 8 +- packages/kbn-es-query/src/kuery/index.ts | 2 +- packages/kbn-es-query/src/kuery/types.ts | 6 + .../search_source/search_source.test.ts | 103 ++++++++++++++---- .../search/search_source/search_source.ts | 10 +- .../table_header/score_sort_warning.tsx | 19 ++++ .../table_header/table_header_column.tsx | 6 + 10 files changed, 155 insertions(+), 31 deletions(-) create mode 100644 src/plugins/discover/public/application/apps/main/components/doc_table/components/table_header/score_sort_warning.tsx 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/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) From dc2a1e1ceaca46e1e23d9fbd51ccabee2976bb38 Mon Sep 17 00:00:00 2001 From: Marco Liberati Date: Thu, 12 Aug 2021 08:29:31 +0200 Subject: [PATCH 06/12] [Lens] Introduce new layer types (#107791) --- .../public/toolbar_button/toolbar_button.scss | 4 + .../embedded_lens_example/public/app.tsx | 1 + .../field_data_row/action_menu/lens_utils.ts | 5 + x-pack/plugins/lens/common/constants.ts | 3 + .../expressions/metric_chart/metric_chart.ts | 2 +- .../common/expressions/metric_chart/types.ts | 3 + .../common/expressions/pie_chart/types.ts | 3 +- .../expressions/xy_chart/layer_config.ts | 4 + x-pack/plugins/lens/common/types.ts | 2 + .../components/dimension_editor.test.tsx | 2 + .../visualization.test.tsx | 79 +++++++- .../datatable_visualization/visualization.tsx | 21 ++ .../editor_frame/config_panel/add_layer.tsx | 128 ++++++++++++ .../buttons/empty_dimension_button.tsx | 6 +- .../config_panel/config_panel.scss | 4 - .../config_panel/config_panel.tsx | 111 ++++++----- .../config_panel/layer_actions.test.ts | 2 + .../config_panel/layer_actions.ts | 5 +- .../config_panel/layer_panel.scss | 44 +++-- .../config_panel/layer_panel.test.tsx | 37 ++-- .../editor_frame/config_panel/layer_panel.tsx | 137 +++++++------ .../config_panel/layer_settings.tsx | 78 +++----- .../config_panel/remove_layer_button.tsx | 21 +- .../editor_frame/frame_layout.scss | 2 +- .../editor_frame/suggestion_helpers.ts | 101 ++++++---- .../workspace_panel/chart_switch.tsx | 23 +-- .../heatmap_visualization/suggestions.test.ts | 12 ++ .../heatmap_visualization/suggestions.ts | 2 + .../public/heatmap_visualization/types.ts | 3 +- .../visualization.test.ts | 30 +++ .../heatmap_visualization/visualization.tsx | 21 +- .../indexpattern_datasource/datapanel.scss | 11 ++ .../dimension_panel/dimension_editor.tsx | 4 +- .../dimension_panel/dimension_panel.test.tsx | 2 + .../droppable/droppable.test.ts | 1 + .../definitions/calculations/counter_rate.tsx | 20 +- .../calculations/cumulative_sum.tsx | 19 +- .../definitions/calculations/differences.tsx | 19 +- .../calculations/moving_average.tsx | 19 +- .../definitions/calculations/utils.test.ts | 11 +- .../definitions/calculations/utils.ts | 14 ++ .../operations/definitions/index.ts | 8 +- .../definitions/last_value.test.tsx | 11 +- .../operations/layer_helpers.ts | 2 + .../metric_visualization/expression.test.tsx | 3 + .../metric_suggestions.test.ts | 1 + .../metric_suggestions.ts | 4 +- .../visualization.test.ts | 27 ++- .../metric_visualization/visualization.tsx | 19 ++ x-pack/plugins/lens/public/mocks.tsx | 6 +- .../pie_visualization/suggestions.test.ts | 11 +- .../public/pie_visualization/suggestions.ts | 5 + .../pie_visualization/visualization.test.ts | 30 +++ .../pie_visualization/visualization.tsx | 17 ++ x-pack/plugins/lens/public/types.ts | 55 ++++-- .../__snapshots__/to_expression.test.ts.snap | 3 + .../axes_configuration.test.ts | 2 + .../axis_settings_popover.test.tsx | 2 + .../xy_visualization/color_assignment.test.ts | 3 + .../xy_visualization/expression.test.tsx | 14 ++ .../public/xy_visualization/expression.tsx | 30 +-- .../get_legend_action.test.tsx | 2 + .../xy_visualization/to_expression.test.ts | 11 ++ .../public/xy_visualization/to_expression.ts | 4 +- .../visual_options_popover.test.tsx | 3 + .../xy_visualization/visualization.test.ts | 108 +++++++++- .../public/xy_visualization/visualization.tsx | 84 +++++--- .../xy_visualization/xy_config_panel.test.tsx | 4 + .../xy_visualization/xy_config_panel.tsx | 91 ++++++++- .../xy_visualization/xy_suggestions.test.ts | 17 +- .../public/xy_visualization/xy_suggestions.ts | 2 + .../embeddable/lens_embeddable_factory.ts | 16 +- .../server/migrations/common_migrations.ts | 20 ++ .../saved_object_migrations.test.ts | 185 ++++++++++++++++++ .../migrations/saved_object_migrations.ts | 20 +- .../plugins/lens/server/migrations/types.ts | 40 ++++ .../configurations/lens_attributes.test.ts | 2 + .../configurations/lens_attributes.ts | 1 + .../test_data/sample_attribute.ts | 1 + .../test_data/sample_attribute_cwv.ts | 1 + .../test_data/sample_attribute_kpi.ts | 1 + .../scheduled_query_group_queries_table.tsx | 1 + .../translations/translations/ja-JP.json | 8 - .../translations/translations/zh-CN.json | 8 - .../api_integration/apis/maps/migrations.js | 2 +- 85 files changed, 1505 insertions(+), 396 deletions(-) create mode 100644 x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/add_layer.tsx 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/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/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/observability/public/components/shared/exploratory_view/configurations/lens_attributes.test.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.test.ts index ae70bbdcfa3b8..bd8e158b2d4ab 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.test.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.test.ts @@ -380,6 +380,7 @@ describe('Lens Attribute', () => { { 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/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/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 9fbfe10f66b20..79e3c27d85f78 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": "折れ線グラフのみをデータ境界に合わせることができます", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index abedf54509baf..99c45b9b2fe8d 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": "仅折线图可适应数据边界", 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'); }); }); }); From 997e9ec9b96b89900abbb684ff45bc8feea49473 Mon Sep 17 00:00:00 2001 From: Esteban Beltran Date: Thu, 12 Aug 2021 09:14:47 +0200 Subject: [PATCH 07/12] [Security Solution] User can make Exceptions for Behavior Protection alerts (#106853) --- .../security_solution/common/ecs/dll/index.ts | 15 + .../security_solution/common/ecs/index.ts | 2 + .../common/ecs/process/index.ts | 11 +- .../common/ecs/registry/index.ts | 5 + .../common/endpoint/generate_data.ts | 107 ++++++ .../common/endpoint/types/index.ts | 20 + .../exceptionable_endpoint_fields.json | 18 +- .../components/exceptions/helpers.test.tsx | 342 ++++++++++++++++++ .../common/components/exceptions/helpers.tsx | 139 +++++++ 9 files changed, 657 insertions(+), 2 deletions(-) create mode 100644 x-pack/plugins/security_solution/common/ecs/dll/index.ts 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..b6d9e5f0f3695 100644 --- a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts +++ b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts @@ -392,6 +392,7 @@ enum AlertTypes { MALWARE = 'MALWARE', MEMORY_SIGNATURE = 'MEMORY_SIGNATURE', MEMORY_SHELLCODE = 'MEMORY_SHELLCODE', + BEHAVIOR = 'BEHAVIOR', } const alertsDefaultDataStream = { @@ -778,11 +779,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/types/index.ts b/x-pack/plugins/security_solution/common/endpoint/types/index.ts index d5a8caac1dffe..5f92965c0e6ed 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; @@ -677,6 +695,8 @@ export type SafeEndpointEvent = Partial<{ }>; }>; network: Partial<{ + transport: ECSField; + type: ECSField; direction: ECSField; forwarded_ip: ECSField; }>; 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({ From e9ac0c6674d2979b48565452713bb5cf41d2dd88 Mon Sep 17 00:00:00 2001 From: Angela Chuang <6295984+angorayc@users.noreply.github.com> Date: Thu, 12 Aug 2021 10:54:17 +0100 Subject: [PATCH 08/12] [RAC] integrating rbac search strategy with alert flyout (#107748) * add alert consumers for useTimelineEventDetails * set entityType to events * rename to AlertConsumers * set entityType to alerts * send entity type to search strategy Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../cases/public/components/case_view/index.tsx | 5 ++++- .../public/components/timeline_context/index.tsx | 8 +++++++- .../public/cases/components/case_view/index.tsx | 9 ++++++++- .../common/components/events_viewer/index.tsx | 6 ++++++ .../components/side_panel/event_details/index.tsx | 9 ++++++++- .../timelines/components/side_panel/index.tsx | 9 +++++++++ .../components/timeline/eql_tab_content/index.tsx | 4 ++++ .../timeline/notes_tab_content/index.tsx | 5 +++++ .../timeline/pinned_tab_content/index.tsx | 4 ++++ .../timeline/query_tab_content/index.tsx | 4 ++++ .../public/timelines/containers/details/index.tsx | 15 ++++++++++++++- .../timelines/common/utils/field_formatters.ts | 2 +- .../timeline/factory/events/details/index.ts | 1 + 13 files changed, 75 insertions(+), 6 deletions(-) diff --git a/x-pack/plugins/cases/public/components/case_view/index.tsx b/x-pack/plugins/cases/public/components/case_view/index.tsx index a44c2cb22010e..b333d908fa77c 100644 --- a/x-pack/plugins/cases/public/components/case_view/index.tsx +++ b/x-pack/plugins/cases/public/components/case_view/index.tsx @@ -105,6 +105,7 @@ export const CaseComponent = React.memo( const [initLoadingData, setInitLoadingData] = useState(true); const init = useRef(true); const timelineUi = useTimelineContext()?.ui; + const alertConsumers = useTimelineContext()?.alertConsumers; const { caseUserActions, @@ -486,7 +487,9 @@ export const CaseComponent = React.memo( - {timelineUi?.renderTimelineDetailsPanel ? timelineUi.renderTimelineDetailsPanel() : null} + {timelineUi?.renderTimelineDetailsPanel + ? timelineUi.renderTimelineDetailsPanel({ alertConsumers }) + : null} ); } diff --git a/x-pack/plugins/cases/public/components/timeline_context/index.tsx b/x-pack/plugins/cases/public/components/timeline_context/index.tsx index 727e4b64628d1..7e8def25f1d41 100644 --- a/x-pack/plugins/cases/public/components/timeline_context/index.tsx +++ b/x-pack/plugins/cases/public/components/timeline_context/index.tsx @@ -7,6 +7,7 @@ import React, { useState } from 'react'; import { EuiMarkdownEditorUiPlugin, EuiMarkdownAstNodePosition } from '@elastic/eui'; +import type { AlertConsumers } from '@kbn/rule-data-utils/target/alerts_as_data_rbac'; import { Plugin } from 'unified'; /** * @description - manage the plugins, hooks, and ui components needed to enable timeline functionality within the cases plugin @@ -28,6 +29,7 @@ interface TimelineProcessingPluginRendererProps { } export interface CasesTimelineIntegration { + alertConsumers?: AlertConsumers[]; editor_plugins: { parsingPlugin: Plugin; processingPluginRenderer: React.FC< @@ -43,7 +45,11 @@ export interface CasesTimelineIntegration { }; ui?: { renderInvestigateInTimelineActionComponent?: (alertIds: string[]) => JSX.Element; - renderTimelineDetailsPanel?: () => JSX.Element; + renderTimelineDetailsPanel?: ({ + alertConsumers, + }: { + alertConsumers?: AlertConsumers[]; + }) => JSX.Element; }; } diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx b/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx index fdb12170309c7..5517ab97ce715 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx @@ -7,6 +7,7 @@ import React, { useCallback, useRef, useState } from 'react'; import { useDispatch } from 'react-redux'; +import { AlertConsumers } from '@kbn/rule-data-utils/target/alerts_as_data_rbac'; import { getCaseDetailsUrl, @@ -33,6 +34,7 @@ import { SpyRoute } from '../../../common/utils/route/spy_routes'; import * as timelineMarkdownPlugin from '../../../common/components/markdown_editor/plugins/timeline'; import { CaseDetailsRefreshContext } from '../../../common/components/endpoint/host_isolation/endpoint_host_isolation_cases_context'; import { getEndpointDetailsPath } from '../../../management/common/routing'; +import { EntityType } from '../../../timelines/containers/details'; interface Props { caseId: string; @@ -53,13 +55,17 @@ export interface CaseProps extends Props { updateCase: (newCase: Case) => void; } -const TimelineDetailsPanel = () => { +const ALERT_CONSUMER: AlertConsumers[] = [AlertConsumers.SIEM]; + +const TimelineDetailsPanel = ({ alertConsumers }: { alertConsumers?: AlertConsumers[] }) => { const { browserFields, docValueFields } = useSourcererScope(SourcererScopeName.detections); return ( @@ -228,6 +234,7 @@ export const CaseView = React.memo(({ caseId, subCaseId, userCanCrud }: Props) = showAlertDetails, subCaseId, timelineIntegration: { + alertConsumers: ALERT_CONSUMER, editor_plugins: { parsingPlugin: timelineMarkdownPlugin.parser, processingPluginRenderer: timelineMarkdownPlugin.renderer, diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx index fe6c7e85e175d..47fe63723faec 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx @@ -11,6 +11,7 @@ import deepEqual from 'fast-deep-equal'; import styled from 'styled-components'; import { isEmpty } from 'lodash/fp'; +import { AlertConsumers } from '@kbn/rule-data-utils/target/alerts_as_data_rbac'; import { inputsModel, inputsSelectors, State } from '../../store'; import { inputsActions } from '../../store/actions'; import { ControlColumnProps, RowRenderer, TimelineId } from '../../../../common/types/timeline'; @@ -30,6 +31,7 @@ import { useKibana } from '../../lib/kibana'; import { defaultControlColumn } from '../../../timelines/components/timeline/body/control_columns'; import { EventsViewer } from './events_viewer'; import * as i18n from './translations'; +import { EntityType } from '../../../timelines/containers/details'; const EMPTY_CONTROL_COLUMNS: ControlColumnProps[] = []; const leadingControlColumns: ControlColumnProps[] = [ @@ -67,6 +69,8 @@ export interface OwnProps { type Props = OwnProps & PropsFromRedux; +const alertConsumers: AlertConsumers[] = [AlertConsumers.SIEM]; + /** * The stateful events viewer component is the highest level component that is utilized across the security_solution pages layer where * timeline is used BESIDES the flyout. The flyout makes use of the `EventsViewer` component which is a subcomponent here @@ -205,7 +209,9 @@ const StatefulEventsViewerComponent: React.FC = ({ = ({ + alertConsumers, browserFields, docValueFields, + entityType, expandedEvent, handleOnEventClosed, isFlyoutView, @@ -74,7 +79,9 @@ const EventDetailsPanelComponent: React.FC = ({ timelineId, }) => { const [loading, detailsData] = useTimelineEventsDetails({ + alertConsumers, docValueFields, + entityType, indexName: expandedEvent.indexName ?? '', eventId: expandedEvent.eventId ?? '', skip: !expandedEvent.eventId, diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/index.tsx index 3e57ec2e039f5..28eb0fb8c9ce0 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/index.tsx @@ -8,6 +8,8 @@ import React, { useCallback, useMemo } from 'react'; import { useDispatch } from 'react-redux'; import { EuiFlyout, EuiFlyoutProps } from '@elastic/eui'; +import { AlertConsumers } from '@kbn/rule-data-utils/target/alerts_as_data_rbac'; + import { timelineActions, timelineSelectors } from '../../store/timeline'; import { timelineDefaults } from '../../store/timeline/defaults'; import { BrowserFields, DocValueFields } from '../../../common/containers/source'; @@ -16,10 +18,13 @@ import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; import { EventDetailsPanel } from './event_details'; import { HostDetailsPanel } from './host_details'; import { NetworkDetailsPanel } from './network_details'; +import { EntityType } from '../../containers/details'; interface DetailsPanelProps { + alertConsumers?: AlertConsumers[]; browserFields: BrowserFields; docValueFields: DocValueFields[]; + entityType?: EntityType; handleOnPanelClosed?: () => void; isFlyoutView?: boolean; tabType?: TimelineTabs; @@ -33,8 +38,10 @@ interface DetailsPanelProps { */ export const DetailsPanel = React.memo( ({ + alertConsumers, browserFields, docValueFields, + entityType, handleOnPanelClosed, isFlyoutView, tabType, @@ -70,8 +77,10 @@ export const DetailsPanel = React.memo( panelSize = 'm'; visiblePanel = ( = ({ activeTab, columns, @@ -346,6 +349,7 @@ export const EqlTabContentComponent: React.FC = ({ = ({ timelineId } () => expandedDetail[TimelineTabs.notes]?.panelView ? ( React.ReactNode; rowRenderers: RowRenderer[]; @@ -266,6 +269,7 @@ export const PinnedTabContentComponent: React.FC = ({ theme.eui.paddingSizes.s}; `; +const alertConsumers: AlertConsumers[] = [AlertConsumers.SIEM]; + const isTimerangeSame = (prevProps: Props, nextProps: Props) => prevProps.end === nextProps.end && prevProps.start === nextProps.start && @@ -414,6 +417,7 @@ export const QueryTabContentComponent: React.FC = ({ { const myRequest = { ...(prevRequest ?? {}), + alertConsumers, docValueFields, + entityType, indexName, eventId, factoryQueryType: TimelineEventsQueries.details, @@ -114,7 +127,7 @@ export const useTimelineEventsDetails = ({ } return prevRequest; }); - }, [docValueFields, eventId, indexName]); + }, [alertConsumers, docValueFields, entityType, eventId, indexName]); useEffect(() => { timelineDetailsSearch(timelineDetailsRequest); diff --git a/x-pack/plugins/timelines/common/utils/field_formatters.ts b/x-pack/plugins/timelines/common/utils/field_formatters.ts index b436f8e616122..a48f03b90af6b 100644 --- a/x-pack/plugins/timelines/common/utils/field_formatters.ts +++ b/x-pack/plugins/timelines/common/utils/field_formatters.ts @@ -43,7 +43,7 @@ export const getDataFromSourceHits = ( category?: string, path?: string ): TimelineEventsDetailsItem[] => - Object.keys(sources).reduce((accumulator, source) => { + Object.keys(sources ?? {}).reduce((accumulator, source) => { const item: EventSource = get(source, sources); if (Array.isArray(item) || isString(item) || isNumber(item)) { const field = path ? `${path}.${source}` : source; diff --git a/x-pack/plugins/timelines/server/search_strategy/timeline/factory/events/details/index.ts b/x-pack/plugins/timelines/server/search_strategy/timeline/factory/events/details/index.ts index c82d9af938a98..c7e973483c07b 100644 --- a/x-pack/plugins/timelines/server/search_strategy/timeline/factory/events/details/index.ts +++ b/x-pack/plugins/timelines/server/search_strategy/timeline/factory/events/details/index.ts @@ -39,6 +39,7 @@ export const timelineEventsDetails: TimelineFactory Date: Thu, 12 Aug 2021 12:47:11 +0200 Subject: [PATCH 09/12] [Observability RAC] Alerts page header updates (#108110) * [RAC] remove gear icon from manage rules button * change text * Change to empty button * remove unused translations * change href const Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../public/pages/alerts/index.tsx | 21 ++++++++++++------- .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 3 files changed, 13 insertions(+), 10 deletions(-) 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/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 79e3c27d85f78..953b38225cd05 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -18332,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 99c45b9b2fe8d..d02e7f025e6e9 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -18748,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": "已关闭", From de9d7840354a005621abf44bb6e70c92734c3d70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20S=C3=A1nchez?= Date: Thu, 12 Aug 2021 12:59:33 +0200 Subject: [PATCH 10/12] Adds new operatorsList prop in exceptions builder to allow pass a list of operators. Add this prop in event filters form (#108015) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../src/autocomplete_operators/index.ts | 7 +++++++ .../exceptions/components/builder/entry_renderer.tsx | 6 +++++- .../components/builder/exception_item_renderer.tsx | 4 ++++ .../components/builder/exception_items_renderer.tsx | 4 ++++ .../pages/event_filters/view/components/form/index.tsx | 2 ++ 5 files changed, 22 insertions(+), 1 deletion(-) 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/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/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] ); From 2ebdf3e629ad46c8e4396aab2759e21e899ba996 Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Thu, 12 Aug 2021 14:53:00 +0300 Subject: [PATCH 11/12] [TSVB] Adds a color picker in percentiles and percentiles rank aggs (#107390) * WIP - Improve the way that percentiles are rendered in TSVB * Adds color picker to percentile and percentile ranks * initialize color * Be backwards compatible * Fixes unit tests * Add a unit test for percentile rank * Fix unit tests * Address PR comments Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../vis_type_timeseries/common/constants.ts | 1 + .../common/types/panel_model.ts | 2 + .../components/aggs/histogram_support.test.js | 2 +- .../application/components/aggs/percentile.js | 8 +- .../percentile_rank/multi_value_row.test.tsx | 49 +++++++++ .../aggs/percentile_rank/multi_value_row.tsx | 23 +++- .../aggs/percentile_rank/percentile_rank.tsx | 19 +++- .../percentile_rank_values.tsx | 37 ++++--- .../components/aggs/percentile_ui.js | 32 +++++- .../components/aggs/percentile_ui.test.tsx | 100 ++++++++++++++++++ .../application/components/color_picker.tsx | 2 +- .../public/metrics_type.ts | 3 +- .../public/test_utils/index.ts | 2 +- .../response_processors/series/percentile.js | 12 ++- .../series/percentile.test.js | 6 +- .../series/percentile_rank.js | 7 +- .../series/percentile_rank.test.ts | 94 ++++++++++++++++ 17 files changed, 364 insertions(+), 35 deletions(-) create mode 100644 src/plugins/vis_type_timeseries/public/application/components/aggs/percentile_rank/multi_value_row.test.tsx create mode 100644 src/plugins/vis_type_timeseries/public/application/components/aggs/percentile_ui.test.tsx create mode 100644 src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/percentile_rank.test.ts 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], + ]); + }); +}); From 2ab5c2c40a61134d9d9e903ed46ea5a40a8b3fe8 Mon Sep 17 00:00:00 2001 From: Jonathan Budzenski Date: Thu, 12 Aug 2021 08:20:28 -0500 Subject: [PATCH 12/12] Revert "[RAC] integrating rbac search strategy with alert flyout (#107748)" This reverts commit e9ac0c6674d2979b48565452713bb5cf41d2dd88. --- .../cases/public/components/case_view/index.tsx | 5 +---- .../public/components/timeline_context/index.tsx | 8 +------- .../public/cases/components/case_view/index.tsx | 9 +-------- .../common/components/events_viewer/index.tsx | 6 ------ .../components/side_panel/event_details/index.tsx | 9 +-------- .../timelines/components/side_panel/index.tsx | 9 --------- .../components/timeline/eql_tab_content/index.tsx | 4 ---- .../timeline/notes_tab_content/index.tsx | 5 ----- .../timeline/pinned_tab_content/index.tsx | 4 ---- .../timeline/query_tab_content/index.tsx | 4 ---- .../public/timelines/containers/details/index.tsx | 15 +-------------- .../timelines/common/utils/field_formatters.ts | 2 +- .../timeline/factory/events/details/index.ts | 1 - 13 files changed, 6 insertions(+), 75 deletions(-) diff --git a/x-pack/plugins/cases/public/components/case_view/index.tsx b/x-pack/plugins/cases/public/components/case_view/index.tsx index b333d908fa77c..a44c2cb22010e 100644 --- a/x-pack/plugins/cases/public/components/case_view/index.tsx +++ b/x-pack/plugins/cases/public/components/case_view/index.tsx @@ -105,7 +105,6 @@ export const CaseComponent = React.memo( const [initLoadingData, setInitLoadingData] = useState(true); const init = useRef(true); const timelineUi = useTimelineContext()?.ui; - const alertConsumers = useTimelineContext()?.alertConsumers; const { caseUserActions, @@ -487,9 +486,7 @@ export const CaseComponent = React.memo( - {timelineUi?.renderTimelineDetailsPanel - ? timelineUi.renderTimelineDetailsPanel({ alertConsumers }) - : null} + {timelineUi?.renderTimelineDetailsPanel ? timelineUi.renderTimelineDetailsPanel() : null} ); } diff --git a/x-pack/plugins/cases/public/components/timeline_context/index.tsx b/x-pack/plugins/cases/public/components/timeline_context/index.tsx index 7e8def25f1d41..727e4b64628d1 100644 --- a/x-pack/plugins/cases/public/components/timeline_context/index.tsx +++ b/x-pack/plugins/cases/public/components/timeline_context/index.tsx @@ -7,7 +7,6 @@ import React, { useState } from 'react'; import { EuiMarkdownEditorUiPlugin, EuiMarkdownAstNodePosition } from '@elastic/eui'; -import type { AlertConsumers } from '@kbn/rule-data-utils/target/alerts_as_data_rbac'; import { Plugin } from 'unified'; /** * @description - manage the plugins, hooks, and ui components needed to enable timeline functionality within the cases plugin @@ -29,7 +28,6 @@ interface TimelineProcessingPluginRendererProps { } export interface CasesTimelineIntegration { - alertConsumers?: AlertConsumers[]; editor_plugins: { parsingPlugin: Plugin; processingPluginRenderer: React.FC< @@ -45,11 +43,7 @@ export interface CasesTimelineIntegration { }; ui?: { renderInvestigateInTimelineActionComponent?: (alertIds: string[]) => JSX.Element; - renderTimelineDetailsPanel?: ({ - alertConsumers, - }: { - alertConsumers?: AlertConsumers[]; - }) => JSX.Element; + renderTimelineDetailsPanel?: () => JSX.Element; }; } diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx b/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx index 5517ab97ce715..fdb12170309c7 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx @@ -7,7 +7,6 @@ import React, { useCallback, useRef, useState } from 'react'; import { useDispatch } from 'react-redux'; -import { AlertConsumers } from '@kbn/rule-data-utils/target/alerts_as_data_rbac'; import { getCaseDetailsUrl, @@ -34,7 +33,6 @@ import { SpyRoute } from '../../../common/utils/route/spy_routes'; import * as timelineMarkdownPlugin from '../../../common/components/markdown_editor/plugins/timeline'; import { CaseDetailsRefreshContext } from '../../../common/components/endpoint/host_isolation/endpoint_host_isolation_cases_context'; import { getEndpointDetailsPath } from '../../../management/common/routing'; -import { EntityType } from '../../../timelines/containers/details'; interface Props { caseId: string; @@ -55,17 +53,13 @@ export interface CaseProps extends Props { updateCase: (newCase: Case) => void; } -const ALERT_CONSUMER: AlertConsumers[] = [AlertConsumers.SIEM]; - -const TimelineDetailsPanel = ({ alertConsumers }: { alertConsumers?: AlertConsumers[] }) => { +const TimelineDetailsPanel = () => { const { browserFields, docValueFields } = useSourcererScope(SourcererScopeName.detections); return ( @@ -234,7 +228,6 @@ export const CaseView = React.memo(({ caseId, subCaseId, userCanCrud }: Props) = showAlertDetails, subCaseId, timelineIntegration: { - alertConsumers: ALERT_CONSUMER, editor_plugins: { parsingPlugin: timelineMarkdownPlugin.parser, processingPluginRenderer: timelineMarkdownPlugin.renderer, diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx index 47fe63723faec..fe6c7e85e175d 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx @@ -11,7 +11,6 @@ import deepEqual from 'fast-deep-equal'; import styled from 'styled-components'; import { isEmpty } from 'lodash/fp'; -import { AlertConsumers } from '@kbn/rule-data-utils/target/alerts_as_data_rbac'; import { inputsModel, inputsSelectors, State } from '../../store'; import { inputsActions } from '../../store/actions'; import { ControlColumnProps, RowRenderer, TimelineId } from '../../../../common/types/timeline'; @@ -31,7 +30,6 @@ import { useKibana } from '../../lib/kibana'; import { defaultControlColumn } from '../../../timelines/components/timeline/body/control_columns'; import { EventsViewer } from './events_viewer'; import * as i18n from './translations'; -import { EntityType } from '../../../timelines/containers/details'; const EMPTY_CONTROL_COLUMNS: ControlColumnProps[] = []; const leadingControlColumns: ControlColumnProps[] = [ @@ -69,8 +67,6 @@ export interface OwnProps { type Props = OwnProps & PropsFromRedux; -const alertConsumers: AlertConsumers[] = [AlertConsumers.SIEM]; - /** * The stateful events viewer component is the highest level component that is utilized across the security_solution pages layer where * timeline is used BESIDES the flyout. The flyout makes use of the `EventsViewer` component which is a subcomponent here @@ -209,9 +205,7 @@ const StatefulEventsViewerComponent: React.FC = ({ = ({ - alertConsumers, browserFields, docValueFields, - entityType, expandedEvent, handleOnEventClosed, isFlyoutView, @@ -79,9 +74,7 @@ const EventDetailsPanelComponent: React.FC = ({ timelineId, }) => { const [loading, detailsData] = useTimelineEventsDetails({ - alertConsumers, docValueFields, - entityType, indexName: expandedEvent.indexName ?? '', eventId: expandedEvent.eventId ?? '', skip: !expandedEvent.eventId, diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/index.tsx index 28eb0fb8c9ce0..3e57ec2e039f5 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/index.tsx @@ -8,8 +8,6 @@ import React, { useCallback, useMemo } from 'react'; import { useDispatch } from 'react-redux'; import { EuiFlyout, EuiFlyoutProps } from '@elastic/eui'; -import { AlertConsumers } from '@kbn/rule-data-utils/target/alerts_as_data_rbac'; - import { timelineActions, timelineSelectors } from '../../store/timeline'; import { timelineDefaults } from '../../store/timeline/defaults'; import { BrowserFields, DocValueFields } from '../../../common/containers/source'; @@ -18,13 +16,10 @@ import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; import { EventDetailsPanel } from './event_details'; import { HostDetailsPanel } from './host_details'; import { NetworkDetailsPanel } from './network_details'; -import { EntityType } from '../../containers/details'; interface DetailsPanelProps { - alertConsumers?: AlertConsumers[]; browserFields: BrowserFields; docValueFields: DocValueFields[]; - entityType?: EntityType; handleOnPanelClosed?: () => void; isFlyoutView?: boolean; tabType?: TimelineTabs; @@ -38,10 +33,8 @@ interface DetailsPanelProps { */ export const DetailsPanel = React.memo( ({ - alertConsumers, browserFields, docValueFields, - entityType, handleOnPanelClosed, isFlyoutView, tabType, @@ -77,10 +70,8 @@ export const DetailsPanel = React.memo( panelSize = 'm'; visiblePanel = ( = ({ activeTab, columns, @@ -349,7 +346,6 @@ export const EqlTabContentComponent: React.FC = ({ = ({ timelineId } () => expandedDetail[TimelineTabs.notes]?.panelView ? ( React.ReactNode; rowRenderers: RowRenderer[]; @@ -269,7 +266,6 @@ export const PinnedTabContentComponent: React.FC = ({ theme.eui.paddingSizes.s}; `; -const alertConsumers: AlertConsumers[] = [AlertConsumers.SIEM]; - const isTimerangeSame = (prevProps: Props, nextProps: Props) => prevProps.end === nextProps.end && prevProps.start === nextProps.start && @@ -417,7 +414,6 @@ export const QueryTabContentComponent: React.FC = ({ { const myRequest = { ...(prevRequest ?? {}), - alertConsumers, docValueFields, - entityType, indexName, eventId, factoryQueryType: TimelineEventsQueries.details, @@ -127,7 +114,7 @@ export const useTimelineEventsDetails = ({ } return prevRequest; }); - }, [alertConsumers, docValueFields, entityType, eventId, indexName]); + }, [docValueFields, eventId, indexName]); useEffect(() => { timelineDetailsSearch(timelineDetailsRequest); diff --git a/x-pack/plugins/timelines/common/utils/field_formatters.ts b/x-pack/plugins/timelines/common/utils/field_formatters.ts index a48f03b90af6b..b436f8e616122 100644 --- a/x-pack/plugins/timelines/common/utils/field_formatters.ts +++ b/x-pack/plugins/timelines/common/utils/field_formatters.ts @@ -43,7 +43,7 @@ export const getDataFromSourceHits = ( category?: string, path?: string ): TimelineEventsDetailsItem[] => - Object.keys(sources ?? {}).reduce((accumulator, source) => { + Object.keys(sources).reduce((accumulator, source) => { const item: EventSource = get(source, sources); if (Array.isArray(item) || isString(item) || isNumber(item)) { const field = path ? `${path}.${source}` : source; diff --git a/x-pack/plugins/timelines/server/search_strategy/timeline/factory/events/details/index.ts b/x-pack/plugins/timelines/server/search_strategy/timeline/factory/events/details/index.ts index c7e973483c07b..c82d9af938a98 100644 --- a/x-pack/plugins/timelines/server/search_strategy/timeline/factory/events/details/index.ts +++ b/x-pack/plugins/timelines/server/search_strategy/timeline/factory/events/details/index.ts @@ -39,7 +39,6 @@ export const timelineEventsDetails: TimelineFactory