From 884b94e9e97ceaf213212135aed93d31548f3011 Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Thu, 28 Mar 2024 15:59:47 +0100 Subject: [PATCH] [ES|QL] Query history (#178302) ## Summary Closes https://github.com/elastic/kibana/issues/173217 Implements the query history component in the ESQL editor. The query history component displays the 20 most recent queries and it doesn't duplicate. If the user reruns a query it will update an existing one and not create a new entry. image image ### Important notes Right now, the query history component has been implemented at: - Unified search ES|QL editor - Lens inline editing component - Alerts - Maps I have hid it from ML data visualizer because it was very difficult to implement it there. There is a quite complex logic fetching the fields statistics so it was a bit complicated to add it there. ML team can follow up as they know the logic already and would be easier for them to adjust. #### Flajy test runner https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/5553 ### Checklist Delete any items that are not applicable to this PR. - [x] 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) - [x] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [x] [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 - [x] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed - [x] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [x] 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)) - [ ] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) - [x] 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)) - [x] 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> --- packages/kbn-text-based-editor/jest.config.js | 1 + packages/kbn-text-based-editor/setup_tests.ts | 10 + .../src/editor_footer.tsx | 452 +++++++++++------- .../src/history_local_storage.test.ts | 54 +++ .../src/history_local_storage.ts | 104 ++++ .../src/query_history.test.tsx | 196 ++++++++ .../src/query_history.tsx | 392 +++++++++++++++ .../src/query_history_helpers.test.ts | 37 ++ .../src/query_history_helpers.ts | 69 +++ .../src/text_based_languages_editor.styles.ts | 17 +- .../src/text_based_languages_editor.test.tsx | 38 ++ .../src/text_based_languages_editor.tsx | 144 ++++-- src/plugins/text_based_languages/README.md | 10 +- .../query_bar_top_row.test.tsx | 35 -- .../query_string_input/query_bar_top_row.tsx | 14 +- .../public/search_bar/search_bar.tsx | 2 - .../apps/discover/group4/_esql_view.ts | 86 ++++ test/functional/services/esql.ts | 52 ++ test/functional/services/index.ts | 2 + .../index_data_visualizer_esql.tsx | 1 + .../lens_configuration_flyout.tsx | 4 + .../expression/esql_query_expression.tsx | 4 + 22 files changed, 1484 insertions(+), 240 deletions(-) create mode 100644 packages/kbn-text-based-editor/setup_tests.ts create mode 100644 packages/kbn-text-based-editor/src/history_local_storage.test.ts create mode 100644 packages/kbn-text-based-editor/src/history_local_storage.ts create mode 100644 packages/kbn-text-based-editor/src/query_history.test.tsx create mode 100644 packages/kbn-text-based-editor/src/query_history.tsx create mode 100644 packages/kbn-text-based-editor/src/query_history_helpers.test.ts create mode 100644 packages/kbn-text-based-editor/src/query_history_helpers.ts create mode 100644 test/functional/services/esql.ts diff --git a/packages/kbn-text-based-editor/jest.config.js b/packages/kbn-text-based-editor/jest.config.js index dec942367396d..52ce99a1351a6 100644 --- a/packages/kbn-text-based-editor/jest.config.js +++ b/packages/kbn-text-based-editor/jest.config.js @@ -10,4 +10,5 @@ module.exports = { preset: '@kbn/test', rootDir: '../..', roots: ['/packages/kbn-text-based-editor'], + setupFilesAfterEnv: ['/packages/kbn-text-based-editor/setup_tests.ts'], }; diff --git a/packages/kbn-text-based-editor/setup_tests.ts b/packages/kbn-text-based-editor/setup_tests.ts new file mode 100644 index 0000000000000..8d1acb9232934 --- /dev/null +++ b/packages/kbn-text-based-editor/setup_tests.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +// eslint-disable-next-line import/no-extraneous-dependencies +import '@testing-library/jest-dom'; diff --git a/packages/kbn-text-based-editor/src/editor_footer.tsx b/packages/kbn-text-based-editor/src/editor_footer.tsx index 10c31cd6ce7e7..ab3d051c6332a 100644 --- a/packages/kbn-text-based-editor/src/editor_footer.tsx +++ b/packages/kbn-text-based-editor/src/editor_footer.tsx @@ -6,11 +6,10 @@ * Side Public License, v 1. */ -import React, { memo, useState } from 'react'; +import React, { memo, useState, useCallback } from 'react'; import { i18n } from '@kbn/i18n'; import { - EuiCode, EuiText, EuiFlexGroup, EuiFlexItem, @@ -18,10 +17,12 @@ import { EuiButton, useEuiTheme, EuiLink, + EuiCode, } from '@elastic/eui'; import { Interpolation, Theme, css } from '@emotion/react'; import type { MonacoMessage } from './helpers'; import { ErrorsWarningsFooterPopover } from './errors_warnings_popover'; +import { QueryHistoryAction, QueryHistory } from './query_history'; const isMac = navigator.platform.toLowerCase().indexOf('mac') >= 0; const COMMAND_KEY = isMac ? '⌘' : '^'; @@ -31,41 +32,71 @@ export function SubmitFeedbackComponent({ isSpaceReduced }: { isSpaceReduced?: b const { euiTheme } = useEuiTheme(); return ( <> - - - - - - {isSpaceReduced - ? i18n.translate('textBasedEditor.query.textBasedLanguagesEditor.feedback', { - defaultMessage: 'Feedback', - }) - : i18n.translate('textBasedEditor.query.textBasedLanguagesEditor.submitFeedback', { - defaultMessage: 'Submit feedback', - })} - - + {isSpaceReduced && ( + + + + + + )} + {!isSpaceReduced && ( + <> + + + + + + {isSpaceReduced + ? i18n.translate('textBasedEditor.query.textBasedLanguagesEditor.feedback', { + defaultMessage: 'Feedback', + }) + : i18n.translate('textBasedEditor.query.textBasedLanguagesEditor.submitFeedback', { + defaultMessage: 'Submit feedback', + })} + + + + )} ); } interface EditorFooterProps { lines: number; - containerCSS: Interpolation; + styles: { + bottomContainer: Interpolation; + historyContainer: Interpolation; + }; errors?: MonacoMessage[]; warnings?: MonacoMessage[]; detectTimestamp: boolean; onErrorClick: (error: MonacoMessage) => void; runQuery: () => void; + updateQuery: (qs: string) => void; + isHistoryOpen: boolean; + setIsHistoryOpen: (status: boolean) => void; + containerWidth: number; hideRunQueryText?: boolean; disableSubmitAction?: boolean; editorIsInline?: boolean; @@ -73,16 +104,19 @@ interface EditorFooterProps { isLoading?: boolean; allowQueryCancellation?: boolean; hideTimeFilterInfo?: boolean; + hideQueryHistory?: boolean; + refetchHistoryItems?: boolean; } export const EditorFooter = memo(function EditorFooter({ lines, - containerCSS, + styles, errors, warnings, detectTimestamp, onErrorClick, runQuery, + updateQuery, hideRunQueryText, disableSubmitAction, editorIsInline, @@ -90,173 +124,253 @@ export const EditorFooter = memo(function EditorFooter({ isLoading, allowQueryCancellation, hideTimeFilterInfo, + isHistoryOpen, + containerWidth, + setIsHistoryOpen, + hideQueryHistory, + refetchHistoryItems, }: EditorFooterProps) { const { euiTheme } = useEuiTheme(); const [isErrorPopoverOpen, setIsErrorPopoverOpen] = useState(false); const [isWarningPopoverOpen, setIsWarningPopoverOpen] = useState(false); + + const onUpdateAndSubmit = useCallback( + (qs: string) => { + // update the query first + updateQuery(qs); + // submit the query with some latency + // if I do it immediately there is some race condition until + // the state is updated and it won't be sumbitted correctly + setTimeout(() => { + runQuery(); + }, 300); + }, + [runQuery, updateQuery] + ); + return ( - - - -

- {i18n.translate('textBasedEditor.query.textBasedLanguagesEditor.lineCount', { - defaultMessage: '{count} {count, plural, one {line} other {lines}}', - values: { count: lines }, - })} -

-
-
- {/* If there is no space and no @timestamp detected hide the information */} - {(detectTimestamp || !isSpaceReduced) && !hideTimeFilterInfo && ( - - - - -

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

-
-
-
-
- )} - {errors && errors.length > 0 && ( - { - if (isOpen) { - setIsWarningPopoverOpen(false); - } - setIsErrorPopoverOpen(isOpen); - }} - onErrorClick={onErrorClick} - /> - )} - {warnings && warnings.length > 0 && ( - { - if (isOpen) { - setIsErrorPopoverOpen(false); - } - setIsWarningPopoverOpen(isOpen); - }} - onErrorClick={onErrorClick} - /> - )} -
-
- {!hideRunQueryText && ( - - - - - -

- {i18n.translate('textBasedEditor.query.textBasedLanguagesEditor.runQuery', { - defaultMessage: 'Run query', - })} -

-
-
- - {`${COMMAND_KEY} + Enter`} - -
-
- )} - {Boolean(editorIsInline) && ( - <> + - - - - + + - +

+ {i18n.translate('textBasedEditor.query.textBasedLanguagesEditor.lineCount', { + defaultMessage: '{count} {count, plural, one {line} other {lines}}', + values: { count: lines }, + })} +

+
+
+ {/* If there is no space and no @timestamp detected hide the information */} + {(detectTimestamp || !isSpaceReduced) && !hideTimeFilterInfo && ( + + + + +

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

+
+
+
+
+ )} + {errors && errors.length > 0 && ( + { + if (isOpen) { + setIsWarningPopoverOpen(false); + } + setIsErrorPopoverOpen(isOpen); + }} + onErrorClick={onErrorClick} + /> + )} + {warnings && warnings.length > 0 && ( + { + if (isOpen) { + setIsErrorPopoverOpen(false); + } + setIsWarningPopoverOpen(isOpen); + }} + onErrorClick={onErrorClick} + /> + )} +
+
+ + + {!Boolean(editorIsInline) && ( + <> + + {!hideQueryHistory && ( + setIsHistoryOpen(!isHistoryOpen)} + isHistoryOpen={isHistoryOpen} + /> + )} + + )} + {!hideRunQueryText && ( + + - {allowQueryCancellation && isLoading - ? i18n.translate('textBasedEditor.query.textBasedLanguagesEditor.cancel', { - defaultMessage: 'Cancel', - }) - : isSpaceReduced - ? i18n.translate('textBasedEditor.query.textBasedLanguagesEditor.run', { - defaultMessage: 'Run', - }) - : i18n.translate( + +

+ {i18n.translate( 'textBasedEditor.query.textBasedLanguagesEditor.runQuery', { defaultMessage: 'Run query', } )} +

+
- - {allowQueryCancellation && isLoading ? 'X' : `${COMMAND_KEY}⏎`} - + >{`${COMMAND_KEY} + Enter`}
- -
+
+ )}
- + {Boolean(editorIsInline) && ( + <> + + + + {!hideQueryHistory && ( + setIsHistoryOpen(!isHistoryOpen)} + isHistoryOpen={isHistoryOpen} + isSpaceReduced={true} + /> + )} + + + + + {allowQueryCancellation && isLoading + ? i18n.translate( + 'textBasedEditor.query.textBasedLanguagesEditor.cancel', + { + defaultMessage: 'Cancel', + } + ) + : isSpaceReduced + ? i18n.translate('textBasedEditor.query.textBasedLanguagesEditor.run', { + defaultMessage: 'Run', + }) + : i18n.translate( + 'textBasedEditor.query.textBasedLanguagesEditor.runQuery', + { + defaultMessage: 'Run query', + } + )} + + + + {allowQueryCancellation && isLoading ? 'X' : `${COMMAND_KEY}⏎`} + + + + + + + + + )} +
+ + {isHistoryOpen && ( + + + )} ); diff --git a/packages/kbn-text-based-editor/src/history_local_storage.test.ts b/packages/kbn-text-based-editor/src/history_local_storage.test.ts new file mode 100644 index 0000000000000..18e76c8e0560a --- /dev/null +++ b/packages/kbn-text-based-editor/src/history_local_storage.test.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { addQueriesToCache, getCachedQueries, updateCachedQueries } from './history_local_storage'; + +describe('history local storage', function () { + const mockGetItem = jest.fn(); + const mockSetItem = jest.fn(); + Object.defineProperty(window, 'localStorage', { + value: { + getItem: (...args: string[]) => mockGetItem(...args), + setItem: (...args: string[]) => mockSetItem(...args), + }, + }); + it('should add queries to cache correctly ', function () { + addQueriesToCache({ + queryString: 'from kibana_sample_data_flights | limit 10', + timeZone: 'Browser', + }); + const historyItems = getCachedQueries(); + expect(historyItems.length).toBe(1); + expect(historyItems[0].queryRunning).toBe(true); + expect(historyItems[0].timeRan).toBeDefined(); + expect(historyItems[0].duration).toBeUndefined(); + expect(historyItems[0].status).toBeUndefined(); + }); + + it('should update queries to cache correctly ', function () { + addQueriesToCache({ + queryString: 'from kibana_sample_data_flights \n | limit 10 \n | stats meow = avg(woof)', + timeZone: 'Browser', + }); + updateCachedQueries({ + queryString: 'from kibana_sample_data_flights \n | limit 10 \n | stats meow = avg(woof)', + status: 'success', + }); + + const historyItems = getCachedQueries(); + expect(historyItems.length).toBe(2); + expect(historyItems[1].queryRunning).toBe(false); + expect(historyItems[1].timeRan).toBeDefined(); + expect(historyItems[1].duration).toBeDefined(); + expect(historyItems[1].status).toBe('success'); + + expect(mockSetItem).toHaveBeenCalledWith( + 'QUERY_HISTORY_ITEM_KEY', + JSON.stringify(historyItems) + ); + }); +}); diff --git a/packages/kbn-text-based-editor/src/history_local_storage.ts b/packages/kbn-text-based-editor/src/history_local_storage.ts new file mode 100644 index 0000000000000..f2ac26680354e --- /dev/null +++ b/packages/kbn-text-based-editor/src/history_local_storage.ts @@ -0,0 +1,104 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import moment from 'moment'; +import 'moment-timezone'; +const QUERY_HISTORY_ITEM_KEY = 'QUERY_HISTORY_ITEM_KEY'; +const dateFormat = 'MMM. D, YY HH:mm:ss.SSS'; + +/** + * We show maximum 20 ES|QL queries in the Query history component + */ + +export interface QueryHistoryItem { + status?: 'success' | 'error' | 'warning'; + queryString: string; + startDateMilliseconds?: number; + timeRan?: string; + timeZone?: string; + duration?: string; + queryRunning?: boolean; +} + +const MAX_QUERIES_NUMBER = 20; + +const getKey = (queryString: string) => { + return queryString.replaceAll('\n', '').trim(); +}; + +const getMomentTimeZone = (timeZone?: string) => { + return !timeZone || timeZone === 'Browser' ? moment.tz.guess() : timeZone; +}; + +const sortDates = (date1?: number, date2?: number) => { + return moment(date1)?.valueOf() - moment(date2)?.valueOf(); +}; + +export const getHistoryItems = (sortDirection: 'desc' | 'asc'): QueryHistoryItem[] => { + const localStorageString = localStorage.getItem(QUERY_HISTORY_ITEM_KEY) ?? '[]'; + const historyItems: QueryHistoryItem[] = JSON.parse(localStorageString); + const sortedByDate = historyItems.sort((a, b) => { + return sortDirection === 'desc' + ? sortDates(b.startDateMilliseconds, a.startDateMilliseconds) + : sortDates(a.startDateMilliseconds, b.startDateMilliseconds); + }); + return sortedByDate; +}; + +const cachedQueries = new Map(); +const localStorageQueries = getHistoryItems('desc'); + +localStorageQueries.forEach((queryItem) => { + const trimmedQueryString = getKey(queryItem.queryString); + cachedQueries.set(trimmedQueryString, queryItem); +}); + +export const getCachedQueries = (): QueryHistoryItem[] => { + return Array.from(cachedQueries, ([name, value]) => ({ ...value })); +}; + +export const addQueriesToCache = (item: QueryHistoryItem) => { + const trimmedQueryString = getKey(item.queryString); + + if (item.queryString) { + const tz = getMomentTimeZone(item.timeZone); + cachedQueries.set(trimmedQueryString, { + ...item, + timeRan: moment().tz(tz).format(dateFormat), + startDateMilliseconds: moment().valueOf(), + queryRunning: true, + }); + } +}; + +export const updateCachedQueries = (item: QueryHistoryItem) => { + const trimmedQueryString = getKey(item.queryString); + const query = cachedQueries.get(trimmedQueryString); + + if (query) { + const now = moment().valueOf(); + const duration = moment(now).diff(moment(query?.startDateMilliseconds)); + cachedQueries.set(trimmedQueryString, { + ...query, + timeRan: query.queryRunning ? query.timeRan : moment().format('MMM. D, YY HH:mm:ss'), + duration: query.queryRunning ? `${duration}ms` : query.duration, + status: item.status, + queryRunning: false, + }); + } + const queriesToStore = getCachedQueries(); + if (queriesToStore.length === MAX_QUERIES_NUMBER) { + const sortedByDate = queriesToStore.sort((a, b) => + sortDates(b?.startDateMilliseconds, a?.startDateMilliseconds) + ); + + // delete the last element + const toBeDeletedQuery = sortedByDate[MAX_QUERIES_NUMBER - 1]; + cachedQueries.delete(toBeDeletedQuery.queryString); + } + localStorage.setItem(QUERY_HISTORY_ITEM_KEY, JSON.stringify(queriesToStore)); +}; diff --git a/packages/kbn-text-based-editor/src/query_history.test.tsx b/packages/kbn-text-based-editor/src/query_history.test.tsx new file mode 100644 index 0000000000000..74cdb096a81f1 --- /dev/null +++ b/packages/kbn-text-based-editor/src/query_history.test.tsx @@ -0,0 +1,196 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import React from 'react'; +import { QueryHistoryAction, getTableColumns, QueryHistory, QueryColumn } from './query_history'; +import { render, screen } from '@testing-library/react'; + +jest.mock('./history_local_storage', () => { + const module = jest.requireActual('./history_local_storage'); + return { + ...module, + getHistoryItems: () => [ + { + queryString: 'from kibana_sample_data_flights | limit 10', + timeZone: 'Browser', + timeRan: 'Mar. 25, 24 08:45:27', + queryRunning: false, + duration: '2ms', + status: 'success', + }, + ], + }; +}); + +describe('QueryHistory', () => { + describe('QueryHistoryAction', () => { + it('should render the history action component as a button if is spaceReduced is undefined', () => { + render(); + expect( + screen.getByTestId('TextBasedLangEditor-toggle-query-history-button-container') + ).toBeInTheDocument(); + + expect( + screen.getByTestId('TextBasedLangEditor-toggle-query-history-button-container') + ).toHaveTextContent('Hide recent queries'); + }); + + it('should render the history action component as an icon if is spaceReduced is true', () => { + render(); + expect( + screen.getByTestId('TextBasedLangEditor-toggle-query-history-icon') + ).toBeInTheDocument(); + }); + }); + + describe('getTableColumns', () => { + it('should get the history table columns correctly', async () => { + const columns = getTableColumns(50, false, []); + expect(columns).toEqual([ + { + css: { + height: '100%', + }, + 'data-test-subj': 'status', + field: 'status', + name: '', + render: expect.anything(), + sortable: false, + width: '40px', + }, + { + 'data-test-subj': 'queryString', + field: 'queryString', + name: 'Recent queries', + render: expect.anything(), + }, + { + 'data-test-subj': 'timeRan', + field: 'timeRan', + name: 'Time ran', + render: expect.anything(), + sortable: true, + width: '240px', + }, + { + 'data-test-subj': 'lastDuration', + field: 'duration', + name: 'Last duration', + sortable: false, + width: '120px', + }, + { + actions: [], + 'data-test-subj': 'actions', + name: '', + width: '40px', + }, + ]); + }); + }); + + it('should get the history table columns correctly for reduced space', async () => { + const columns = getTableColumns(50, true, []); + expect(columns).toEqual([ + { + css: { + height: '100%', + }, + 'data-test-subj': 'status', + field: 'status', + name: '', + render: expect.anything(), + sortable: false, + width: 'auto', + }, + { + 'data-test-subj': 'timeRan', + field: 'timeRan', + name: 'Time ran', + render: expect.anything(), + sortable: true, + width: 'auto', + }, + { + 'data-test-subj': 'queryString', + field: 'queryString', + name: 'Recent queries', + render: expect.anything(), + }, + { + 'data-test-subj': 'lastDuration', + field: 'duration', + name: 'Last duration', + sortable: false, + width: 'auto', + }, + { + actions: [], + 'data-test-subj': 'actions', + name: '', + width: 'auto', + }, + ]); + }); + + describe('QueryHistory component', () => { + it('should not fetch the query items if refetchHistoryItems is not given', async () => { + render(); + expect(screen.getByRole('table')).toHaveTextContent('No items found'); + }); + + it('should fetch the query items if refetchHistoryItems is given ', async () => { + render( + + ); + expect(screen.getByRole('table')).toHaveTextContent( + 'Time ranRecent queriesLast durationTime ranMar. 25, 24 08:45:27Recent queriesfrom kibana_sample_data_flights | limit 10Last duration2ms' + ); + }); + }); + + describe('Querystring column', () => { + it('should not render the expanded button for large viewports', async () => { + render( + + ); + expect( + screen.queryByTestId('TextBasedLangEditor-queryHistory-queryString-expanded') + ).not.toBeInTheDocument(); + }); + + it('should render the expanded button for small viewports', async () => { + Object.defineProperty(HTMLElement.prototype, 'scrollWidth', { + configurable: true, + value: 400, + }); + Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { + configurable: true, + value: 200, + }); + render( + + ); + expect( + screen.getByTestId('TextBasedLangEditor-queryHistory-queryString-expanded') + ).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/kbn-text-based-editor/src/query_history.tsx b/packages/kbn-text-based-editor/src/query_history.tsx new file mode 100644 index 0000000000000..2a5a5b05dc2c8 --- /dev/null +++ b/packages/kbn-text-based-editor/src/query_history.tsx @@ -0,0 +1,392 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useState, useRef, useEffect, useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + useEuiTheme, + EuiInMemoryTable, + EuiBasicTableColumn, + EuiButtonEmpty, + Criteria, + EuiButtonIcon, + CustomItemAction, + EuiCopy, + EuiToolTip, + euiScrollBarStyles, +} from '@elastic/eui'; +import { css, Interpolation, Theme } from '@emotion/react'; +import { type QueryHistoryItem, getHistoryItems } from './history_local_storage'; +import { getReducedSpaceStyling, swapArrayElements } from './query_history_helpers'; + +const CONTAINER_MAX_HEIGHT = 190; + +export function QueryHistoryAction({ + toggleHistory, + isHistoryOpen, + isSpaceReduced, +}: { + toggleHistory: () => void; + isHistoryOpen: boolean; + isSpaceReduced?: boolean; +}) { + const { euiTheme } = useEuiTheme(); + // get history items from local storage + const items: QueryHistoryItem[] = getHistoryItems('desc'); + if (!items.length) return null; + return ( + <> + {isSpaceReduced && ( + + + + )} + {!isSpaceReduced && ( + + + {isHistoryOpen + ? i18n.translate('textBasedEditor.query.textBasedLanguagesEditor.hideQueriesLabel', { + defaultMessage: 'Hide recent queries', + }) + : i18n.translate('textBasedEditor.query.textBasedLanguagesEditor.showQueriesLabel', { + defaultMessage: 'Show recent queries', + })} + + + )} + + ); +} + +export const getTableColumns = ( + width: number, + isOnReducedSpaceLayout: boolean, + actions: Array> +): Array> => { + const columnsArray = [ + { + field: 'status', + name: '', + sortable: false, + 'data-test-subj': 'status', + render: (status: QueryHistoryItem['status']) => { + switch (status) { + case 'success': + default: + return ( + + ); + case 'error': + return ( + + ); + case 'warning': + return ( + + ); + } + }, + width: isOnReducedSpaceLayout ? 'auto' : '40px', + css: { height: '100%' }, // Vertically align icon + }, + { + field: 'queryString', + 'data-test-subj': 'queryString', + name: i18n.translate( + 'textBasedEditor.query.textBasedLanguagesEditor.recentQueriesColumnLabel', + { + defaultMessage: 'Recent queries', + } + ), + render: (queryString: QueryHistoryItem['queryString']) => ( + + ), + }, + { + field: 'timeRan', + 'data-test-subj': 'timeRan', + name: i18n.translate('textBasedEditor.query.textBasedLanguagesEditor.timeRanColumnLabel', { + defaultMessage: 'Time ran', + }), + sortable: true, + render: (timeRan: QueryHistoryItem['timeRan']) => timeRan, + width: isOnReducedSpaceLayout ? 'auto' : '240px', + }, + { + field: 'duration', + 'data-test-subj': 'lastDuration', + name: i18n.translate( + 'textBasedEditor.query.textBasedLanguagesEditor.lastDurationColumnLabel', + { + defaultMessage: 'Last duration', + } + ), + sortable: false, + width: isOnReducedSpaceLayout ? 'auto' : '120px', + }, + { + name: '', + actions, + 'data-test-subj': 'actions', + width: isOnReducedSpaceLayout ? 'auto' : '40px', + }, + ]; + + // I need to swap the elements here to get the desired design + return isOnReducedSpaceLayout ? swapArrayElements(columnsArray, 1, 2) : columnsArray; +}; + +export function QueryHistory({ + containerCSS, + containerWidth, + refetchHistoryItems, + onUpdateAndSubmit, +}: { + containerCSS: Interpolation; + containerWidth: number; + onUpdateAndSubmit: (qs: string) => void; + refetchHistoryItems?: boolean; +}) { + const theme = useEuiTheme(); + const scrollBarStyles = euiScrollBarStyles(theme); + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc'); + const [historyItems, setHistoryItems] = useState([]); + + useEffect(() => { + if (refetchHistoryItems) { + // get history items from local storage + setHistoryItems(getHistoryItems(sortDirection)); + } + }, [refetchHistoryItems, sortDirection]); + + const actions: Array> = useMemo(() => { + return [ + { + render: (item: QueryHistoryItem) => { + return ( + + + + onUpdateAndSubmit(item.queryString)} + css={css` + cursor: pointer; + `} + /> + + + + + {(copy) => ( + + )} + + + + ); + }, + }, + ]; + }, [onUpdateAndSubmit]); + const isOnReducedSpaceLayout = containerWidth < 560; + const columns = useMemo(() => { + return getTableColumns(containerWidth, isOnReducedSpaceLayout, actions); + }, [actions, containerWidth, isOnReducedSpaceLayout]); + + const onTableChange = ({ page, sort }: Criteria) => { + if (sort) { + const { direction } = sort; + setSortDirection(direction); + } + }; + + const sorting = { + sort: { + field: 'timeRan', + direction: sortDirection, + }, + }; + const { euiTheme } = theme; + const extraStyling = isOnReducedSpaceLayout + ? getReducedSpaceStyling() + : `width: ${containerWidth}px`; + const tableStyling = css` + .euiTable { + background-color: ${euiTheme.colors.lightestShade}; + } + .euiTable tbody tr:nth-child(odd) { + background-color: ${euiTheme.colors.emptyShade}; + } + max-height: ${CONTAINER_MAX_HEIGHT}px; + overflow-y: auto; + ${scrollBarStyles} + ${extraStyling} + `; + + return ( +
+ +
+ ); +} + +export function QueryColumn({ + queryString, + containerWidth, + isOnReducedSpaceLayout, +}: { + containerWidth: number; + queryString: string; + isOnReducedSpaceLayout: boolean; +}) { + const { euiTheme } = useEuiTheme(); + const containerRef = useRef(null); + + const [isExpandable, setIsExpandable] = useState(false); + const [isRowExpanded, setIsRowExpanded] = useState(false); + + useEffect(() => { + if (containerRef.current) { + const textIsOverlapping = containerRef.current.offsetWidth < containerRef.current.scrollWidth; + setIsExpandable(textIsOverlapping); + } + }, [containerWidth]); + + return ( + <> + {isExpandable && ( + { + setIsRowExpanded(!isRowExpanded); + }} + data-test-subj="TextBasedLangEditor-queryHistory-queryString-expanded" + aria-label={ + isRowExpanded + ? i18n.translate('textBasedEditor.query.textBasedLanguagesEditor.collapseLabel', { + defaultMessage: 'Collapse', + }) + : i18n.translate('textBasedEditor.query.textBasedLanguagesEditor.expandLabel', { + defaultMessage: 'Expand', + }) + } + iconType={isRowExpanded ? 'arrowDown' : 'arrowRight'} + size="xs" + /> + )} + + {queryString} + + + ); +} diff --git a/packages/kbn-text-based-editor/src/query_history_helpers.test.ts b/packages/kbn-text-based-editor/src/query_history_helpers.test.ts new file mode 100644 index 0000000000000..c0f9f26d4315a --- /dev/null +++ b/packages/kbn-text-based-editor/src/query_history_helpers.test.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { swapArrayElements } from './query_history_helpers'; + +describe('query history helpers', function () { + it('should swap 2 elements in an array', function () { + const array = [ + { + field: 'woof', + name: 'woof', + sortable: true, + }, + { + field: 'meow', + name: 'meow', + sortable: false, + }, + ]; + expect(swapArrayElements(array, 1, 0)).toEqual([ + { + field: 'meow', + name: 'meow', + sortable: false, + }, + { + field: 'woof', + name: 'woof', + sortable: true, + }, + ]); + }); +}); diff --git a/packages/kbn-text-based-editor/src/query_history_helpers.ts b/packages/kbn-text-based-editor/src/query_history_helpers.ts new file mode 100644 index 0000000000000..97c9ceac454c6 --- /dev/null +++ b/packages/kbn-text-based-editor/src/query_history_helpers.ts @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import type { EuiBasicTableColumn } from '@elastic/eui'; +import type { QueryHistoryItem } from './history_local_storage'; + +export const getReducedSpaceStyling = () => { + return ` + /* Use grid display instead of standard table display CSS */ + .euiTable thead, + .euiTable tbody { + display: block; + } + .euiTable thead tr { + display: grid; + grid-template-columns: 40px 1fr 0 auto 72px; + } + .euiTable tbody tr { + display: grid; + grid-template-columns: 40px 1fr auto 72px; + grid-template-areas: + 'status timeRan lastDuration actions' + '. queryString queryString queryString'; + } + /* Set grid template areas */ + .euiTable td[data-test-subj='status'] { + grid-area: status; + } + .euiTable td[data-test-subj='timeRan'] { + grid-area: timeRan; + } + .euiTable td[data-test-subj='lastDuration'] { + grid-area: lastDuration; + } + .euiTable td[data-test-subj='actions'] { + grid-area: actions; + } + /** + * Special full-width cell that comes after all other cells + */ + .euiTable td[data-test-subj='queryString'] { + grid-area: queryString; + border: 0; + .euiTableCellContent { + padding-top: 0; + } + } + /* Unset the border between this cell and other cells */ + .euiTable .euiTableRowCell:not([data-test-subj='queryString']) { + border-bottom: 0; + } + `; +}; + +export const swapArrayElements = ( + array: Array>, + index1: number, + index2: number +) => { + const temp = array[index1]; + array[index1] = array[index2]; + array[index2] = temp; + + return array; +}; diff --git a/packages/kbn-text-based-editor/src/text_based_languages_editor.styles.ts b/packages/kbn-text-based-editor/src/text_based_languages_editor.styles.ts index 44874074e8464..8d8bd72eb8dcd 100644 --- a/packages/kbn-text-based-editor/src/text_based_languages_editor.styles.ts +++ b/packages/kbn-text-based-editor/src/text_based_languages_editor.styles.ts @@ -21,7 +21,8 @@ export const textBasedLanguagedEditorStyles = ( hasWarning: boolean, isCodeEditorExpandedFocused: boolean, hasReference: boolean, - editorIsInline: boolean + editorIsInline: boolean, + historyIsOpen: boolean ) => { let position = isCompactFocused ? ('absolute' as 'absolute') : ('relative' as 'relative'); // cast string to type 'relative' | 'absolute' if (isCodeEditorExpanded) { @@ -86,6 +87,20 @@ export const textBasedLanguagedEditorStyles = ( marginTop: 0, marginLeft: 0, marginBottom: 0, + borderBottomLeftRadius: editorIsInline || historyIsOpen ? 0 : euiTheme.border.radius.medium, + borderBottomRightRadius: editorIsInline || historyIsOpen ? 0 : euiTheme.border.radius.medium, + }, + historyContainer: { + border: euiTheme.border.thin, + borderTop: `2px solid ${euiTheme.colors.lightShade}`, + borderLeft: editorIsInline ? 'none' : euiTheme.border.thin, + borderRight: editorIsInline ? 'none' : euiTheme.border.thin, + backgroundColor: euiTheme.colors.lightestShade, + width: 'calc(100% + 2px)', + position: 'relative' as 'relative', // cast string to type 'relative', + marginTop: 0, + marginLeft: 0, + marginBottom: 0, borderBottomLeftRadius: editorIsInline ? 0 : euiTheme.border.radius.medium, borderBottomRightRadius: editorIsInline ? 0 : euiTheme.border.radius.medium, }, 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 29bc09d321f26..bbcb29e253981 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 @@ -124,6 +124,44 @@ describe('TextBasedLanguagesEditor', () => { ).toStrictEqual('@timestamp found'); }); + it('should render the query history action if isLoading is defined', async () => { + const newProps = { + ...props, + isCodeEditorExpanded: true, + isLoading: true, + }; + const component = mount(renderTextBasedLanguagesEditorComponent({ ...newProps })); + expect( + component.find('[data-test-subj="TextBasedLangEditor-toggle-query-history-button-container"]') + .length + ).not.toBe(0); + }); + + it('should not render the query history action if isLoading is undefined', async () => { + const newProps = { + ...props, + isCodeEditorExpanded: true, + }; + const component = mount(renderTextBasedLanguagesEditorComponent({ ...newProps })); + expect( + component.find('[data-test-subj="TextBasedLangEditor-toggle-query-history-button-container"]') + .length + ).toBe(0); + }); + + it('should not render the query history action if hideQueryHistory is set to true', async () => { + const newProps = { + ...props, + isCodeEditorExpanded: true, + hideQueryHistory: true, + }; + const component = mount(renderTextBasedLanguagesEditorComponent({ ...newProps })); + expect( + component.find('[data-test-subj="TextBasedLangEditor-toggle-query-history-button-container"]') + .length + ).toBe(0); + }); + it('should render the errors badge for the inline mode by default if errors are provided', async () => { const newProps = { ...props, 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 3e848661edeac..96e6e879da6a0 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 @@ -21,6 +21,7 @@ import type { AggregateQuery } from '@kbn/es-query'; import { getAggregateQueryMode, getLanguageDisplayName } from '@kbn/es-query'; import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import type { ExpressionsStart } from '@kbn/expressions-plugin/public'; +import type { CoreStart } from '@kbn/core/public'; import type { IndexManagementPluginSetup } from '@kbn/index-management-plugin/public'; import { TooltipWrapper } from '@kbn/visualization-utils'; import { @@ -63,6 +64,7 @@ import { EditorFooter } from './editor_footer'; import { ResizableButton } from './resizable_button'; import { fetchFieldsFromESQL } from './fetch_fields_from_esql'; import { ErrorsWarningsCompactViewPopover } from './errors_warnings_popover'; +import { addQueriesToCache, updateCachedQueries } from './history_local_storage'; import './overwrite.scss'; @@ -89,7 +91,9 @@ export interface TextBasedLanguagesEditorProps { errors?: Error[]; /** Warning string as it comes from ES */ warning?: string; - /** Disables the editor and displays loading icon in run button */ + /** Disables the editor and displays loading icon in run button + * It is also used for hiding the history component if it is not defined + */ isLoading?: boolean; /** Disables the editor */ isDisabled?: boolean; @@ -115,9 +119,13 @@ export interface TextBasedLanguagesEditorProps { /** hide @timestamp info **/ hideTimeFilterInfo?: boolean; + + /** hide query history **/ + hideQueryHistory?: boolean; } interface TextBasedEditorDeps { + core: CoreStart; dataViews: DataViewsPublicPluginStart; expressions: ExpressionsStart; indexManagementApiService?: IndexManagementPluginSetup['apiService']; @@ -170,13 +178,16 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({ dataTestSubj, allowQueryCancellation, hideTimeFilterInfo, + hideQueryHistory, }: TextBasedLanguagesEditorProps) { const { euiTheme } = useEuiTheme(); const language = getAggregateQueryMode(query); const queryString: string = query[language] ?? ''; const kibana = useKibana(); - const { dataViews, expressions, indexManagementApiService, application, docLinks } = + const { dataViews, expressions, indexManagementApiService, application, docLinks, core } = kibana.services; + const timeZone = core?.uiSettings?.get('dateFormat:tz'); + const [code, setCode] = useState(queryString ?? ''); const [codeOneLiner, setCodeOneLiner] = useState(''); // To make server side errors less "sticky", register the state of the code when submitting @@ -185,11 +196,14 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({ isCodeEditorExpanded ? EDITOR_INITIAL_HEIGHT_EXPANDED : EDITOR_INITIAL_HEIGHT ); const [isSpaceReduced, setIsSpaceReduced] = useState(false); + const [editorWidth, setEditorWidth] = useState(0); + const [isHistoryOpen, setIsHistoryOpen] = useState(false); const [showLineNumbers, setShowLineNumbers] = useState(isCodeEditorExpanded); const [isCompactFocused, setIsCompactFocused] = useState(isCodeEditorExpanded); const [isCodeEditorExpandedFocused, setIsCodeEditorExpandedFocused] = useState(false); const [isQueryLoading, setIsQueryLoading] = useState(true); const [abortController, setAbortController] = useState(new AbortController()); + // contains both client side validation and server messages const [editorMessages, setEditorMessages] = useState<{ errors: MonacoMessage[]; warnings: MonacoMessage[]; @@ -197,6 +211,28 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({ errors: serverErrors ? parseErrors(serverErrors, code) : [], warnings: serverWarning ? parseWarning(serverWarning) : [], }); + // contains only client side validation messages + const [clientParserMessages, setClientParserMessages] = useState<{ + errors: MonacoMessage[]; + warnings: MonacoMessage[]; + }>({ + errors: [], + warnings: [], + }); + const [refetchHistoryItems, setRefetchHistoryItems] = useState(false); + + // as the duration on the history component is being calculated from + // the isLoading property, if this property is not defined we want + // to hide the history component + const hideHistoryComponent = hideQueryHistory || isLoading == null; + + const onQueryUpdate = useCallback( + (value: string) => { + setCode(value); + onTextLangQueryChange({ [language]: value } as AggregateQuery); + }, + [language, onTextLangQueryChange] + ); const onQuerySubmit = useCallback(() => { if (isQueryLoading && allowQueryCancellation) { @@ -224,6 +260,10 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({ const codeRef = useRef(code); + const toggleHistory = useCallback((status: boolean) => { + setIsHistoryOpen(status); + }, []); + // Registers a command to redirect users to the index management page // to create a new policy. The command is called by the buildNoPoliciesAvailableDefinition monaco.editor.registerCommand('esql.policies.create', (...args) => { @@ -242,7 +282,8 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({ Boolean(editorMessages.warnings.length), isCodeEditorExpandedFocused, Boolean(documentationSections), - Boolean(editorIsInline) + Boolean(editorIsInline), + isHistoryOpen ); const isDark = isDarkMode; const editorModel = useRef(); @@ -394,15 +435,52 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({ ] ); + const parseMessages = useCallback(async () => { + if (editorModel.current) { + return await ESQLLang.validate(editorModel.current, queryString, esqlCallbacks); + } + return { + errors: [], + warnings: [], + }; + }, [esqlCallbacks, queryString]); + + useEffect(() => { + const validateQuery = async () => { + if (editorModel?.current) { + const parserMessages = await parseMessages(); + setClientParserMessages({ + errors: parserMessages?.errors ?? [], + warnings: parserMessages?.warnings ?? [], + }); + } + }; + if (isQueryLoading || isLoading) { + addQueriesToCache({ + queryString, + timeZone, + }); + validateQuery(); + setRefetchHistoryItems(false); + } else { + updateCachedQueries({ + queryString, + status: clientParserMessages.errors?.length + ? 'error' + : clientParserMessages.warnings.length + ? 'warning' + : 'success', + }); + + setRefetchHistoryItems(true); + } + }, [clientParserMessages, isLoading, isQueryLoading, parseMessages, queryString, timeZone]); + const queryValidation = useCallback( async ({ active }: { active: boolean }) => { if (!editorModel.current || language !== 'esql' || editorModel.current.isDisposed()) return; monaco.editor.setModelMarkers(editorModel.current, 'Unified search', []); - const { warnings: parserWarnings, errors: parserErrors } = await ESQLLang.validate( - editorModel.current, - code, - esqlCallbacks - ); + const { warnings: parserWarnings, errors: parserErrors } = await parseMessages(); const markers = []; if (parserErrors.length) { @@ -414,11 +492,11 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({ return; } }, - [esqlCallbacks, language, code] + [language, parseMessages] ); useDebounceWithOptions( - () => { + async () => { if (!editorModel.current) return; const subscription = { active: true }; if (code === codeWhenSubmitted) { @@ -434,6 +512,11 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({ 'Unified search', parsedErrors.length ? parsedErrors : [] ); + const parserMessages = await parseMessages(); + setClientParserMessages({ + errors: parserMessages?.errors ?? [], + warnings: parserMessages?.warnings ?? [], + }); return; } } else { @@ -538,19 +621,12 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({ const onResize = ({ width }: { width: number }) => { setIsSpaceReduced(Boolean(editorIsInline && width < BREAKPOINT_WIDTH)); calculateVisibleCode(width); + setEditorWidth(width); if (editor1.current) { editor1.current.layout({ width, height: editorHeight }); } }; - const onQueryUpdate = useCallback( - (value: string) => { - setCode(value); - onTextLangQueryChange({ [language]: value } as AggregateQuery); - }, - [language, onTextLangQueryChange] - ); - useEffect(() => { async function getDocumentation() { const sections = await getDocumentationSections(language); @@ -871,14 +947,14 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({ {isCompactFocused && !isCodeEditorExpanded && ( { - if (editorMessages.errors.some((e) => e.source !== 'client')) { - onQuerySubmit(); - } - }} + runQuery={onQuerySubmit} + updateQuery={onQueryUpdate} detectTimestamp={detectTimestamp} editorIsInline={editorIsInline} disableSubmitAction={disableSubmitAction} @@ -887,6 +963,11 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({ isLoading={isQueryLoading} allowQueryCancellation={allowQueryCancellation} hideTimeFilterInfo={hideTimeFilterInfo} + isHistoryOpen={isHistoryOpen} + setIsHistoryOpen={toggleHistory} + containerWidth={editorWidth} + hideQueryHistory={hideHistoryComponent} + refetchHistoryItems={refetchHistoryItems} /> )} @@ -971,11 +1052,13 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({ {isCodeEditorExpanded && ( { - onQuerySubmit(); + styles={{ + bottomContainer: styles.bottomContainer, + historyContainer: styles.historyContainer, }} + onErrorClick={onErrorClick} + runQuery={onQuerySubmit} + updateQuery={onQueryUpdate} detectTimestamp={detectTimestamp} hideRunQueryText={hideRunQueryText} editorIsInline={editorIsInline} @@ -985,6 +1068,11 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({ allowQueryCancellation={allowQueryCancellation} hideTimeFilterInfo={hideTimeFilterInfo} {...editorMessages} + isHistoryOpen={isHistoryOpen} + setIsHistoryOpen={toggleHistory} + containerWidth={editorWidth} + hideQueryHistory={hideHistoryComponent} + refetchHistoryItems={refetchHistoryItems} /> )} {isCodeEditorExpanded && ( diff --git a/src/plugins/text_based_languages/README.md b/src/plugins/text_based_languages/README.md index 99155161173d7..42d3375220682 100644 --- a/src/plugins/text_based_languages/README.md +++ b/src/plugins/text_based_languages/README.md @@ -6,7 +6,9 @@ The editor accepts the following properties: - onTextLangQueryChange: callback that is called every time the query is updated - expandCodeEditor: flag that opens the editor on the expanded mode - errors: array of `Error`. +- warning: A string for visualizing warnings - onTextLangQuerySubmit: callback that is called when the user submits the query +- isLoading: As the editor is not responsible for the data fetching request, the consumer could update this property when the data are being fetched. If this property is defined, the query history component will be rendered ``` To use it on your application, you need to add the textBasedLanguages to your requiredBundles and the @kbn/text-based-languages to your tsconfig.json and use the component like that: @@ -31,4 +33,10 @@ If your application uses the dataview picker then it can be enabled by adding textBasedLanguages: ['ESQL'], ``` -om the dataViewPickerProps property. \ No newline at end of file +om the dataViewPickerProps property. + +It is also part of the: +- Lens inline editing component +- Maps new ES|QL layer +- ML data visualizer +- Alerts \ No newline at end of file diff --git a/src/plugins/unified_search/public/query_string_input/query_bar_top_row.test.tsx b/src/plugins/unified_search/public/query_string_input/query_bar_top_row.test.tsx index c8a6531c6de01..e2b78c7f9656e 100644 --- a/src/plugins/unified_search/public/query_string_input/query_bar_top_row.test.tsx +++ b/src/plugins/unified_search/public/query_string_input/query_bar_top_row.test.tsx @@ -347,41 +347,6 @@ describe('QueryBarTopRowTopRow', () => { `); }); - it('should render query input bar with hideRunQueryText when configured', () => { - const component = mount( - wrapQueryBarTopRowInContext({ - query: sqlQuery, - isDirty: false, - screenTitle: 'SQL Screen', - timeHistory: mockTimeHistory, - indexPatterns: [stubIndexPattern], - showDatePicker: true, - dateRangeFrom: 'now-7d', - dateRangeTo: 'now', - hideTextBasedRunQueryLabel: true, - }) - ); - - expect(component.find(TEXT_BASED_EDITOR).prop('hideRunQueryText')).toBe(true); - }); - - it('should render query input bar with hideRunQueryText as undefined if not configured', () => { - const component = mount( - wrapQueryBarTopRowInContext({ - query: sqlQuery, - isDirty: false, - screenTitle: 'SQL Screen', - timeHistory: mockTimeHistory, - indexPatterns: [stubIndexPattern], - showDatePicker: true, - dateRangeFrom: 'now-7d', - dateRangeTo: 'now', - }) - ); - - expect(component.find(TEXT_BASED_EDITOR).prop('hideRunQueryText')).toBe(undefined); - }); - it('Should render custom data view picker', () => { const dataViewPickerOverride =
; const { getByTestId } = render( diff --git a/src/plugins/unified_search/public/query_string_input/query_bar_top_row.tsx b/src/plugins/unified_search/public/query_string_input/query_bar_top_row.tsx index dd9fb37258ed3..f7f99fb21eddb 100644 --- a/src/plugins/unified_search/public/query_string_input/query_bar_top_row.tsx +++ b/src/plugins/unified_search/public/query_string_input/query_bar_top_row.tsx @@ -60,6 +60,9 @@ import type { } from '../typeahead/suggestions_component'; import './query_bar.scss'; +const isMac = navigator.platform.toLowerCase().indexOf('mac') >= 0; +const COMMAND_KEY = isMac ? '⌘' : 'CTRL'; + export const strings = { getNeedsUpdatingLabel: () => i18n.translate('unifiedSearch.queryBarTopRow.submitButton.update', { @@ -152,7 +155,6 @@ export interface QueryBarTopRowProps dataViewPickerComponentProps?: DataViewPickerProps; textBasedLanguageModeErrors?: Error[]; textBasedLanguageModeWarning?: string; - hideTextBasedRunQueryLabel?: boolean; onTextBasedSavedAndExit?: ({ onSave }: OnSaveTextLanguageQueryProps) => void; filterBar?: React.ReactNode; showDatePickerAsBadge?: boolean; @@ -545,9 +547,12 @@ export const QueryBarTopRow = React.memo( if (!shouldRenderUpdatebutton() && !shouldRenderDatePicker()) { return null; } + const textBasedRunShortcut = `${COMMAND_KEY} + Enter`; const buttonLabelUpdate = strings.getNeedsUpdatingLabel(); - const buttonLabelRefresh = strings.getRefreshQueryLabel(); - const buttonLabelRun = strings.getRunQueryLabel(); + const buttonLabelRefresh = Boolean(isQueryLangSelected) + ? textBasedRunShortcut + : strings.getRefreshQueryLabel(); + const buttonLabelRun = textBasedRunShortcut; const iconDirty = Boolean(isQueryLangSelected) ? 'play' : 'kqlFunction'; const tooltipDirty = Boolean(isQueryLangSelected) ? buttonLabelRun : buttonLabelUpdate; @@ -724,8 +729,9 @@ export const QueryBarTopRow = React.memo( }) } isDisabled={props.isDisabled} - hideRunQueryText={props.hideTextBasedRunQueryLabel} + hideRunQueryText={true} data-test-subj="unifiedTextLangEditor" + isLoading={props.isLoading} /> ) ); diff --git a/src/plugins/unified_search/public/search_bar/search_bar.tsx b/src/plugins/unified_search/public/search_bar/search_bar.tsx index 12e47e8d62a2b..9bb60972734b0 100644 --- a/src/plugins/unified_search/public/search_bar/search_bar.tsx +++ b/src/plugins/unified_search/public/search_bar/search_bar.tsx @@ -114,7 +114,6 @@ export interface SearchBarOwnProps { dataViewPickerComponentProps?: DataViewPickerProps; textBasedLanguageModeErrors?: Error[]; textBasedLanguageModeWarning?: string; - hideTextBasedRunQueryLabel?: boolean; onTextBasedSavedAndExit?: ({ onSave }: OnSaveTextLanguageQueryProps) => void; showSubmitButton?: boolean; submitButtonStyle?: QueryBarTopRowProps['submitButtonStyle']; @@ -635,7 +634,6 @@ class SearchBarUI extends C dataViewPickerComponentProps={this.props.dataViewPickerComponentProps} textBasedLanguageModeErrors={this.props.textBasedLanguageModeErrors} textBasedLanguageModeWarning={this.props.textBasedLanguageModeWarning} - hideTextBasedRunQueryLabel={this.props.hideTextBasedRunQueryLabel} onTextBasedSavedAndExit={this.props.onTextBasedSavedAndExit} showDatePickerAsBadge={this.shouldShowDatePickerAsBadge()} filterBar={filterBar} diff --git a/test/functional/apps/discover/group4/_esql_view.ts b/test/functional/apps/discover/group4/_esql_view.ts index 454f3cd45fde3..1a328e415b951 100644 --- a/test/functional/apps/discover/group4/_esql_view.ts +++ b/test/functional/apps/discover/group4/_esql_view.ts @@ -20,6 +20,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const security = getService('security'); const retry = getService('retry'); const find = getService('find'); + const esql = getService('esql'); const PageObjects = getPageObjects([ 'common', 'discover', @@ -238,5 +239,90 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); }); }); + + describe('query history', () => { + beforeEach(async () => { + await PageObjects.common.navigateToApp('discover'); + await PageObjects.timePicker.setDefaultAbsoluteRange(); + }); + + it('should see my current query in the history', async () => { + await PageObjects.discover.selectTextBaseLang(); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + await PageObjects.unifiedFieldList.waitUntilSidebarHasLoaded(); + + await testSubjects.click('TextBasedLangEditor-expand'); + await testSubjects.click('TextBasedLangEditor-toggle-query-history-button'); + const historyItems = await esql.getHistoryItems(); + log.debug(historyItems); + const queryAdded = historyItems.some((item) => { + return item[1] === 'from logstash-* | limit 10'; + }); + + expect(queryAdded).to.be(true); + }); + + it('updating the query should add this to the history', async () => { + await PageObjects.discover.selectTextBaseLang(); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + await PageObjects.unifiedFieldList.waitUntilSidebarHasLoaded(); + + const testQuery = 'from logstash-* | limit 100 | drop @timestamp'; + await monacoEditor.setCodeEditorValue(testQuery); + await testSubjects.click('querySubmitButton'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + + await testSubjects.click('TextBasedLangEditor-expand'); + await testSubjects.click('TextBasedLangEditor-toggle-query-history-button'); + const historyItems = await esql.getHistoryItems(); + log.debug(historyItems); + const queryAdded = historyItems.some((item) => { + return item[1] === 'from logstash-* | limit 100 | drop @timestamp'; + }); + + expect(queryAdded).to.be(true); + }); + + it('should select a query from the history and submit it', async () => { + await PageObjects.discover.selectTextBaseLang(); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + await PageObjects.unifiedFieldList.waitUntilSidebarHasLoaded(); + + await testSubjects.click('TextBasedLangEditor-expand'); + await testSubjects.click('TextBasedLangEditor-toggle-query-history-button'); + // click a history item + await esql.clickHistoryItem(1); + + const historyItems = await esql.getHistoryItems(); + log.debug(historyItems); + const queryAdded = historyItems.some((item) => { + return item[1] === 'from logstash-* | limit 100 | drop @timestamp'; + }); + + expect(queryAdded).to.be(true); + }); + + it('should add a failed query to the history', async () => { + await PageObjects.discover.selectTextBaseLang(); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + await PageObjects.unifiedFieldList.waitUntilSidebarHasLoaded(); + + const testQuery = 'from logstash-* | limit 100 | woof and meow'; + await monacoEditor.setCodeEditorValue(testQuery); + await testSubjects.click('querySubmitButton'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + + await testSubjects.click('TextBasedLangEditor-expand'); + await testSubjects.click('TextBasedLangEditor-toggle-query-history-button'); + const historyItem = await esql.getHistoryItem(0); + await historyItem.findByTestSubject('TextBasedLangEditor-queryHistory-error'); + }); + }); }); } diff --git a/test/functional/services/esql.ts b/test/functional/services/esql.ts new file mode 100644 index 0000000000000..8ccce3af629d1 --- /dev/null +++ b/test/functional/services/esql.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { FtrService } from '../ftr_provider_context'; + +export class ESQLService extends FtrService { + private readonly retry = this.ctx.getService('retry'); + private readonly testSubjects = this.ctx.getService('testSubjects'); + + public async getHistoryItems(): Promise { + const queryHistory = await this.testSubjects.find('TextBasedLangEditor-queryHistory'); + const tableBody = await this.retry.try(async () => queryHistory.findByTagName('tbody')); + const $ = await tableBody.parseDomContent(); + return $('tr') + .toArray() + .map((tr) => { + return $(tr) + .find('td') + .toArray() + .map((cell) => { + // if this is an EUI table, filter down to the specific cell content + // otherwise this will include mobile-specific header information + const euiTableCellContent = $(cell).find('.euiTableCellContent'); + + if (euiTableCellContent.length > 0) { + return $(cell).find('.euiTableCellContent').text().trim(); + } else { + return $(cell).text().trim(); + } + }); + }); + } + + public async getHistoryItem(rowIndex = 0) { + const queryHistory = await this.testSubjects.find('TextBasedLangEditor-queryHistory'); + const tableBody = await this.retry.try(async () => queryHistory.findByTagName('tbody')); + const rows = await this.retry.try(async () => tableBody.findAllByTagName('tr')); + + return rows[rowIndex]; + } + + public async clickHistoryItem(rowIndex = 0) { + const row = await this.getHistoryItem(rowIndex); + const toggle = await row.findByTestSubject('TextBasedLangEditor-queryHistory-runQuery-button'); + await toggle.click(); + } +} diff --git a/test/functional/services/index.ts b/test/functional/services/index.ts index 43a588cdf9385..01d5983c55493 100644 --- a/test/functional/services/index.ts +++ b/test/functional/services/index.ts @@ -53,6 +53,7 @@ import { MonacoEditorService } from './monaco_editor'; import { UsageCollectionService } from './usage_collection'; import { SavedObjectsFinderService } from './saved_objects_finder'; import { DashboardSettingsProvider } from './dashboard/dashboard_settings'; +import { ESQLService } from './esql'; export const services = { ...commonServiceProviders, @@ -95,4 +96,5 @@ export const services = { menuToggle: MenuToggleService, usageCollection: UsageCollectionService, savedObjectsFinder: SavedObjectsFinderService, + esql: ESQLService, }; diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_data_visualizer_view/index_data_visualizer_esql.tsx b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_data_visualizer_view/index_data_visualizer_esql.tsx index c8051cda137aa..badfadb16d8d9 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_data_visualizer_view/index_data_visualizer_esql.tsx +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_data_visualizer_view/index_data_visualizer_esql.tsx @@ -256,6 +256,7 @@ export const IndexDataVisualizerESQL: FC = (dataVi detectTimestamp={true} hideMinimizeButton={true} hideRunQueryText={false} + hideQueryHistory /> 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 f0d106285b14d..f1173e8f90d66 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 @@ -82,6 +82,7 @@ export function LensEditConfigurationFlyout({ const [isLayerAccordionOpen, setIsLayerAccordionOpen] = useState(true); const [suggestsLimitedColumns, setSuggestsLimitedColumns] = useState(false); const [isSuggestionsAccordionOpen, setIsSuggestionsAccordionOpen] = useState(false); + const [isVisualizationLoading, setIsVisualizationLoading] = useState(false); const datasourceState = attributes.state.datasourceStates[datasourceId]; const activeDatasource = datasourceMap[datasourceId]; @@ -296,6 +297,7 @@ export function LensEditConfigurationFlyout({ setErrors([]); updateSuggestion?.(attrs); } + setIsVisualizationLoading(false); }, [ startDependencies, @@ -451,11 +453,13 @@ export function LensEditConfigurationFlyout({ hideRunQueryText onTextLangQuerySubmit={async (q, a) => { if (q) { + setIsVisualizationLoading(true); await runQuery(q, a); } }} isDisabled={false} allowQueryCancellation + isLoading={isVisualizationLoading} /> )} diff --git a/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/esql_query_expression.tsx b/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/esql_query_expression.tsx index 0bf6ccd93f7d2..8c8a9ef56efb3 100644 --- a/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/esql_query_expression.tsx +++ b/x-pack/plugins/stack_alerts/public/rule_types/es_query/expression/esql_query_expression.tsx @@ -66,6 +66,7 @@ export const EsqlQueryExpression: React.FC< const [timeFieldOptions, setTimeFieldOptions] = useState([firstFieldOption]); const [detectTimestamp, setDetectTimestamp] = useState(false); const [esFields, setEsFields] = useState([]); + const [isLoading, setIsLoading] = useState(false); const setParam = useCallback( (paramField: string, paramValue: unknown) => { @@ -109,6 +110,7 @@ export const EsqlQueryExpression: React.FC< } const timeWindow = parseDuration(window); const now = Date.now(); + setIsLoading(true); const table = await fetchFieldsFromESQL( esqlQuery, expressions, @@ -126,6 +128,7 @@ export const EsqlQueryExpression: React.FC< if (table) { const esqlTable = transformDatatableToEsqlTable(table); const hits = toEsQueryHits(esqlTable); + setIsLoading(false); return { testResults: parseAggregationResults({ isCountAgg: true, @@ -225,6 +228,7 @@ export const EsqlQueryExpression: React.FC< detectTimestamp={detectTimestamp} hideMinimizeButton={true} hideRunQueryText={true} + isLoading={isLoading} />