From 009d4d8778fa0f77c904ff36f6f934caa42a80bd Mon Sep 17 00:00:00 2001 From: "Qingyang(Abby) Hu" Date: Fri, 19 Jul 2024 21:19:58 -0700 Subject: [PATCH] New Query Enhancement UI (#7309) * UI changes Signed-off-by: abbyhu2000 * update snapshots Signed-off-by: abbyhu2000 * styling Signed-off-by: abbyhu2000 * correct license Signed-off-by: abbyhu2000 * more cleanup Signed-off-by: abbyhu2000 * more styling and update snapshots Signed-off-by: abbyhu2000 --------- Signed-off-by: abbyhu2000 --- changelogs/fragments/7309.yml | 2 + .../language_selector.test.tsx.snap | 1103 +++++++++++++++++ .../ui/query_editor/_language_selector.scss | 23 +- .../public/ui/query_editor/_query_editor.scss | 43 +- .../query_editor/language_selector.test.tsx | 18 +- .../ui/query_editor/language_selector.tsx | 82 +- .../public/ui/query_editor/query_editor.tsx | 246 +++- .../query_editor_btn_collapse.tsx | 31 + .../query_editor_extension.tsx | 1 - .../ui/query_editor/query_editor_top_row.tsx | 23 +- .../saved_query_management_component.tsx | 1 - .../public/search/ppl_search_interceptor.ts | 15 +- .../public/search/sql_search_interceptor.ts | 7 +- 13 files changed, 1502 insertions(+), 93 deletions(-) create mode 100644 changelogs/fragments/7309.yml create mode 100644 src/plugins/data/public/ui/query_editor/__snapshots__/language_selector.test.tsx.snap create mode 100644 src/plugins/data/public/ui/query_editor/query_editor_btn_collapse.tsx diff --git a/changelogs/fragments/7309.yml b/changelogs/fragments/7309.yml new file mode 100644 index 00000000000..0925eb17079 --- /dev/null +++ b/changelogs/fragments/7309.yml @@ -0,0 +1,2 @@ +feat: +- Update query enhancement UI ([#7309](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7309)) \ No newline at end of file diff --git a/src/plugins/data/public/ui/query_editor/__snapshots__/language_selector.test.tsx.snap b/src/plugins/data/public/ui/query_editor/__snapshots__/language_selector.test.tsx.snap new file mode 100644 index 00000000000..031b1203317 --- /dev/null +++ b/src/plugins/data/public/ui/query_editor/__snapshots__/language_selector.test.tsx.snap @@ -0,0 +1,1103 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`LanguageSelector should select DQL if language is kuery 1`] = ` + + + + DQL + + } + className="languageSelector" + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + isOpen={false} + ownFocus={true} + panelPaddingSize="none" + > +
+
+ + + +
+
+
+
+
+`; + +exports[`LanguageSelector should select lucene if language is lucene 1`] = ` + + + + Lucene + + } + className="languageSelector" + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + isOpen={false} + ownFocus={true} + panelPaddingSize="none" + > +
+
+ + + +
+
+
+
+
+`; diff --git a/src/plugins/data/public/ui/query_editor/_language_selector.scss b/src/plugins/data/public/ui/query_editor/_language_selector.scss index 2c44dc60ea2..d7847a96f0a 100644 --- a/src/plugins/data/public/ui/query_editor/_language_selector.scss +++ b/src/plugins/data/public/ui/query_editor/_language_selector.scss @@ -3,6 +3,25 @@ * SPDX-License-Identifier: Apache-2.0 */ .languageSelector { - min-width: 140px; - border-bottom: $euiBorderThin !important; + height: 100%; + + .languageSelector__button { + &:hover { + text-decoration: none; // Prevents text underline on hover + } + } + + * { + font-size: small; + } +} + +.languageSelector__menuItem { + padding-left: 2px; + padding-right: 2px; + align-items: center; + + &:hover { + text-decoration: none; // Prevents text underline on hover + } } diff --git a/src/plugins/data/public/ui/query_editor/_query_editor.scss b/src/plugins/data/public/ui/query_editor/_query_editor.scss index 80ddd42fede..8fc81308b53 100644 --- a/src/plugins/data/public/ui/query_editor/_query_editor.scss +++ b/src/plugins/data/public/ui/query_editor/_query_editor.scss @@ -15,14 +15,32 @@ // overflow: auto; } -.osdQueryEditor__languageWrapper { - :first-child { - box-shadow: none !important; - height: 100%; - border-radius: 0; +.osdQueryEditorFooter-isHidden { + display: none; +} + +.osdQueryEditorFooter { + color: $euiTextSubduedColor; // Apply the subdued color to all text in this class + height: 25px; + + * { + color: inherit; + font-size: $euiFontSizeXS; + align-items: center; } } +.osdQueryEditor__collapseWrapper { + box-shadow: $euiTextSubduedColor; +} + +.osdQueryEditor__languageWrapper { + align-items: center; + justify-content: center; + max-height: 40px; + border: $euiBorderThin; +} + .osdQueryEditor__dataSourceWrapper { .dataSourceSelect { border-bottom: $euiBorderThin !important; @@ -42,6 +60,7 @@ .osdQueryEditor__dataSetWrapper { .dataExplorerDSSelect { border-bottom: $euiBorderThin !important; + max-width: 375px; div:is([class$="--group"]) { padding: 0 !important; @@ -53,6 +72,20 @@ } } +.osdQueryEditor__prependWrapper { + box-shadow: $euiTextSubduedColor; +} + +.osdQueryEditor__prependWrapper-isCollapsed { + box-shadow: none; +} + +.osdQueryEditor--updateButtonWrapper { + :first-child { + min-width: 0 !important; + } +} + @include euiBreakpoint("xs", "s") { .osdQueryEditor--withDatePicker { > :first-child { diff --git a/src/plugins/data/public/ui/query_editor/language_selector.test.tsx b/src/plugins/data/public/ui/query_editor/language_selector.test.tsx index 709678e29d6..f61134211a4 100644 --- a/src/plugins/data/public/ui/query_editor/language_selector.test.tsx +++ b/src/plugins/data/public/ui/query_editor/language_selector.test.tsx @@ -53,14 +53,7 @@ describe('LanguageSelector', () => { }, }) ); - const euiComboBox = component.find(EuiCompressedComboBox); - expect(euiComboBox.prop('selectedOptions')).toEqual( - expect.arrayContaining([ - { - label: 'Lucene', - }, - ]) - ); + expect(component).toMatchSnapshot(); }); it('should select DQL if language is kuery', () => { @@ -72,13 +65,6 @@ describe('LanguageSelector', () => { }, }) ); - const euiComboBox = component.find(EuiCompressedComboBox); - expect(euiComboBox.prop('selectedOptions')).toEqual( - expect.arrayContaining([ - { - label: 'DQL', - }, - ]) - ); + expect(component).toMatchSnapshot(); }); }); diff --git a/src/plugins/data/public/ui/query_editor/language_selector.tsx b/src/plugins/data/public/ui/query_editor/language_selector.tsx index 0f35b84ff59..ce7c1e17178 100644 --- a/src/plugins/data/public/ui/query_editor/language_selector.tsx +++ b/src/plugins/data/public/ui/query_editor/language_selector.tsx @@ -4,19 +4,22 @@ */ import { - EuiCompressedComboBox, - EuiComboBoxOptionOption, PopoverAnchorPosition, + EuiContextMenuPanel, + EuiPopover, + EuiButtonEmpty, + EuiContextMenuItem, } from '@elastic/eui'; import { i18n } from '@osd/i18n'; -import React from 'react'; +import React, { useState } from 'react'; import { getUiService } from '../../services'; -interface Props { +export interface QueryLanguageSelectorProps { language: string; onSelectLanguage: (newLanguage: string) => void; anchorPosition?: PopoverAnchorPosition; appName?: string; + isFooter?: boolean; } const mapExternalLanguageToOptions = (language: string) => { @@ -26,7 +29,13 @@ const mapExternalLanguageToOptions = (language: string) => { }; }; -export const QueryLanguageSelector = (props: Props) => { +export const QueryLanguageSelector = (props: QueryLanguageSelectorProps) => { + const [isPopoverOpen, setPopover] = useState(false); + + const onButtonClick = () => { + setPopover(!isPopoverOpen); + }; + const dqlLabel = i18n.translate('data.query.queryEditor.dqlLanguageName', { defaultMessage: 'DQL', }); @@ -34,7 +43,7 @@ export const QueryLanguageSelector = (props: Props) => { defaultMessage: 'Lucene', }); - const languageOptions: EuiComboBoxOptionOption[] = [ + const languageOptions = [ { label: dqlLabel, value: 'kuery', @@ -68,25 +77,58 @@ export const QueryLanguageSelector = (props: Props) => { )?.label as string) ?? languageOptions[0].label, }; - const handleLanguageChange = (newLanguage: EuiComboBoxOptionOption[]) => { - const queryLanguage = newLanguage[0].value as string; - props.onSelectLanguage(queryLanguage); - uiService.Settings.setUserQueryLanguage(queryLanguage); + const handleLanguageChange = (newLanguage: string) => { + props.onSelectLanguage(newLanguage); + uiService.Settings.setUserQueryLanguage(newLanguage); }; uiService.Settings.setUserQueryLanguage(props.language); + const languageOptionsMenu = languageOptions.map((language) => { + return ( + { + setPopover(false); + handleLanguageChange(language.value); + }} + > + {language.label} + + ); + }); return ( - + button={ + + {selectedLanguage.label} + + } + isOpen={isPopoverOpen} + closePopover={() => setPopover(false)} + panelPaddingSize="none" + anchorPosition={props.anchorPosition ?? 'downLeft'} + > + option.label === selectedLanguage.label + )} + size="s" + items={languageOptionsMenu} + /> + ); }; + +// Needed for React.lazy +// eslint-disable-next-line import/no-default-export +export default QueryLanguageSelector; diff --git a/src/plugins/data/public/ui/query_editor/query_editor.tsx b/src/plugins/data/public/ui/query_editor/query_editor.tsx index 59d5645fcf6..69d332c65c0 100644 --- a/src/plugins/data/public/ui/query_editor/query_editor.tsx +++ b/src/plugins/data/public/ui/query_editor/query_editor.tsx @@ -3,12 +3,27 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { EuiFlexGroup, EuiFlexItem, htmlIdGenerator, PopoverAnchorPosition } from '@elastic/eui'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiForm, + EuiFormRow, + htmlIdGenerator, + PopoverAnchorPosition, +} from '@elastic/eui'; import classNames from 'classnames'; import { isEqual } from 'lodash'; import React, { Component, createRef, RefObject } from 'react'; +import { monaco } from '@osd/monaco'; import { Settings } from '..'; -import { DataSource, IDataPluginServices, IIndexPattern, Query, TimeRange } from '../..'; +import { + DataSource, + IDataPluginServices, + IFieldType, + IIndexPattern, + Query, + TimeRange, +} from '../..'; import { CodeEditor, OpenSearchDashboardsReactContextValue, @@ -20,13 +35,15 @@ import { DataSettings } from '../types'; import { fetchIndexPatterns } from './fetch_index_patterns'; import { QueryLanguageSelector } from './language_selector'; import { QueryEditorExtensions } from './query_editor_extensions'; - +import { QueryEditorBtnCollapse } from './query_editor_btn_collapse'; export interface QueryEditorProps { indexPatterns: Array; dataSource?: DataSource; query: Query; + container?: HTMLDivElement; dataSourceContainerRef?: React.RefCallback; containerRef?: React.RefCallback; + languageSelectorContainerRef?: React.RefCallback; settings: Settings; disableAutoFocus?: boolean; screenTitle?: string; @@ -47,6 +64,8 @@ export interface QueryEditorProps { queryLanguage?: string; headerClassName?: string; bannerClassName?: string; + footerClassName?: string; + filterBar?: any; } interface Props extends QueryEditorProps { @@ -60,6 +79,9 @@ interface State { index: number | null; suggestions: QuerySuggestion[]; indexPatterns: IIndexPattern[]; + isCollapsed: boolean; + timeStamp: IFieldType | null; + lineCount: number | undefined; } const KEY_CODES = { @@ -85,9 +107,12 @@ export default class QueryEditorUI extends Component { index: null, suggestions: [], indexPatterns: [], + isCollapsed: false, // default to expand mode + timeStamp: null, + lineCount: undefined, }; - public inputRef: HTMLElement | null = null; + public inputRef: monaco.editor.IStandaloneCodeEditor | null = null; private persistedLog: PersistedLog | undefined; private abortController?: AbortController; @@ -175,6 +200,12 @@ export default class QueryEditorUI extends Component { private onInputChange = (value: string) => { this.onQueryStringChange(value); + + if (!this.inputRef) return; + + const currentLineCount = this.inputRef.getModel()?.getLineCount(); + if (this.state.lineCount === currentLineCount) return; + this.setState({ lineCount: currentLineCount }); }; private onClickInput = (event: React.MouseEvent) => { @@ -282,10 +313,65 @@ export default class QueryEditorUI extends Component { } }; + editorDidMount = (editor: monaco.editor.IStandaloneCodeEditor) => { + this.setState({ lineCount: editor.getModel()?.getLineCount() }); + this.inputRef = editor; + }; + + private onSingleLineInputChange = (value: string) => { + // Replace new lines with an empty string to prevent multi-line input + this.onQueryStringChange(value.replace(/[\r\n]+/gm, '')); + + this.setState({ lineCount: undefined }); + }; + + singleLineEditorDidMount = (editor: monaco.editor.IStandaloneCodeEditor) => { + this.inputRef = editor; + + const editorNode = editor.getDomNode(); + if (editorNode) { + const containerId = 'single-line-editor-wrapper'; + const style = document.createElement('style'); + const customCursorHeight = 20; + // eslint-disable-next-line no-unsanitized/property + style.innerHTML = ` + .${containerId} .monaco-editor .view-lines { + padding-left: 15px; + } + .${containerId} .monaco-editor .cursor { + height: ${customCursorHeight}px !important; + margin-top: ${(38 - customCursorHeight) / 2}px !important; + } + `; + + document.head.appendChild(style); + } + const handleEnterPress = () => { + this.onSubmit(this.props.query); + }; + + const disposable = editor.onKeyDown((e) => { + if (e.keyCode === monaco.KeyCode.Enter) { + // Prevent default Enter key behavior + e.preventDefault(); + handleEnterPress(); + } + }); + + // Optional: Cleanup on component unmount + return () => { + disposable.dispose(); + }; + }; + public render() { const className = classNames(this.props.className); const headerClassName = classNames('osdQueryEditorHeader', this.props.headerClassName); const bannerClassName = classNames('osdQueryEditorBanner', this.props.bannerClassName); + const footerClassName = classNames('osdQueryEditorFooter', this.props.footerClassName); + + const useQueryEditor = + this.props.query.language !== 'kuery' && this.props.query.language !== 'lucene'; return (
@@ -293,48 +379,142 @@ export default class QueryEditorUI extends Component { - {this.props.prepend} + + this.setState({ isCollapsed: !this.state.isCollapsed })} + isCollapsed={!this.state.isCollapsed} + /> + {this.state.isDataSourcesVisible && ( - +
)} - - - + {this.state.isDataSetsVisible && ( - +
)} + + + {(this.state.isCollapsed || !useQueryEditor) && ( + +
+ +
+
+ )} + {!useQueryEditor && ( + +
+ +
+
+ )} +
+
+ + {this.props.prepend} + +
- + {!this.state.isCollapsed && useQueryEditor && ( + + )} + +
+ + + + + + + {this.state.lineCount} {this.state.lineCount === 1 ? 'line' : 'lines'} + + + {typeof this.props.indexPatterns?.[0] !== 'string' && + '@' + this.props.indexPatterns?.[0].timeFieldName} + + +
+ + {!this.state.isCollapsed && ( + {this.props.filterBar} + )} {this.renderQueryEditorExtensions()}
diff --git a/src/plugins/data/public/ui/query_editor/query_editor_btn_collapse.tsx b/src/plugins/data/public/ui/query_editor/query_editor_btn_collapse.tsx new file mode 100644 index 00000000000..1bde59570fd --- /dev/null +++ b/src/plugins/data/public/ui/query_editor/query_editor_btn_collapse.tsx @@ -0,0 +1,31 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { i18n } from '@osd/i18n'; +import { EuiToolTip, EuiButtonIcon } from '@elastic/eui'; + +export interface Props { + onClick: () => void; + isCollapsed: boolean; +} + +export function QueryEditorBtnCollapse({ onClick, isCollapsed }: Props) { + const label = i18n.translate('queryEditor.collapse', { + defaultMessage: 'Toggle query editor', + }); + return ( + + + + ); +} diff --git a/src/plugins/data/public/ui/query_editor/query_editor_extensions/query_editor_extension.tsx b/src/plugins/data/public/ui/query_editor/query_editor_extensions/query_editor_extension.tsx index f684aebea1d..78b402df7c6 100644 --- a/src/plugins/data/public/ui/query_editor/query_editor_extensions/query_editor_extension.tsx +++ b/src/plugins/data/public/ui/query_editor/query_editor_extensions/query_editor_extension.tsx @@ -61,7 +61,6 @@ export interface QueryEditorExtensionConfig { */ getBanner?: (dependencies: QueryEditorExtensionDependencies) => React.ReactElement | null; } - const QueryEditorExtensionPortal: React.FC<{ container: Element }> = (props) => { if (!props.children) return null; diff --git a/src/plugins/data/public/ui/query_editor/query_editor_top_row.tsx b/src/plugins/data/public/ui/query_editor/query_editor_top_row.tsx index e5b7529f414..8304fdc252e 100644 --- a/src/plugins/data/public/ui/query_editor/query_editor_top_row.tsx +++ b/src/plugins/data/public/ui/query_editor/query_editor_top_row.tsx @@ -4,11 +4,11 @@ */ import dateMath from '@elastic/datemath'; import { + EuiButton, EuiCompressedFieldText, EuiFlexGroup, EuiFlexItem, EuiSuperDatePicker, - EuiSuperUpdateButton, OnRefreshProps, prettyDuration, } from '@elastic/eui'; @@ -28,7 +28,7 @@ import { withOpenSearchDashboards, } from '../../../../opensearch_dashboards_react/public'; import { UI_SETTINGS } from '../../../common'; -import { fromUser, getQueryLog, PersistedLog } from '../../query'; +import { getQueryLog, PersistedLog } from '../../query'; import { Settings } from '../types'; import { NoDataPopover } from './no_data_popover'; import QueryEditorUI from './query_editor'; @@ -235,6 +235,7 @@ export default function QueryEditorTopRow(props: QueryEditorTopRowProps) { className="osdQueryEditor" dataTestSubj={props.dataTestSubj} queryLanguage={queryLanguage} + filterBar={props.filterBar} />
); @@ -275,13 +276,16 @@ export default function QueryEditorTopRow(props: QueryEditorTopRowProps) { const button = props.customSubmitButton ? ( React.cloneElement(props.customSubmitButton, { onClick: onClickSubmitButton }) ) : ( - + className="euiSuperUpdateButton" + iconType="play" + > + {props.isDirty ? 'Refresh' : 'Run'} + ); if (!shouldRenderDatePicker()) { @@ -363,12 +367,15 @@ export default function QueryEditorTopRow(props: QueryEditorTopRowProps) { direction="column" justifyContent="flexEnd" > + + + {renderUpdateButton()} + + {renderQueryEditor()} - - {props.filterBar} + {renderSharingMetaFields()} - {renderUpdateButton()} diff --git a/src/plugins/data/public/ui/saved_query_management/saved_query_management_component.tsx b/src/plugins/data/public/ui/saved_query_management/saved_query_management_component.tsx index 77643ac6413..4e4dd26cec4 100644 --- a/src/plugins/data/public/ui/saved_query_management/saved_query_management_component.tsx +++ b/src/plugins/data/public/ui/saved_query_management/saved_query_management_component.tsx @@ -183,7 +183,6 @@ export function SavedQueryManagementComponent({ data-test-subj="saved-query-management-popover-button" > - ); diff --git a/src/plugins/query_enhancements/public/search/ppl_search_interceptor.ts b/src/plugins/query_enhancements/public/search/ppl_search_interceptor.ts index aac50de3bb9..bca9961fea3 100644 --- a/src/plugins/query_enhancements/public/search/ppl_search_interceptor.ts +++ b/src/plugins/query_enhancements/public/search/ppl_search_interceptor.ts @@ -5,6 +5,7 @@ import { trimEnd } from 'lodash'; import { Observable, throwError } from 'rxjs'; +import { i18n } from '@osd/i18n'; import { concatMap } from 'rxjs/operators'; import { DataFrameAggConfig, @@ -147,7 +148,15 @@ export class PPLSearchInterceptor extends SearchInterceptor { const dataFrame = getRawDataFrame(searchRequest); if (!dataFrame) { - return throwError(this.handleSearchError('DataFrame is not defined', request, signal!)); + return throwError( + this.handleSearchError( + { + stack: 'DataFrame is not defined', + }, + request, + signal! + ) + ); } let queryString = dataFrame.meta?.queryConfig?.qs ?? getRawQueryString(searchRequest) ?? ''; @@ -171,6 +180,10 @@ export class PPLSearchInterceptor extends SearchInterceptor { return fetchDataFrame(dfContext, queryString, dataFrame).pipe( concatMap((response) => { const df = response.body; + if (df.error) { + const jsError = new Error(df.error.response); + return throwError(jsError); + } const timeField = getTimeField(df, aggConfig); if (timeField) { const timeFilter = getTimeFilter(timeField); diff --git a/src/plugins/query_enhancements/public/search/sql_search_interceptor.ts b/src/plugins/query_enhancements/public/search/sql_search_interceptor.ts index c4dd7409faf..5a3b8278c65 100644 --- a/src/plugins/query_enhancements/public/search/sql_search_interceptor.ts +++ b/src/plugins/query_enhancements/public/search/sql_search_interceptor.ts @@ -71,12 +71,7 @@ export class SQLSearchInterceptor extends SearchInterceptor { const df = response.body; if (df.error) { const jsError = new Error(df.error.response); - this.deps.toasts.addError(jsError, { - title: i18n.translate('queryEnhancements.sqlQueryError', { - defaultMessage: 'Could not complete the SQL query', - }), - toastMessage: df.error.msg, - }); + return throwError(jsError); } return fetchDataFrame(dfContext, queryString, df); })