From 27738481b9eff3457c057b4c51fa8b6fd538a47b Mon Sep 17 00:00:00 2001 From: Amardeepsingh Siglani Date: Fri, 25 Oct 2024 08:44:56 -0700 Subject: [PATCH] Saved queries new UI (#8469) * loading query functional Signed-off-by: Amardeepsingh Siglani * search bar working; save working Signed-off-by: Amardeepsingh Siglani * implemented pagination Signed-off-by: Amardeepsingh Siglani * Changeset file for PR #8469 created/updated * implemented tab for template queries; updated UI elements for new mocks Signed-off-by: Amardeepsingh Siglani * added i18n; minor refactors Signed-off-by: Amardeepsingh Siglani * refactored css Signed-off-by: Amardeepsingh Siglani * addressed comments Signed-off-by: Amardeepsingh Siglani * fixed i18n error Signed-off-by: Amardeepsingh Siglani * show template queries tab only when templates present Signed-off-by: Amardeepsingh Siglani * addressed PR comments Signed-off-by: Amardeepsingh Siglani * fixed unit tests Signed-off-by: Amardeepsingh Siglani * introduced a new gate for the changes Signed-off-by: Amardeepsingh Siglani --------- Signed-off-by: Amardeepsingh Siglani Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/8469.yml | 2 + config/opensearch_dashboards.yml | 5 +- src/plugins/data/config.ts | 3 + src/plugins/data/public/plugin.ts | 10 +- .../data/public/query/query_service.ts | 4 +- .../saved_query/saved_query_service.test.ts | 7 +- .../query/saved_query/saved_query_service.ts | 50 +-- .../data/public/query/saved_query/types.ts | 3 + src/plugins/data/public/services.ts | 10 + src/plugins/data/public/ui/_index.scss | 1 + .../public/ui/filter_bar/filter_options.tsx | 24 +- .../open_saved_query_flyout.tsx | 283 +++++++++++++++ .../saved_query_flyouts/save_query_flyout.tsx | 53 +++ .../saved_query_flyouts/saved_query_card.tsx | 196 ++++++++++ .../saved_query_flyouts.scss | 23 ++ .../public/ui/saved_query_form/helpers.tsx | 334 ++++++++++++++++++ .../data/public/ui/saved_query_form/index.ts | 2 +- .../ui/saved_query_form/save_query_form.tsx | 246 +++---------- .../delete_saved_query_confirmation_modal.tsx | 72 ++++ .../saved_query_list_item.tsx | 23 +- .../saved_query_management_component.test.tsx | 13 +- .../saved_query_management_component.tsx | 103 +++++- .../data/public/ui/search_bar/search_bar.tsx | 29 +- src/plugins/data/server/index.ts | 1 + .../data/server/saved_objects/query.ts | 31 +- 25 files changed, 1244 insertions(+), 284 deletions(-) create mode 100644 changelogs/fragments/8469.yml create mode 100644 src/plugins/data/public/ui/saved_query_flyouts/open_saved_query_flyout.tsx create mode 100644 src/plugins/data/public/ui/saved_query_flyouts/save_query_flyout.tsx create mode 100644 src/plugins/data/public/ui/saved_query_flyouts/saved_query_card.tsx create mode 100644 src/plugins/data/public/ui/saved_query_flyouts/saved_query_flyouts.scss create mode 100644 src/plugins/data/public/ui/saved_query_form/helpers.tsx create mode 100644 src/plugins/data/public/ui/saved_query_management/delete_saved_query_confirmation_modal.tsx diff --git a/changelogs/fragments/8469.yml b/changelogs/fragments/8469.yml new file mode 100644 index 000000000000..3c7cfdb44e17 --- /dev/null +++ b/changelogs/fragments/8469.yml @@ -0,0 +1,2 @@ +feat: +- Enhances the saved query UX ([#8469](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8469)) \ No newline at end of file diff --git a/config/opensearch_dashboards.yml b/config/opensearch_dashboards.yml index a36d673349b7..7b21bbd5c0dc 100644 --- a/config/opensearch_dashboards.yml +++ b/config/opensearch_dashboards.yml @@ -374,4 +374,7 @@ # The default config is [], and no one will be dashboard admin. # If the user config is set to wildcard ["*"], anyone will be dashboard admin. # opensearchDashboards.dashboardAdmin.groups: ["dashboard_admin"] -# opensearchDashboards.dashboardAdmin.users: ["dashboard_admin"] \ No newline at end of file +# opensearchDashboards.dashboardAdmin.users: ["dashboard_admin"] + +# Set the value to true to enable the new UI for savedQueries in Discover +# data.savedQueriesNewUI.enabled: true diff --git a/src/plugins/data/config.ts b/src/plugins/data/config.ts index 186691f3c917..50cf5aef413b 100644 --- a/src/plugins/data/config.ts +++ b/src/plugins/data/config.ts @@ -57,6 +57,9 @@ export const configSchema = schema.object({ enabled: schema.boolean({ defaultValue: false }), }), }), + savedQueriesNewUI: schema.object({ + enabled: schema.boolean({ defaultValue: false }), + }), }); export type ConfigSchema = TypeOf; diff --git a/src/plugins/data/public/plugin.ts b/src/plugins/data/public/plugin.ts index 70977deb5d1b..0354c33796fc 100644 --- a/src/plugins/data/public/plugin.ts +++ b/src/plugins/data/public/plugin.ts @@ -54,6 +54,7 @@ import { } from './index_patterns'; import { setApplication, + setUseNewSavedQueriesUI, setFieldFormats, setIndexPatterns, setNotifications, @@ -93,7 +94,7 @@ import { DEFAULT_DATA_SOURCE_TYPE } from './data_sources/constants'; import { getSuggestions as getSQLSuggestions } from './antlr/opensearch_sql/code_completion'; import { getSuggestions as getDQLSuggestions } from './antlr/dql/code_completion'; import { getSuggestions as getPPLSuggestions } from './antlr/opensearch_ppl/code_completion'; -import { createStorage, DataStorage } from '../common'; +import { createStorage, DataStorage, UI_SETTINGS } from '../common'; declare module '../../ui_actions/public' { export interface ActionContextMapping { @@ -118,6 +119,7 @@ export class DataPublicPlugin private readonly queryService: QueryService; private readonly storage: DataStorage; private readonly sessionStorage: DataStorage; + private readonly config: ConfigSchema; constructor(initializerContext: PluginInitializerContext) { this.searchService = new SearchService(initializerContext); @@ -130,6 +132,7 @@ export class DataPublicPlugin engine: window.sessionStorage, prefix: 'opensearchDashboards.', }); + this.config = initializerContext.config.get(); } public setup( @@ -178,6 +181,11 @@ export class DataPublicPlugin autoComplete.addQuerySuggestionProvider('kuery', getDQLSuggestions); autoComplete.addQuerySuggestionProvider('PPL', getPPLSuggestions); + const useNewSavedQueriesUI = + core.uiSettings.get(UI_SETTINGS.QUERY_ENHANCEMENTS_ENABLED) && + this.config.savedQueriesNewUI.enabled; + setUseNewSavedQueriesUI(useNewSavedQueriesUI); + return { // TODO: MQL autocomplete: this.autocomplete.setup(core), diff --git a/src/plugins/data/public/query/query_service.ts b/src/plugins/data/public/query/query_service.ts index b009d4eeef01..9211ef4fc89b 100644 --- a/src/plugins/data/public/query/query_service.ts +++ b/src/plugins/data/public/query/query_service.ts @@ -111,8 +111,8 @@ export class QueryService { queryString: this.queryStringManager, savedQueries: createSavedQueryService( savedObjectsClient, - this.queryStringManager, - application + { application, uiSettings }, + this.queryStringManager ), state$: this.state$, timefilter: this.timefilter, diff --git a/src/plugins/data/public/query/saved_query/saved_query_service.test.ts b/src/plugins/data/public/query/saved_query/saved_query_service.test.ts index 9fc8cdf61016..b50c47f80408 100644 --- a/src/plugins/data/public/query/saved_query/saved_query_service.test.ts +++ b/src/plugins/data/public/query/saved_query/saved_query_service.test.ts @@ -31,6 +31,7 @@ import { createSavedQueryService } from './saved_query_service'; import { FilterStateStore } from '../../../common'; import { SavedQueryAttributes } from './types'; +import { applicationServiceMock, uiSettingsServiceMock } from '../../../../../core/public/mocks'; const savedQueryAttributes: SavedQueryAttributes = { title: 'foo', @@ -89,7 +90,11 @@ const { getSavedQueryCount, } = createSavedQueryService( // @ts-ignore - mockSavedObjectsClient + mockSavedObjectsClient, + { + application: applicationServiceMock.create(), + uiSettings: uiSettingsServiceMock.createStartContract(), + } ); describe('saved query service', () => { diff --git a/src/plugins/data/public/query/saved_query/saved_query_service.ts b/src/plugins/data/public/query/saved_query/saved_query_service.ts index 8fba257fad27..92af9b7c024e 100644 --- a/src/plugins/data/public/query/saved_query/saved_query_service.ts +++ b/src/plugins/data/public/query/saved_query/saved_query_service.ts @@ -33,27 +33,24 @@ import { SavedObjectsClientContract, SavedObjectAttributes, CoreStart } from 'sr import { first } from 'rxjs/operators'; import { SavedQueryAttributes, SavedQuery, SavedQueryService } from './types'; import { QueryStringContract } from '../query_string'; +import { getUseNewSavedQueriesUI } from '../../services'; -type SerializedSavedQueryAttributes = SavedObjectAttributes & - SavedQueryAttributes & { - query: { - query: string; - language: string; - }; - }; +type SerializedSavedQueryAttributes = SavedObjectAttributes & SavedQueryAttributes; export const createSavedQueryService = ( savedObjectsClient: SavedObjectsClientContract, - queryStringManager?: QueryStringContract, - application?: CoreStart['application'] + coreStartServices: { application: CoreStart['application']; uiSettings: CoreStart['uiSettings'] }, + queryStringManager?: QueryStringContract ): SavedQueryService => { + const { application } = coreStartServices; + const saveQuery = async (attributes: SavedQueryAttributes, { overwrite = false } = {}) => { if (!attributes.title.length) { // title is required extra check against circumventing the front end throw new Error('Cannot create saved query without a title'); } - const query = { + const query: SerializedSavedQueryAttributes['query'] = { query: typeof attributes.query.query === 'string' ? attributes.query.query @@ -61,7 +58,11 @@ export const createSavedQueryService = ( language: attributes.query.language, }; - const queryObject: SerializedSavedQueryAttributes = { + if (getUseNewSavedQueriesUI() && attributes.query.dataset) { + query.dataset = attributes.query.dataset; + } + + const queryObject: SavedQueryAttributes = { title: attributes.title.trim(), // trim whitespace before save as an extra precaution against circumventing the front end description: attributes.description, query, @@ -75,6 +76,10 @@ export const createSavedQueryService = ( queryObject.timefilter = attributes.timefilter; } + if (getUseNewSavedQueriesUI() && attributes.isTemplate) { + queryObject.isTemplate = true; + } + let rawQueryResponse; if (!overwrite) { rawQueryResponse = await savedObjectsClient.create('query', queryObject, { @@ -126,8 +131,7 @@ export const createSavedQueryService = ( parseSavedQueryObject(savedObject) ); - const currentAppId = - (await application?.currentAppId$?.pipe(first()).toPromise()) ?? Promise.resolve(undefined); + const currentAppId = (await application?.currentAppId$?.pipe(first()).toPromise()) ?? undefined; const languageService = queryStringManager?.getLanguageService(); // Filtering saved queries based on language supported by cirrent application @@ -159,11 +163,8 @@ export const createSavedQueryService = ( return await savedObjectsClient.delete('query', id); }; - const parseSavedQueryObject = (savedQuery: { - id: string; - attributes: SerializedSavedQueryAttributes; - }) => { - const queryString = savedQuery.attributes.query.query; + const parseSavedQueryObject = (savedQuery: SavedQuery) => { + const queryString = savedQuery.attributes.query.query as string; let parsedQuery; try { parsedQuery = JSON.parse(queryString); @@ -172,7 +173,7 @@ export const createSavedQueryService = ( parsedQuery = queryString; } - const savedQueryItems: SavedQueryAttributes = { + const savedQueryItem: SavedQueryAttributes = { title: savedQuery.attributes.title || '', description: savedQuery.attributes.description || '', query: { @@ -181,15 +182,20 @@ export const createSavedQueryService = ( }, }; + if (getUseNewSavedQueriesUI()) { + savedQueryItem.query.dataset = savedQuery.attributes.query.dataset; + savedQueryItem.isTemplate = !!savedQuery.attributes.isTemplate; + } + if (savedQuery.attributes.filters) { - savedQueryItems.filters = savedQuery.attributes.filters; + savedQueryItem.filters = savedQuery.attributes.filters; } if (savedQuery.attributes.timefilter) { - savedQueryItems.timefilter = savedQuery.attributes.timefilter; + savedQueryItem.timefilter = savedQuery.attributes.timefilter; } return { id: savedQuery.id, - attributes: savedQueryItems, + attributes: savedQueryItem, }; }; diff --git a/src/plugins/data/public/query/saved_query/types.ts b/src/plugins/data/public/query/saved_query/types.ts index 8635547e0094..8b3b2cc1e5a0 100644 --- a/src/plugins/data/public/query/saved_query/types.ts +++ b/src/plugins/data/public/query/saved_query/types.ts @@ -42,6 +42,9 @@ export interface SavedQuery { export interface SavedQueryAttributes { title: string; description: string; + // If isTemplate is true, then saved query cannot be updated/deleted from the UI and + // will show up under Templates tab for saved queries + isTemplate?: boolean; query: Query; filters?: Filter[]; timefilter?: SavedQueryTimeFilter; diff --git a/src/plugins/data/public/services.ts b/src/plugins/data/public/services.ts index c042c2b6d9b8..ef827a63405f 100644 --- a/src/plugins/data/public/services.ts +++ b/src/plugins/data/public/services.ts @@ -63,3 +63,13 @@ export const [getSearchService, setSearchService] = createGetterSetter< export const [getUiService, setUiService] = createGetterSetter('Ui'); export const [getApplication, setApplication] = createGetterSetter('Application'); + +let useNewSavedQueriesUI = false; + +export function getUseNewSavedQueriesUI() { + return useNewSavedQueriesUI; +} + +export const setUseNewSavedQueriesUI = (value: boolean) => { + useNewSavedQueriesUI = value; +}; diff --git a/src/plugins/data/public/ui/_index.scss b/src/plugins/data/public/ui/_index.scss index 9ab4ca672b38..daace2e65281 100644 --- a/src/plugins/data/public/ui/_index.scss +++ b/src/plugins/data/public/ui/_index.scss @@ -5,3 +5,4 @@ @import "./dataset_selector/index"; @import "./query_editor/index"; @import "./shard_failure_modal/shard_failure_modal"; +@import "./saved_query_flyouts/saved_query_flyouts"; diff --git a/src/plugins/data/public/ui/filter_bar/filter_options.tsx b/src/plugins/data/public/ui/filter_bar/filter_options.tsx index 6de37d562ce1..3cda39731fa7 100644 --- a/src/plugins/data/public/ui/filter_bar/filter_options.tsx +++ b/src/plugins/data/public/ui/filter_bar/filter_options.tsx @@ -55,12 +55,13 @@ import { unpinFilter, UI_SETTINGS, IIndexPattern, - isQueryStringFilter, } from '../../../common'; import { FilterEditor } from './filter_editor'; import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; import { SavedQueryManagementComponent } from '../saved_query_management'; import { SavedQuery, SavedQueryService } from '../../query'; +import { SavedQueryMeta } from '../saved_query_form'; +import { getUseNewSavedQueriesUI } from '../../services'; interface Props { intl: InjectedIntl; @@ -69,14 +70,15 @@ interface Props { savedQueryService: SavedQueryService; // Show when user has privileges to save showSaveQuery?: boolean; - onSave: () => void; - onSaveAsNew: () => void; + onInitiateSave: () => void; + onInitiateSaveAsNew: () => void; onLoad: (savedQuery: SavedQuery) => void; onClearSavedQuery: () => void; onFiltersUpdated?: (filters: Filter[]) => void; loadedSavedQuery?: SavedQuery; useSaveQueryMenu: boolean; isQueryEditorControl: boolean; + saveQuery: (savedQueryMeta: SavedQueryMeta, saveAsNew?: boolean) => Promise; } const maxFilterWidth = 600; @@ -285,8 +287,8 @@ const FilterOptionsUI = (props: Props) => { ]; const handleSave = () => { - if (props.onSave) { - props.onSave(); + if (props.onInitiateSave) { + props.onInitiateSave(); } setIsPopoverOpen(false); }; @@ -297,8 +299,8 @@ const FilterOptionsUI = (props: Props) => { { setIsPopoverOpen(false); }} key={'savedQueryManagement'} + useNewSavedQueryUI={getUseNewSavedQueriesUI()} + saveQuery={props.saveQuery} />, ]} data-test-subj="save-query-panel" @@ -364,6 +368,10 @@ const FilterOptionsUI = (props: Props) => { defaultMessage: 'See saved queries', }); + const iconForQueryEditorControlPopoverBtn = getUseNewSavedQueriesUI() + ? 'boxesHorizontal' + : 'folderOpen'; + const savedQueryPopoverButton = ( { title={label} > diff --git a/src/plugins/data/public/ui/saved_query_flyouts/open_saved_query_flyout.tsx b/src/plugins/data/public/ui/saved_query_flyouts/open_saved_query_flyout.tsx new file mode 100644 index 000000000000..c7f13f27db08 --- /dev/null +++ b/src/plugins/data/public/ui/saved_query_flyouts/open_saved_query_flyout.tsx @@ -0,0 +1,283 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiButton, + EuiButtonEmpty, + EuiEmptyPrompt, + EuiFlexGroup, + EuiFlexItem, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiSearchBar, + EuiSearchBarProps, + EuiSpacer, + EuiTabbedContent, + EuiTablePagination, + EuiTitle, + Pager, +} from '@elastic/eui'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { i18n } from '@osd/i18n'; +import { SavedQuery, SavedQueryService } from '../../query'; +import { SavedQueryCard } from './saved_query_card'; + +export interface OpenSavedQueryFlyoutProps { + savedQueryService: SavedQueryService; + onClose: () => void; + onQueryOpen: (query: SavedQuery) => void; + handleQueryDelete: (query: SavedQuery) => Promise; +} + +interface SavedQuerySearchableItem { + id: string; + title: string; + description: string; + language: string; + datasetType?: string; + savedQuery: SavedQuery; +} + +export function OpenSavedQueryFlyout({ + savedQueryService, + onClose, + onQueryOpen, + handleQueryDelete, +}: OpenSavedQueryFlyoutProps) { + const [selectedTabId, setSelectedTabId] = useState('mutable-saved-queries'); + const [savedQueries, setSavedQueries] = useState([]); + const [hasTemplateQueries, setHasTemplateQueries] = useState(false); + const [itemsPerPage, setItemsPerPage] = useState(10); + const pager = useRef(new Pager(savedQueries.length, itemsPerPage)); + const [activePage, setActivePage] = useState(pager.current.getCurrentPageIndex()); + const [queriesOnCurrentPage, setQueriesOnCurrentPage] = useState([]); + const [datasetTypeFilterOptions, setDatasetTypeFilterOptions] = useState([]); + const [languageFilterOptions, setLanguageFilterOptions] = useState([]); + const [selectedQuery, setSelectedQuery] = useState(undefined); + const [searchQuery, setSearchQuery] = useState(EuiSearchBar.Query.MATCH_ALL); + + const fetchAllSavedQueriesForSelectedTab = useCallback(async () => { + const allQueries = await savedQueryService.getAllSavedQueries(); + const templateQueriesPresent = allQueries.some((q) => q.attributes.isTemplate); + const queriesForSelectedTab = allQueries.filter( + (q) => + (selectedTabId === 'mutable-saved-queries' && !q.attributes.isTemplate) || + (selectedTabId === 'template-saved-queries' && q.attributes.isTemplate) + ); + setSavedQueries(queriesForSelectedTab); + setHasTemplateQueries(templateQueriesPresent); + }, [savedQueryService, selectedTabId, setSavedQueries]); + + const updatePageIndex = useCallback((index: number) => { + pager.current.goToPageIndex(index); + setActivePage(index); + }, []); + + useEffect(() => { + fetchAllSavedQueriesForSelectedTab(); + setSearchQuery(EuiSearchBar.Query.MATCH_ALL); + updatePageIndex(0); + }, [selectedTabId, fetchAllSavedQueriesForSelectedTab, updatePageIndex]); + + useEffect(() => { + const queryLanguages = new Set(); + const queryDatasetTypes = new Set(); + + savedQueries.forEach((q) => { + queryLanguages.add(q.attributes.query.language); + if (q.attributes.query.dataset?.type) { + queryDatasetTypes.add(q.attributes.query.dataset.type); + } + }); + setLanguageFilterOptions(Array.from(queryLanguages)); + setDatasetTypeFilterOptions(Array.from(queryDatasetTypes)); + }, [savedQueries]); + + useEffect(() => { + const searchableItems = savedQueries.map((q) => ({ + id: q.id, + title: q.attributes.title, + description: q.attributes.description, + language: q.attributes.query.language, + datasetType: q.attributes.query.dataset?.type, + savedQuery: q, + })); + + const filteredSavedQueries = EuiSearchBar.Query.execute(searchQuery, searchableItems, { + defaultFields: ['language', 'title', 'description', 'datasetType'], + }); + pager.current.setTotalItems(filteredSavedQueries.length); + setQueriesOnCurrentPage( + filteredSavedQueries.slice( + pager.current.getFirstItemIndex(), + pager.current.getLastItemIndex() + 1 + ) + ); + }, [savedQueries, searchQuery, activePage, itemsPerPage]); + + const onChange: EuiSearchBarProps['onChange'] = ({ query, error }) => { + if (!error) { + setSearchQuery(query); + updatePageIndex(0); + } + }; + + const schema = { + strict: true, + fields: { + title: { + type: 'string', + }, + description: { + type: 'string', + }, + language: { + type: 'string', + }, + }, + }; + + const flyoutBodyContent = ( + <> + + ({ + value: datasetType, + view: datasetType.toUpperCase(), + })), + }, + { + type: 'field_value_selection', + field: 'language', + name: 'Query language', + multiSelect: 'or', + options: languageFilterOptions.map((language) => ({ + value: language, + view: language.toUpperCase(), + })), + }, + ]} + onChange={onChange} + /> + + {queriesOnCurrentPage.length > 0 ? ( + queriesOnCurrentPage.map((query) => ( + { + handleQueryDelete(queryToDelete).then(() => { + fetchAllSavedQueriesForSelectedTab(); + }); + }} + /> + )) + ) : ( + + {i18n.translate('data.openSavedQueryFlyout.queryTable.noQueryFoundText', { + defaultMessage: 'No saved query found.', + })} +

+ } + /> + )} + + {queriesOnCurrentPage.length > 0 && ( + { + pager.current.setItemsPerPage(pageSize); + setItemsPerPage(pageSize); + }} + onChangePage={(pageIndex) => { + updatePageIndex(pageIndex); + }} + /> + )} + + ); + + const tabs = [ + { + id: 'mutable-saved-queries', + name: 'Saved queries', + content: flyoutBodyContent, + }, + ]; + + if (hasTemplateQueries) { + tabs.push({ + id: 'template-saved-queries', + name: 'Templates', + content: flyoutBodyContent, + }); + } + + return ( + + + +

Saved queries

+
+
+ + { + setSelectedTabId(tab.id); + }} + /> + + + + + + Cancel + + + + { + if (selectedQuery) { + onQueryOpen(selectedQuery); + onClose(); + } + }} + > + Open query + + + + +
+ ); +} diff --git a/src/plugins/data/public/ui/saved_query_flyouts/save_query_flyout.tsx b/src/plugins/data/public/ui/saved_query_flyouts/save_query_flyout.tsx new file mode 100644 index 000000000000..c0356b864485 --- /dev/null +++ b/src/plugins/data/public/ui/saved_query_flyouts/save_query_flyout.tsx @@ -0,0 +1,53 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState } from 'react'; +import { SavedQueryAttributes, SavedQueryService } from '../../query'; +import { SaveQueryForm, SavedQueryMeta } from '../saved_query_form'; + +interface SaveQueryFlyoutProps { + savedQuery?: SavedQueryAttributes; + savedQueryService: SavedQueryService; + onSave: (savedQueryMeta: SavedQueryMeta, saveAsNew?: boolean) => Promise; + onClose: () => void; + showFilterOption: boolean | undefined; + showTimeFilterOption: boolean | undefined; +} + +export function SaveQueryFlyout({ + savedQuery, + savedQueryService, + onSave, + onClose, + showFilterOption, + showTimeFilterOption, +}: SaveQueryFlyoutProps) { + const [saveAsNew, setSaveAsNew] = useState(!savedQuery || savedQuery.isTemplate); + + return ( + { + try { + await onSave(savedQueryMeta, saveAsNew); + onClose(); + } catch (error) { + // error toast is already shown inside the onSave above, + // catching error to avoid UI crash + // adding comment to prevent no-empty lint error + } + }} + savedQueryService={savedQueryService} + showFilterOption={showFilterOption} + showTimeFilterOption={showTimeFilterOption} + showDataSourceOption={true} + setSaveAsNew={(shouldSaveAsNew) => setSaveAsNew(shouldSaveAsNew)} + savedQuery={saveAsNew ? undefined : savedQuery} + saveAsNew={saveAsNew} + cannotBeOverwritten={!!savedQuery?.isTemplate} + /> + ); +} diff --git a/src/plugins/data/public/ui/saved_query_flyouts/saved_query_card.tsx b/src/plugins/data/public/ui/saved_query_flyouts/saved_query_card.tsx new file mode 100644 index 000000000000..7107a8906004 --- /dev/null +++ b/src/plugins/data/public/ui/saved_query_flyouts/saved_query_card.tsx @@ -0,0 +1,196 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiBadge, + EuiButtonIcon, + EuiCheckableCard, + EuiCopy, + EuiFlexGroup, + EuiFlexItem, + EuiLink, + EuiSpacer, + EuiText, + EuiTitle, +} from '@elastic/eui'; +import React, { useRef, useState, useEffect } from 'react'; +import MonacoEditor from 'react-monaco-editor'; +import { monaco } from '@osd/monaco'; +import { i18n } from '@osd/i18n'; +import { SavedQuery } from '../../query'; +import { DeleteSavedQueryConfirmationModal } from '../saved_query_management/delete_saved_query_confirmation_modal'; + +export interface SavedQueryCardProps { + savedQuery: SavedQuery; + selectedQuery?: SavedQuery; + onSelect: (query: SavedQuery) => void; + handleQueryDelete: (query: SavedQuery) => void; +} + +export function SavedQueryCard({ + savedQuery, + selectedQuery, + onSelect, + handleQueryDelete, +}: SavedQueryCardProps) { + const [shouldTruncate, setShouldTruncate] = useState(false); + const [isTruncated, setIsTruncated] = useState(true); + const [editorHeight, setEditorHeight] = useState(60); + const customHTMLRef = useRef(null); + const [showDeletionConfirmationModal, setShowDeletionConfirmationModal] = useState(false); + const [lineCount, setLineCount] = useState(0); + const toggleView = () => { + setIsTruncated(!isTruncated); + }; + + useEffect(() => { + if (!shouldTruncate) { + return; + } + + if (isTruncated) { + setEditorHeight(80); + return; + } + + const editor = customHTMLRef.current; + if (!editor) { + return; + } + + const editorElement = editor.getDomNode(); + + if (!editorElement) { + return; + } + + const height = editor.getScrollHeight(); + setEditorHeight(height); + }, [isTruncated, shouldTruncate]); + + function handleHTMLEditorDidMount(editor: monaco.editor.IStandaloneCodeEditor) { + const scrollHeight = editor.getScrollHeight(); + setEditorHeight(scrollHeight); + + if (scrollHeight > 80) { + setShouldTruncate(true); + } + + setLineCount(editor.getModel()?.getLineCount() || 0); + customHTMLRef.current = editor; + } + + return ( + <> + { + onSelect(savedQuery); + }} + label={ + <> + + + + + +

{savedQuery.attributes.title}

+
+
+ + {savedQuery.attributes.query.language} + +
+
+ {!savedQuery.attributes.isTemplate && ( + + { + setShowDeletionConfirmationModal(true); + }} + /> + + )} +
+ {savedQuery.attributes.description && ( + +

{savedQuery.attributes.description}

+
+ )} + {savedQuery.attributes.query.dataset?.title && ( + +

{savedQuery.attributes.query.dataset.title}

+
+ )} + + } + > + + +
+ + {shouldTruncate && ( +
+ + {i18n.translate('data.saved_query.view_more_label', { + defaultMessage: '{viewMoreLabel}', + values: { + viewMoreLabel: `${ + isTruncated ? `View full query (${lineCount} lines)` : 'View Less' + }`, + }, + })} + +
+ )} +
+
+ + + {(copy) => ( + + )} + + +
+
+ + {showDeletionConfirmationModal && ( + { + handleQueryDelete(savedQuery); + setShowDeletionConfirmationModal(false); + }} + onCancel={() => { + setShowDeletionConfirmationModal(false); + }} + /> + )} + + ); +} diff --git a/src/plugins/data/public/ui/saved_query_flyouts/saved_query_flyouts.scss b/src/plugins/data/public/ui/saved_query_flyouts/saved_query_flyouts.scss new file mode 100644 index 000000000000..1ab1074b27a5 --- /dev/null +++ b/src/plugins/data/public/ui/saved_query_flyouts/saved_query_flyouts.scss @@ -0,0 +1,23 @@ +.editor-container { + position: relative; + overflow-y: clip; +} + +.read-more-wrap { + /* stylelint-disable @osd/stylelint/no_restricted_values */ + background: linear-gradient(rgba($euiColorEmptyShade, 5%) 10%, $euiColorEmptyShade 60%, $euiColorEmptyShade 100%); + height: 90px; + position: absolute; + z-index: 1; + left: 0; + bottom: 0; + text-align: center; + width: 100%; +} + +.read-more-btn { + position: absolute; + left: 50%; + bottom: 0; + transform: translateX(-50%); +} diff --git a/src/plugins/data/public/ui/saved_query_form/helpers.tsx b/src/plugins/data/public/ui/saved_query_form/helpers.tsx new file mode 100644 index 000000000000..467eac2de475 --- /dev/null +++ b/src/plugins/data/public/ui/saved_query_form/helpers.tsx @@ -0,0 +1,334 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { useEffect, useState, useCallback } from 'react'; +import { + EuiSmallButtonEmpty, + EuiSmallButton, + EuiForm, + EuiCompressedFormRow, + EuiCompressedFieldText, + EuiCompressedSwitch, + EuiText, + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, + EuiButton, + EuiCompressedCheckbox, +} from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import { sortBy, isEqual } from 'lodash'; +import { SavedQuery, SavedQueryService } from '../..'; +import { SavedQueryAttributes } from '../../query'; +import { SavedQueryMeta } from './save_query_form'; + +interface Props { + savedQuery?: SavedQueryAttributes; + savedQueryService: SavedQueryService; + onSave: (savedQueryMeta: SavedQueryMeta) => void; + onClose: () => void; + formUiType: 'Modal' | 'Flyout'; + showFilterOption?: boolean; + showTimeFilterOption?: boolean; + showDataSourceOption?: boolean; + saveAsNew?: boolean; + setSaveAsNew?: (shouldSaveAsNew: boolean) => void; + cannotBeOverwritten?: boolean; +} + +export function useSaveQueryFormContent({ + savedQuery, + savedQueryService, + onSave, + onClose, + showFilterOption = true, + showTimeFilterOption = true, + showDataSourceOption = false, + formUiType, + saveAsNew, + setSaveAsNew, + cannotBeOverwritten, +}: Props) { + const [title, setTitle] = useState(''); + const [enabledSaveButton, setEnabledSaveButton] = useState(false); + const [description, setDescription] = useState(''); + const [savedQueries, setSavedQueries] = useState([]); + const [shouldIncludeFilters, setShouldIncludeFilters] = useState(true); + const [shouldIncludeDataSource, setShouldIncludeDataSource] = useState(true); + // Defaults to false because saved queries are meant to be as portable as possible and loading + // a saved query with a time filter will override whatever the current value of the global timepicker + // is. We expect this option to be used rarely and only when the user knows they want this behavior. + const [shouldIncludeTimeFilter, setIncludeTimefilter] = useState(false); + const [formErrors, setFormErrors] = useState([]); + + // Need this effect so that in case when user select "Save as new" in the flyout, + // the initial state resets since savedQuery will be undefined + useEffect(() => { + setTitle(savedQuery?.title || ''); + setEnabledSaveButton(Boolean(savedQuery)); + setDescription(savedQuery?.description || ''); + setShouldIncludeFilters(savedQuery ? !!savedQuery.filters : true); + setIncludeTimefilter(!!savedQuery?.timefilter); + setShouldIncludeDataSource(savedQuery ? !!savedQuery.query.dataset : true); + setFormErrors([]); + }, [savedQuery]); + + const titleConflictErrorText = i18n.translate( + 'data.search.searchBar.savedQueryForm.titleConflictText', + { + defaultMessage: 'Name conflicts with an existing saved query', + } + ); + + const savedQueryDescriptionText = i18n.translate( + 'data.search.searchBar.savedQueryDescriptionText', + { + defaultMessage: 'Save query text and filters that you want to use again.', + } + ); + + useEffect(() => { + const fetchQueries = async () => { + const allSavedQueries = await savedQueryService.getAllSavedQueries(); + const sortedAllSavedQueries = sortBy(allSavedQueries, 'attributes.title') as SavedQuery[]; + setSavedQueries(sortedAllSavedQueries); + }; + fetchQueries(); + }, [savedQueryService]); + + const validate = useCallback(() => { + const errors = []; + if ( + !savedQuery && + savedQueries.some((existingSavedQuery) => existingSavedQuery.attributes.title === title) + ) { + errors.push(titleConflictErrorText); + } + + if (!isEqual(errors, formErrors)) { + setFormErrors(errors); + return false; + } + + return !formErrors.length; + }, [savedQueries, savedQuery, title, titleConflictErrorText, formErrors]); + + const onClickSave = useCallback(() => { + if (validate()) { + onSave({ + title, + description, + shouldIncludeFilters, + shouldIncludeTimeFilter, + shouldIncludeDataSource, + }); + } + }, [ + validate, + onSave, + title, + description, + shouldIncludeFilters, + shouldIncludeTimeFilter, + shouldIncludeDataSource, + ]); + + const onInputChange = useCallback((event) => { + setEnabledSaveButton(Boolean(event.target.value)); + setFormErrors([]); + setTitle(event.target.value); + }, []); + + const autoTrim = useCallback(() => { + const trimmedTitle = title.trim(); + if (title.length > trimmedTitle.length) { + setTitle(trimmedTitle); + } + }, [title]); + + const hasErrors = formErrors.length > 0; + + const saveQueryFormBody = ( + + + + {savedQueryDescriptionText} + + + {formUiType === 'Flyout' && ( + + setSaveAsNew?.(event.target.checked)} + checked={saveAsNew} + label={i18n.translate('data.search.searchBar.SaveAsNewLabelText', { + defaultMessage: 'Save as new query', + })} + disabled={cannotBeOverwritten} + /> + + )} + + + + + + { + setDescription(event.target.value); + }} + data-test-subj="saveQueryFormDescription" + /> + + {showDataSourceOption && ( + + { + setShouldIncludeDataSource(!shouldIncludeDataSource); + }} + data-test-subj="saveQueryFormIncludeDataSourceOption" + /> + + )} + {showFilterOption && ( + + { + setShouldIncludeFilters(!shouldIncludeFilters); + }} + data-test-subj="saveQueryFormIncludeFiltersOption" + /> + + )} + + {showTimeFilterOption && ( + + { + setIncludeTimefilter(!shouldIncludeTimeFilter); + }} + data-test-subj="saveQueryFormIncludeTimeFilterOption" + /> + + )} + + ); + + const footer = + formUiType === 'Modal' ? ( + <> + + {i18n.translate('data.search.searchBar.savedQueryFormCancelButtonText', { + defaultMessage: 'Cancel', + })} + + + + {i18n.translate('data.search.searchBar.savedQueryFormSaveButtonText', { + defaultMessage: 'Save', + })} + + + ) : ( + + + + {i18n.translate('data.search.searchBar.savedQueryFormCancelButtonText', { + defaultMessage: 'Cancel', + })} + + + + + {i18n.translate('data.search.searchBar.savedQueryFlyoutFormSaveButtonText', { + defaultMessage: '{saveText}', + values: { saveText: savedQuery ? 'Save changes' : 'Save' }, + })} + + + + ); + + return { + header: ( + +

+ {i18n.translate('data.search.searchBar.savedQueryFormTitle', { + defaultMessage: 'Save query', + })} +

+
+ ), + body: saveQueryFormBody, + footer, + }; +} diff --git a/src/plugins/data/public/ui/saved_query_form/index.ts b/src/plugins/data/public/ui/saved_query_form/index.ts index e9c62cf646df..6160e53b54e4 100644 --- a/src/plugins/data/public/ui/saved_query_form/index.ts +++ b/src/plugins/data/public/ui/saved_query_form/index.ts @@ -29,4 +29,4 @@ */ // @internal -export { SavedQueryMeta, SaveQueryForm } from '../saved_query_form/save_query_form'; +export { SavedQueryMeta, SaveQueryForm } from './save_query_form'; diff --git a/src/plugins/data/public/ui/saved_query_form/save_query_form.tsx b/src/plugins/data/public/ui/saved_query_form/save_query_form.tsx index 9d1b5c29342d..e25f3229d88c 100644 --- a/src/plugins/data/public/ui/saved_query_form/save_query_form.tsx +++ b/src/plugins/data/public/ui/saved_query_form/save_query_form.tsx @@ -28,242 +28,86 @@ * under the License. */ -import React, { useEffect, useState, useCallback } from 'react'; +import React from 'react'; import { - EuiSmallButtonEmpty, EuiModal, - EuiSmallButton, EuiModalHeader, EuiModalHeaderTitle, EuiModalBody, EuiModalFooter, - EuiForm, - EuiCompressedFormRow, - EuiCompressedFieldText, - EuiCompressedSwitch, - EuiText, + EuiFlyoutHeader, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutFooter, } from '@elastic/eui'; -import { i18n } from '@osd/i18n'; -import { sortBy, isEqual } from 'lodash'; -import { SavedQuery, SavedQueryService } from '../..'; +import { SavedQueryService } from '../..'; import { SavedQueryAttributes } from '../../query'; +import { useSaveQueryFormContent } from './helpers'; interface Props { + formUiType: 'Modal' | 'Flyout'; savedQuery?: SavedQueryAttributes; savedQueryService: SavedQueryService; onSave: (savedQueryMeta: SavedQueryMeta) => void; onClose: () => void; - showFilterOption: boolean | undefined; - showTimeFilterOption: boolean | undefined; + setSaveAsNew?: (shouldSaveAsNew: boolean) => void; + showFilterOption?: boolean; + showTimeFilterOption?: boolean; + showDataSourceOption?: boolean; + saveAsNew?: boolean; + cannotBeOverwritten?: boolean; } export interface SavedQueryMeta { title: string; description: string; shouldIncludeFilters: boolean; - shouldIncludeTimefilter: boolean; + shouldIncludeTimeFilter: boolean; + shouldIncludeDataSource: boolean; } export function SaveQueryForm({ + formUiType, savedQuery, savedQueryService, onSave, onClose, showFilterOption = true, showTimeFilterOption = true, + showDataSourceOption = false, + saveAsNew, + setSaveAsNew, + cannotBeOverwritten, }: Props) { - const [title, setTitle] = useState(savedQuery ? savedQuery.title : ''); - const [enabledSaveButton, setEnabledSaveButton] = useState(Boolean(savedQuery)); - const [description, setDescription] = useState(savedQuery ? savedQuery.description : ''); - const [savedQueries, setSavedQueries] = useState([]); - const [shouldIncludeFilters, setShouldIncludeFilters] = useState( - savedQuery ? !!savedQuery.filters : true - ); - // Defaults to false because saved queries are meant to be as portable as possible and loading - // a saved query with a time filter will override whatever the current value of the global timepicker - // is. We expect this option to be used rarely and only when the user knows they want this behavior. - const [shouldIncludeTimefilter, setIncludeTimefilter] = useState( - savedQuery ? !!savedQuery.timefilter : false - ); - const [formErrors, setFormErrors] = useState([]); - - const titleConflictErrorText = i18n.translate( - 'data.search.searchBar.savedQueryForm.titleConflictText', - { - defaultMessage: 'Name conflicts with an existing saved query', - } - ); - - const savedQueryDescriptionText = i18n.translate( - 'data.search.searchBar.savedQueryDescriptionText', - { - defaultMessage: 'Save query text and filters that you want to use again.', - } - ); - - useEffect(() => { - const fetchQueries = async () => { - const allSavedQueries = await savedQueryService.getAllSavedQueries(); - const sortedAllSavedQueries = sortBy(allSavedQueries, 'attributes.title') as SavedQuery[]; - setSavedQueries(sortedAllSavedQueries); - }; - fetchQueries(); - }, [savedQueryService]); - - const validate = useCallback(() => { - const errors = []; - if ( - !!savedQueries.find( - (existingSavedQuery) => !savedQuery && existingSavedQuery.attributes.title === title - ) - ) { - errors.push(titleConflictErrorText); - } - - if (!isEqual(errors, formErrors)) { - setFormErrors(errors); - return false; - } - - return !formErrors.length; - }, [savedQueries, savedQuery, title, titleConflictErrorText, formErrors]); - - const onClickSave = useCallback(() => { - if (validate()) { - onSave({ - title, - description, - shouldIncludeFilters, - shouldIncludeTimefilter, - }); - } - }, [validate, onSave, title, description, shouldIncludeFilters, shouldIncludeTimefilter]); - - const onInputChange = useCallback((event) => { - setEnabledSaveButton(Boolean(event.target.value)); - setFormErrors([]); - setTitle(event.target.value); - }, []); - - const autoTrim = useCallback(() => { - const trimmedTitle = title.trim(); - if (title.length > trimmedTitle.length) { - setTitle(trimmedTitle); - } - }, [title]); - - const hasErrors = formErrors.length > 0; - - const saveQueryForm = ( - - - - {savedQueryDescriptionText} - - - - - - - - { - setDescription(event.target.value); - }} - data-test-subj="saveQueryFormDescription" - /> - - {showFilterOption && ( - - { - setShouldIncludeFilters(!shouldIncludeFilters); - }} - data-test-subj="saveQueryFormIncludeFiltersOption" - /> - - )} - - {showTimeFilterOption && ( - - { - setIncludeTimefilter(!shouldIncludeTimefilter); - }} - data-test-subj="saveQueryFormIncludeTimeFilterOption" - /> - - )} - - ); - - return ( + const { header, body, footer } = useSaveQueryFormContent({ + formUiType, + savedQuery, + savedQueryService, + onSave, + onClose, + showFilterOption, + showTimeFilterOption, + showDataSourceOption, + saveAsNew, + setSaveAsNew, + cannotBeOverwritten, + }); + + return formUiType === 'Modal' ? ( - - -

- {i18n.translate('data.search.searchBar.savedQueryFormTitle', { - defaultMessage: 'Save query', - })} -

-
-
+ {header}
- {saveQueryForm} - - - - {i18n.translate('data.search.searchBar.savedQueryFormCancelButtonText', { - defaultMessage: 'Cancel', - })} - + {body} - - {i18n.translate('data.search.searchBar.savedQueryFormSaveButtonText', { - defaultMessage: 'Save', - })} - - + {footer}
+ ) : ( + + {header} + {body} + {footer} + ); } diff --git a/src/plugins/data/public/ui/saved_query_management/delete_saved_query_confirmation_modal.tsx b/src/plugins/data/public/ui/saved_query_management/delete_saved_query_confirmation_modal.tsx new file mode 100644 index 000000000000..035fc22ae3d0 --- /dev/null +++ b/src/plugins/data/public/ui/saved_query_management/delete_saved_query_confirmation_modal.tsx @@ -0,0 +1,72 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { EuiConfirmModal } from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import { SavedQuery } from '../..'; + +export interface DeleteSavedQueryConfirmationModalProps { + savedQuery: SavedQuery; + onConfirm: () => void; + onCancel: () => void; +} + +export const DeleteSavedQueryConfirmationModal = ({ + savedQuery, + onConfirm, + onCancel, +}: DeleteSavedQueryConfirmationModalProps) => { + return ( + + ); +}; diff --git a/src/plugins/data/public/ui/saved_query_management/saved_query_list_item.tsx b/src/plugins/data/public/ui/saved_query_management/saved_query_list_item.tsx index 77711d6382a4..8e015adf1eb5 100644 --- a/src/plugins/data/public/ui/saved_query_management/saved_query_list_item.tsx +++ b/src/plugins/data/public/ui/saved_query_management/saved_query_list_item.tsx @@ -34,6 +34,7 @@ import React, { Fragment, useState } from 'react'; import classNames from 'classnames'; import { i18n } from '@osd/i18n'; import { SavedQuery } from '../..'; +import { DeleteSavedQueryConfirmationModal } from './delete_saved_query_confirmation_modal'; interface Props { savedQuery: SavedQuery; @@ -137,30 +138,12 @@ export const SavedQueryListItem = ({ /> {showDeletionConfirmationModal && ( - { onDelete(savedQuery); setShowDeletionConfirmationModal(false); }} - buttonColor="danger" onCancel={() => { setShowDeletionConfirmationModal(false); }} diff --git a/src/plugins/data/public/ui/saved_query_management/saved_query_management_component.test.tsx b/src/plugins/data/public/ui/saved_query_management/saved_query_management_component.test.tsx index 8b92609d419a..acafe2a444a3 100644 --- a/src/plugins/data/public/ui/saved_query_management/saved_query_management_component.test.tsx +++ b/src/plugins/data/public/ui/saved_query_management/saved_query_management_component.test.tsx @@ -18,8 +18,8 @@ const mockProps = () => ({ deleteSavedQuery: jest.fn(), getSavedQueryCount: jest.fn(), }, - onSave: jest.fn(), - onSaveAsNew: jest.fn(), + onInitiateSave: jest.fn(), + onInitiateSaveAsNew: jest.fn(), onLoad: jest.fn(), onClearSavedQuery: jest.fn(), closeMenuPopover: jest.fn(), @@ -33,6 +33,7 @@ const mockProps = () => ({ query: { query: '', language: 'kuery' } as Query, } as SavedQueryAttributes, }, + saveQuery: jest.fn(), }); describe('Saved query management component', () => { @@ -42,25 +43,25 @@ describe('Saved query management component', () => { expect(wrapper.exists()).toBe(true); }); - it('should call onSave when save button is clicked', () => { + it('should call onInitiateSave when save button is clicked', () => { const props = mockProps(); const wrapper = shallowWithIntl(); const saveButton = wrapper .find('[data-test-subj="saved-query-management-save-changes-button"]') .at(0); saveButton.simulate('click'); - expect(props.onSave).toHaveBeenCalled(); + expect(props.onInitiateSave).toHaveBeenCalled(); expect(props.closeMenuPopover).toHaveBeenCalled(); }); - it('should call onSaveAsNew when save as new button is clicked', () => { + it('should call onInitiateSaveAsNew when save as new button is clicked', () => { const props = mockProps(); const wrapper = shallowWithIntl(); const saveAsNewButton = wrapper .find('[data-test-subj="saved-query-management-save-as-new-button"]') .at(0); saveAsNewButton.simulate('click'); - expect(props.onSaveAsNew).toHaveBeenCalled(); + expect(props.onInitiateSaveAsNew).toHaveBeenCalled(); }); it('should call onClearSavedQuery when clear saved query button is clicked', () => { 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 3048554fe2fc..01f9b97e978f 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 @@ -39,6 +39,7 @@ import { EuiPagination, EuiText, EuiSpacer, + EuiListGroupItem, } from '@elastic/eui'; import { i18n } from '@osd/i18n'; @@ -46,33 +47,50 @@ import React, { useCallback, useEffect, useState, Fragment, useRef } from 'react import { sortBy } from 'lodash'; import { SavedQuery, SavedQueryService } from '../..'; import { SavedQueryListItem } from './saved_query_list_item'; +import { + toMountPoint, + useOpenSearchDashboards, +} from '../../../../opensearch_dashboards_react/public'; +import { SaveQueryFlyout } from '../saved_query_flyouts/save_query_flyout'; +import { + OpenSavedQueryFlyout, + OpenSavedQueryFlyoutProps, +} from '../saved_query_flyouts/open_saved_query_flyout'; +import { SavedQueryMeta } from '../saved_query_form'; const perPage = 50; interface Props { showSaveQuery?: boolean; loadedSavedQuery?: SavedQuery; savedQueryService: SavedQueryService; - onSave: () => void; - onSaveAsNew: () => void; - onLoad: (savedQuery: SavedQuery) => void; + useNewSavedQueryUI?: boolean; + onInitiateSave: () => void; + onInitiateSaveAsNew: () => void; + onLoad: OpenSavedQueryFlyoutProps['onQueryOpen']; onClearSavedQuery: () => void; closeMenuPopover: () => void; + saveQuery: (savedQueryMeta: SavedQueryMeta, saveAsNew?: boolean) => Promise; } export function SavedQueryManagementComponent({ showSaveQuery, loadedSavedQuery, - onSave, - onSaveAsNew, + onInitiateSave, + onInitiateSaveAsNew, onLoad, onClearSavedQuery, savedQueryService, closeMenuPopover, + useNewSavedQueryUI, + saveQuery, }: Props) { const [savedQueries, setSavedQueries] = useState([] as SavedQuery[]); const [count, setTotalCount] = useState(0); const [activePage, setActivePage] = useState(0); const cancelPendingListingRequest = useRef<() => void>(() => {}); + const { + services: { overlays }, + } = useOpenSearchDashboards(); useEffect(() => { const fetchCountAndSavedQueries = async () => { @@ -102,13 +120,13 @@ export function SavedQueryManagementComponent({ const handleSave = useCallback(() => { handleClosePopover(); - onSave(); - }, [handleClosePopover, onSave]); + onInitiateSave(); + }, [handleClosePopover, onInitiateSave]); const handleSaveAsNew = useCallback(() => { handleClosePopover(); - onSaveAsNew(); - }, [handleClosePopover, onSaveAsNew]); + onInitiateSaveAsNew(); + }, [handleClosePopover, onInitiateSaveAsNew]); const handleSelect = useCallback( (savedQueryToSelect) => { @@ -134,10 +152,21 @@ export function SavedQueryManagementComponent({ setActivePage(0); }; - onDeleteSavedQuery(savedQueryToDelete); - handleClosePopover(); + const deletePromise = onDeleteSavedQuery(savedQueryToDelete); + if (!useNewSavedQueryUI) { + handleClosePopover(); + } + + return deletePromise; }, - [handleClosePopover, loadedSavedQuery, onClearSavedQuery, savedQueries, savedQueryService] + [ + handleClosePopover, + loadedSavedQuery, + onClearSavedQuery, + savedQueries, + savedQueryService, + useNewSavedQueryUI, + ] ); const savedQueryDescriptionText = i18n.translate( @@ -186,7 +215,55 @@ export function SavedQueryManagementComponent({ )); }; - return ( + return useNewSavedQueryUI ? ( +
+ + { + closeMenuPopover(); + const saveQueryFlyout = overlays?.openFlyout( + toMountPoint( + saveQueryFlyout?.close().then()} + onSave={saveQuery} + showFilterOption={true} + showTimeFilterOption={true} + savedQuery={loadedSavedQuery?.attributes} + /> + ) + ); + }} + /> + { + closeMenuPopover(); + const openSavedQueryFlyout = overlays?.openFlyout( + toMountPoint( + openSavedQueryFlyout?.close().then()} + onQueryOpen={onLoad} + handleQueryDelete={handleDelete} + /> + ) + ); + }} + /> + +
+ ) : (
; @@ -285,10 +286,15 @@ class SearchBarUI extends Component { public onSave = async (savedQueryMeta: SavedQueryMeta, saveAsNew = false) => { if (!this.state.query) return; + const query = cloneDeep(this.state.query); + if (getUseNewSavedQueriesUI() && !savedQueryMeta.shouldIncludeDataSource) { + delete query.dataset; + } + const savedQueryAttributes: SavedQueryAttributes = { title: savedQueryMeta.title, description: savedQueryMeta.description, - query: this.state.query, + query, }; if (savedQueryMeta.shouldIncludeFilters) { @@ -296,7 +302,7 @@ class SearchBarUI extends Component { } if ( - savedQueryMeta.shouldIncludeTimefilter && + savedQueryMeta.shouldIncludeTimeFilter && this.state.dateRangeTo !== undefined && this.state.dateRangeFrom !== undefined && this.props.refreshInterval !== undefined && @@ -334,9 +340,15 @@ class SearchBarUI extends Component { if (this.props.onSaved) { this.props.onSaved(response); } - } catch (error) { + } catch (error: any) { this.services.notifications.toasts.addDanger( - `An error occured while saving your query: ${error.message}` + this.props.intl.formatMessage( + { + id: 'data.search_bar.save_query.failedToSaveQuery', + defaultMessage: 'An error occured while saving your query{errorMessage}', + }, + { errorMessage: error.message ? `: ${error.message}` : '' } + ) ); throw error; } @@ -447,13 +459,14 @@ class SearchBarUI extends Component { indexPatterns={this.props.indexPatterns!} showSaveQuery={this.props.showSaveQuery} loadedSavedQuery={this.props.savedQuery} - onSave={this.onInitiateSave} - onSaveAsNew={this.onInitiateSaveNew} + onInitiateSave={this.onInitiateSave} + onInitiateSaveAsNew={this.onInitiateSaveNew} onLoad={this.onLoadSavedQuery} savedQueryService={this.savedQueryService} onClearSavedQuery={this.props.onClearSavedQuery} useSaveQueryMenu={useSaveQueryMenu} isQueryEditorControl={isQueryEditorControl} + saveQuery={this.onSave} /> ) ); @@ -569,6 +582,7 @@ class SearchBarUI extends Component { {this.state.showSaveQueryModal ? ( { ) : null} {this.state.showSaveNewQueryModal ? ( this.onSave(savedQueryMeta, true)} onClose={() => this.setState({ showSaveNewQueryModal: false })} diff --git a/src/plugins/data/server/index.ts b/src/plugins/data/server/index.ts index 289e390070e2..b64c93372581 100644 --- a/src/plugins/data/server/index.ts +++ b/src/plugins/data/server/index.ts @@ -306,6 +306,7 @@ export const config: PluginConfigDescriptor = { enhancements: true, autocomplete: true, search: true, + savedQueriesNewUI: true, }, schema: configSchema, }; diff --git a/src/plugins/data/server/saved_objects/query.ts b/src/plugins/data/server/saved_objects/query.ts index 1b7b027a6121..67b45111a832 100644 --- a/src/plugins/data/server/saved_objects/query.ts +++ b/src/plugins/data/server/saved_objects/query.ts @@ -52,8 +52,37 @@ export const querySavedObjectType: SavedObjectsType = { properties: { title: { type: 'text' }, description: { type: 'text' }, + isTemplate: { type: 'boolean' }, query: { - properties: { language: { type: 'keyword' }, query: { type: 'keyword', index: false } }, + properties: { + language: { type: 'keyword' }, + query: { type: 'keyword', index: false }, + dataset: { + type: 'object', + properties: { + id: { type: 'text' }, + title: { type: 'text' }, + type: { type: 'text' }, + timeFieldName: { type: 'text' }, + language: { type: 'text' }, + dataSource: { + type: 'object', + properties: { + id: { type: 'text' }, + title: { type: 'text' }, + type: { type: 'text' }, + meta: { + type: 'object', + properties: { + name: { type: 'text' }, + sessionId: { type: 'text' }, + }, + }, + }, + }, + }, + }, + }, }, filters: { type: 'object', enabled: false }, timefilter: { type: 'object', enabled: false },