From 25d3de74f4452c40c05568ba6aebaddd2393a0dc Mon Sep 17 00:00:00 2001 From: Joanne Wang Date: Wed, 13 Nov 2024 10:33:12 -0800 Subject: [PATCH] Fix template queries loading and update getSampleQuery interface (#8848) * update sample query impl and fix saved query load Signed-off-by: Joanne Wang * Changeset file for PR #8848 created/updated * Changeset file for PR #8848 created/updated * add loading spinner Signed-off-by: Joanne Wang * check if tab is shown based on if sample query isTemplate Signed-off-by: Joanne Wang * added UTs for svaed queries flyout Signed-off-by: Riya Saxena --------- Signed-off-by: Joanne Wang Signed-off-by: Riya Saxena Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> Co-authored-by: Riya Saxena --- changelogs/fragments/8848.yml | 2 + .../query_string/dataset_service/types.ts | 2 +- .../public/ui/filter_bar/filter_options.tsx | 4 +- .../open_saved_query_flyout.test.tsx | 228 ++++++++++++++++++ .../open_saved_query_flyout.tsx | 72 ++++-- .../saved_query_management_component.tsx | 5 +- .../ui/search_bar/create_search_bar.tsx | 1 + .../data/public/ui/search_bar/search_bar.tsx | 10 +- .../components/no_results/no_results.tsx | 57 +++-- 9 files changed, 342 insertions(+), 39 deletions(-) create mode 100644 changelogs/fragments/8848.yml create mode 100644 src/plugins/data/public/ui/saved_query_flyouts/open_saved_query_flyout.test.tsx diff --git a/changelogs/fragments/8848.yml b/changelogs/fragments/8848.yml new file mode 100644 index 000000000000..f8cc51214cf5 --- /dev/null +++ b/changelogs/fragments/8848.yml @@ -0,0 +1,2 @@ +fix: +- Fix template queries loading and update getSampleQuery interface ([#8848](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8848)) \ No newline at end of file diff --git a/src/plugins/data/public/query/query_string/dataset_service/types.ts b/src/plugins/data/public/query/query_string/dataset_service/types.ts index f303fa6af56d..1e5af41ef785 100644 --- a/src/plugins/data/public/query/query_string/dataset_service/types.ts +++ b/src/plugins/data/public/query/query_string/dataset_service/types.ts @@ -83,5 +83,5 @@ export interface DatasetTypeConfig { /** * Returns a list of sample queries for this dataset type */ - getSampleQueries?: (dataset: Dataset, language: string) => any; + getSampleQueries?: (dataset?: Dataset, language?: string) => Promise | any; } 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 3cda39731fa7..4af53fa28df1 100644 --- a/src/plugins/data/public/ui/filter_bar/filter_options.tsx +++ b/src/plugins/data/public/ui/filter_bar/filter_options.tsx @@ -59,7 +59,7 @@ import { import { FilterEditor } from './filter_editor'; import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; import { SavedQueryManagementComponent } from '../saved_query_management'; -import { SavedQuery, SavedQueryService } from '../../query'; +import { QueryStringManager, SavedQuery, SavedQueryService } from '../../query'; import { SavedQueryMeta } from '../saved_query_form'; import { getUseNewSavedQueriesUI } from '../../services'; @@ -79,6 +79,7 @@ interface Props { useSaveQueryMenu: boolean; isQueryEditorControl: boolean; saveQuery: (savedQueryMeta: SavedQueryMeta, saveAsNew?: boolean) => Promise; + queryStringManager: QueryStringManager; } const maxFilterWidth = 600; @@ -310,6 +311,7 @@ const FilterOptionsUI = (props: Props) => { key={'savedQueryManagement'} useNewSavedQueryUI={getUseNewSavedQueriesUI()} saveQuery={props.saveQuery} + queryStringManager={props.queryStringManager} />, ]} data-test-subj="save-query-panel" diff --git a/src/plugins/data/public/ui/saved_query_flyouts/open_saved_query_flyout.test.tsx b/src/plugins/data/public/ui/saved_query_flyouts/open_saved_query_flyout.test.tsx new file mode 100644 index 000000000000..8daaafe0fdcb --- /dev/null +++ b/src/plugins/data/public/ui/saved_query_flyouts/open_saved_query_flyout.test.tsx @@ -0,0 +1,228 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; +import { OpenSavedQueryFlyout } from './open_saved_query_flyout'; +import { createSavedQueryService } from '../../../public/query/saved_query/saved_query_service'; +import { applicationServiceMock, uiSettingsServiceMock } from '../../../../../core/public/mocks'; +import { SavedQueryAttributes } from '../../../public/query/saved_query/types'; +import '@testing-library/jest-dom'; +import { queryStringManagerMock } from '../../../../data/public/query/query_string/query_string_manager.mock'; + +const savedQueryAttributesWithTemplate: SavedQueryAttributes = { + title: 'foo', + description: 'bar', + query: { + language: 'kuery', + query: 'response:200', + dataset: 'my_dataset', + }, +}; + +const mockSavedObjectsClient = { + create: jest.fn(), + error: jest.fn(), + find: jest.fn(), + get: jest.fn(), + delete: jest.fn(), +}; + +mockSavedObjectsClient.create.mockReturnValue({ + id: 'foo', + attributes: { + ...savedQueryAttributesWithTemplate, + query: { + ...savedQueryAttributesWithTemplate.query, + }, + }, +}); + +jest.mock('./saved_query_card', () => ({ + SavedQueryCard: ({ + savedQuery = { + id: 'foo1', + attributes: savedQueryAttributesWithTemplate, + }, + onSelect, + handleQueryDelete, + }) => ( +
+
{savedQuery?.attributes?.title}
+ + +
+ ), +})); + +jest.mock('@osd/i18n', () => ({ + i18n: { + translate: jest.fn((id, { defaultMessage }) => defaultMessage), + }, +})); + +const mockSavedQueryService = createSavedQueryService( + // @ts-ignore + mockSavedObjectsClient, + { + application: applicationServiceMock.create(), + uiSettings: uiSettingsServiceMock.createStartContract(), + } +); + +const mockHandleQueryDelete = jest.fn(); +const mockOnQueryOpen = jest.fn(); +const mockOnClose = jest.fn(); + +const savedQueries = [ + { + id: '1', + attributes: { + title: 'Saved Query 1', + description: 'Description for Query 1', + query: { query: 'SELECT * FROM table1', language: 'sql' }, + }, + }, + { + id: '2', + attributes: { + title: 'Saved Query 2', + description: 'Description for Query 2', + query: { query: 'SELECT * FROM table2', language: 'sql' }, + }, + }, +]; + +jest.spyOn(mockSavedQueryService, 'getAllSavedQueries').mockResolvedValue(savedQueries); + +describe('OpenSavedQueryFlyout', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render the flyout with correct tabs and content', async () => { + render( + + ); + + const savedQueriesTextElements = screen.getAllByText('Saved queries'); + + expect(savedQueriesTextElements).toHaveLength(2); + + await waitFor(() => screen.getByPlaceholderText('Search')); + + await waitFor(() => screen.getByText('Saved Query 1')); + await waitFor(() => screen.getByText('Saved Query 2')); + + const openQueryButton = screen.getByText('Open query'); + + fireEvent.change(screen.getByPlaceholderText('Search'), { target: { value: 'Saved Query 1' } }); + + await waitFor(() => screen.getByText('Saved Query 1')); + expect(screen.queryByText('Saved Query 2')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByText('Saved Query 1')); + + expect(openQueryButton).toBeEnabled(); + }); + + it('should filter saved queries based on search input', async () => { + render( + + ); + + await waitFor(() => screen.getByText('Saved Query 1')); + await waitFor(() => screen.getByText('Saved Query 2')); + + const searchBar = screen.getByPlaceholderText('Search'); + fireEvent.change(searchBar, { target: { value: 'Saved Query 1' } }); + + expect(screen.getByText('Saved Query 1')).toBeInTheDocument(); + expect(screen.queryByText('Saved Query 2')).toBeNull(); + }); + + it('should select a query when clicking on it and enable the "Open query" button', async () => { + render( + + ); + + await waitFor(() => screen.getByText('Saved Query 1')); + + fireEvent.click(screen.getByText('Saved Query 1')); + + expect(screen.getByText('Open query')).toBeEnabled(); + }); + + it('should call handleQueryDelete when deleting a query', async () => { + mockHandleQueryDelete.mockResolvedValueOnce(); + render( + + ); + + await waitFor(() => screen.getByText('Saved Query 1')); + + const deleteButtons = screen.getAllByText('Delete'); + + fireEvent.click(deleteButtons[0]); + + await waitFor(() => { + expect(mockHandleQueryDelete).toHaveBeenCalledWith({ + id: '1', + attributes: { + description: 'Description for Query 1', + query: { + language: 'sql', + query: 'SELECT * FROM table1', + }, + title: 'Saved Query 1', + }, + }); + }); + expect(mockHandleQueryDelete).toHaveBeenCalledTimes(1); + }); + + it('should handle pagination controls correctly', async () => { + render( + + ); + + await waitFor(() => screen.getByText('Saved Query 1')); + + const pageSizeButton = await screen.findByText(/10/); + fireEvent.click(pageSizeButton); + + expect(mockSavedQueryService.getAllSavedQueries).toHaveBeenCalled(); + }); +}); 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 index c7f13f27db08..41aa344bbaef 100644 --- 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 @@ -13,6 +13,7 @@ import { EuiFlyoutBody, EuiFlyoutFooter, EuiFlyoutHeader, + EuiLoadingSpinner, EuiSearchBar, EuiSearchBarProps, EuiSpacer, @@ -23,14 +24,16 @@ import { } from '@elastic/eui'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { i18n } from '@osd/i18n'; -import { SavedQuery, SavedQueryService } from '../../query'; +import { QueryStringManager, SavedQuery, SavedQueryService } from '../../query'; import { SavedQueryCard } from './saved_query_card'; +import { Query } from '../../../common'; export interface OpenSavedQueryFlyoutProps { savedQueryService: SavedQueryService; onClose: () => void; onQueryOpen: (query: SavedQuery) => void; handleQueryDelete: (query: SavedQuery) => Promise; + queryStringManager: QueryStringManager; } interface SavedQuerySearchableItem { @@ -47,6 +50,7 @@ export function OpenSavedQueryFlyout({ onClose, onQueryOpen, handleQueryDelete, + queryStringManager, }: OpenSavedQueryFlyoutProps) { const [selectedTabId, setSelectedTabId] = useState('mutable-saved-queries'); const [savedQueries, setSavedQueries] = useState([]); @@ -59,18 +63,39 @@ export function OpenSavedQueryFlyout({ const [languageFilterOptions, setLanguageFilterOptions] = useState([]); const [selectedQuery, setSelectedQuery] = useState(undefined); const [searchQuery, setSearchQuery] = useState(EuiSearchBar.Query.MATCH_ALL); + const [isLoading, setIsLoading] = useState(false); + const currentTabIdRef = useRef(selectedTabId); 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]); + setIsLoading(true); + const query = queryStringManager.getQuery(); + let templateQueries: any[] = []; + + // fetch sample query based on dataset type + if (query?.dataset?.type) { + templateQueries = + (await queryStringManager + .getDatasetService() + ?.getType(query.dataset.type) + ?.getSampleQueries?.()) || []; + + // Check if any sample query has isTemplate set to true + const hasTemplates = templateQueries.some((q) => q?.attributes?.isTemplate); + setHasTemplateQueries(hasTemplates); + } + + // Set queries based on the current tab + if (currentTabIdRef.current === 'mutable-saved-queries') { + const allQueries = await savedQueryService.getAllSavedQueries(); + const mutableSavedQueries = allQueries.filter((q) => !q.attributes.isTemplate); + if (currentTabIdRef.current === 'mutable-saved-queries') { + setSavedQueries(mutableSavedQueries); + } + } else if (currentTabIdRef.current === 'template-saved-queries') { + setSavedQueries(templateQueries); + } + setIsLoading(false); + }, [savedQueryService, currentTabIdRef, setSavedQueries, queryStringManager]); const updatePageIndex = useCallback((index: number) => { pager.current.goToPageIndex(index); @@ -179,7 +204,13 @@ export function OpenSavedQueryFlyout({ onChange={onChange} /> - {queriesOnCurrentPage.length > 0 ? ( + {isLoading ? ( + + + + + + ) : queriesOnCurrentPage.length > 0 ? ( queriesOnCurrentPage.map((query) => ( )} - {queriesOnCurrentPage.length > 0 && ( + {!isLoading && queriesOnCurrentPage.length > 0 && ( { setSelectedTabId(tab.id); + currentTabIdRef.current = tab.id; }} /> @@ -268,7 +300,19 @@ export function OpenSavedQueryFlyout({ fill onClick={() => { if (selectedQuery) { - onQueryOpen(selectedQuery); + if ( + // Template queries are not associated with data sources. Apply data source from current query + selectedQuery.attributes.isTemplate + ) { + const updatedQuery: Query = { + ...queryStringManager?.getQuery(), + query: selectedQuery.attributes.query.query, + language: selectedQuery.attributes.query.language, + }; + queryStringManager.setQuery(updatedQuery); + } else { + onQueryOpen(selectedQuery); + } onClose(); } }} 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 01f9b97e978f..44c5ef384966 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 @@ -45,7 +45,7 @@ import { import { i18n } from '@osd/i18n'; import React, { useCallback, useEffect, useState, Fragment, useRef } from 'react'; import { sortBy } from 'lodash'; -import { SavedQuery, SavedQueryService } from '../..'; +import { QueryStringManager, SavedQuery, SavedQueryService } from '../..'; import { SavedQueryListItem } from './saved_query_list_item'; import { toMountPoint, @@ -70,6 +70,7 @@ interface Props { onClearSavedQuery: () => void; closeMenuPopover: () => void; saveQuery: (savedQueryMeta: SavedQueryMeta, saveAsNew?: boolean) => Promise; + queryStringManager: QueryStringManager; } export function SavedQueryManagementComponent({ @@ -83,6 +84,7 @@ export function SavedQueryManagementComponent({ closeMenuPopover, useNewSavedQueryUI, saveQuery, + queryStringManager, }: Props) { const [savedQueries, setSavedQueries] = useState([] as SavedQuery[]); const [count, setTotalCount] = useState(0); @@ -256,6 +258,7 @@ export function SavedQueryManagementComponent({ onClose={() => openSavedQueryFlyout?.close().then()} onQueryOpen={onLoad} handleQueryDelete={handleDelete} + queryStringManager={queryStringManager} /> ) ); diff --git a/src/plugins/data/public/ui/search_bar/create_search_bar.tsx b/src/plugins/data/public/ui/search_bar/create_search_bar.tsx index f8b9694caabc..d3f89d0f559d 100644 --- a/src/plugins/data/public/ui/search_bar/create_search_bar.tsx +++ b/src/plugins/data/public/ui/search_bar/create_search_bar.tsx @@ -202,6 +202,7 @@ export function createSearchBar({ core, storage, data }: StatefulSearchBarDeps) isRefreshPaused={refreshInterval.pause} filters={filters} query={query} + queryStringManager={data.query.queryString} onFiltersUpdated={defaultFiltersUpdated(data.query)} onRefreshChange={defaultOnRefreshChange(data.query)} savedQuery={savedQuery} diff --git a/src/plugins/data/public/ui/search_bar/search_bar.tsx b/src/plugins/data/public/ui/search_bar/search_bar.tsx index 1f1b20b8c952..3cd6cdcca25e 100644 --- a/src/plugins/data/public/ui/search_bar/search_bar.tsx +++ b/src/plugins/data/public/ui/search_bar/search_bar.tsx @@ -38,7 +38,13 @@ import { withOpenSearchDashboards, } from '../../../../opensearch_dashboards_react/public'; import { Filter, IIndexPattern, Query, TimeRange, UI_SETTINGS } from '../../../common'; -import { SavedQuery, SavedQueryAttributes, TimeHistoryContract, QueryStatus } from '../../query'; +import { + SavedQuery, + SavedQueryAttributes, + TimeHistoryContract, + QueryStatus, + QueryStringManager, +} from '../../query'; import { IDataPluginServices } from '../../types'; import { FilterBar } from '../filter_bar/filter_bar'; import { QueryEditorTopRow } from '../query_editor'; @@ -95,6 +101,7 @@ export interface SearchBarOwnProps { onRefresh?: (payload: { dateRange: TimeRange }) => void; indicateNoData?: boolean; queryStatus?: QueryStatus; + queryStringManager: QueryStringManager; } export type SearchBarProps = SearchBarOwnProps & SearchBarInjectedDeps; @@ -467,6 +474,7 @@ class SearchBarUI extends Component { useSaveQueryMenu={useSaveQueryMenu} isQueryEditorControl={isQueryEditorControl} saveQuery={this.onSave} + queryStringManager={this.props.queryStringManager} /> ) ); diff --git a/src/plugins/discover/public/application/components/no_results/no_results.tsx b/src/plugins/discover/public/application/components/no_results/no_results.tsx index b1ec382b4fd5..24a4b80c7204 100644 --- a/src/plugins/discover/public/application/components/no_results/no_results.tsx +++ b/src/plugins/discover/public/application/components/no_results/no_results.tsx @@ -174,6 +174,7 @@ export const DiscoverNoResults = ({ queryString, query, savedQuery, timeFieldNam // } const [savedQueries, setSavedQueries] = useState([]); + const [sampleQueries, setSampleQueries] = useState([]); useEffect(() => { const fetchSavedQueries = async () => { @@ -186,6 +187,39 @@ export const DiscoverNoResults = ({ queryString, query, savedQuery, timeFieldNam fetchSavedQueries(); }, [setSavedQueries, query, savedQuery]); + useEffect(() => { + // Samples for the language + const newSampleQueries: any = []; + if (query?.language) { + const languageSampleQueries = queryString.getLanguageService()?.getLanguage(query.language) + ?.sampleQueries; + if (Array.isArray(languageSampleQueries)) { + newSampleQueries.push(...languageSampleQueries); + } + } + + // Samples for the dataset type + if (query?.dataset?.type) { + const datasetType = queryString.getDatasetService()?.getType(query.dataset.type); + if (datasetType?.getSampleQueries) { + const sampleQueriesResponse = datasetType.getSampleQueries(query.dataset, query.language); + if (Array.isArray(sampleQueriesResponse)) { + setSampleQueries([...sampleQueriesResponse, ...newSampleQueries]); + } else if (sampleQueriesResponse instanceof Promise) { + sampleQueriesResponse + .then((datasetSampleQueries: any) => { + if (Array.isArray(datasetSampleQueries)) { + setSampleQueries([...datasetSampleQueries, ...newSampleQueries]); + } + }) + .catch((error: any) => { + // noop + }); + } + } + } + }, [queryString, query]); + const tabs = useMemo(() => { const buildSampleQueryBlock = (sampleTitle: string, sampleQuery: string) => { return ( @@ -197,25 +231,6 @@ export const DiscoverNoResults = ({ queryString, query, savedQuery, timeFieldNam ); }; - - const sampleQueries = []; - - // Samples for the dataset type - if (query?.dataset?.type) { - const datasetSampleQueries = queryString - .getDatasetService() - ?.getType(query.dataset.type) - ?.getSampleQueries?.(query.dataset, query.language); - if (Array.isArray(datasetSampleQueries)) sampleQueries.push(...datasetSampleQueries); - } - - // Samples for the language - if (query?.language) { - const languageSampleQueries = queryString.getLanguageService()?.getLanguage(query.language) - ?.sampleQueries; - if (Array.isArray(languageSampleQueries)) sampleQueries.push(...languageSampleQueries); - } - return [ ...(sampleQueries.length > 0 ? [ @@ -229,7 +244,7 @@ export const DiscoverNoResults = ({ queryString, query, savedQuery, timeFieldNam {sampleQueries .slice(0, 5) - .map((sampleQuery) => + .map((sampleQuery: any) => buildSampleQueryBlock(sampleQuery.title, sampleQuery.query) )} @@ -256,7 +271,7 @@ export const DiscoverNoResults = ({ queryString, query, savedQuery, timeFieldNam ] : []), ]; - }, [queryString, query, savedQueries]); + }, [savedQueries, sampleQueries]); return (