From b55dae32f67a5cd2fb106c57a9712f443e7492f2 Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Thu, 23 Nov 2023 11:26:40 +0100 Subject: [PATCH] [ES|QL] Edits query in the dashboard (#169911) ## Summary Part of https://github.com/elastic/kibana/issues/165928 Closes https://github.com/elastic/kibana/issues/144498 Allows the user to edit the ES|QL query from the dashboard. Also allows the user to select one of the suggestions. image image ### Testing Navigate to Discover ES|QL mode and save a Lens chart to a dashboard. Click the edit Visualization. ### Important notes - We can very easily enable suggestions for the dataview panels but I am going to do it on a follow up PR to keep this PR clean - Creation is going to be added on a follow up PR - Warnings are not rendered in the editor because I am using the limit 0 for performance reasons. We need to find another way to depict them via the embeddable or store. It will be on a follow up PR. - Errors are being displayed though. The user is not allowed to apply the changes when an error occurs. - Creating ES|QL charts from dashboard will happen to a follow up PR ### Running queries which don't return numeric fields In these cases (i.e. `from logstash-* | keep clientip` we are returning a table. I had to change the datatable logic for text based datasource to not depend to isBucketed flag. This is something we had foreseen from the [beginning of text based languages](https://github.com/elastic/kibana/issues/144498) image ### Running queries which return a lot of fields For queries with many fields Lens is going to suggest a huge table trying to add the fields to the different dimensions. This is not something we want: - not performant - user possibly will start removing fields from the dimensions - this table is unreadable For this reason we decided to select the first 5 fields and then the user can easily adjust the dimensions with the fields they want. image ### Checklist - [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [ ] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [ ] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) - [ ] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [ ] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../src/editor_footer.tsx | 114 ++++- .../src/resizable_button.tsx | 4 +- .../src/text_based_languages_editor.styles.ts | 33 +- .../src/text_based_languages_editor.test.tsx | 28 +- .../src/text_based_languages_editor.tsx | 57 ++- .../unified_histogram/public/chart/chart.tsx | 5 +- .../public/chart/chart_config_panel.tsx | 1 + .../layout/hooks/use_lens_suggestions.test.ts | 7 + .../layout/hooks/use_lens_suggestions.ts | 11 +- .../public/layout/layout.tsx | 30 +- .../expressions/datatable/datatable_column.ts | 1 + x-pack/plugins/lens/kibana.jsonc | 1 + .../shared/edit_on_the_fly/flyout_wrapper.tsx | 157 ++++++ .../get_edit_lens_configuration.test.tsx | 4 +- .../get_edit_lens_configuration.tsx | 44 +- .../shared/edit_on_the_fly/helpers.test.ts | 127 +++++ .../shared/edit_on_the_fly/helpers.ts | 148 ++++++ .../layer_configuration_section.tsx | 76 +++ .../lens_configuration_flyout.test.tsx | 59 ++- .../lens_configuration_flyout.tsx | 476 +++++++++--------- .../shared/edit_on_the_fly/types.ts | 85 ++++ .../text_based/dnd/get_drop_props.test.tsx | 18 + .../text_based/dnd/get_drop_props.tsx | 15 +- .../datasources/text_based/dnd/mocks.tsx | 31 ++ .../text_based/layerpanel.test.tsx | 86 ---- .../datasources/text_based/layerpanel.tsx | 41 -- .../text_based/text_based_languages.test.ts | 204 +++++++- .../text_based/text_based_languages.tsx | 69 ++- .../public/datasources/text_based/types.ts | 1 + .../datasources/text_based/utils.test.ts | 108 ++++ .../public/datasources/text_based/utils.ts | 24 + .../config_panel/config_panel.tsx | 2 +- .../editor_frame/config_panel/layer_panel.tsx | 11 +- .../editor_frame/config_panel/types.ts | 2 +- .../editor_frame/suggestion_helpers.ts | 8 +- .../editor_frame/suggestion_panel.scss | 13 +- .../editor_frame/suggestion_panel.tsx | 171 +++++-- .../workspace_panel_wrapper.scss | 7 + .../workspace_panel_wrapper.tsx | 10 +- .../lens/public/embeddable/embeddable.tsx | 20 +- .../lens/public/mocks/dataview_mock.ts | 56 +++ x-pack/plugins/lens/public/mocks/index.ts | 2 + .../lens/public/mocks/suggestions_mock.ts | 291 +++++++++++ .../open_lens_config/helpers.scss | 1 + .../open_lens_config/helpers.ts | 1 + x-pack/plugins/lens/public/types.ts | 6 + .../datatable/visualization.test.tsx | 70 ++- .../datatable/visualization.tsx | 79 ++- .../heatmap/suggestions.test.ts | 64 +++ .../visualizations/heatmap/suggestions.ts | 4 +- .../legacy_metric/metric_suggestions.test.ts | 24 + .../legacy_metric/metric_suggestions.ts | 6 + x-pack/plugins/lens/tsconfig.json | 6 +- .../translations/translations/fr-FR.json | 1 - .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - .../apps/discover/visualize_field.ts | 49 ++ 57 files changed, 2403 insertions(+), 568 deletions(-) create mode 100644 x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/flyout_wrapper.tsx create mode 100644 x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/helpers.test.ts create mode 100644 x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/helpers.ts create mode 100644 x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/layer_configuration_section.tsx create mode 100644 x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/types.ts delete mode 100644 x-pack/plugins/lens/public/datasources/text_based/layerpanel.test.tsx delete mode 100644 x-pack/plugins/lens/public/datasources/text_based/layerpanel.tsx create mode 100644 x-pack/plugins/lens/public/mocks/dataview_mock.ts create mode 100644 x-pack/plugins/lens/public/mocks/suggestions_mock.ts diff --git a/packages/kbn-text-based-editor/src/editor_footer.tsx b/packages/kbn-text-based-editor/src/editor_footer.tsx index 5070e2d5789e7..d31f731821bb3 100644 --- a/packages/kbn-text-based-editor/src/editor_footer.tsx +++ b/packages/kbn-text-based-editor/src/editor_footer.tsx @@ -19,6 +19,8 @@ import { EuiPopoverTitle, EuiDescriptionList, EuiDescriptionListDescription, + EuiButton, + useEuiTheme, } from '@elastic/eui'; import { Interpolation, Theme, css } from '@emotion/react'; import { css as classNameCss } from '@emotion/css'; @@ -60,12 +62,14 @@ export function ErrorsWarningsPopover({ type, setIsPopoverOpen, onErrorClick, + isSpaceReduced, }: { isPopoverOpen: boolean; items: MonacoError[]; type: 'error' | 'warning'; setIsPopoverOpen: (flag: boolean) => void; onErrorClick: (error: MonacoError) => void; + isSpaceReduced?: boolean; }) { const strings = getConstsByType(type, items.length); return ( @@ -90,7 +94,7 @@ export function ErrorsWarningsPopover({ setIsPopoverOpen(!isPopoverOpen); }} > -

{strings.message}

+

{isSpaceReduced ? items.length : strings.message}

} ownFocus={false} @@ -151,8 +155,11 @@ interface EditorFooterProps { warning?: MonacoError[]; detectTimestamp: boolean; onErrorClick: (error: MonacoError) => void; - refreshErrors: () => void; + runQuery: () => void; hideRunQueryText?: boolean; + disableSubmitAction?: boolean; + editorIsInline?: boolean; + isSpaceReduced?: boolean; } export const EditorFooter = memo(function EditorFooter({ @@ -162,10 +169,15 @@ export const EditorFooter = memo(function EditorFooter({ warning, detectTimestamp, onErrorClick, - refreshErrors, + runQuery, hideRunQueryText, + disableSubmitAction, + editorIsInline, + isSpaceReduced, }: EditorFooterProps) { + const { euiTheme } = useEuiTheme(); const [isPopoverOpen, setIsPopoverOpen] = useState(false); + return ( - {errors && errors.length > 0 && ( - - )} - {warning && warning.length > 0 && ( - - )}

@@ -206,23 +200,22 @@ export const EditorFooter = memo(function EditorFooter({ - - -

- {detectTimestamp + {isSpaceReduced + ? '@timestamp' + : detectTimestamp ? i18n.translate( 'textBasedEditor.query.textBasedLanguagesEditor.timestampDetected', { - defaultMessage: '@timestamp detected', + defaultMessage: '@timestamp found', } ) : i18n.translate( 'textBasedEditor.query.textBasedLanguagesEditor.timestampNotDetected', { - defaultMessage: '@timestamp not detected', + defaultMessage: '@timestamp not found', } )}

@@ -230,6 +223,26 @@ export const EditorFooter = memo(function EditorFooter({
+ {errors && errors.length > 0 && ( + + )} + {warning && warning.length > 0 && ( + + )}
{!hideRunQueryText && ( @@ -255,6 +268,53 @@ export const EditorFooter = memo(function EditorFooter({ )} + {Boolean(editorIsInline) && ( + + + + + {isSpaceReduced + ? i18n.translate('textBasedEditor.query.textBasedLanguagesEditor.run', { + defaultMessage: 'Run', + }) + : i18n.translate('textBasedEditor.query.textBasedLanguagesEditor.runQuery', { + defaultMessage: 'Run query', + })} + + + + {COMMAND_KEY}⏎ + + + + + + )} ); }); diff --git a/packages/kbn-text-based-editor/src/resizable_button.tsx b/packages/kbn-text-based-editor/src/resizable_button.tsx index fb4ee944bc2f5..5a52d67780ca6 100644 --- a/packages/kbn-text-based-editor/src/resizable_button.tsx +++ b/packages/kbn-text-based-editor/src/resizable_button.tsx @@ -13,11 +13,13 @@ import { css } from '@emotion/react'; export function ResizableButton({ onMouseDownResizeHandler, onKeyDownResizeHandler, + editorIsInline, }: { onMouseDownResizeHandler: ( mouseDownEvent: React.MouseEvent | React.TouchEvent ) => void; onKeyDownResizeHandler: (keyDownEvernt: React.KeyboardEvent) => void; + editorIsInline?: boolean; }) { return ( { let position = isCompactFocused ? ('absolute' as 'absolute') : ('relative' as 'relative'); // cast string to type 'relative' | 'absolute' if (isCodeEditorExpanded) { @@ -33,7 +34,9 @@ export const textBasedLanguagedEditorStyles = ( zIndex: isCompactFocused ? 4 : 0, height: `${editorHeight}px`, border: isCompactFocused ? euiTheme.border.thin : 'none', - borderTopLeftRadius: isCodeEditorExpanded ? 0 : '6px', + borderLeft: editorIsInline || !isCompactFocused ? 'none' : euiTheme.border.thin, + borderRight: editorIsInline || !isCompactFocused ? 'none' : euiTheme.border.thin, + borderTopLeftRadius: isCodeEditorExpanded ? 0 : euiTheme.border.radius.medium, borderBottom: isCodeEditorExpanded ? 'none' : isCompactFocused @@ -45,8 +48,8 @@ export const textBasedLanguagedEditorStyles = ( width: isCodeEditorExpanded ? '100%' : `calc(100% - ${hasReference ? 80 : 40}px)`, alignItems: isCompactFocused ? 'flex-start' : 'center', border: !isCompactFocused ? euiTheme.border.thin : 'none', - borderTopLeftRadius: '6px', - borderBottomLeftRadius: '6px', + borderTopLeftRadius: euiTheme.border.radius.medium, + borderBottomLeftRadius: euiTheme.border.radius.medium, borderBottomWidth: hasErrors ? '2px' : '1px', borderBottomColor: hasErrors ? euiTheme.colors.danger : euiTheme.colors.lightShade, }, @@ -66,6 +69,8 @@ export const textBasedLanguagedEditorStyles = ( }, bottomContainer: { border: euiTheme.border.thin, + borderLeft: editorIsInline ? 'none' : euiTheme.border.thin, + borderRight: editorIsInline ? 'none' : euiTheme.border.thin, borderTop: isCodeEditorExpanded && !isCodeEditorExpandedFocused ? hasErrors @@ -75,29 +80,29 @@ export const textBasedLanguagedEditorStyles = ( backgroundColor: euiTheme.colors.lightestShade, paddingLeft: euiTheme.size.base, paddingRight: euiTheme.size.base, - paddingTop: euiTheme.size.xs, - paddingBottom: euiTheme.size.xs, + paddingTop: editorIsInline ? euiTheme.size.s : euiTheme.size.xs, + paddingBottom: editorIsInline ? euiTheme.size.s : euiTheme.size.xs, width: 'calc(100% + 2px)', position: 'relative' as 'relative', // cast string to type 'relative', marginTop: 0, marginLeft: 0, marginBottom: 0, - borderBottomLeftRadius: '6px', - borderBottomRightRadius: '6px', + borderBottomLeftRadius: editorIsInline ? 0 : euiTheme.border.radius.medium, + borderBottomRightRadius: editorIsInline ? 0 : euiTheme.border.radius.medium, }, topContainer: { - border: euiTheme.border.thin, - borderTopLeftRadius: '6px', - borderTopRightRadius: '6px', + border: editorIsInline ? 'none' : euiTheme.border.thin, + borderTopLeftRadius: editorIsInline ? 0 : euiTheme.border.radius.medium, + borderTopRightRadius: editorIsInline ? 0 : euiTheme.border.radius.medium, backgroundColor: euiTheme.colors.lightestShade, paddingLeft: euiTheme.size.base, paddingRight: euiTheme.size.base, - paddingTop: euiTheme.size.xs, - paddingBottom: euiTheme.size.xs, + paddingTop: editorIsInline ? euiTheme.size.s : euiTheme.size.xs, + paddingBottom: editorIsInline ? euiTheme.size.s : euiTheme.size.xs, width: 'calc(100% + 2px)', position: 'relative' as 'relative', // cast string to type 'relative', marginLeft: 0, - marginTop: euiTheme.size.s, + marginTop: editorIsInline ? 0 : euiTheme.size.s, }, dragResizeContainer: { width: '100%', diff --git a/packages/kbn-text-based-editor/src/text_based_languages_editor.test.tsx b/packages/kbn-text-based-editor/src/text_based_languages_editor.test.tsx index 0be4c38eed749..173c023f8b619 100644 --- a/packages/kbn-text-based-editor/src/text_based_languages_editor.test.tsx +++ b/packages/kbn-text-based-editor/src/text_based_languages_editor.test.tsx @@ -84,7 +84,7 @@ describe('TextBasedLanguagesEditor', () => { }); }); - it('should render the date info with no @timestamp detected', async () => { + it('should render the date info with no @timestamp found', async () => { const newProps = { ...props, isCodeEditorExpanded: true, @@ -93,11 +93,11 @@ describe('TextBasedLanguagesEditor', () => { const component = mount(renderTextBasedLanguagesEditorComponent({ ...newProps })); expect( component.find('[data-test-subj="TextBasedLangEditor-date-info"]').at(0).text() - ).toStrictEqual('@timestamp not detected'); + ).toStrictEqual('@timestamp not found'); }); }); - it('should render the date info with @timestamp detected if detectTimestamp is true', async () => { + it('should render the date info with @timestamp found if detectTimestamp is true', async () => { const newProps = { ...props, isCodeEditorExpanded: true, @@ -107,7 +107,7 @@ describe('TextBasedLanguagesEditor', () => { const component = mount(renderTextBasedLanguagesEditorComponent({ ...newProps })); expect( component.find('[data-test-subj="TextBasedLangEditor-date-info"]').at(0).text() - ).toStrictEqual('@timestamp detected'); + ).toStrictEqual('@timestamp found'); }); }); @@ -265,4 +265,24 @@ describe('TextBasedLanguagesEditor', () => { expect(component.find('[data-test-subj="TextBasedLangEditor-run-query"]').length).toBe(0); }); }); + + it('should render correctly if editorIsInline prop is set to true', async () => { + const onTextLangQuerySubmit = jest.fn(); + const newProps = { + ...props, + isCodeEditorExpanded: true, + hideRunQueryText: true, + editorIsInline: true, + onTextLangQuerySubmit, + }; + await act(async () => { + const component = mount(renderTextBasedLanguagesEditorComponent({ ...newProps })); + expect(component.find('[data-test-subj="TextBasedLangEditor-run-query"]').length).toBe(0); + expect( + component.find('[data-test-subj="TextBasedLangEditor-run-query-button"]').length + ).not.toBe(1); + findTestSubject(component, 'TextBasedLangEditor-run-query-button').simulate('click'); + expect(onTextLangQuerySubmit).toHaveBeenCalled(); + }); + }); }); diff --git a/packages/kbn-text-based-editor/src/text_based_languages_editor.tsx b/packages/kbn-text-based-editor/src/text_based_languages_editor.tsx index 312d08cadf0c2..a6cdea64704dc 100644 --- a/packages/kbn-text-based-editor/src/text_based_languages_editor.tsx +++ b/packages/kbn-text-based-editor/src/text_based_languages_editor.tsx @@ -65,19 +65,43 @@ import { fetchFieldsFromESQL } from './fetch_fields_from_esql'; import './overwrite.scss'; export interface TextBasedLanguagesEditorProps { + /** The aggregate type query */ query: AggregateQuery; + /** Callback running everytime the query changes */ onTextLangQueryChange: (query: AggregateQuery) => void; - onTextLangQuerySubmit: () => void; + /** Callback running when the user submits the query */ + onTextLangQuerySubmit: (query?: AggregateQuery) => void; + /** Can be used to expand/minimize the editor */ expandCodeEditor: (status: boolean) => void; + /** If it is true, the editor initializes with height EDITOR_INITIAL_HEIGHT_EXPANDED */ isCodeEditorExpanded: boolean; + /** If it is true, the editor displays the message @timestamp found + * The text based queries are relying on adhoc dataviews which + * can have an @timestamp timefield or nothing + */ detectTimestamp?: boolean; + /** Array of errors */ errors?: Error[]; + /** Warning string as it comes from ES */ warning?: string; + /** Disables the editor */ isDisabled?: boolean; + /** Indicator if the editor is on dark mode */ isDarkMode?: boolean; dataTestSubj?: string; + /** If true it hides the minimize button and the user can't return to the minimized version + * Useful when the application doesn't want to give this capability + */ hideMinimizeButton?: boolean; + /** Hide the Run query information which appears on the footer*/ hideRunQueryText?: boolean; + /** This is used for applications (such as the inline editing flyout in dashboards) + * which want to add the editor without being part of the Unified search component + * It renders a submit query button inside the editor + */ + editorIsInline?: boolean; + /** Disables the submit query action*/ + disableSubmitAction?: boolean; } interface TextBasedEditorDeps { @@ -94,6 +118,9 @@ const EDITOR_ONE_LINER_UNUSED_SPACE_WITH_ERRORS = 220; const KEYCODE_ARROW_UP = 38; const KEYCODE_ARROW_DOWN = 40; +// for editor width smaller than this value we want to start hiding some text +const BREAKPOINT_WIDTH = 410; + const languageId = (language: string) => { switch (language) { case 'esql': { @@ -125,6 +152,8 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({ isDarkMode, hideMinimizeButton, hideRunQueryText, + editorIsInline, + disableSubmitAction, dataTestSubj, }: TextBasedLanguagesEditorProps) { const { euiTheme } = useEuiTheme(); @@ -137,6 +166,7 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({ const [editorHeight, setEditorHeight] = useState( isCodeEditorExpanded ? EDITOR_INITIAL_HEIGHT_EXPANDED : EDITOR_INITIAL_HEIGHT ); + const [isSpaceReduced, setIsSpaceReduced] = useState(false); const [showLineNumbers, setShowLineNumbers] = useState(isCodeEditorExpanded); const [isCompactFocused, setIsCompactFocused] = useState(isCodeEditorExpanded); const [isCodeEditorExpandedFocused, setIsCodeEditorExpandedFocused] = useState(false); @@ -166,7 +196,8 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({ Boolean(errors?.length), Boolean(warning), isCodeEditorExpandedFocused, - Boolean(documentationSections) + Boolean(documentationSections), + Boolean(editorIsInline) ); const isDark = isDarkMode; const editorModel = useRef(); @@ -216,6 +247,11 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({ [editorHeight] ); + const onQuerySubmit = useCallback(() => { + const currentValue = editor1.current?.getValue(); + onTextLangQuerySubmit({ [language]: currentValue } as AggregateQuery); + }, [language, onTextLangQuerySubmit]); + const restoreInitialMode = () => { setIsCodeEditorExpandedFocused(false); if (isCodeEditorExpanded) return; @@ -355,6 +391,7 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({ }, [code, isCodeEditorExpanded, isWordWrapped]); const onResize = ({ width }: { width: number }) => { + setIsSpaceReduced(Boolean(editorIsInline && width < BREAKPOINT_WIDTH)); calculateVisibleCode(width); if (editor1.current) { editor1.current.layout({ width, height: editorHeight }); @@ -514,6 +551,7 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({ { expandCodeEditor(false); updateLinesFromModel = false; @@ -584,6 +623,7 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({ sections={documentationSections} buttonProps={{ color: 'text', + size: 's', 'data-test-subj': 'TextBasedLangEditor-documentation', 'aria-label': i18n.translate( 'textBasedEditor.query.textBasedLanguagesEditor.documentationLabel', @@ -712,7 +752,7 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({ // eslint-disable-next-line no-bitwise monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter, function () { - onTextLangQuerySubmit(); + onQuerySubmit(); } ); if (!isCodeEditorExpanded) { @@ -729,9 +769,12 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({ errors={editorErrors} warning={editorWarning} onErrorClick={onErrorClick} - refreshErrors={onTextLangQuerySubmit} + runQuery={onQuerySubmit} detectTimestamp={detectTimestamp} + editorIsInline={editorIsInline} + disableSubmitAction={disableSubmitAction} hideRunQueryText={hideRunQueryText} + isSpaceReduced={isSpaceReduced} /> )} @@ -816,15 +859,19 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({ errors={editorErrors} warning={editorWarning} onErrorClick={onErrorClick} - refreshErrors={onTextLangQuerySubmit} + runQuery={onQuerySubmit} detectTimestamp={detectTimestamp} hideRunQueryText={hideRunQueryText} + editorIsInline={editorIsInline} + disableSubmitAction={disableSubmitAction} + isSpaceReduced={isSpaceReduced} /> )} {isCodeEditorExpanded && ( )} diff --git a/src/plugins/unified_histogram/public/chart/chart.tsx b/src/plugins/unified_histogram/public/chart/chart.tsx index 42f3d56584789..a64944d73b5fe 100644 --- a/src/plugins/unified_histogram/public/chart/chart.tsx +++ b/src/plugins/unified_histogram/public/chart/chart.tsx @@ -76,6 +76,7 @@ export interface ChartProps { lensAdapters?: UnifiedHistogramChartLoadEvent['adapters']; lensEmbeddableOutput$?: Observable; isOnHistogramMode?: boolean; + histogramQuery?: AggregateQuery; isChartLoading?: boolean; onResetChartHeight?: () => void; onChartHiddenChange?: (chartHidden: boolean) => void; @@ -115,6 +116,7 @@ export function Chart({ lensAdapters, lensEmbeddableOutput$, isOnHistogramMode, + histogramQuery, isChartLoading, onResetChartHeight, onChartHiddenChange, @@ -216,7 +218,7 @@ export function Chart({ getLensAttributes({ title: chart?.title, filters, - query, + query: histogramQuery ?? query, dataView, timeInterval: chart?.timeInterval, breakdownField: breakdown?.field, @@ -230,6 +232,7 @@ export function Chart({ dataView, filters, query, + histogramQuery, ] ); diff --git a/src/plugins/unified_histogram/public/chart/chart_config_panel.tsx b/src/plugins/unified_histogram/public/chart/chart_config_panel.tsx index 486ea7da79872..5ed329f251a97 100644 --- a/src/plugins/unified_histogram/public/chart/chart_config_panel.tsx +++ b/src/plugins/unified_histogram/public/chart/chart_config_panel.tsx @@ -68,6 +68,7 @@ export function ChartConfigPanel({ updatePanelState={updateSuggestion} lensAdapters={lensAdapters} output$={lensEmbeddableOutput$} + displayFlyoutHeader closeFlyout={() => { setIsFlyoutVisible(false); }} diff --git a/src/plugins/unified_histogram/public/layout/hooks/use_lens_suggestions.test.ts b/src/plugins/unified_histogram/public/layout/hooks/use_lens_suggestions.test.ts index d891c62df3514..7bd7e6f21ed5e 100644 --- a/src/plugins/unified_histogram/public/layout/hooks/use_lens_suggestions.test.ts +++ b/src/plugins/unified_histogram/public/layout/hooks/use_lens_suggestions.test.ts @@ -38,6 +38,7 @@ describe('useLensSuggestions', () => { allSuggestions: [], currentSuggestion: undefined, isOnHistogramMode: false, + histogramQuery: undefined, suggestionUnsupported: false, }); }); @@ -66,6 +67,7 @@ describe('useLensSuggestions', () => { allSuggestions: allSuggestionsMock, currentSuggestion: allSuggestionsMock[0], isOnHistogramMode: false, + histogramQuery: undefined, suggestionUnsupported: false, }); }); @@ -94,6 +96,7 @@ describe('useLensSuggestions', () => { allSuggestions: [], currentSuggestion: undefined, isOnHistogramMode: false, + histogramQuery: undefined, suggestionUnsupported: true, }); }); @@ -133,6 +136,9 @@ describe('useLensSuggestions', () => { allSuggestions: [], currentSuggestion: allSuggestionsMock[0], isOnHistogramMode: true, + histogramQuery: { + esql: 'from the-data-view | limit 100 | EVAL timestamp=DATE_TRUNC(30 minute, @timestamp) | stats rows = count(*) by timestamp | rename timestamp as `@timestamp every 30 minute`', + }, suggestionUnsupported: false, }); }); @@ -172,6 +178,7 @@ describe('useLensSuggestions', () => { allSuggestions: [], currentSuggestion: undefined, isOnHistogramMode: false, + histogramQuery: undefined, suggestionUnsupported: true, }); }); diff --git a/src/plugins/unified_histogram/public/layout/hooks/use_lens_suggestions.ts b/src/plugins/unified_histogram/public/layout/hooks/use_lens_suggestions.ts index 514c32cefcb80..5441e08a28c71 100644 --- a/src/plugins/unified_histogram/public/layout/hooks/use_lens_suggestions.ts +++ b/src/plugins/unified_histogram/public/layout/hooks/use_lens_suggestions.ts @@ -61,7 +61,7 @@ export const useLensSuggestions = ({ const [allSuggestions, setAllSuggestions] = useState(suggestions.allSuggestions); const currentSuggestion = originalSuggestion ?? suggestions.firstSuggestion; const suggestionDeps = useRef(getSuggestionDeps({ dataView, query, columns })); - + const histogramQuery = useRef(); const histogramSuggestion = useMemo(() => { if ( !currentSuggestion && @@ -85,8 +85,7 @@ export const useLensSuggestions = ({ const interval = computeInterval(timeRange, data); const language = getAggregateQueryMode(query); - const histogramQuery = `${query[language]} - | EVAL timestamp=DATE_TRUNC(${interval}, ${dataView.timeFieldName}) | stats rows = count(*) by timestamp | rename timestamp as \`${dataView.timeFieldName} every ${interval}\``; + const esqlQuery = `${query[language]} | EVAL timestamp=DATE_TRUNC(${interval}, ${dataView.timeFieldName}) | stats rows = count(*) by timestamp | rename timestamp as \`${dataView.timeFieldName} every ${interval}\``; const context = { dataViewSpec: dataView?.toSpec(), fieldName: '', @@ -107,15 +106,16 @@ export const useLensSuggestions = ({ }, ] as DatatableColumn[], query: { - esql: histogramQuery, + esql: esqlQuery, }, }; const sug = lensSuggestionsApi(context, dataView, ['lnsDatatable']) ?? []; if (sug.length) { + histogramQuery.current = { esql: esqlQuery }; return sug[0]; } - return undefined; } + histogramQuery.current = undefined; return undefined; }, [currentSuggestion, dataView, query, timeRange, data, lensSuggestionsApi]); @@ -142,6 +142,7 @@ export const useLensSuggestions = ({ currentSuggestion: histogramSuggestion ?? currentSuggestion, suggestionUnsupported: !currentSuggestion && !histogramSuggestion && isPlainRecord, isOnHistogramMode: Boolean(histogramSuggestion), + histogramQuery: histogramQuery.current ? histogramQuery.current : undefined, }; }; diff --git a/src/plugins/unified_histogram/public/layout/layout.tsx b/src/plugins/unified_histogram/public/layout/layout.tsx index d923ea3031a50..17eaf65fcde5f 100644 --- a/src/plugins/unified_histogram/public/layout/layout.tsx +++ b/src/plugins/unified_histogram/public/layout/layout.tsx @@ -215,18 +215,23 @@ export const UnifiedHistogramLayout = ({ children, withDefaultActions, }: UnifiedHistogramLayoutProps) => { - const { allSuggestions, currentSuggestion, suggestionUnsupported, isOnHistogramMode } = - useLensSuggestions({ - dataView, - query, - originalSuggestion, - isPlainRecord, - columns, - timeRange, - data: services.data, - lensSuggestionsApi, - onSuggestionChange, - }); + const { + allSuggestions, + currentSuggestion, + suggestionUnsupported, + isOnHistogramMode, + histogramQuery, + } = useLensSuggestions({ + dataView, + query, + originalSuggestion, + isPlainRecord, + columns, + timeRange, + data: services.data, + lensSuggestionsApi, + onSuggestionChange, + }); const chart = suggestionUnsupported ? undefined : originalChart; const [topPanelNode] = useState(() => @@ -302,6 +307,7 @@ export const UnifiedHistogramLayout = ({ lensAdapters={lensAdapters} lensEmbeddableOutput$={lensEmbeddableOutput$} isOnHistogramMode={isOnHistogramMode} + histogramQuery={histogramQuery} withDefaultActions={withDefaultActions} /> diff --git a/x-pack/plugins/lens/common/expressions/datatable/datatable_column.ts b/x-pack/plugins/lens/common/expressions/datatable/datatable_column.ts index d54cc4de89fa7..267671a062655 100644 --- a/x-pack/plugins/lens/common/expressions/datatable/datatable_column.ts +++ b/x-pack/plugins/lens/common/expressions/datatable/datatable_column.ts @@ -45,6 +45,7 @@ export interface ColumnState { summaryRow?: 'none' | 'sum' | 'avg' | 'count' | 'min' | 'max'; summaryLabel?: string; collapseFn?: CollapseFunction; + isMetric?: boolean; } export type DatatableColumnResult = ColumnState & { type: 'lens_datatable_column' }; diff --git a/x-pack/plugins/lens/kibana.jsonc b/x-pack/plugins/lens/kibana.jsonc index 04bb96af59388..1813264f7ca57 100644 --- a/x-pack/plugins/lens/kibana.jsonc +++ b/x-pack/plugins/lens/kibana.jsonc @@ -56,6 +56,7 @@ "embeddable", "fieldFormats", "charts", + "textBasedLanguages", ], "extraPublicDirs": [ "common/constants" diff --git a/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/flyout_wrapper.tsx b/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/flyout_wrapper.tsx new file mode 100644 index 0000000000000..5f0abbf3a952f --- /dev/null +++ b/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/flyout_wrapper.tsx @@ -0,0 +1,157 @@ +/* + * 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 { + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiTitle, + EuiToolTip, + EuiButton, + EuiLink, + EuiBetaBadge, +} from '@elastic/eui'; +import { euiThemeVars } from '@kbn/ui-theme'; +import { css } from '@emotion/react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import type { FlyoutWrapperProps } from './types'; + +export const FlyoutWrapper = ({ + children, + isInlineFlyoutVisible, + isScrollable, + displayFlyoutHeader, + language, + attributesChanged, + onCancel, + navigateToLensEditor, + onApply, +}: FlyoutWrapperProps) => { + return ( + <> + {isInlineFlyoutVisible && displayFlyoutHeader && ( + + + + +

+ {i18n.translate('xpack.lens.config.editVisualizationLabel', { + defaultMessage: 'Edit {lang} visualization', + values: { lang: language }, + })} + + + +

+
+
+ {navigateToLensEditor && ( + + + {i18n.translate('xpack.lens.config.editLinkLabel', { + defaultMessage: 'Edit in Lens', + })} + + + )} +
+
+ )} + + * { + pointer-events: auto; + } + } + .euiFlyoutBody__overflowContent { + padding: 0; + block-size: 100%; + } + `} + > + {children} + + {isInlineFlyoutVisible && ( + + + + + + + + + + + + + + + )} + + ); +}; diff --git a/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/get_edit_lens_configuration.test.tsx b/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/get_edit_lens_configuration.test.tsx index 34adf0c9c2549..26428091032e9 100644 --- a/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/get_edit_lens_configuration.test.tsx +++ b/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/get_edit_lens_configuration.test.tsx @@ -39,11 +39,11 @@ describe('Lens flyout', () => { newDatasourceState: 'newDatasourceState', }) ); - expect(updaterFn).toHaveBeenCalledWith('newDatasourceState', null); + expect(updaterFn).toHaveBeenCalledWith('newDatasourceState', null, 'testVis'); store.dispatch( updateVisualizationState({ visualizationId: 'testVis', newState: 'newVisState' }) ); - expect(updaterFn).toHaveBeenCalledWith('newDatasourceState', 'newVisState'); + expect(updaterFn).toHaveBeenCalledWith('newDatasourceState', 'newVisState', 'testVis'); }); test('updater is not run if it does not modify visualization or datasource state', () => { diff --git a/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/get_edit_lens_configuration.tsx b/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/get_edit_lens_configuration.tsx index fc6511b66ec15..91b094c141161 100644 --- a/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/get_edit_lens_configuration.tsx +++ b/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/get_edit_lens_configuration.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback } from 'react'; +import React, { useCallback, useState } from 'react'; import { EuiFlyout, EuiLoadingSpinner, EuiOverlayMask } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { Provider } from 'react-redux'; @@ -25,16 +25,21 @@ import { } from '../../../state_management'; import { generateId } from '../../../id_generator'; import type { DatasourceMap, VisualizationMap } from '../../../types'; -import { - LensEditConfigurationFlyout, - type EditConfigPanelProps, -} from './lens_configuration_flyout'; +import { LensEditConfigurationFlyout } from './lens_configuration_flyout'; +import type { EditConfigPanelProps } from './types'; import { SavedObjectIndexStore, type Document } from '../../../persistence'; +import type { TypedLensByValueInput } from '../../../embeddable/embeddable_component'; import { DOC_TYPE } from '../../../../common/constants'; export type EditLensConfigurationProps = Omit< EditConfigPanelProps, - 'startDependencies' | 'coreStart' | 'visualizationMap' | 'datasourceMap' | 'saveByRef' + | 'startDependencies' + | 'coreStart' + | 'visualizationMap' + | 'datasourceMap' + | 'saveByRef' + | 'setCurrentAttributes' + | 'previousAttributes' >; function LoadingSpinnerWithOverlay() { return ( @@ -44,7 +49,11 @@ function LoadingSpinnerWithOverlay() { ); } -type UpdaterType = (datasourceState: unknown, visualizationState: unknown) => void; +type UpdaterType = ( + datasourceState: unknown, + visualizationState: unknown, + visualizationType?: string +) => void; // exported for testing export const updatingMiddleware = @@ -68,7 +77,12 @@ export const updatingMiddleware = if (initExisting.match(action) || initEmpty.match(action)) { return; } - updater(datasourceStates[activeDatasourceId].state, visualization.state); + + updater( + datasourceStates[activeDatasourceId].state, + visualization.state, + visualization.activeId + ); } }; @@ -88,6 +102,7 @@ export async function getEditLensConfiguration( return ({ attributes, updatePanelState, + updateSuggestion, closeFlyout, wrapInFlyout, datasourceId, @@ -98,10 +113,13 @@ export async function getEditLensConfiguration( updateByRefInput, navigateToLensEditor, displayFlyoutHeader, + canEditTextBasedQuery, }: EditLensConfigurationProps) => { if (!lensServices || !datasourceMap || !visualizationMap) { return ; } + const [currentAttributes, setCurrentAttributes] = + useState(attributes); /** * During inline editing of a by reference panel, the panel is converted to a by value one. * When the user applies the changes we save them to the Lens SO @@ -117,7 +135,7 @@ export async function getEditLensConfiguration( }, [savedObjectId] ); - const datasourceState = attributes.state.datasourceStates[datasourceId]; + const datasourceState = currentAttributes.state.datasourceStates[datasourceId]; const storeDeps = { lensServices, datasourceMap, @@ -135,7 +153,7 @@ export async function getEditLensConfiguration( lensStore.dispatch( loadInitial({ initialInput: { - attributes, + attributes: currentAttributes, id: panelId ?? generateId(), }, inlineEditing: true, @@ -148,6 +166,7 @@ export async function getEditLensConfiguration( { closeFlyout?.(); }} @@ -169,8 +188,9 @@ export async function getEditLensConfiguration( }; const configPanelProps = { - attributes, + attributes: currentAttributes, updatePanelState, + updateSuggestion, closeFlyout, datasourceId, coreStart, @@ -184,6 +204,8 @@ export async function getEditLensConfiguration( updateByRefInput, navigateToLensEditor, displayFlyoutHeader, + canEditTextBasedQuery, + setCurrentAttributes, }; return getWrapper( diff --git a/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/helpers.test.ts b/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/helpers.test.ts new file mode 100644 index 0000000000000..0fe5f148a25d0 --- /dev/null +++ b/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/helpers.test.ts @@ -0,0 +1,127 @@ +/* + * 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 { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; +import type { LensPluginStartDependencies } from '../../../plugin'; +import { createMockStartDependencies } from '../../../editor_frame_service/mocks'; +import { + mockVisualizationMap, + mockDatasourceMap, + mockDataViewWithTimefield, + mockAllSuggestions, +} from '../../../mocks'; +import { suggestionsApi } from '../../../lens_suggestions_api'; +import { fetchDataFromAggregateQuery } from '../../../datasources/text_based/fetch_data_from_aggregate_query'; +import { getSuggestions } from './helpers'; + +const mockSuggestionApi = suggestionsApi as jest.Mock; +const mockFetchData = fetchDataFromAggregateQuery as jest.Mock; + +jest.mock('../../../lens_suggestions_api', () => ({ + suggestionsApi: jest.fn(() => mockAllSuggestions), +})); + +jest.mock('../../../datasources/text_based/fetch_data_from_aggregate_query', () => ({ + fetchDataFromAggregateQuery: jest.fn(() => { + return { + columns: [ + { + name: '@timestamp', + id: '@timestamp', + meta: { + type: 'date', + }, + }, + { + name: 'bytes', + id: 'bytes', + meta: { + type: 'number', + }, + }, + { + name: 'memory', + id: 'memory', + meta: { + type: 'number', + }, + }, + ], + }; + }), +})); + +describe('getSuggestions', () => { + const query = { + esql: 'from index1 | limit 10 | stats average = avg(bytes', + }; + const mockStartDependencies = + createMockStartDependencies() as unknown as LensPluginStartDependencies; + const dataViews = dataViewPluginMocks.createStartContract(); + dataViews.create.mockResolvedValue(mockDataViewWithTimefield); + const dataviewSpecArr = [ + { + id: 'd2588ae7-9ea0-4439-9f5b-f808754a3b97', + title: 'index1', + timeFieldName: '@timestamp', + sourceFilters: [], + fieldFormats: {}, + runtimeFieldMap: {}, + fieldAttrs: {}, + allowNoIndex: false, + name: 'index1', + }, + ]; + const startDependencies = { + ...mockStartDependencies, + dataViews, + }; + + it('returns the suggestions attributes correctly', async () => { + const suggestionsAttributes = await getSuggestions( + query, + startDependencies, + mockDatasourceMap(), + mockVisualizationMap(), + dataviewSpecArr, + jest.fn() + ); + expect(suggestionsAttributes?.visualizationType).toBe(mockAllSuggestions[0].visualizationId); + expect(suggestionsAttributes?.state.visualization).toStrictEqual( + mockAllSuggestions[0].visualizationState + ); + }); + + it('returns undefined if no suggestions are computed', async () => { + mockSuggestionApi.mockResolvedValueOnce([]); + const suggestionsAttributes = await getSuggestions( + query, + startDependencies, + mockDatasourceMap(), + mockVisualizationMap(), + dataviewSpecArr, + jest.fn() + ); + expect(suggestionsAttributes).toBeUndefined(); + }); + + it('returns an error if fetching the data fails', async () => { + mockFetchData.mockImplementation(() => { + throw new Error('sorry!'); + }); + const setErrorsSpy = jest.fn(); + const suggestionsAttributes = await getSuggestions( + query, + startDependencies, + mockDatasourceMap(), + mockVisualizationMap(), + dataviewSpecArr, + setErrorsSpy + ); + expect(suggestionsAttributes).toBeUndefined(); + expect(setErrorsSpy).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/helpers.ts b/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/helpers.ts new file mode 100644 index 0000000000000..faecb37ba7fd7 --- /dev/null +++ b/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/helpers.ts @@ -0,0 +1,148 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { i18n } from '@kbn/i18n'; +import { getIndexPatternFromSQLQuery, getIndexPatternFromESQLQuery } from '@kbn/es-query'; +import type { AggregateQuery, Query, Filter } from '@kbn/es-query'; +import type { DataView, DataViewSpec } from '@kbn/data-views-plugin/public'; +import type { Suggestion } from '../../../types'; +import type { TypedLensByValueInput } from '../../../embeddable/embeddable_component'; +import type { LensPluginStartDependencies } from '../../../plugin'; +import type { DatasourceMap, VisualizationMap } from '../../../types'; +import { fetchDataFromAggregateQuery } from '../../../datasources/text_based/fetch_data_from_aggregate_query'; +import { suggestionsApi } from '../../../lens_suggestions_api'; + +export const getQueryColumns = async ( + query: AggregateQuery, + dataView: DataView, + deps: LensPluginStartDependencies +) => { + // Fetching only columns for ES|QL for performance reasons with limit 0 + // Important note: ES doesnt return the warnings for 0 limit, + // I am skipping them in favor of performance now + // but we should think another way to get them (from Lens embeddable or store) + const performantQuery = { ...query }; + if ('esql' in performantQuery && performantQuery.esql) { + performantQuery.esql = `${performantQuery.esql} | limit 0`; + } + const table = await fetchDataFromAggregateQuery( + performantQuery, + dataView, + deps.data, + deps.expressions + ); + return table?.columns; +}; + +export const getSuggestions = async ( + query: AggregateQuery, + deps: LensPluginStartDependencies, + datasourceMap: DatasourceMap, + visualizationMap: VisualizationMap, + adHocDataViews: DataViewSpec[], + setErrors: (errors: Error[]) => void +) => { + try { + let indexPattern = ''; + if ('sql' in query) { + indexPattern = getIndexPatternFromSQLQuery(query.sql); + } + if ('esql' in query) { + indexPattern = getIndexPatternFromESQLQuery(query.esql); + } + const dataViewSpec = adHocDataViews.find((adHoc) => { + return adHoc.name === indexPattern; + }); + + const dataView = await deps.dataViews.create( + dataViewSpec ?? { + title: indexPattern, + } + ); + if (dataView.fields.getByName('@timestamp')?.type === 'date' && !dataViewSpec) { + dataView.timeFieldName = '@timestamp'; + } + const columns = await getQueryColumns(query, dataView, deps); + const context = { + dataViewSpec: dataView?.toSpec(), + fieldName: '', + textBasedColumns: columns, + query, + }; + + const allSuggestions = + suggestionsApi({ context, dataView, datasourceMap, visualizationMap }) ?? []; + + // Lens might not return suggestions for some cases, i.e. in case of errors + if (!allSuggestions.length) return undefined; + + const firstSuggestion = allSuggestions[0]; + + const attrs = getLensAttributes({ + filters: [], + query, + suggestion: firstSuggestion, + dataView, + }); + return attrs; + } catch (e) { + setErrors([e]); + } + return undefined; +}; + +export const getLensAttributes = ({ + filters, + query, + suggestion, + dataView, +}: { + filters: Filter[]; + query: Query | AggregateQuery; + suggestion: Suggestion | undefined; + dataView?: DataView; +}) => { + const suggestionDatasourceState = Object.assign({}, suggestion?.datasourceState); + const suggestionVisualizationState = Object.assign({}, suggestion?.visualizationState); + const datasourceStates = + suggestion && suggestion.datasourceState + ? { + [suggestion.datasourceId!]: { + ...suggestionDatasourceState, + }, + } + : { + formBased: {}, + }; + const visualization = suggestionVisualizationState; + const attributes = { + title: suggestion + ? suggestion.title + : i18n.translate('xpack.lens.config.suggestion.title', { + defaultMessage: 'New suggestion', + }), + references: [ + { + id: dataView?.id ?? '', + name: `textBasedLanguages-datasource-layer-suggestion`, + type: 'index-pattern', + }, + ], + state: { + datasourceStates, + filters, + query, + visualization, + ...(dataView && + dataView.id && + !dataView.isPersisted() && { + adHocDataViews: { [dataView.id]: dataView.toSpec(false) }, + }), + }, + visualizationType: suggestion ? suggestion.visualizationId : 'lnsXY', + } as TypedLensByValueInput['attributes']; + return attributes; +}; diff --git a/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/layer_configuration_section.tsx b/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/layer_configuration_section.tsx new file mode 100644 index 0000000000000..7ae7b456ab669 --- /dev/null +++ b/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/layer_configuration_section.tsx @@ -0,0 +1,76 @@ +/* + * 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, { useMemo } from 'react'; +import { EuiSpacer, useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; +import { VisualizationToolbar } from '../../../editor_frame_service/editor_frame/workspace_panel'; +import { ConfigPanelWrapper } from '../../../editor_frame_service/editor_frame/config_panel/config_panel'; +import { createIndexPatternService } from '../../../data_views_service/service'; +import { useLensDispatch, updateIndexPatterns } from '../../../state_management'; +import { replaceIndexpattern } from '../../../state_management/lens_slice'; +import type { LayerConfigurationProps } from './types'; +import { useLensSelector } from '../../../state_management'; + +export function LayerConfiguration({ + attributes, + coreStart, + startDependencies, + visualizationMap, + datasourceMap, + datasourceId, + framePublicAPI, + hasPadding, + setIsInlineFlyoutVisible, +}: LayerConfigurationProps) { + const dispatch = useLensDispatch(); + const { euiTheme } = useEuiTheme(); + const { visualization } = useLensSelector((state) => state.lens); + const activeVisualization = + visualizationMap[visualization.activeId ?? attributes.visualizationType]; + const indexPatternService = useMemo( + () => + createIndexPatternService({ + dataViews: startDependencies.dataViews, + uiActions: startDependencies.uiActions, + core: coreStart, + updateIndexPatterns: (newIndexPatternsState, options) => { + dispatch(updateIndexPatterns(newIndexPatternsState)); + }, + replaceIndexPattern: (newIndexPattern, oldId, options) => { + dispatch(replaceIndexpattern({ newIndexPattern, oldId })); + }, + }), + [coreStart, dispatch, startDependencies.dataViews, startDependencies.uiActions] + ); + + const layerPanelsProps = { + framePublicAPI, + datasourceMap, + visualizationMap, + core: coreStart, + dataViews: startDependencies.dataViews, + uiActions: startDependencies.uiActions, + hideLayerHeader: datasourceId === 'textBased', + indexPatternService, + setIsInlineFlyoutVisible, + }; + return ( +
+ + + +
+ ); +} diff --git a/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/lens_configuration_flyout.test.tsx b/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/lens_configuration_flyout.test.tsx index 4731afe037249..0462e4ad251de 100644 --- a/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/lens_configuration_flyout.test.tsx +++ b/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/lens_configuration_flyout.test.tsx @@ -14,10 +14,8 @@ import { mockVisualizationMap, mockDatasourceMap, mockDataPlugin } from '../../. import type { LensPluginStartDependencies } from '../../../plugin'; import { createMockStartDependencies } from '../../../editor_frame_service/mocks'; import type { TypedLensByValueInput } from '../../../embeddable/embeddable_component'; -import { - LensEditConfigurationFlyout, - type EditConfigPanelProps, -} from './lens_configuration_flyout'; +import { LensEditConfigurationFlyout } from './lens_configuration_flyout'; +import type { EditConfigPanelProps } from './types'; const lensAttributes = { title: 'test', @@ -29,14 +27,12 @@ const lensAttributes = { visualization: {}, filters: [], query: { - language: 'lucene', - query: '', + esql: 'from index1 | limit 10', }, }, filters: [], query: { - language: 'lucene', - query: '', + esql: 'from index1 | limit 10', }, references: [], } as unknown as TypedLensByValueInput['attributes']; @@ -109,6 +105,16 @@ describe('LensEditConfigurationFlyout', () => { expect(closeFlyoutSpy).toHaveBeenCalled(); }); + it('should call the updatePanelState callback if cancel button is clicked', async () => { + const updatePanelStateSpy = jest.fn(); + renderConfigFlyout({ + updatePanelState: updatePanelStateSpy, + }); + expect(screen.getByTestId('lns-layerPanel-0')).toBeInTheDocument(); + userEvent.click(screen.getByTestId('cancelFlyoutButton')); + expect(updatePanelStateSpy).toHaveBeenCalled(); + }); + it('should call the updateByRefInput callback if cancel button is clicked and savedObjectId exists', async () => { const updateByRefInputSpy = jest.fn(); @@ -135,4 +141,41 @@ describe('LensEditConfigurationFlyout', () => { expect(updateByRefInputSpy).toHaveBeenCalled(); expect(saveByRefSpy).toHaveBeenCalled(); }); + + it('should not display the editor if canEditTextBasedQuery prop is false', async () => { + renderConfigFlyout({ + canEditTextBasedQuery: false, + }); + expect(screen.queryByTestId('TextBasedLangEditor')).toBeNull(); + }); + + it('should not display the editor if canEditTextBasedQuery prop is true but the query is not text based', async () => { + renderConfigFlyout({ + canEditTextBasedQuery: true, + attributes: { + ...lensAttributes, + state: { + ...lensAttributes.state, + query: { + type: 'kql', + query: '', + } as unknown as Query, + }, + }, + }); + expect(screen.queryByTestId('TextBasedLangEditor')).toBeNull(); + }); + + it('should display the suggestions if canEditTextBasedQuery prop is true', async () => { + renderConfigFlyout( + { + canEditTextBasedQuery: true, + }, + { + esql: 'from index1 | limit 10', + } + ); + expect(screen.getByTestId('InlineEditingESQLEditor')).toBeInTheDocument(); + expect(screen.getByTestId('InlineEditingSuggestions')).toBeInTheDocument(); + }); }); diff --git a/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/lens_configuration_flyout.tsx b/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/lens_configuration_flyout.tsx index a60e3df063aaa..72dbd313d6452 100644 --- a/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/lens_configuration_flyout.tsx +++ b/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/lens_configuration_flyout.tsx @@ -6,85 +6,34 @@ */ import React, { useMemo, useCallback, useRef, useEffect, useState } from 'react'; +import { isEqual } from 'lodash'; +import { css } from '@emotion/react'; +import { i18n } from '@kbn/i18n'; import { - EuiButtonEmpty, - EuiButton, - EuiFlyoutBody, - EuiFlyoutFooter, EuiTitle, - EuiLink, - EuiIcon, - EuiToolTip, - EuiSpacer, + EuiAccordion, + useEuiTheme, EuiFlexGroup, EuiFlexItem, - useEuiTheme, - EuiCallOut, + euiScrollBarStyles, } from '@elastic/eui'; -import { isEqual } from 'lodash'; -import type { Observable } from 'rxjs'; import { euiThemeVars } from '@kbn/ui-theme'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { css } from '@emotion/react'; -import type { CoreStart } from '@kbn/core/public'; import type { Datatable } from '@kbn/expressions-plugin/public'; -import type { LensPluginStartDependencies } from '../../../plugin'; import { - useLensSelector, - selectFramePublicAPI, - useLensDispatch, - updateIndexPatterns, -} from '../../../state_management'; -import { replaceIndexpattern } from '../../../state_management/lens_slice'; -import { VisualizationToolbar } from '../../../editor_frame_service/editor_frame/workspace_panel'; - -import type { DatasourceMap, VisualizationMap } from '../../../types'; + getAggregateQueryMode, + isOfAggregateQueryType, + getLanguageDisplayName, +} from '@kbn/es-query'; +import type { AggregateQuery, Query } from '@kbn/es-query'; +import { TextBasedLangEditor } from '@kbn/text-based-languages/public'; +import { useLensSelector, selectFramePublicAPI } from '../../../state_management'; import type { TypedLensByValueInput } from '../../../embeddable/embeddable_component'; -import type { LensEmbeddableOutput } from '../../../embeddable'; -import type { LensInspector } from '../../../lens_inspector_service'; -import { ConfigPanelWrapper } from '../../../editor_frame_service/editor_frame/config_panel/config_panel'; import { extractReferencesFromState } from '../../../utils'; -import type { Document } from '../../../persistence'; -import { createIndexPatternService } from '../../../data_views_service/service'; - -export interface EditConfigPanelProps { - coreStart: CoreStart; - startDependencies: LensPluginStartDependencies; - visualizationMap: VisualizationMap; - datasourceMap: DatasourceMap; - /** The attributes of the Lens embeddable */ - attributes: TypedLensByValueInput['attributes']; - /** Callback for updating the visualization and datasources state */ - updatePanelState: (datasourceState: unknown, visualizationState: unknown) => void; - /** Lens visualizations can be either created from ESQL (textBased) or from dataviews (formBased) */ - datasourceId: 'formBased' | 'textBased'; - /** Embeddable output observable, useful for dashboard flyout */ - output$?: Observable; - /** Contains the active data, necessary for some panel configuration such as coloring */ - lensAdapters?: LensInspector['adapters']; - /** Optional callback called when updating the by reference embeddable */ - updateByRefInput?: (soId: string) => void; - /** Callback for closing the edit flyout */ - closeFlyout?: () => void; - /** Boolean used for adding a flyout wrapper */ - wrapInFlyout?: boolean; - /** Optional parameter for panel identification - * If not given, Lens generates a new one - */ - panelId?: string; - /** Optional parameter for saved object id - * Should be given if the lens embeddable is a by reference one - * (saved in the library) - */ - savedObjectId?: string; - /** Callback for saving the embeddable as a SO */ - saveByRef?: (attrs: Document) => void; - /** Optional callback for navigation from the header of the flyout */ - navigateToLensEditor?: () => void; - /** If set to true it displays a header on the flyout */ - displayFlyoutHeader?: boolean; -} +import { LayerConfiguration } from './layer_configuration_section'; +import type { EditConfigPanelProps } from './types'; +import { FlyoutWrapper } from './flyout_wrapper'; +import { getSuggestions } from './helpers'; +import { SuggestionPanel } from '../../../editor_frame_service/editor_frame/suggestion_panel'; export function LensEditConfigurationFlyout({ attributes, @@ -94,6 +43,8 @@ export function LensEditConfigurationFlyout({ datasourceMap, datasourceId, updatePanelState, + updateSuggestion, + setCurrentAttributes, closeFlyout, saveByRef, savedObjectId, @@ -102,15 +53,21 @@ export function LensEditConfigurationFlyout({ lensAdapters, navigateToLensEditor, displayFlyoutHeader, + canEditTextBasedQuery, }: EditConfigPanelProps) { + const euiTheme = useEuiTheme(); const previousAttributes = useRef(attributes); + const prevQuery = useRef(attributes.state.query); + const [query, setQuery] = useState(attributes.state.query); + const [errors, setErrors] = useState(); + const [isInlineFlyoutVisible, setIsInlineFlyoutVisible] = useState(true); + const [isLayerAccordionOpen, setIsLayerAccordionOpen] = useState(true); + const [isSuggestionsAccordionOpen, setIsSuggestionsAccordionOpen] = useState(false); const datasourceState = attributes.state.datasourceStates[datasourceId]; const activeVisualization = visualizationMap[attributes.visualizationType]; const activeDatasource = datasourceMap[datasourceId]; - const [isInlineFooterVisible, setIsInlineFlyoutFooterVisible] = useState(true); - const { euiTheme } = useEuiTheme(); const { datasourceStates, visualization, isLoading } = useLensSelector((state) => state.lens); - const dispatch = useLensDispatch(); + const suggestsLimitedColumns = activeDatasource?.suggestsLimitedColumns?.(datasourceState); const activeData: Record = useMemo(() => { return {}; }, []); @@ -149,28 +106,34 @@ export function LensEditConfigurationFlyout({ const onCancel = useCallback(() => { const previousAttrs = previousAttributes.current; - if (attributesChanged) { - const currentDatasourceState = datasourceMap[datasourceId].injectReferencesToLayers - ? datasourceMap[datasourceId]?.injectReferencesToLayers?.( - previousAttrs.state.datasourceStates[datasourceId], - previousAttrs.references - ) - : previousAttrs.state.datasourceStates[datasourceId]; - updatePanelState?.(currentDatasourceState, previousAttrs.state.visualization); + if (previousAttrs.visualizationType === visualization.activeId) { + const currentDatasourceState = datasourceMap[datasourceId].injectReferencesToLayers + ? datasourceMap[datasourceId]?.injectReferencesToLayers?.( + previousAttrs.state.datasourceStates[datasourceId], + previousAttrs.references + ) + : previousAttrs.state.datasourceStates[datasourceId]; + updatePanelState?.(currentDatasourceState, previousAttrs.state.visualization); + } else { + updateSuggestion?.(previousAttrs); + } if (savedObjectId) { updateByRefInput?.(savedObjectId); } } closeFlyout?.(); }, [ + previousAttributes, attributesChanged, - savedObjectId, closeFlyout, datasourceMap, datasourceId, updatePanelState, + updateSuggestion, + savedObjectId, updateByRefInput, + visualization, ]); const onApply = useCallback(() => { @@ -218,20 +181,33 @@ export function LensEditConfigurationFlyout({ datasourceMap, ]); - const indexPatternService = useMemo( - () => - createIndexPatternService({ - dataViews: startDependencies.dataViews, - uiActions: startDependencies.uiActions, - core: coreStart, - updateIndexPatterns: (newIndexPatternsState, options) => { - dispatch(updateIndexPatterns(newIndexPatternsState)); - }, - replaceIndexPattern: (newIndexPattern, oldId, options) => { - dispatch(replaceIndexpattern({ newIndexPattern, oldId })); - }, - }), - [coreStart, dispatch, startDependencies.dataViews, startDependencies.uiActions] + // needed for text based languages mode which works ONLY with adHoc dataviews + const adHocDataViews = Object.values(attributes.state.adHocDataViews ?? {}); + + const runQuery = useCallback( + async (q) => { + const attrs = await getSuggestions( + q, + startDependencies, + datasourceMap, + visualizationMap, + adHocDataViews, + setErrors + ); + if (attrs) { + setCurrentAttributes?.(attrs); + setErrors([]); + updateSuggestion?.(attrs); + } + }, + [ + startDependencies, + datasourceMap, + visualizationMap, + adHocDataViews, + setCurrentAttributes, + updateSuggestion, + ] ); const framePublicAPI = useLensSelector((state) => { @@ -244,155 +220,197 @@ export function LensEditConfigurationFlyout({ }; return selectFramePublicAPI(newState, datasourceMap); }); + + const textBasedMode = isOfAggregateQueryType(query) ? getAggregateQueryMode(query) : undefined; + if (isLoading) return null; + // Example is the Discover editing where we dont want to render the text based editor on the panel + if (!canEditTextBasedQuery) { + return ( + + + + ); + } - const layerPanelsProps = { - framePublicAPI, - datasourceMap, - visualizationMap, - core: coreStart, - dataViews: startDependencies.dataViews, - uiActions: startDependencies.uiActions, - hideLayerHeader: datasourceId === 'textBased', - indexPatternService, - setIsInlineFlyoutFooterVisible, - }; return ( <> - * { - pointer-events: auto; - } - } - .euiFlyoutBody__overflowContent { - padding: 0; - } - `} + - - {displayFlyoutHeader && ( - - - - - - -

- {i18n.translate('xpack.lens.config.editVisualizationLabel', { - defaultMessage: 'Edit visualization', - })} -

-
-
- - - - - -
-
- {navigateToLensEditor && ( - - - {i18n.translate('xpack.lens.config.editLinkLabel', { - defaultMessage: 'Edit in Lens', - })} - - - )} -
+ + {isOfAggregateQueryType(query) && ( + + { + setQuery(q); + prevQuery.current = q; + }} + expandCodeEditor={(status: boolean) => {}} + isCodeEditorExpanded + detectTimestamp={Boolean(adHocDataViews?.[0]?.timeFieldName)} + errors={errors} + warning={ + suggestsLimitedColumns + ? i18n.translate('xpack.lens.config.configFlyoutCallout', { + defaultMessage: + 'Displaying a limited portion of the available fields. Add more from the configuration panel.', + }) + : undefined + } + hideMinimizeButton + editorIsInline + hideRunQueryText + disableSubmitAction={isEqual(query, prevQuery.current)} + onTextLangQuerySubmit={(q) => { + if (q) { + runQuery(q); + } + }} + isDisabled={false} + /> )} - {datasourceId === 'textBased' && ( - +
+ {i18n.translate('xpack.lens.config.layerConfigurationLabel', { + defaultMessage: 'Layer configuration', + })} +
+ + } + buttonProps={{ + paddingSize: 'm', + }} + initialIsOpen={isLayerAccordionOpen} + forceState={isLayerAccordionOpen ? 'open' : 'closed'} + onToggle={(status) => { + if (status && isSuggestionsAccordionOpen) { + setIsSuggestionsAccordionOpen(!status); + } + setIsLayerAccordionOpen(!isLayerAccordionOpen); + }} + > + - )} - - - - +
+ + + { + if (!status && isLayerAccordionOpen) { + setIsLayerAccordionOpen(status); + } + setIsSuggestionsAccordionOpen(!isSuggestionsAccordionOpen); + }} />
-
- {isInlineFooterVisible && ( - - - - - - - - - - - - - - - )} + ); } diff --git a/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/types.ts b/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/types.ts new file mode 100644 index 0000000000000..ae207865bbf9a --- /dev/null +++ b/x-pack/plugins/lens/public/app_plugin/shared/edit_on_the_fly/types.ts @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { Observable } from 'rxjs'; +import type { CoreStart } from '@kbn/core/public'; +import type { TypedLensByValueInput } from '../../../embeddable/embeddable_component'; +import type { LensPluginStartDependencies } from '../../../plugin'; +import type { DatasourceMap, VisualizationMap, FramePublicAPI } from '../../../types'; +import type { LensEmbeddableOutput } from '../../../embeddable'; +import type { LensInspector } from '../../../lens_inspector_service'; +import type { Document } from '../../../persistence'; + +export interface FlyoutWrapperProps { + children: JSX.Element; + isInlineFlyoutVisible: boolean; + isScrollable: boolean; + displayFlyoutHeader?: boolean; + language?: string; + attributesChanged?: boolean; + onCancel?: () => void; + onApply?: () => void; + navigateToLensEditor?: () => void; +} + +export interface EditConfigPanelProps { + coreStart: CoreStart; + startDependencies: LensPluginStartDependencies; + visualizationMap: VisualizationMap; + datasourceMap: DatasourceMap; + /** The attributes of the Lens embeddable */ + attributes: TypedLensByValueInput['attributes']; + /** Callback for updating the visualization and datasources state.*/ + updatePanelState: ( + datasourceState: unknown, + visualizationState: unknown, + visualizationType?: string + ) => void; + updateSuggestion?: (attrs: TypedLensByValueInput['attributes']) => void; + /** Set the attributes state */ + setCurrentAttributes?: (attrs: TypedLensByValueInput['attributes']) => void; + /** Lens visualizations can be either created from ESQL (textBased) or from dataviews (formBased) */ + datasourceId: 'formBased' | 'textBased'; + /** Embeddable output observable, useful for dashboard flyout */ + output$?: Observable; + /** Contains the active data, necessary for some panel configuration such as coloring */ + lensAdapters?: LensInspector['adapters']; + /** Optional callback called when updating the by reference embeddable */ + updateByRefInput?: (soId: string) => void; + /** Callback for closing the edit flyout */ + closeFlyout?: () => void; + /** Boolean used for adding a flyout wrapper */ + wrapInFlyout?: boolean; + /** Optional parameter for panel identification + * If not given, Lens generates a new one + */ + panelId?: string; + /** Optional parameter for saved object id + * Should be given if the lens embeddable is a by reference one + * (saved in the library) + */ + savedObjectId?: string; + /** Callback for saving the embeddable as a SO */ + saveByRef?: (attrs: Document) => void; + /** Optional callback for navigation from the header of the flyout */ + navigateToLensEditor?: () => void; + /** If set to true it displays a header on the flyout */ + displayFlyoutHeader?: boolean; + /** If set to true the layout changes to accordion and the text based query (i.e. ES|QL) can be edited */ + canEditTextBasedQuery?: boolean; +} + +export interface LayerConfigurationProps { + attributes: TypedLensByValueInput['attributes']; + coreStart: CoreStart; + startDependencies: LensPluginStartDependencies; + visualizationMap: VisualizationMap; + datasourceMap: DatasourceMap; + datasourceId: 'formBased' | 'textBased'; + framePublicAPI: FramePublicAPI; + hasPadding?: boolean; + setIsInlineFlyoutVisible: (flag: boolean) => void; +} diff --git a/x-pack/plugins/lens/public/datasources/text_based/dnd/get_drop_props.test.tsx b/x-pack/plugins/lens/public/datasources/text_based/dnd/get_drop_props.test.tsx index ef4811f254cb2..ca9d48c17cbb8 100644 --- a/x-pack/plugins/lens/public/datasources/text_based/dnd/get_drop_props.test.tsx +++ b/x-pack/plugins/lens/public/datasources/text_based/dnd/get_drop_props.test.tsx @@ -13,6 +13,7 @@ import { column3, numericDraggedColumn, fieldList, + fieldListNonNumericOnly, notNumericDraggedField, numericDraggedField, } from './mocks'; @@ -74,6 +75,23 @@ describe('Text-based: getDropProps', () => { } as unknown as DatasourceDimensionDropHandlerProps; expect(getDropProps(props)).toBeUndefined(); }); + it('should not return undefined if source is a non-numeric field, target is a metric dimension but datatable doesnt have numeric fields', () => { + const props = { + ...defaultProps, + state: { + ...defaultProps.state, + layers: { + first: { + columns: [column1, column2, column3], + allColumns: [...fieldListNonNumericOnly, column1, column2, column3], + }, + }, + fieldList: fieldListNonNumericOnly, + }, + source: notNumericDraggedField, + } as unknown as DatasourceDimensionDropHandlerProps; + expect(getDropProps(props)).toEqual({ dropTypes: ['field_replace'], nextLabel: 'category' }); + }); it('should return reorder if source and target are operations from the same group', () => { const props = { ...defaultProps, diff --git a/x-pack/plugins/lens/public/datasources/text_based/dnd/get_drop_props.tsx b/x-pack/plugins/lens/public/datasources/text_based/dnd/get_drop_props.tsx index 9f79fff3d6080..78e1c98f3a301 100644 --- a/x-pack/plugins/lens/public/datasources/text_based/dnd/get_drop_props.tsx +++ b/x-pack/plugins/lens/public/datasources/text_based/dnd/get_drop_props.tsx @@ -10,6 +10,7 @@ import { isOperation } from '../../../types'; import type { TextBasedPrivateState } from '../types'; import type { GetDropPropsArgs } from '../../../types'; import { isDraggedField, isOperationFromTheSameGroup } from '../../../utils'; +import { canColumnBeDroppedInMetricDimension } from '../utils'; export const getDropProps = ( props: GetDropPropsArgs @@ -44,14 +45,24 @@ export const getDropProps = ( return { dropTypes: ['reorder'], nextLabel }; } + const sourceFieldCanMoveToMetricDimension = canColumnBeDroppedInMetricDimension( + layer.allColumns, + sourceField?.meta?.type + ); + + const targetFieldCanMoveToMetricDimension = canColumnBeDroppedInMetricDimension( + layer.allColumns, + targetField?.meta?.type + ); + const isMoveable = !target?.isMetricDimension || - (target.isMetricDimension && sourceField?.meta?.type === 'number'); + (target.isMetricDimension && sourceFieldCanMoveToMetricDimension); if (targetColumn) { const isSwappable = (isMoveable && !source?.isMetricDimension) || - (source.isMetricDimension && targetField?.meta?.type === 'number'); + (source.isMetricDimension && targetFieldCanMoveToMetricDimension); if (isMoveable) { if (isSwappable) { return { diff --git a/x-pack/plugins/lens/public/datasources/text_based/dnd/mocks.tsx b/x-pack/plugins/lens/public/datasources/text_based/dnd/mocks.tsx index 5cb3bcd37e2cc..90a37acab1043 100644 --- a/x-pack/plugins/lens/public/datasources/text_based/dnd/mocks.tsx +++ b/x-pack/plugins/lens/public/datasources/text_based/dnd/mocks.tsx @@ -83,6 +83,37 @@ export const numericDraggedField = { }, }; +export const fieldListNonNumericOnly = [ + { + columnId: 'category', + fieldName: 'category', + meta: { + type: 'string', + }, + }, + { + columnId: 'currency', + fieldName: 'currency', + meta: { + type: 'string', + }, + }, + { + columnId: 'products.sold_date', + fieldName: 'products.sold_date', + meta: { + type: 'date', + }, + }, + { + columnId: 'products.buyer', + fieldName: 'products.buyer', + meta: { + type: 'string', + }, + }, +]; + export const fieldList = [ { columnId: 'category', diff --git a/x-pack/plugins/lens/public/datasources/text_based/layerpanel.test.tsx b/x-pack/plugins/lens/public/datasources/text_based/layerpanel.test.tsx deleted file mode 100644 index d6e4be8c99387..0000000000000 --- a/x-pack/plugins/lens/public/datasources/text_based/layerpanel.test.tsx +++ /dev/null @@ -1,86 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import type { DatatableColumn } from '@kbn/expressions-plugin/public'; -import { TextBasedPrivateState } from './types'; -import type { DataViewsState } from '../../state_management/types'; - -import { TextBasedLayerPanelProps, LayerPanel } from './layerpanel'; -import { shallowWithIntl as shallow } from '@kbn/test-jest-helpers'; -import { ChangeIndexPattern } from '../../shared_components/dataview_picker/dataview_picker'; - -const fields = [ - { - name: 'timestamp', - id: 'timestamp', - meta: { - type: 'date', - }, - }, - { - name: 'bytes', - id: 'bytes', - meta: { - type: 'number', - }, - }, - { - name: 'memory', - id: 'memory', - meta: { - type: 'number', - }, - }, -] as DatatableColumn[]; - -const initialState: TextBasedPrivateState = { - layers: { - first: { - index: '1', - columns: [], - allColumns: [], - query: { sql: 'SELECT * FROM foo' }, - }, - }, - indexPatternRefs: [ - { id: '1', title: 'my-fake-index-pattern' }, - { id: '2', title: 'my-fake-restricted-pattern' }, - { id: '3', title: 'my-compatible-pattern' }, - ], - fieldList: fields, -}; -describe('Layer Data Panel', () => { - let defaultProps: TextBasedLayerPanelProps; - - beforeEach(() => { - defaultProps = { - layerId: 'first', - state: initialState, - onChangeIndexPattern: jest.fn(), - dataViews: { - indexPatternRefs: [ - { id: '1', title: 'my-fake-index-pattern', name: 'My fake index pattern' }, - { id: '2', title: 'my-fake-restricted-pattern', name: 'my-fake-restricted-pattern' }, - { id: '3', title: 'my-compatible-pattern', name: 'my-compatible-pattern' }, - ], - indexPatterns: {}, - } as DataViewsState, - }; - }); - - it('should display the selected dataview but disabled', () => { - const instance = shallow(); - expect(instance.find(ChangeIndexPattern).prop('trigger')).toStrictEqual({ - fontWeight: 'normal', - isDisabled: true, - label: 'my-fake-index-pattern', - size: 's', - title: 'my-fake-index-pattern', - }); - }); -}); diff --git a/x-pack/plugins/lens/public/datasources/text_based/layerpanel.tsx b/x-pack/plugins/lens/public/datasources/text_based/layerpanel.tsx deleted file mode 100644 index 3ce8c333114ff..0000000000000 --- a/x-pack/plugins/lens/public/datasources/text_based/layerpanel.tsx +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import { DatasourceLayerPanelProps } from '../../types'; -import { TextBasedPrivateState } from './types'; -import { ChangeIndexPattern } from '../../shared_components/dataview_picker/dataview_picker'; - -export interface TextBasedLayerPanelProps extends DatasourceLayerPanelProps { - state: TextBasedPrivateState; -} - -export function LayerPanel({ state, layerId, dataViews }: TextBasedLayerPanelProps) { - const layer = state.layers[layerId]; - const dataView = state.indexPatternRefs.find((ref) => ref.id === layer.index); - - const notFoundTitleLabel = i18n.translate('xpack.lens.layerPanel.missingDataView', { - defaultMessage: 'Data view not found', - }); - return ( - {}} - /> - ); -} diff --git a/x-pack/plugins/lens/public/datasources/text_based/text_based_languages.test.ts b/x-pack/plugins/lens/public/datasources/text_based/text_based_languages.test.ts index 8aab2f3670959..1528a3ff623c0 100644 --- a/x-pack/plugins/lens/public/datasources/text_based/text_based_languages.test.ts +++ b/x-pack/plugins/lens/public/datasources/text_based/text_based_languages.test.ts @@ -116,7 +116,7 @@ describe('Textbased Data Source', () => { }, ], index: 'foo', - query: { sql: 'SELECT * FROM foo' }, + query: { esql: 'FROM foo' }, }, }, fieldList: [ @@ -226,7 +226,7 @@ describe('Textbased Data Source', () => { }, }, ], - query: { sql: 'SELECT * FROM foo' }, + query: { esql: 'FROM foo' }, index: 'foo', }, }, @@ -261,7 +261,7 @@ describe('Textbased Data Source', () => { ...baseState.layers, newLayer: { index: 'foo', - query: { sql: 'SELECT * FROM foo' }, + query: { esql: 'FROM foo' }, allColumns: [ { columnId: 'col1', @@ -296,7 +296,7 @@ describe('Textbased Data Source', () => { }, }, ], - query: { sql: 'SELECT * FROM foo' }, + query: { esql: 'FROM foo' }, index: 'foo', }, }, @@ -353,7 +353,7 @@ describe('Textbased Data Source', () => { }, }, ], - query: { sql: 'SELECT * FROM foo' }, + query: { esql: 'FROM foo' }, index: 'foo', }, }, @@ -402,12 +402,20 @@ describe('Textbased Data Source', () => { expect(suggestions[0].state).toEqual({ ...state, fieldList: textBasedQueryColumns, + indexPatternRefs: [ + { + id: '1', + timeField: undefined, + title: 'foo', + }, + ], layers: { newid: { allColumns: [ { columnId: 'bytes', fieldName: 'bytes', + inMetricDimension: true, meta: { type: 'number', }, @@ -424,6 +432,7 @@ describe('Textbased Data Source', () => { { columnId: 'bytes', fieldName: 'bytes', + inMetricDimension: true, meta: { type: 'number', }, @@ -466,6 +475,7 @@ describe('Textbased Data Source', () => { ], isMultiRow: false, layerId: 'newid', + notAssignedMetrics: false, }); }); @@ -504,6 +514,175 @@ describe('Textbased Data Source', () => { ); expect(suggestions).toEqual([]); }); + + it('should return the correct suggestions if non numeric columns are given', () => { + const textBasedQueryColumns = [ + { + id: '@timestamp', + name: '@timestamp', + meta: { + type: 'date', + }, + }, + { + id: 'dest', + name: 'dest', + meta: { + type: 'string', + }, + }, + ]; + const state = { + layers: {}, + initialContext: { + textBasedColumns: textBasedQueryColumns, + query: { esql: 'from foo' }, + dataViewSpec: { + title: 'foo', + id: '1', + name: 'Foo', + }, + }, + } as unknown as TextBasedPrivateState; + const suggestions = TextBasedDatasource.getDatasourceSuggestionsForVisualizeField( + state, + '1', + '', + indexPatterns + ); + expect(suggestions[0].state).toEqual({ + ...state, + fieldList: textBasedQueryColumns, + indexPatternRefs: [ + { + id: '1', + timeField: undefined, + title: 'foo', + }, + ], + layers: { + newid: { + allColumns: [ + { + columnId: '@timestamp', + fieldName: '@timestamp', + inMetricDimension: true, + meta: { + type: 'date', + }, + }, + { + columnId: 'dest', + fieldName: 'dest', + inMetricDimension: true, + meta: { + type: 'string', + }, + }, + ], + columns: [ + { + columnId: '@timestamp', + fieldName: '@timestamp', + inMetricDimension: true, + meta: { + type: 'date', + }, + }, + { + columnId: 'dest', + fieldName: 'dest', + inMetricDimension: true, + meta: { + type: 'string', + }, + }, + ], + index: '1', + query: { + esql: 'from foo', + }, + }, + }, + }); + + expect(suggestions[0].table).toEqual({ + changeType: 'initial', + columns: [ + { + columnId: '@timestamp', + operation: { + dataType: 'date', + isBucketed: true, + label: '@timestamp', + }, + }, + { + columnId: 'dest', + operation: { + dataType: 'string', + isBucketed: true, + label: 'dest', + }, + }, + ], + isMultiRow: false, + layerId: 'newid', + notAssignedMetrics: true, + }); + }); + }); + + describe('#suggestsLimitedColumns', () => { + it('should return true if query returns big number of columns', () => { + const fieldList = [ + { + id: 'a', + name: 'Test 1', + meta: { + type: 'number', + }, + }, + { + id: 'b', + name: 'Test 2', + meta: { + type: 'number', + }, + }, + { + id: 'c', + name: 'Test 3', + meta: { + type: 'date', + }, + }, + { + id: 'd', + name: 'Test 4', + meta: { + type: 'string', + }, + }, + { + id: 'e', + name: 'Test 5', + meta: { + type: 'string', + }, + }, + ]; + const state = { + fieldList, + layers: { + a: { + query: { esql: 'from foo' }, + index: 'foo', + }, + }, + } as unknown as TextBasedPrivateState; + expect(TextBasedDatasource?.suggestsLimitedColumns?.(state)).toBeTruthy(); + }); }); describe('#getUserMessages', () => { @@ -544,7 +723,7 @@ describe('Textbased Data Source', () => { }, ], errors: [new Error('error 1'), new Error('error 2')], - query: { sql: 'SELECT * FROM foo' }, + query: { esql: 'FROM foo' }, index: 'foo', }, }, @@ -626,7 +805,7 @@ describe('Textbased Data Source', () => { }, }, ], - query: { sql: 'SELECT * FROM foo' }, + query: { esql: 'FROM foo' }, index: '1', }, }, @@ -673,7 +852,7 @@ describe('Textbased Data Source', () => { }, }, ], - query: { sql: 'SELECT * FROM foo' }, + query: { esql: 'FROM foo' }, index: '1', }, }, @@ -731,7 +910,7 @@ describe('Textbased Data Source', () => { }, }, ], - query: { sql: 'SELECT * FROM foo' }, + query: { esql: 'FROM foo' }, index: '1', }, }, @@ -759,11 +938,14 @@ describe('Textbased Data Source', () => { }, Object { "arguments": Object { + "locale": Array [ + "en", + ], "query": Array [ - "SELECT * FROM foo", + "FROM foo", ], }, - "function": "essql", + "function": "esql", "type": "function", }, Object { diff --git a/x-pack/plugins/lens/public/datasources/text_based/text_based_languages.tsx b/x-pack/plugins/lens/public/datasources/text_based/text_based_languages.tsx index 43d971caf24a9..a24c52b88d217 100644 --- a/x-pack/plugins/lens/public/datasources/text_based/text_based_languages.tsx +++ b/x-pack/plugins/lens/public/datasources/text_based/text_based_languages.tsx @@ -42,10 +42,10 @@ import type { } from './types'; import { FieldSelect } from './field_select'; import type { Datasource } from '../../types'; -import { LayerPanel } from './layerpanel'; import { getUniqueLabelGenerator, nonNullable } from '../../utils'; import { onDrop, getDropProps } from './dnd'; import { removeColumn } from './remove_column'; +import { canColumnBeUsedBeInMetricDimension, MAX_NUM_OF_COLUMNS } from './utils'; function getLayerReferenceName(layerId: string) { return `textBasedLanguages-datasource-layer-${layerId}`; @@ -88,12 +88,20 @@ export function getTextBasedDatasource({ layerId: id, columns: layer.columns?.map((f) => { + const inMetricDimension = canColumnBeUsedBeInMetricDimension( + layer.allColumns, + f?.meta?.type + ); return { columnId: f.columnId, operation: { dataType: f?.meta?.type as DataType, label: f.fieldName, isBucketed: Boolean(f?.meta?.type !== 'number'), + // makes non-number fields to act as metrics, used for datatable suggestions + ...(inMetricDimension && { + inMetricDimension, + }), }, }; }) ?? [], @@ -113,11 +121,23 @@ export function getTextBasedDatasource({ if (context && 'dataViewSpec' in context && context.dataViewSpec.title && context.query) { const newLayerId = generateId(); const textBasedQueryColumns = context.textBasedColumns ?? []; + // Number fields are assigned automatically as metrics (!isBucketed). There are cases where the query + // will not return number fields. In these cases we want to suggest a datatable + // Datatable works differently in this case. On the metrics dimension can be all type of fields + const hasNumberTypeColumns = textBasedQueryColumns?.some((c) => c?.meta?.type === 'number'); const newColumns = textBasedQueryColumns.map((c) => { + const inMetricDimension = canColumnBeUsedBeInMetricDimension( + textBasedQueryColumns, + c?.meta?.type + ); return { columnId: c.id, fieldName: c.name, meta: c.meta, + // makes non-number fields to act as metrics, used for datatable suggestions + ...(inMetricDimension && { + inMetricDimension, + }), }; }); @@ -126,12 +146,23 @@ export function getTextBasedDatasource({ const updatedState = { ...state, fieldList: textBasedQueryColumns, + ...(context.dataViewSpec.id + ? { + indexPatternRefs: [ + { + id: context.dataViewSpec.id, + title: context.dataViewSpec.title, + timeField: context.dataViewSpec.timeFieldName, + }, + ], + } + : {}), layers: { ...state.layers, [newLayerId]: { index, query, - columns: newColumns ?? [], + columns: newColumns.slice(0, MAX_NUM_OF_COLUMNS) ?? [], allColumns: newColumns ?? [], timeField: context.dataViewSpec.timeFieldName, }, @@ -146,9 +177,10 @@ export function getTextBasedDatasource({ table: { changeType: 'initial' as TableChangeType, isMultiRow: false, + notAssignedMetrics: !hasNumberTypeColumns, layerId: newLayerId, columns: - newColumns?.map((f) => { + newColumns?.slice(0, MAX_NUM_OF_COLUMNS)?.map((f) => { return { columnId: f.columnId, operation: { @@ -304,6 +336,13 @@ export function getTextBasedDatasource({ getLayers(state: TextBasedPrivateState) { return state && state.layers ? Object.keys(state?.layers) : []; }, + // there are cases where a query can return a big amount of columns + // at this case we don't suggest all columns in a table but the first + // MAX_NUM_OF_COLUMNS + suggestsLimitedColumns(state: TextBasedPrivateState) { + const fieldsList = state?.fieldList ?? []; + return fieldsList.length >= MAX_NUM_OF_COLUMNS; + }, isTimeBased: (state, indexPatterns) => { if (!state) return false; const { layers } = state; @@ -382,20 +421,21 @@ export function getTextBasedDatasource({ DimensionEditorComponent: (props: DatasourceDimensionEditorProps) => { const fields = props.state.fieldList; - const selectedField = props.state.layers[props.layerId]?.allColumns?.find( - (column) => column.columnId === props.columnId - ); + const allColumns = props.state.layers[props.layerId]?.allColumns; + const selectedField = allColumns?.find((column) => column.columnId === props.columnId); + const hasNumberTypeColumns = allColumns?.some((c) => c?.meta?.type === 'number'); const updatedFields = fields?.map((f) => { return { ...f, - compatible: props.isMetricDimension - ? props.filterOperations({ - dataType: f.meta.type as DataType, - isBucketed: Boolean(f?.meta?.type !== 'number'), - scale: 'ordinal', - }) - : true, + compatible: + props.isMetricDimension && hasNumberTypeColumns + ? props.filterOperations({ + dataType: f.meta.type as DataType, + isBucketed: Boolean(f?.meta?.type !== 'number'), + scale: 'ordinal', + }) + : true, }; }); return ( @@ -472,7 +512,7 @@ export function getTextBasedDatasource({ }, LayerPanelComponent: (props: DatasourceLayerPanelProps) => { - return ; + return null; }, uniqueLabels(state: TextBasedPrivateState) { @@ -519,6 +559,7 @@ export function getTextBasedDatasource({ dataType: column?.meta?.type as DataType, label: columnLabelMap[columnId] ?? column?.fieldName, isBucketed: Boolean(column?.meta?.type !== 'number'), + inMetricDimension: column.inMetricDimension, hasTimeShift: false, hasReducedTimeRange: false, }; diff --git a/x-pack/plugins/lens/public/datasources/text_based/types.ts b/x-pack/plugins/lens/public/datasources/text_based/types.ts index 8da183f9b9054..4d1f9dfea510f 100644 --- a/x-pack/plugins/lens/public/datasources/text_based/types.ts +++ b/x-pack/plugins/lens/public/datasources/text_based/types.ts @@ -13,6 +13,7 @@ export interface TextBasedLayerColumn { columnId: string; fieldName: string; meta?: DatatableColumn['meta']; + inMetricDimension?: boolean; } export interface TextBasedField { diff --git a/x-pack/plugins/lens/public/datasources/text_based/utils.test.ts b/x-pack/plugins/lens/public/datasources/text_based/utils.test.ts index 593d34f450212..3a01a7ba9efea 100644 --- a/x-pack/plugins/lens/public/datasources/text_based/utils.test.ts +++ b/x-pack/plugins/lens/public/datasources/text_based/utils.test.ts @@ -15,6 +15,7 @@ import { loadIndexPatternRefs, getStateFromAggregateQuery, getAllColumns, + canColumnBeUsedBeInMetricDimension, } from './utils'; import type { TextBasedLayerColumn } from './types'; import { type AggregateQuery } from '@kbn/es-query'; @@ -485,4 +486,111 @@ describe('Text based languages utils', () => { }); }); }); + + describe('canColumnBeUsedBeInMetricDimension', () => { + it('should return true if there are non numeric field', async () => { + const fieldList = [ + { + id: 'a', + name: 'Test 1', + meta: { + type: 'string', + }, + }, + { + id: 'b', + name: 'Test 2', + meta: { + type: 'string', + }, + }, + ] as DatatableColumn[]; + const flag = canColumnBeUsedBeInMetricDimension(fieldList, 'string'); + expect(flag).toBeTruthy(); + }); + + it('should return true if there are numeric field and the selected type is number', async () => { + const fieldList = [ + { + id: 'a', + name: 'Test 1', + meta: { + type: 'number', + }, + }, + { + id: 'b', + name: 'Test 2', + meta: { + type: 'string', + }, + }, + ] as DatatableColumn[]; + const flag = canColumnBeUsedBeInMetricDimension(fieldList, 'number'); + expect(flag).toBeTruthy(); + }); + + it('should return false if there are non numeric fields and the selected type is non numeric', async () => { + const fieldList = [ + { + id: 'a', + name: 'Test 1', + meta: { + type: 'number', + }, + }, + { + id: 'b', + name: 'Test 2', + meta: { + type: 'string', + }, + }, + ] as DatatableColumn[]; + const flag = canColumnBeUsedBeInMetricDimension(fieldList, 'date'); + expect(flag).toBeFalsy(); + }); + + it('should return true if there are many columns regardless the types', async () => { + const fieldList = [ + { + id: 'a', + name: 'Test 1', + meta: { + type: 'number', + }, + }, + { + id: 'b', + name: 'Test 2', + meta: { + type: 'number', + }, + }, + { + id: 'c', + name: 'Test 3', + meta: { + type: 'date', + }, + }, + { + id: 'd', + name: 'Test 4', + meta: { + type: 'string', + }, + }, + { + id: 'e', + name: 'Test 5', + meta: { + type: 'string', + }, + }, + ] as DatatableColumn[]; + const flag = canColumnBeUsedBeInMetricDimension(fieldList, 'date'); + expect(flag).toBeTruthy(); + }); + }); }); diff --git a/x-pack/plugins/lens/public/datasources/text_based/utils.ts b/x-pack/plugins/lens/public/datasources/text_based/utils.ts index aea93add73680..ecf4fbcd12ff2 100644 --- a/x-pack/plugins/lens/public/datasources/text_based/utils.ts +++ b/x-pack/plugins/lens/public/datasources/text_based/utils.ts @@ -20,6 +20,8 @@ import { fetchDataFromAggregateQuery } from './fetch_data_from_aggregate_query'; import type { IndexPatternRef, TextBasedPrivateState, TextBasedLayerColumn } from './types'; import type { DataViewsState } from '../../state_management'; +export const MAX_NUM_OF_COLUMNS = 5; + export async function loadIndexPatternRefs( indexPatternsService: DataViewsPublicPluginStart ): Promise { @@ -146,3 +148,25 @@ export function getIndexPatternFromTextBasedQuery(query: AggregateQuery): string return indexPattern; } + +export function canColumnBeDroppedInMetricDimension( + columns: TextBasedLayerColumn[] | DatatableColumn[], + selectedColumnType?: string +): boolean { + // check if at least one numeric field exists + const hasNumberTypeColumns = columns?.some((c) => c?.meta?.type === 'number'); + return !hasNumberTypeColumns || (hasNumberTypeColumns && selectedColumnType === 'number'); +} + +export function canColumnBeUsedBeInMetricDimension( + columns: TextBasedLayerColumn[] | DatatableColumn[], + selectedColumnType?: string +): boolean { + // check if at least one numeric field exists + const hasNumberTypeColumns = columns?.some((c) => c?.meta?.type === 'number'); + return ( + !hasNumberTypeColumns || + columns.length >= MAX_NUM_OF_COLUMNS || + (hasNumberTypeColumns && selectedColumnType === 'number') + ); +} 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 41184d2212c45..8c31fb2f9d800 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 @@ -370,7 +370,7 @@ export function LayerPanels( } }, registerLibraryAnnotationGroup: registerLibraryAnnotationGroupFunction, - isInlineEditing: Boolean(props?.setIsInlineFlyoutFooterVisible), + isInlineEditing: Boolean(props?.setIsInlineFlyoutVisible), })} ); 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 78a06408902b5..024fb04998d37 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 @@ -95,7 +95,7 @@ export function LayerPanel( indexPatternService?: IndexPatternServiceAPI; getUserMessages?: UserMessagesGetter; displayLayerSettings: boolean; - setIsInlineFlyoutFooterVisible?: (status: boolean) => void; + setIsInlineFlyoutVisible?: (status: boolean) => void; } ) { const [activeDimension, setActiveDimension] = useState( @@ -139,7 +139,7 @@ export function LayerPanel( useEffect(() => { // is undefined when the dimension panel is closed const activeDimensionId = activeDimension.activeId; - props?.setIsInlineFlyoutFooterVisible?.(!Boolean(activeDimensionId)); + props?.setIsInlineFlyoutVisible?.(!Boolean(activeDimensionId)); }, [activeDimension.activeId, activeVisualization.id, props]); const panelRef = useRef(null); @@ -394,6 +394,7 @@ export function LayerPanel( )} {props.indexPatternService && + !isTextBasedLanguage && (layerDatasource || activeVisualization.LayerPanelComponent) && ( )} @@ -680,7 +681,7 @@ export function LayerPanel( setPanelSettingsOpen(false); return true; }} - isInlineEditing={Boolean(props?.setIsInlineFlyoutFooterVisible)} + isInlineEditing={Boolean(props?.setIsInlineFlyoutVisible)} >
@@ -749,7 +750,7 @@ export function LayerPanel( isOpen={isDimensionPanelOpen} isFullscreen={isFullscreen} groupLabel={activeGroup?.dimensionEditorGroupLabel ?? (activeGroup?.groupLabel || '')} - isInlineEditing={Boolean(props?.setIsInlineFlyoutFooterVisible)} + isInlineEditing={Boolean(props?.setIsInlineFlyoutVisible)} handleClose={() => { if (layerDatasource) { if (layerDatasource.updateStateOnCloseDimension) { @@ -828,7 +829,7 @@ export function LayerPanel( addLayer: props.addLayer, removeLayer: props.onRemoveLayer, panelRef, - isInlineEditing: Boolean(props?.setIsInlineFlyoutFooterVisible), + isInlineEditing: Boolean(props?.setIsInlineFlyoutVisible), }} />
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/types.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/types.ts index 6d06dfb7e6aac..2ef775b51f54b 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/types.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/types.ts @@ -29,7 +29,7 @@ export interface ConfigPanelWrapperProps { uiActions: UiActionsStart; getUserMessages?: UserMessagesGetter; hideLayerHeader?: boolean; - setIsInlineFlyoutFooterVisible?: (status: boolean) => void; + setIsInlineFlyoutVisible?: (status: boolean) => void; } export interface LayerPanelProps { 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 c1032d144ac33..fd2868b8a4063 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 @@ -150,6 +150,7 @@ export function getSuggestions({ return filteredCount || filteredCount === datasourceSuggestion.keptLayerIds.length; }) .flatMap((datasourceSuggestion) => { + const datasourceId = datasourceSuggestion.datasourceId; const table = datasourceSuggestion.table; const currentVisualizationState = visualizationId === activeVisualization?.id ? visualizationState : undefined; @@ -170,7 +171,8 @@ export function getSuggestions({ palette, visualizeTriggerFieldContext && 'isVisualizeAction' in visualizeTriggerFieldContext, activeData, - allowMixed + allowMixed, + datasourceId ); }); }) @@ -240,7 +242,8 @@ function getVisualizationSuggestions( mainPalette?: SuggestionRequest['mainPalette'], isFromContext?: boolean, activeData?: Record, - allowMixed?: boolean + allowMixed?: boolean, + datasourceId?: string ) { try { return visualization @@ -253,6 +256,7 @@ function getVisualizationSuggestions( isFromContext, activeData, allowMixed, + datasourceId, }) .map(({ state, ...visualizationSuggestion }) => ({ ...visualizationSuggestion, diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.scss index f139bbe3ca122..cd2ee706c1e18 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.scss +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.scss @@ -1,6 +1,10 @@ @import '../../mixins'; @import '../../variables'; +.lnsSuggestionPanel .euiAccordion__buttonContent { + width: 100%; +} + .lnsSuggestionPanel__suggestions { @include euiScrollBar; @include lnsOverflowShadowHorizontal; @@ -16,14 +20,9 @@ margin-right: -$euiSizeXS; } -.lnsSuggestionPanel { - padding-bottom: $euiSizeS; -} - .lnsSuggestionPanel__button { position: relative; // Let the expression progress indicator position itself against the button flex: 0 0 auto; - width: $lnsSuggestionWidth !important; // sass-lint:disable-line no-important height: $lnsSuggestionHeight; margin-right: $euiSizeS; margin-left: $euiSizeXS / 2; @@ -58,6 +57,10 @@ } } +.lnsSuggestionPanel__button-fixedWidth { + width: $lnsSuggestionWidth !important; // sass-lint:disable-line no-important +} + .lnsSuggestionPanel__suggestionIcon { color: $euiColorDarkShade; width: 100%; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx index a4e65b280d203..c487f31fd82ff 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx @@ -10,6 +10,7 @@ import './suggestion_panel.scss'; import { camelCase, pick } from 'lodash'; import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; +import { css } from '@emotion/react'; import useLocalStorage from 'react-use/lib/useLocalStorage'; import { EuiIcon, @@ -20,7 +21,9 @@ import { EuiButtonEmpty, EuiAccordion, EuiText, + EuiNotificationBadge, } from '@elastic/eui'; +import { euiThemeVars } from '@kbn/ui-theme'; import { IconType } from '@elastic/eui/src/components/icon/icon'; import { Ast, fromExpression, toExpression } from '@kbn/interpreter'; import { i18n } from '@kbn/i18n'; @@ -65,6 +68,7 @@ import { selectFramePublicAPI, } from '../../state_management'; import { filterAndSortUserMessages } from '../../app_plugin/get_application_user_messages'; + const MAX_SUGGESTIONS_DISPLAYED = 5; const LOCAL_STORAGE_SUGGESTIONS_PANEL = 'LENS_SUGGESTIONS_PANEL_HIDDEN'; @@ -99,10 +103,13 @@ export interface SuggestionPanelProps { visualizationMap: VisualizationMap; ExpressionRenderer: ReactExpressionRendererType; frame: FramePublicAPI; - getUserMessages: UserMessagesGetter; + getUserMessages?: UserMessagesGetter; nowProvider: DataPublicPluginStart['nowProvider']; core: CoreStart; showOnlyIcons?: boolean; + wrapSuggestions?: boolean; + isAccordionOpen?: boolean; + toggleAccordionCb?: (flag: boolean) => void; } const PreviewRenderer = ({ @@ -165,6 +172,7 @@ const SuggestionPreview = ({ onSelect, showTitleAsLabel, onRender, + wrapSuggestions, }: { onSelect: () => void; preview: { @@ -177,15 +185,30 @@ const SuggestionPreview = ({ selected: boolean; showTitleAsLabel?: boolean; onRender: () => void; + wrapSuggestions?: boolean; }) => { return ( - +
selectFramePublicAPI(state, datasourceMap)); const changesApplied = useLensSelector(selectChangesApplied); // get user's selection from localStorage, this key defines if the suggestions panel will be hidden or not + const initialAccordionStatusValue = + typeof isAccordionOpen !== 'undefined' ? !Boolean(isAccordionOpen) : false; const [hideSuggestions, setHideSuggestions] = useLocalStorage( LOCAL_STORAGE_SUGGESTIONS_PANEL, - false + initialAccordionStatusValue ); + useEffect(() => { + if (typeof isAccordionOpen !== 'undefined') { + setHideSuggestions(!Boolean(isAccordionOpen)); + } + }, [isAccordionOpen, setHideSuggestions]); const toggleSuggestions = useCallback(() => { setHideSuggestions(!hideSuggestions); - }, [setHideSuggestions, hideSuggestions]); + toggleAccordionCb?.(!hideSuggestions); + }, [setHideSuggestions, hideSuggestions, toggleAccordionCb]); const missingIndexPatterns = getMissingIndexPattern( activeDatasourceId ? datasourceMap[activeDatasourceId] : null, @@ -304,8 +338,10 @@ export function SuggestionPanel({ ), })); - const hasErrors = - getUserMessages(['visualization', 'visualizationInEditor'], { severity: 'error' }).length > 0; + const hasErrors = getUserMessages + ? getUserMessages(['visualization', 'visualizationInEditor'], { severity: 'error' }).length > + 0 + : false; const newStateExpression = currentVisualization.state && currentVisualization.activeId && !hasErrors @@ -450,6 +486,7 @@ export function SuggestionPanel({ selected={lastSelectedSuggestion === -1} showTitleAsLabel onRender={() => onSuggestionRender(0)} + wrapSuggestions={wrapSuggestions} /> )} {!hideSuggestions && @@ -474,64 +511,88 @@ export function SuggestionPanel({ selected={index === lastSelectedSuggestion} onRender={() => onSuggestionRender(index + 1)} showTitleAsLabel={showOnlyIcons} + wrapSuggestions={wrapSuggestions} /> ); })} ); }; - + const title = ( + +

+ +

+
+ ); return ( -
- -

- -

- - } - forceState={hideSuggestions ? 'closed' : 'open'} - onToggle={toggleSuggestions} - extraAction={ - existsStagedPreview && - !hideSuggestions && ( - - { - dispatchLens(submitSuggestion()); - }} - > - {i18n.translate('xpack.lens.sugegstion.refreshSuggestionLabel', { - defaultMessage: 'Refresh', + + {existsStagedPreview && ( + - - ) - } + > + { + dispatchLens(submitSuggestion()); + }} + > + {i18n.translate('xpack.lens.sugegstion.refreshSuggestionLabel', { + defaultMessage: 'Refresh', + })} + + + )} + {wrapSuggestions && ( + + {suggestions.length + 1} + + )} + + ) + } + > +
-
- {changesApplied ? renderSuggestionsUI() : renderApplyChangesPrompt()} -
- -
+ {changesApplied ? renderSuggestionsUI() : renderApplyChangesPrompt()} +
+ ); } diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.scss index 391361570d04b..2c0d374a7d07f 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.scss +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.scss @@ -149,3 +149,10 @@ 75% { transform: translateY(15%); } 100% { transform: translateY(10%); } } + +.lnsVisualizationToolbar--fixed { + position: fixed; + width: 100%; + z-index: 1; + background-color: $euiColorLightestShade; +} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.tsx index ea2c8d27086fb..f5ee47cbcd294 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.tsx @@ -53,10 +53,11 @@ export interface WorkspacePanelWrapperProps { export function VisualizationToolbar(props: { activeVisualization: Visualization | null; framePublicAPI: FramePublicAPI; + isFixedPosition?: boolean; }) { const dispatchLens = useLensDispatch(); const visualization = useLensSelector(selectVisualizationState); - const { activeVisualization } = props; + const { activeVisualization, isFixedPosition } = props; const setVisualizationState = useCallback( (newState: unknown) => { if (!activeVisualization) { @@ -77,7 +78,12 @@ export function VisualizationToolbar(props: { return ( <> {ToolbarComponent && ( - + {ToolbarComponent({ frame: props.framePublicAPI, state: visualization.state, diff --git a/x-pack/plugins/lens/public/embeddable/embeddable.tsx b/x-pack/plugins/lens/public/embeddable/embeddable.tsx index bb99e061893f2..16d730139c14a 100644 --- a/x-pack/plugins/lens/public/embeddable/embeddable.tsx +++ b/x-pack/plugins/lens/public/embeddable/embeddable.tsx @@ -747,7 +747,11 @@ export class Embeddable * Gets the Lens embeddable's datasource and visualization states * updates the embeddable input */ - async updateVisualization(datasourceState: unknown, visualizationState: unknown) { + async updateVisualization( + datasourceState: unknown, + visualizationState: unknown, + visualizationType?: string + ) { const viz = this.savedVis; const activeDatasourceId = (this.activeDatasourceId ?? 'formBased') as EditLensConfigurationProps['datasourceId']; @@ -769,7 +773,7 @@ export class Embeddable ), visualizationState, activeVisualization: this.activeVisualizationId - ? this.deps.visualizationMap[this.activeVisualizationId] + ? this.deps.visualizationMap[visualizationType ?? this.activeVisualizationId] : undefined, }); const attrs = { @@ -780,6 +784,7 @@ export class Embeddable datasourceStates, }, references, + visualizationType: visualizationType ?? viz.visualizationType, }; /** @@ -795,6 +800,15 @@ export class Embeddable } } + async updateSuggestion(attrs: LensSavedObjectAttributes) { + const viz = this.savedVis; + const newViz = { + ...viz, + ...attrs, + }; + this.updateInput({ attributes: newViz }); + } + /** * Callback which allows the navigation to the editor. * Used for the Edit in Lens link inside the inline editing flyout. @@ -848,6 +862,7 @@ export class Embeddable ); } diff --git a/x-pack/plugins/lens/public/mocks/dataview_mock.ts b/x-pack/plugins/lens/public/mocks/dataview_mock.ts new file mode 100644 index 0000000000000..880b9a3f287db --- /dev/null +++ b/x-pack/plugins/lens/public/mocks/dataview_mock.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { DataView } from '@kbn/data-views-plugin/public'; +import { buildDataViewMock } from '@kbn/discover-utils/src/__mocks__'; + +const fields = [ + { + name: '_index', + type: 'string', + scripted: false, + filterable: true, + }, + { + name: '@timestamp', + displayName: 'timestamp', + type: 'date', + scripted: false, + filterable: true, + aggregatable: true, + sortable: true, + }, + { + name: 'message', + displayName: 'message', + type: 'string', + scripted: false, + filterable: false, + }, + { + name: 'extension', + displayName: 'extension', + type: 'string', + scripted: false, + filterable: true, + aggregatable: true, + }, + { + name: 'bytes', + displayName: 'bytes', + type: 'number', + scripted: false, + filterable: true, + aggregatable: true, + }, +] as DataView['fields']; + +export const mockDataViewWithTimefield = buildDataViewMock({ + name: 'index-pattern-with-timefield', + fields, + timeFieldName: '%timestamp', +}); diff --git a/x-pack/plugins/lens/public/mocks/index.ts b/x-pack/plugins/lens/public/mocks/index.ts index 6cc3ff02ff92e..f90ecc9b99fe9 100644 --- a/x-pack/plugins/lens/public/mocks/index.ts +++ b/x-pack/plugins/lens/public/mocks/index.ts @@ -29,6 +29,8 @@ export { renderWithReduxStore, } from './store_mocks'; export { lensPluginMock } from './lens_plugin_mock'; +export { mockDataViewWithTimefield } from './dataview_mock'; +export { mockAllSuggestions } from './suggestions_mock'; export type FrameMock = jest.Mocked; diff --git a/x-pack/plugins/lens/public/mocks/suggestions_mock.ts b/x-pack/plugins/lens/public/mocks/suggestions_mock.ts new file mode 100644 index 0000000000000..0ed32fbfd84da --- /dev/null +++ b/x-pack/plugins/lens/public/mocks/suggestions_mock.ts @@ -0,0 +1,291 @@ +/* + * 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 type { Suggestion } from '../types'; + +export const currentSuggestionMock = { + title: 'Heat map', + hide: false, + score: 0.6, + previewIcon: 'heatmap', + visualizationId: 'lnsHeatmap', + visualizationState: { + shape: 'heatmap', + layerId: '46aa21fa-b747-4543-bf90-0b40007c546d', + layerType: 'data', + legend: { + isVisible: true, + position: 'right', + type: 'heatmap_legend', + }, + gridConfig: { + type: 'heatmap_grid', + isCellLabelVisible: false, + isYAxisLabelVisible: true, + isXAxisLabelVisible: true, + isYAxisTitleVisible: false, + isXAxisTitleVisible: false, + }, + valueAccessor: '5b9b8b76-0836-4a12-b9c0-980c9900502f', + xAccessor: '81e332d6-ee37-42a8-a646-cea4fc75d2d3', + }, + keptLayerIds: ['46aa21fa-b747-4543-bf90-0b40007c546d'], + datasourceState: { + layers: { + '46aa21fa-b747-4543-bf90-0b40007c546d': { + index: 'd3d7af60-4c81-11e8-b3d7-01146121b73d', + query: { + esql: 'FROM kibana_sample_data_flights | keep Dest, AvgTicketPrice', + }, + columns: [ + { + columnId: '81e332d6-ee37-42a8-a646-cea4fc75d2d3', + fieldName: 'Dest', + meta: { + type: 'string', + }, + }, + { + columnId: '5b9b8b76-0836-4a12-b9c0-980c9900502f', + fieldName: 'AvgTicketPrice', + meta: { + type: 'number', + }, + }, + ], + allColumns: [ + { + columnId: '81e332d6-ee37-42a8-a646-cea4fc75d2d3', + fieldName: 'Dest', + meta: { + type: 'string', + }, + }, + { + columnId: '5b9b8b76-0836-4a12-b9c0-980c9900502f', + fieldName: 'AvgTicketPrice', + meta: { + type: 'number', + }, + }, + ], + timeField: 'timestamp', + }, + }, + fieldList: [], + indexPatternRefs: [], + initialContext: { + dataViewSpec: { + id: 'd3d7af60-4c81-11e8-b3d7-01146121b73d', + version: 'WzM1ODA3LDFd', + title: 'kibana_sample_data_flights', + timeFieldName: 'timestamp', + sourceFilters: [], + fields: { + AvgTicketPrice: { + count: 0, + name: 'AvgTicketPrice', + type: 'number', + esTypes: ['float'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + format: { + id: 'number', + params: { + pattern: '$0,0.[00]', + }, + }, + shortDotsEnable: false, + isMapped: true, + }, + Dest: { + count: 0, + name: 'Dest', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + format: { + id: 'string', + }, + shortDotsEnable: false, + isMapped: true, + }, + timestamp: { + count: 0, + name: 'timestamp', + type: 'date', + esTypes: ['date'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + format: { + id: 'date', + }, + shortDotsEnable: false, + isMapped: true, + }, + }, + allowNoIndex: false, + name: 'Kibana Sample Data Flights', + }, + fieldName: '', + contextualFields: ['Dest', 'AvgTicketPrice'], + query: { + esql: 'FROM "kibana_sample_data_flights"', + }, + }, + }, + datasourceId: 'textBased', + columns: 2, + changeType: 'initial', +} as Suggestion; + +export const mockAllSuggestions = [ + currentSuggestionMock, + { + title: 'Donut', + score: 0.46, + visualizationId: 'lnsPie', + previewIcon: 'pie', + visualizationState: { + shape: 'donut', + layers: [ + { + layerId: '2513a3d4-ad9d-48ea-bd58-8b6419ab97e6', + primaryGroups: ['923f0681-3fe1-4987-aa27-d9c91fb95fa6'], + metrics: ['b5f41c04-4bca-4abe-ae5c-b1d4d6fb00e0'], + numberDisplay: 'percent', + categoryDisplay: 'default', + legendDisplay: 'default', + nestedLegend: false, + layerType: 'data', + }, + ], + }, + keptLayerIds: ['2513a3d4-ad9d-48ea-bd58-8b6419ab97e6'], + datasourceState: { + layers: { + '2513a3d4-ad9d-48ea-bd58-8b6419ab97e6': { + index: 'd3d7af60-4c81-11e8-b3d7-01146121b73d', + query: { + esql: 'FROM "kibana_sample_data_flights"', + }, + columns: [ + { + columnId: '923f0681-3fe1-4987-aa27-d9c91fb95fa6', + fieldName: 'Dest', + meta: { + type: 'string', + }, + }, + { + columnId: 'b5f41c04-4bca-4abe-ae5c-b1d4d6fb00e0', + fieldName: 'AvgTicketPrice', + meta: { + type: 'number', + }, + }, + ], + allColumns: [ + { + columnId: '923f0681-3fe1-4987-aa27-d9c91fb95fa6', + fieldName: 'Dest', + meta: { + type: 'string', + }, + }, + { + columnId: 'b5f41c04-4bca-4abe-ae5c-b1d4d6fb00e0', + fieldName: 'AvgTicketPrice', + meta: { + type: 'number', + }, + }, + ], + timeField: 'timestamp', + }, + }, + fieldList: [], + indexPatternRefs: [], + initialContext: { + dataViewSpec: { + id: 'd3d7af60-4c81-11e8-b3d7-01146121b73d', + version: 'WzM1ODA3LDFd', + title: 'kibana_sample_data_flights', + timeFieldName: 'timestamp', + sourceFilters: [], + fields: { + AvgTicketPrice: { + count: 0, + name: 'AvgTicketPrice', + type: 'number', + esTypes: ['float'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + format: { + id: 'number', + params: { + pattern: '$0,0.[00]', + }, + }, + shortDotsEnable: false, + isMapped: true, + }, + Dest: { + count: 0, + name: 'Dest', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + format: { + id: 'string', + }, + shortDotsEnable: false, + isMapped: true, + }, + timestamp: { + count: 0, + name: 'timestamp', + type: 'date', + esTypes: ['date'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + format: { + id: 'date', + }, + shortDotsEnable: false, + isMapped: true, + }, + }, + typeMeta: {}, + allowNoIndex: false, + name: 'Kibana Sample Data Flights', + }, + fieldName: '', + contextualFields: ['Dest', 'AvgTicketPrice'], + query: { + esql: 'FROM "kibana_sample_data_flights"', + }, + }, + }, + datasourceId: 'textBased', + columns: 2, + changeType: 'unchanged', + } as Suggestion, +]; diff --git a/x-pack/plugins/lens/public/trigger_actions/open_lens_config/helpers.scss b/x-pack/plugins/lens/public/trigger_actions/open_lens_config/helpers.scss index 56cfe41c4b889..6572fd969a5be 100644 --- a/x-pack/plugins/lens/public/trigger_actions/open_lens_config/helpers.scss +++ b/x-pack/plugins/lens/public/trigger_actions/open_lens_config/helpers.scss @@ -1,6 +1,7 @@ // styles needed to display extra drop targets that are outside of the config panel main area while also allowing to scroll vertically .lnsConfigPanel__overlay { clip-path: polygon(-100% 0, 100% 0, 100% 100%, -100% 100%); + background: $euiColorLightestShade; .kbnOverlayMountWrapper { padding-left: $euiFormMaxWidth; margin-left: -$euiFormMaxWidth; diff --git a/x-pack/plugins/lens/public/trigger_actions/open_lens_config/helpers.ts b/x-pack/plugins/lens/public/trigger_actions/open_lens_config/helpers.ts index b1647876581fd..8fd011fddfb2e 100644 --- a/x-pack/plugins/lens/public/trigger_actions/open_lens_config/helpers.ts +++ b/x-pack/plugins/lens/public/trigger_actions/open_lens_config/helpers.ts @@ -52,6 +52,7 @@ export async function executeAction({ embeddable, startDependencies, overlays, t size: 's', 'data-test-subj': 'customizeLens', type: 'push', + paddingSize: 'm', hideCloseButton: true, onClose: (overlayRef) => { if (overlayTracker) overlayTracker.clearOverlays(); diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index 25b637aebf071..e5c9fad96d6ca 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -198,6 +198,8 @@ export interface TableSuggestion { * The change type indicates what was changed in this table compared to the currently active table of this layer. */ changeType: TableChangeType; + + notAssignedMetrics?: boolean; } /** @@ -509,6 +511,8 @@ export interface Datasource { ) => Promise; injectReferencesToLayers?: (state: T, references?: SavedObjectReference[]) => T; + + suggestsLimitedColumns?: (state: T) => boolean; } export interface DatasourceFixAction { @@ -746,6 +750,7 @@ export interface OperationMetadata { export interface OperationDescriptor extends Operation { hasTimeShift: boolean; hasReducedTimeRange: boolean; + inMetricDimension?: boolean; } export interface VisualizationConfigProps { @@ -882,6 +887,7 @@ export interface SuggestionRequest { subVisualizationId?: string; activeData?: Record; allowMixed?: boolean; + datasourceId?: string; } /** diff --git a/x-pack/plugins/lens/public/visualizations/datatable/visualization.test.tsx b/x-pack/plugins/lens/public/visualizations/datatable/visualization.test.tsx index a6810e77d4388..a3f4f6f797ee1 100644 --- a/x-pack/plugins/lens/public/visualizations/datatable/visualization.test.tsx +++ b/x-pack/plugins/lens/public/visualizations/datatable/visualization.test.tsx @@ -138,6 +138,26 @@ describe('Datatable Visualization', () => { expect(suggestions.length).toBeGreaterThan(0); }); + it('should force table as suggestion when there are no number fields', () => { + const suggestions = datatableVisualization.getSuggestions({ + state: { + layerId: 'first', + layerType: LayerTypes.DATA, + columns: [{ columnId: 'col1' }], + }, + table: { + isMultiRow: true, + layerId: 'first', + changeType: 'initial', + columns: [strCol('col1'), strCol('col2')], + notAssignedMetrics: true, + }, + keptLayerIds: [], + }); + + expect(suggestions.length).toBeGreaterThan(0); + }); + it('should reject suggestion with static value', () => { function staticValueCol(columnId: string): TableSuggestionColumn { return { @@ -387,6 +407,48 @@ describe('Datatable Visualization', () => { }).groups[2].accessors ).toEqual([{ columnId: 'c' }, { columnId: 'b' }]); }); + + it('should compute the groups correctly for text based languages', () => { + const datasource = createMockDatasource('textBased', { + isTextBasedLanguage: jest.fn(() => true), + }); + datasource.publicAPIMock.getTableSpec.mockReturnValue([ + { columnId: 'c', fields: [] }, + { columnId: 'b', fields: [] }, + ]); + const frame = mockFrame(); + frame.datasourceLayers = { first: datasource.publicAPIMock }; + + const groups = datatableVisualization.getConfiguration({ + layerId: 'first', + state: { + layerId: 'first', + layerType: LayerTypes.DATA, + columns: [{ columnId: 'b', isMetric: true }, { columnId: 'c' }], + }, + frame, + }).groups; + + // rows + expect(groups[0].accessors).toEqual([ + { + columnId: 'c', + triggerIconType: undefined, + }, + ]); + + // columns + expect(groups[1].accessors).toEqual([]); + + // metrics + expect(groups[2].accessors).toEqual([ + { + columnId: 'b', + triggerIconType: undefined, + palette: undefined, + }, + ]); + }); }); describe('#removeDimension', () => { @@ -462,7 +524,11 @@ describe('Datatable Visualization', () => { ).toEqual({ layerId: 'layer1', layerType: LayerTypes.DATA, - columns: [{ columnId: 'b' }, { columnId: 'c' }, { columnId: 'd', isTransposed: false }], + columns: [ + { columnId: 'b' }, + { columnId: 'c' }, + { columnId: 'd', isTransposed: false, isMetric: false }, + ], }); }); @@ -482,7 +548,7 @@ describe('Datatable Visualization', () => { ).toEqual({ layerId: 'layer1', layerType: LayerTypes.DATA, - columns: [{ columnId: 'b', isTransposed: false }, { columnId: 'c' }], + columns: [{ columnId: 'b', isTransposed: false, isMetric: false }, { columnId: 'c' }], }); }); }); diff --git a/x-pack/plugins/lens/public/visualizations/datatable/visualization.tsx b/x-pack/plugins/lens/public/visualizations/datatable/visualization.tsx index 35757771af754..505b20bdc3e58 100644 --- a/x-pack/plugins/lens/public/visualizations/datatable/visualization.tsx +++ b/x-pack/plugins/lens/public/visualizations/datatable/visualization.tsx @@ -165,11 +165,14 @@ export const getDatatableVisualization = ({ ? 0.5 : 1; + // forcing datatable as a suggestion when there are no metrics (number fields) + const forceSuggestion = Boolean(table?.notAssignedMetrics); + return [ { title, // table with >= 10 columns will have a score of 0.4, fewer columns reduce score - score: (Math.min(table.columns.length, 10) / 10) * 0.4 * changeFactor, + score: forceSuggestion ? 1 : (Math.min(table.columns.length, 10) / 10) * 0.4 * changeFactor, state: { ...(state || {}), layerId: table.layerId, @@ -187,6 +190,13 @@ export const getDatatableVisualization = ({ ]; }, + /* + Datatable works differently on text based datasource and form based + - Form based: It relies on the isBucketed flag to identify groups. It allows only numeric fields + on the Metrics dimension + - Text based: It relies on the isMetric flag to identify groups. It allows all type of fields + on the Metric dimension in cases where there are no numeric columns + **/ getConfiguration({ state, frame, layerId }) { const { sortedColumns, datasource } = getDataSourceAndSortedColumns(state, frame.datasourceLayers, layerId) || {}; @@ -199,9 +209,11 @@ export const getDatatableVisualization = ({ if (!sortedColumns) { return { groups: [] }; } + const isTextBasedLanguage = datasource?.isTextBasedLanguage(); return { groups: [ + // In this group we get columns that are not transposed and are not on the metric dimension { groupId: 'rows', groupLabel: i18n.translate('xpack.lens.datatable.breakdownRows', { @@ -216,11 +228,17 @@ export const getDatatableVisualization = ({ }), layerId: state.layerId, accessors: sortedColumns - .filter( - (c) => - datasource!.getOperationForColumnId(c)?.isBucketed && - !state.columns.find((col) => col.columnId === c)?.isTransposed - ) + .filter((c) => { + const column = state.columns.find((col) => col.columnId === c); + if (isTextBasedLanguage) { + return ( + !datasource!.getOperationForColumnId(c)?.inMetricDimension && + !column?.isMetric && + !column?.isTransposed + ); + } + return datasource!.getOperationForColumnId(c)?.isBucketed && !column?.isTransposed; + }) .map((accessor) => ({ columnId: accessor, triggerIconType: columnMap[accessor].hidden @@ -236,6 +254,7 @@ export const getDatatableVisualization = ({ hideGrouping: true, nestingOrder: 1, }, + // In this group we get columns that are transposed and are not on the metric dimension { groupId: 'columns', groupLabel: i18n.translate('xpack.lens.datatable.breakdownColumns', { @@ -250,11 +269,15 @@ export const getDatatableVisualization = ({ }), layerId: state.layerId, accessors: sortedColumns - .filter( - (c) => + .filter((c) => { + if (isTextBasedLanguage) { + return state.columns.find((col) => col.columnId === c)?.isTransposed; + } + return ( datasource!.getOperationForColumnId(c)?.isBucketed && state.columns.find((col) => col.columnId === c)?.isTransposed - ) + ); + }) .map((accessor) => ({ columnId: accessor })), supportsMoreColumns: true, filterOperations: (op) => op.isBucketed, @@ -263,6 +286,7 @@ export const getDatatableVisualization = ({ hideGrouping: true, nestingOrder: 0, }, + // In this group we get columns are on the metric dimension { groupId: 'metrics', groupLabel: i18n.translate('xpack.lens.datatable.metrics', { @@ -278,7 +302,16 @@ export const getDatatableVisualization = ({ }, layerId: state.layerId, accessors: sortedColumns - .filter((c) => !datasource!.getOperationForColumnId(c)?.isBucketed) + .filter((c) => { + const operation = datasource!.getOperationForColumnId(c); + if (isTextBasedLanguage) { + return ( + operation?.inMetricDimension || + state.columns.find((col) => col.columnId === c)?.isMetric + ); + } + return !operation?.isBucketed; + }) .map((accessor) => { const columnConfig = columnMap[accessor]; const stops = columnConfig?.palette?.params?.stops; @@ -316,7 +349,12 @@ export const getDatatableVisualization = ({ ...prevState, columns: prevState.columns.map((column) => { if (column.columnId === columnId || column.columnId === previousColumn) { - return { ...column, columnId, isTransposed: groupId === 'columns' }; + return { + ...column, + columnId, + isTransposed: groupId === 'columns', + isMetric: groupId === 'metrics', + }; } return column; }), @@ -324,7 +362,10 @@ export const getDatatableVisualization = ({ } return { ...prevState, - columns: [...prevState.columns, { columnId, isTransposed: groupId === 'columns' }], + columns: [ + ...prevState.columns, + { columnId, isTransposed: groupId === 'columns', isMetric: groupId === 'metrics' }, + ], }; }, removeDimension({ prevState, columnId }) { @@ -371,9 +412,11 @@ export const getDatatableVisualization = ({ ): Ast | null { const { sortedColumns, datasource } = getDataSourceAndSortedColumns(state, datasourceLayers, state.layerId) || {}; + const isTextBasedLanguage = datasource?.isTextBasedLanguage(); if ( sortedColumns?.length && + !isTextBasedLanguage && sortedColumns.filter((c) => !datasource!.getOperationForColumnId(c)?.isBucketed).length === 0 ) { return null; @@ -435,6 +478,15 @@ export const getDatatableVisualization = ({ const canColor = datasource!.getOperationForColumnId(column.columnId)?.dataType === 'number'; + let isTransposable = + !isTextBasedLanguage && + !datasource!.getOperationForColumnId(column.columnId)?.isBucketed; + + if (isTextBasedLanguage) { + const operation = datasource!.getOperationForColumnId(column.columnId); + isTransposable = Boolean(column?.isMetric || operation?.inMetricDimension); + } + const datatableColumnFn = buildExpressionFunction( 'lens_datatable_column', { @@ -443,8 +495,7 @@ export const getDatatableVisualization = ({ oneClickFilter: column.oneClickFilter, width: column.width, isTransposed: column.isTransposed, - transposable: !datasource!.getOperationForColumnId(column.columnId)?.isBucketed, - alignment: column.alignment, + transposable: isTransposable, colorMode: canColor && column.colorMode ? column.colorMode : 'none', palette: paletteService.get(CUSTOM_PALETTE).toExpression(paletteParams), summaryRow: hasNoSummaryRow ? undefined : column.summaryRow!, diff --git a/x-pack/plugins/lens/public/visualizations/heatmap/suggestions.test.ts b/x-pack/plugins/lens/public/visualizations/heatmap/suggestions.test.ts index ff631ee605a59..8a89bca65a2d9 100644 --- a/x-pack/plugins/lens/public/visualizations/heatmap/suggestions.test.ts +++ b/x-pack/plugins/lens/public/visualizations/heatmap/suggestions.test.ts @@ -305,6 +305,70 @@ describe('heatmap suggestions', () => { }, ]); }); + + test('when no metric dimension but groups', () => { + expect( + getSuggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [ + { + columnId: 'date-column', + operation: { + isBucketed: true, + dataType: 'date', + scale: 'interval', + label: 'Date', + }, + }, + { + columnId: 'string-column-01', + operation: { + isBucketed: true, + dataType: 'string', + label: 'Bucket 1', + }, + }, + ], + changeType: 'initial', + }, + state: { + layerId: 'first', + layerType: LayerTypes.DATA, + } as HeatmapVisualizationState, + keptLayerIds: ['first'], + }) + ).toEqual([ + { + state: { + layerId: 'first', + layerType: LayerTypes.DATA, + shape: 'heatmap', + xAccessor: 'date-column', + yAccessor: 'string-column-01', + gridConfig: { + type: HEATMAP_GRID_FUNCTION, + isCellLabelVisible: false, + isYAxisLabelVisible: true, + isXAxisLabelVisible: true, + isYAxisTitleVisible: false, + isXAxisTitleVisible: false, + }, + legend: { + isVisible: true, + position: Position.Right, + type: LEGEND_FUNCTION, + }, + }, + title: 'Heat map', + hide: true, + incomplete: true, + previewIcon: IconChartHeatmap, + score: 0, + }, + ]); + }); test('for tables with a single bucket dimension', () => { expect( getSuggestions({ diff --git a/x-pack/plugins/lens/public/visualizations/heatmap/suggestions.ts b/x-pack/plugins/lens/public/visualizations/heatmap/suggestions.ts index ccb9c1014c25b..3f748485bdb03 100644 --- a/x-pack/plugins/lens/public/visualizations/heatmap/suggestions.ts +++ b/x-pack/plugins/lens/public/visualizations/heatmap/suggestions.ts @@ -61,6 +61,7 @@ export const getSuggestions: Visualization['getSugges const isSingleBucketDimension = groups.length === 1 && metrics.length === 0; const isOnlyMetricDimension = groups.length === 0 && metrics.length === 1; + const isOnlyBucketDimension = groups.length > 0 && metrics.length === 0; /** * Hide for: @@ -77,6 +78,7 @@ export const getSuggestions: Visualization['getSugges table.changeType === 'reorder' || isSingleBucketDimension || hasOnlyDatehistogramBuckets || + isOnlyBucketDimension || isOnlyMetricDimension; const newState: HeatmapVisualizationState = { @@ -130,7 +132,7 @@ export const getSuggestions: Visualization['getSugges hide, previewIcon: IconChartHeatmap, score: Number(score.toFixed(1)), - incomplete: isSingleBucketDimension || isOnlyMetricDimension, + incomplete: isSingleBucketDimension || isOnlyMetricDimension || isOnlyBucketDimension, }, ]; }; diff --git a/x-pack/plugins/lens/public/visualizations/legacy_metric/metric_suggestions.test.ts b/x-pack/plugins/lens/public/visualizations/legacy_metric/metric_suggestions.test.ts index e1803e106f2eb..e4b4dc1879456 100644 --- a/x-pack/plugins/lens/public/visualizations/legacy_metric/metric_suggestions.test.ts +++ b/x-pack/plugins/lens/public/visualizations/legacy_metric/metric_suggestions.test.ts @@ -98,6 +98,7 @@ describe('metric_suggestions', () => { ).map((table) => expect(getSuggestions({ table, keptLayerIds: ['l1'] })).toEqual([])) ); }); + test('does not suggest for a static value', () => { const suggestion = getSuggestions({ table: { @@ -133,6 +134,29 @@ describe('metric_suggestions', () => { expect(suggestion).toHaveLength(0); }); + + test('does not suggest for text based languages', () => { + const col = { + columnId: 'id', + operation: { + dataType: 'number', + label: `Top values`, + isBucketed: false, + }, + } as const; + const suggestion = getSuggestions({ + table: { + columns: [col], + isMultiRow: false, + layerId: 'l1', + changeType: 'unchanged', + }, + keptLayerIds: [], + datasourceId: 'textBased', + }); + + expect(suggestion).toHaveLength(0); + }); test('suggests a basic metric chart', () => { const [suggestion, ...rest] = getSuggestions({ table: { diff --git a/x-pack/plugins/lens/public/visualizations/legacy_metric/metric_suggestions.ts b/x-pack/plugins/lens/public/visualizations/legacy_metric/metric_suggestions.ts index c48e463ec83df..7e00322fedcd6 100644 --- a/x-pack/plugins/lens/public/visualizations/legacy_metric/metric_suggestions.ts +++ b/x-pack/plugins/lens/public/visualizations/legacy_metric/metric_suggestions.ts @@ -20,6 +20,7 @@ export function getSuggestions({ table, state, keptLayerIds, + datasourceId, }: SuggestionRequest): Array> { // We only render metric charts for single-row queries. We require a single, numeric column. if ( @@ -39,6 +40,11 @@ export function getSuggestions({ return []; } + // do not return the legacy metric vis for the textbased mode (i.e. ES|QL) + if (datasourceId === 'textBased') { + return []; + } + return [getSuggestion(table)]; } diff --git a/x-pack/plugins/lens/tsconfig.json b/x-pack/plugins/lens/tsconfig.json index 6cb071d18352a..8af19f218fee9 100644 --- a/x-pack/plugins/lens/tsconfig.json +++ b/x-pack/plugins/lens/tsconfig.json @@ -90,9 +90,11 @@ "@kbn/search-response-warnings", "@kbn/logging", "@kbn/core-plugins-server", + "@kbn/text-based-languages", "@kbn/field-utils", - "@kbn/shared-ux-button-toolbar", - "@kbn/cell-actions" + "@kbn/discover-utils", + "@kbn/cell-actions", + "@kbn/shared-ux-button-toolbar" ], "exclude": [ "target/**/*", diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 7be7358c87010..31320c75ba89b 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -22333,7 +22333,6 @@ "xpack.lens.config.configFlyoutCallout": "ES|QL propose actuellement des options de configuration limitées", "xpack.lens.config.editLabel": "Modifier la configuration", "xpack.lens.config.editLinkLabel": "Modifier dans Lens", - "xpack.lens.config.editVisualizationLabel": "Modifier la visualisation", "xpack.lens.config.experimentalLabel": "Version d'évaluation technique", "xpack.lens.configPanel.addLayerButton": "Ajouter un calque", "xpack.lens.configPanel.experimentalLabel": "Version d'évaluation technique", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 4b2a982b0be43..9667518d447fd 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -22347,7 +22347,6 @@ "xpack.lens.config.configFlyoutCallout": "現在、ES|QLでは、構成オプションは限られています。", "xpack.lens.config.editLabel": "構成の編集", "xpack.lens.config.editLinkLabel": "Lensで編集", - "xpack.lens.config.editVisualizationLabel": "ビジュアライゼーションを編集", "xpack.lens.config.experimentalLabel": "テクニカルプレビュー", "xpack.lens.configPanel.addLayerButton": "レイヤーを追加", "xpack.lens.configPanel.experimentalLabel": "テクニカルプレビュー", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 93f98c413795a..0f0e76b89e48a 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -22347,7 +22347,6 @@ "xpack.lens.config.configFlyoutCallout": "ES|QL 当前提供的配置选项数量有限", "xpack.lens.config.editLabel": "编辑配置", "xpack.lens.config.editLinkLabel": "在 Lens 中编辑", - "xpack.lens.config.editVisualizationLabel": "编辑可视化", "xpack.lens.config.experimentalLabel": "技术预览", "xpack.lens.configPanel.addLayerButton": "添加图层", "xpack.lens.configPanel.experimentalLabel": "技术预览", diff --git a/x-pack/test/functional/apps/discover/visualize_field.ts b/x-pack/test/functional/apps/discover/visualize_field.ts index a61a2abba1870..e84e0469e62db 100644 --- a/x-pack/test/functional/apps/discover/visualize_field.ts +++ b/x-pack/test/functional/apps/discover/visualize_field.ts @@ -255,5 +255,54 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const data = await PageObjects.lens.getCurrentChartDebugStateForVizType('xyVisChart'); assertMatchesExpectedData(data!); }); + + it('should allow editing the query in the dashboard', async () => { + await PageObjects.discover.selectTextBaseLang(); + await PageObjects.header.waitUntilLoadingHasFinished(); + await monacoEditor.setCodeEditorValue('from logstash-* | limit 10'); + await testSubjects.click('querySubmitButton'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await testSubjects.click('TextBasedLangEditor-expand'); + // save the visualization + await testSubjects.click('unifiedHistogramSaveVisualization'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.lens.saveModal('TextBasedChart1', false, false, false, 'new'); + await testSubjects.existOrFail('embeddablePanelHeading-TextBasedChart1'); + await elasticChart.setNewChartUiDebugFlag(true); + await PageObjects.header.waitUntilLoadingHasFinished(); + // open the inline editing flyout + await testSubjects.click('embeddablePanelToggleMenuIcon'); + await testSubjects.click('embeddablePanelAction-ACTION_CONFIGURE_IN_LENS'); + await PageObjects.header.waitUntilLoadingHasFinished(); + + // change the query + await monacoEditor.setCodeEditorValue('from logstash-* | stats maxB = max(bytes)'); + await testSubjects.click('TextBasedLangEditor-run-query-button'); + await PageObjects.header.waitUntilLoadingHasFinished(); + + expect((await PageObjects.lens.getMetricVisualizationData()).length).to.be.equal(1); + + // change the query to display a datatabler + await monacoEditor.setCodeEditorValue('from logstash-* | limit 10'); + await testSubjects.click('TextBasedLangEditor-run-query-button'); + await PageObjects.lens.waitForVisualization(); + expect(await testSubjects.exists('lnsDataTable')).to.be(true); + + await PageObjects.lens.removeDimension('lnsDatatable_metrics'); + await PageObjects.lens.removeDimension('lnsDatatable_metrics'); + await PageObjects.lens.removeDimension('lnsDatatable_metrics'); + await PageObjects.lens.removeDimension('lnsDatatable_metrics'); + + await PageObjects.lens.configureTextBasedLanguagesDimension({ + dimension: 'lnsDatatable_metrics > lns-empty-dimension', + field: 'bytes', + keepOpen: true, + }); + await testSubjects.click('lns-indexPattern-dimensionContainerBack'); + // click donut from suggestions + await testSubjects.click('lensSuggestionsPanelToggleButton'); + await testSubjects.click('lnsSuggestion-donut'); + expect(await testSubjects.exists('partitionVisChart')).to.be(true); + }); }); }