diff --git a/.betterer.results b/.betterer.results index 8e643b38615..bc0dd082f2a 100644 --- a/.betterer.results +++ b/.betterer.results @@ -2545,8 +2545,7 @@ exports[`better eslint`] = { [0, 0, 0, "No untranslated strings. Wrap text with ", "7"], [0, 0, 0, "No untranslated strings. Wrap text with ", "8"], [0, 0, 0, "No untranslated strings. Wrap text with ", "9"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "10"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "11"] + [0, 0, 0, "No untranslated strings. Wrap text with ", "10"] ], "public/app/features/alerting/unified/components/rules/Filter/RulesFilter.v1.tsx:5381": [ [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"], @@ -4644,9 +4643,6 @@ exports[`better eslint`] = { [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "8"], [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "9"] ], - "public/app/features/explore/ExplorePage.tsx:5381": [ - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"] - ], "public/app/features/explore/ExploreRunQueryButton.tsx:5381": [ [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"], [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "1"] diff --git a/packages/grafana-runtime/src/services/dataSourceSrv.ts b/packages/grafana-runtime/src/services/dataSourceSrv.ts index 36f7664489b..cd918209080 100644 --- a/packages/grafana-runtime/src/services/dataSourceSrv.ts +++ b/packages/grafana-runtime/src/services/dataSourceSrv.ts @@ -13,7 +13,7 @@ import { RuntimeDataSource } from './RuntimeDataSource'; export interface DataSourceSrv { /** * Returns the requested dataSource. If it cannot be found it rejects the promise. - * @param ref - The datasource identifier, typically an object with UID and type, + * @param ref - The datasource identifier, it can be a name, UID or DataSourceRef (an object with UID), * @param scopedVars - variables used to interpolate a templated passed as name. */ get(ref?: DataSourceRef | string | null, scopedVars?: ScopedVars): Promise; diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.tsx index 75abcf9ff84..cef20ef129f 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.tsx @@ -13,6 +13,7 @@ import { } from '@grafana/scenes'; import { DataQuery } from '@grafana/schema'; import { Button, Stack, Tab } from '@grafana/ui'; +import { t, Trans } from 'app/core/internationalization'; import { addQuery } from 'app/core/utils/query'; import { getLastUsedDatasourceFromStorage } from 'app/features/dashboard/utils/dashboard'; import { storeLastUsedDataSourceInLocalStorage } from 'app/features/datasources/components/picker/utils'; @@ -24,6 +25,8 @@ import { updateQueries } from 'app/features/query/state/updateQueries'; import { isSharedDashboardQuery } from 'app/plugins/datasource/dashboard/runSharedRequest'; import { QueryGroupOptions } from 'app/types'; +import { useQueryLibraryContext } from '../../../explore/QueryLibrary/QueryLibraryContext'; +import { QueryActionButtonProps } from '../../../explore/QueryLibrary/types'; import { PanelTimeRange } from '../../scene/PanelTimeRange'; import { getDashboardSceneFor, getPanelIdForVizPanel, getQueryRunnerFor } from '../../utils/utils'; import { getUpdatedHoverHeader } from '../getPanelFrameOptions'; @@ -306,13 +309,20 @@ export class PanelDataQueriesTab extends SceneObjectBase) { const { datasource, dsSettings } = model.useState(); const { data, queries } = model.queryRunner.useState(); + const { openDrawer: openQueryLibraryDrawer } = useQueryLibraryContext(); if (!datasource || !dsSettings || !data) { return null; } - const showAddButton = !isSharedDashboardQuery(dsSettings.name); + // Make the final query library action button by injecting actual addQuery functionality into the button. + const addQueryActionButton = makeQueryActionButton((queries) => { + for (const query of queries) { + model.onQueriesChange(addQuery(model.getQueries(), query)); + } + }); + return (
{showAddButton && ( - + <> + + {config.featureToggles.queryLibrary && ( + + )} + )} {config.expressionsEnabled && model.isExpressionsSupported(dsSettings) && ( + ); + }; +} + +function getDatasourceNames(datasource: DataSourceApi, queries: DataQuery[]): string[] { + if (datasource.uid === '-- Mixed --') { + // If datasource is mixed, the datasource UID is on the query. Here we map the UIDs to datasource names. + const dsSrv = getDataSourceSrv(); + return queries.map((ds) => dsSrv.getInstanceSettings(ds.datasource)?.name).filter((name) => name !== undefined); + } else { + return [datasource.name]; + } +} + interface QueriesTabProps extends PanelDataTabHeaderProps { model: PanelDataQueriesTab; } diff --git a/public/app/features/explore/ExplorePage.tsx b/public/app/features/explore/ExplorePage.tsx index 1764a81ed16..96b5d540d84 100644 --- a/public/app/features/explore/ExplorePage.tsx +++ b/public/app/features/explore/ExplorePage.tsx @@ -1,30 +1,22 @@ import { css, cx } from '@emotion/css'; -import { useEffect, useState } from 'react'; -import { useLocalStorage } from 'react-use'; +import { useEffect } from 'react'; -import { CoreApp, GrafanaTheme2 } from '@grafana/data'; +import { GrafanaTheme2 } from '@grafana/data'; import { config } from '@grafana/runtime'; -import { DataQuery } from '@grafana/schema/dist/esm/index'; -import { Badge, ErrorBoundaryAlert, Modal, useStyles2, useTheme2 } from '@grafana/ui'; -import { QueryOperationAction } from 'app/core/components/QueryOperationRow/QueryOperationAction'; +import { ErrorBoundaryAlert, useStyles2, useTheme2 } from '@grafana/ui'; import { SplitPaneWrapper } from 'app/core/components/SplitPaneWrapper/SplitPaneWrapper'; import { useGrafana } from 'app/core/context/GrafanaContext'; import { useNavModel } from 'app/core/hooks/useNavModel'; -import { Trans, t } from 'app/core/internationalization'; +import { Trans } from 'app/core/internationalization'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; import { useSelector } from 'app/types'; import { ExploreQueryParams } from 'app/types/explore'; -import { RowActionComponents } from '../query/components/QueryActionComponent'; - import { CorrelationEditorModeBar } from './CorrelationEditorModeBar'; import { ExploreActions } from './ExploreActions'; import { ExploreDrawer } from './ExploreDrawer'; import { ExplorePaneContainer } from './ExplorePaneContainer'; import { useQueriesDrawerContext } from './QueriesDrawer/QueriesDrawerContext'; -import { QUERY_LIBRARY_LOCAL_STORAGE_KEYS } from './QueryLibrary/QueryLibrary'; -import { queryLibraryTrackAddFromQueryRow } from './QueryLibrary/QueryLibraryAnalyticsEvents'; -import { QueryTemplateForm } from './QueryLibrary/QueryTemplateForm'; import RichHistoryContainer from './RichHistory/RichHistoryContainer'; import { useExplorePageTitle } from './hooks/useExplorePageTitle'; import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'; @@ -34,7 +26,6 @@ import { useTimeSrvFix } from './hooks/useTimeSrvFix'; import { isSplit, selectCorrelationDetails, selectPanesEntries } from './state/selectors'; const MIN_PANE_WIDTH = 200; -const QUERY_LIBRARY_ACTION_KEY = 'queryLibraryAction'; export default function ExplorePage(props: GrafanaRouteComponentProps<{}, ExploreQueryParams>) { return ; @@ -58,13 +49,8 @@ function ExplorePageContent(props: GrafanaRouteComponentProps<{}, ExploreQueryPa const panes = useSelector(selectPanesEntries); const hasSplit = useSelector(isSplit); const correlationDetails = useSelector(selectCorrelationDetails); - const { drawerOpened, setDrawerOpened, queryLibraryAvailable } = useQueriesDrawerContext(); + const { drawerOpened, setDrawerOpened } = useQueriesDrawerContext(); const showCorrelationEditorBar = config.featureToggles.correlations && (correlationDetails?.editorMode || false); - const [queryToAdd, setQueryToAdd] = useState(); - const [showQueryLibraryBadgeButton, setShowQueryLibraryBadgeButton] = useLocalStorage( - QUERY_LIBRARY_LOCAL_STORAGE_KEYS.explore.newButton, - true - ); useEffect(() => { //This is needed for breadcrumbs and topnav. @@ -74,38 +60,6 @@ function ExplorePageContent(props: GrafanaRouteComponentProps<{}, ExploreQueryPa }); }, [chrome, navModel]); - useEffect(() => { - const hasQueryLibrary = config.featureToggles.queryLibrary || false; - if (hasQueryLibrary) { - RowActionComponents.addKeyedExtraRenderAction(QUERY_LIBRARY_ACTION_KEY, { - scope: CoreApp.Explore, - queryActionComponent: (props) => - showQueryLibraryBadgeButton ? ( - { - setQueryToAdd(props.query); - setShowQueryLibraryBadgeButton(false); - }} - style={{ cursor: 'pointer' }} - /> - ) : ( - { - setQueryToAdd(props.query); - }} - /> - ), - }); - } - }, [showQueryLibraryBadgeButton, setShowQueryLibraryBadgeButton]); - useKeyboardShortcuts(); return ( @@ -139,7 +93,7 @@ function ExplorePageContent(props: GrafanaRouteComponentProps<{}, ExploreQueryPa })} {drawerOpened && ( - + { setDrawerOpened(false); @@ -147,24 +101,6 @@ function ExplorePageContent(props: GrafanaRouteComponentProps<{}, ExploreQueryPa /> )} - setQueryToAdd(undefined)} - > - { - setQueryToAdd(undefined); - }} - onSave={(isSuccess) => { - if (isSuccess) { - setQueryToAdd(undefined); - queryLibraryTrackAddFromQueryRow(queryToAdd?.datasource?.type || ''); - } - }} - queryToAdd={queryToAdd!} - /> -
); } diff --git a/public/app/features/explore/ExploreRunQueryButton.tsx b/public/app/features/explore/ExploreRunQueryButton.tsx index 7cb37a62a45..48c7831c9d2 100644 --- a/public/app/features/explore/ExploreRunQueryButton.tsx +++ b/public/app/features/explore/ExploreRunQueryButton.tsx @@ -3,7 +3,7 @@ import { ConnectedProps, connect } from 'react-redux'; import { config, reportInteraction } from '@grafana/runtime'; import { DataQuery } from '@grafana/schema'; -import { Button, ButtonVariant, Dropdown, Menu, ToolbarButton } from '@grafana/ui'; +import { Button, Dropdown, Menu, ToolbarButton } from '@grafana/ui'; import { t } from 'app/core/internationalization'; import { useSelector } from 'app/types'; @@ -22,7 +22,6 @@ interface ExploreRunQueryButtonProps { queries: DataQuery[]; rootDatasourceUid?: string; disabled?: boolean; - variant?: ButtonVariant; onClick?: () => void; } @@ -37,7 +36,6 @@ export function ExploreRunQueryButton({ rootDatasourceUid, queries, disabled = false, - variant = 'secondary', onClick, changeDatasource, setQueries, @@ -84,7 +82,7 @@ export function ExploreRunQueryButton({ const buttonText = runQueryText(exploreId, rootDatasourceUid); return ( + + {/* Show either a drops down button so that user can select QL or QH, or show a close button if one of them is + already open.*/} + {drawerOpened || isQueryLibraryDrawerOpen ? ( + ) : ( - + )} diff --git a/public/app/features/explore/QueriesDrawer/mocks.tsx b/public/app/features/explore/QueriesDrawer/mocks.tsx index 37bf17fada4..3aba930f566 100644 --- a/public/app/features/explore/QueriesDrawer/mocks.tsx +++ b/public/app/features/explore/QueriesDrawer/mocks.tsx @@ -8,13 +8,12 @@ type Props = { } & PropsWithChildren; export function QueriesDrawerContextProviderMock(props: Props) { - const [selectedTab, setSelectedTab] = useState(Tabs.QueryLibrary); + const [selectedTab, setSelectedTab] = useState(Tabs.RichHistory); const [drawerOpened, setDrawerOpened] = useState(false); return ( void; + query?: DataQuery; +}; + +export function AddToQueryLibraryModal({ query, close, isOpen }: Props) { + return ( + close()} + > + { + close(); + }} + onSave={(isSuccess) => { + if (isSuccess) { + close(); + queryLibraryTrackAddFromQueryRow(query?.datasource?.type || ''); + } + }} + queryToAdd={query!} + /> + + ); +} diff --git a/public/app/features/explore/QueryLibrary/QueryLibrary.tsx b/public/app/features/explore/QueryLibrary/QueryLibrary.tsx index fffc454ee3a..c9b81574b1f 100644 --- a/public/app/features/explore/QueryLibrary/QueryLibrary.tsx +++ b/public/app/features/explore/QueryLibrary/QueryLibrary.tsx @@ -2,11 +2,13 @@ import { useLocalStorage } from 'react-use'; import { QueryLibraryExpmInfo } from './QueryLibraryExpmInfo'; import { QueryTemplatesList } from './QueryTemplatesList'; +import { QueryActionButton } from './types'; export interface QueryLibraryProps { // List of active datasources to filter the query library by // E.g in Explore the active datasources are the datasources that are currently selected in the query editor activeDatasources?: string[]; + queryActionButton?: QueryActionButton; } export const QUERY_LIBRARY_LOCAL_STORAGE_KEYS = { @@ -16,7 +18,7 @@ export const QUERY_LIBRARY_LOCAL_STORAGE_KEYS = { }, }; -export function QueryLibrary({ activeDatasources }: QueryLibraryProps) { +export function QueryLibrary({ activeDatasources, queryActionButton }: QueryLibraryProps) { const [notifyUserAboutQueryLibrary, setNotifyUserAboutQueryLibrary] = useLocalStorage( QUERY_LIBRARY_LOCAL_STORAGE_KEYS.explore.notifyUserAboutQueryLibrary, true @@ -28,7 +30,7 @@ export function QueryLibrary({ activeDatasources }: QueryLibraryProps) { isOpen={notifyUserAboutQueryLibrary || false} onDismiss={() => setNotifyUserAboutQueryLibrary(false)} /> - + ); } diff --git a/public/app/features/explore/QueryLibrary/QueryLibraryContext.test.tsx b/public/app/features/explore/QueryLibrary/QueryLibraryContext.test.tsx new file mode 100644 index 00000000000..c48495b9103 --- /dev/null +++ b/public/app/features/explore/QueryLibrary/QueryLibraryContext.test.tsx @@ -0,0 +1,79 @@ +import { act, render, screen, waitFor } from '@testing-library/react'; +import { ComponentType } from 'react'; + +import { PromQuery } from '@grafana/prometheus'; + +import { useQueryLibraryContext, QueryLibraryContextProvider, QueryLibraryContextType } from './QueryLibraryContext'; + +// Bit of mocking here mainly so we don't have to mock too much of the API calls here and keep this test focused on the +// context state management and correct rendering. + +jest.mock('./AddToQueryLibraryModal', () => ({ + __esModule: true, + AddToQueryLibraryModal: (props: { isOpen: boolean; query: unknown }) => + props.isOpen &&
QUERY_MODAL {JSON.stringify(props.query)}
, +})); + +jest.mock('./QueryLibraryDrawer', () => ({ + __esModule: true, + QueryLibraryDrawer: (props: { + isOpen: boolean; + activeDatasources: string[] | undefined; + queryActionButton: ComponentType; + }) => + props.isOpen && ( +
+ QUERY_DRAWER {JSON.stringify(props.activeDatasources)} {props.queryActionButton && } +
+ ), +})); + +function setup() { + let ctx: { current: QueryLibraryContextType | undefined } = { current: undefined }; + function TestComp() { + ctx.current = useQueryLibraryContext(); + return
; + } + // rendering instead of just using renderHook so we can check if the modal and drawer actually render. + const renderResult = render( + + + + ); + + return { ctx, renderResult }; +} + +describe('QueryLibraryContext', () => { + it('should not render modal or drawer by default', () => { + setup(); + // should catch both modal and drawer + expect(screen.queryByText(/QUERY_MODAL/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/QUERY_DRAWER/i)).not.toBeInTheDocument(); + }); + + it('should be able to open modal', async () => { + const { ctx } = setup(); + act(() => { + ctx.current!.openAddQueryModal({ refId: 'A', expr: 'http_requests_total{job="test"}' } as PromQuery); + }); + + await waitFor(() => { + expect(screen.queryByText(/QUERY_MODAL/i)).toBeInTheDocument(); + expect(screen.queryByText(/http_requests_total\{job=\\"test\\"}/i)).toBeInTheDocument(); + }); + }); + + it('should be able to open drawer', async () => { + const { ctx } = setup(); + act(() => { + ctx.current!.openDrawer(['PROM_TEST_DS'], () =>
QUERY_ACTION_BUTTON
); + }); + + await waitFor(() => { + expect(screen.queryByText(/QUERY_DRAWER/i)).toBeInTheDocument(); + expect(screen.queryByText(/PROM_TEST_DS/i)).toBeInTheDocument(); + expect(screen.queryByText(/QUERY_ACTION_BUTTON/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/public/app/features/explore/QueryLibrary/QueryLibraryContext.tsx b/public/app/features/explore/QueryLibrary/QueryLibraryContext.tsx new file mode 100644 index 00000000000..65a815c7401 --- /dev/null +++ b/public/app/features/explore/QueryLibrary/QueryLibraryContext.tsx @@ -0,0 +1,118 @@ +import { PropsWithChildren, useState, createContext, useContext, useCallback, useMemo } from 'react'; + +import { DataQuery } from '@grafana/schema'; + +import { AddToQueryLibraryModal } from './AddToQueryLibraryModal'; +import { QueryLibraryDrawer } from './QueryLibraryDrawer'; +import { QueryActionButton, QueryActionButtonProps } from './types'; + +/** + * Context with state and action to interact with Query Library. The Query Library feature consists of a drawer + * that shows existing queries and allows users to use them and manage them and then an AddQueryModal which allows + * users to save a query into the library. Both of those are included in Grafana AppChrome component. + * + * Use this context to interact with those components, showing, hiding and setting initial state for them. + */ +export type QueryLibraryContextType = { + /** + * Opens a drawer with query library. + * @param datasourceFilters Data source names that will be used for initial filter in the library. + * @param queryActionButton Action button will be shown in the library next to the query and can implement context + * specific actions with the library, like running the query or updating some query in the current app. + */ + openDrawer: (datasourceFilters: string[], queryActionButton: QueryActionButton) => void; + closeDrawer: () => void; + isDrawerOpen: boolean; + + /** + * Opens a modal for adding a query to the library. + * @param query + */ + openAddQueryModal: (query: DataQuery) => void; + closeAddQueryModal: () => void; +}; + +export const QueryLibraryContext = createContext({ + openDrawer: () => {}, + closeDrawer: () => {}, + isDrawerOpen: false, + + openAddQueryModal: () => {}, + closeAddQueryModal: () => {}, +}); + +export function useQueryLibraryContext() { + return useContext(QueryLibraryContext); +} + +export function QueryLibraryContextProvider({ children }: PropsWithChildren) { + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + const [activeDatasources, setActiveDatasources] = useState([]); + const [isAddQueryModalOpen, setIsAddQueryModalOpen] = useState(false); + const [activeQuery, setActiveQuery] = useState(undefined); + const [queryActionButton, setQueryActionButton] = useState(undefined); + + const openDrawer = useCallback((datasourceFilters: string[], queryActionButton: QueryActionButton) => { + setActiveDatasources(datasourceFilters); + // Because the queryActionButton can be a function component it would be called as a callback if just passed in. + setQueryActionButton(() => queryActionButton); + setIsDrawerOpen(true); + }, []); + + const closeDrawer = useCallback(() => { + setActiveDatasources([]); + setQueryActionButton(undefined); + setIsDrawerOpen(false); + }, []); + + const openAddQueryModal = useCallback((query: DataQuery) => { + setActiveQuery(query); + setIsAddQueryModalOpen(true); + }, []); + + const closeAddQueryModal = useCallback(() => { + setActiveQuery(undefined); + setIsAddQueryModalOpen(false); + }, []); + + // We wrap the action button one time to add the closeDrawer behaviour. This way whoever injects the action button + // does not need to remember to do it nor the query table inside that renders it needs to know about the drawer. + const finalActionButton = useMemo(() => { + if (!queryActionButton) { + return queryActionButton; + } + return (props: QueryActionButtonProps) => { + const QButton = queryActionButton; + return ( + { + props.onClick(); + closeDrawer(); + }} + /> + ); + }; + }, [closeDrawer, queryActionButton]); + + return ( + + {children} + + + + ); +} diff --git a/public/app/features/explore/QueryLibrary/QueryLibraryDrawer.tsx b/public/app/features/explore/QueryLibrary/QueryLibraryDrawer.tsx new file mode 100644 index 00000000000..18e2caead3d --- /dev/null +++ b/public/app/features/explore/QueryLibrary/QueryLibraryDrawer.tsx @@ -0,0 +1,53 @@ +import { skipToken } from '@reduxjs/toolkit/query/react'; + +import { selectors } from '@grafana/e2e-selectors'; +import { TabbedContainer, TabConfig } from '@grafana/ui'; + +import { t } from '../../../core/internationalization'; +import { useListQueryTemplateQuery } from '../../query-library'; +import { QUERY_LIBRARY_GET_LIMIT } from '../../query-library/api/factory'; +import { ExploreDrawer } from '../ExploreDrawer'; + +import { QueryLibrary } from './QueryLibrary'; +import { QueryActionButton } from './types'; + +type Props = { + isOpen: boolean; + // List of datasource names to filter query templates by + activeDatasources: string[] | undefined; + close: () => void; + queryActionButton?: QueryActionButton; +}; + +/** + * Drawer with query library feature. Handles its own state and should be included in some top level component. + */ +export function QueryLibraryDrawer({ isOpen, activeDatasources, close, queryActionButton }: Props) { + const { data } = useListQueryTemplateQuery(isOpen ? {} : skipToken); + const queryTemplatesCount = data?.items?.length ?? 0; + + // TODO: the tabbed container is here mainly for close button and some margins maybe make sense to use something + // else as there is only one tab. + const tabs: TabConfig[] = [ + { + label: `${t('explore.rich-history.query-library', 'Query library')} (${queryTemplatesCount}/${QUERY_LIBRARY_GET_LIMIT})`, + value: 'Query library', + content: , + icon: 'book', + }, + ]; + + return ( + isOpen && ( + + + + ) + ); +} diff --git a/public/app/features/explore/QueryLibrary/QueryTemplateForm.tsx b/public/app/features/explore/QueryLibrary/QueryTemplateForm.tsx index 801b9899802..06dcf044093 100644 --- a/public/app/features/explore/QueryLibrary/QueryTemplateForm.tsx +++ b/public/app/features/explore/QueryLibrary/QueryTemplateForm.tsx @@ -28,11 +28,6 @@ export type QueryDetails = { description: string; }; -const VisibilityOptions = [ - { value: 'Public', label: t('explore.query-library.public', 'Public') }, - { value: 'Private', label: t('explore.query-library.private', 'Private') }, -]; - const getInstuctions = (isAdd: boolean) => { return isAdd ? t( @@ -155,7 +150,14 @@ export const QueryTemplateForm = ({ onCancel, onSave, queryToAdd, templateData } - + { + const actual = jest.requireActual('app/features/query-library'); + return { + ...actual, + useDeleteQueryTemplateMutation: () => [() => {}], + useListQueryTemplateQuery: () => { + return { + data: data, + isLoading: false, + error: null, + }; + }, + }; +}); + +jest.mock('./utils/dataFetching', () => { + return { + __esModule: true, + useLoadQueryMetadata: () => { + return { + loading: false, + value: [ + { + index: '0', + uid: '0', + datasourceName: 'prometheus', + datasourceRef: { type: 'prometheus', uid: 'Prometheus0' }, + datasourceType: 'prometheus', + createdAtTimestamp: 0, + query: { refId: 'A' }, + queryText: 'http_requests_total{job="test"}', + description: 'template0', + user: { + uid: 'viewer:JohnDoe', + displayName: 'John Doe', + avatarUrl: '', + }, + error: undefined, + }, + ], + }; + }, + useLoadUsers: () => { + return { + value: { + display: [ + { + avatarUrl: '', + displayName: 'john doe', + identity: { + name: 'JohnDoe', + type: 'viewer', + }, + }, + ], + }, + loading: false, + error: null, + }; + }, + }; +}); + +describe('QueryTemplatesList', () => { + it('renders empty state', async () => { + data = {}; + render(); + await waitFor(() => { + expect(screen.getByText(/You haven't saved any queries to your library yet/)).toBeInTheDocument(); + }); + }); + + it('renders query', async () => { + data.items = testItems; + render(); + await waitFor(() => { + // We don't really show query template title for some reason so creator name + expect(screen.getByText(/John Doe/)).toBeInTheDocument(); + }); + }); + + it('renders actionButton for query', async () => { + data.items = testItems; + let passedProps: QueryActionButtonProps; + + const queryActionButton = (props: QueryActionButtonProps) => { + passedProps = props; + return ; + }; + + render(); + await waitFor(() => { + // We don't really show query template title for some reason so creator name + expect(screen.getByText(/John Doe/)).toBeInTheDocument(); + expect(screen.getByText(/TEST_ACTION_BUTTON/)).toBeInTheDocument(); + // We didn't put much else into the query object but should be enough to check the prop + expect(passedProps.queries).toMatchObject([{ refId: 'A' }]); + }); + }); +}); + +const testItems = [ + { + metadata: { + name: 'TEST_QUERY', + creationTimestamp: '2025-01-01T11:11:11.00Z', + annotations: { + [CREATED_BY_KEY]: 'viewer:JohnDoe', + }, + }, + spec: { + title: 'Test Query title', + targets: [ + { + variables: {}, + properties: { + refId: 'A', + datasource: { + uid: 'Prometheus', + type: 'prometheus', + }, + }, + }, + ], + }, + }, +]; diff --git a/public/app/features/explore/QueryLibrary/QueryTemplatesList.tsx b/public/app/features/explore/QueryLibrary/QueryTemplatesList.tsx index 7f238354704..54dc5951b88 100644 --- a/public/app/features/explore/QueryLibrary/QueryTemplatesList.tsx +++ b/public/app/features/explore/QueryLibrary/QueryTemplatesList.tsx @@ -1,24 +1,22 @@ import { css } from '@emotion/css'; -import { compact, uniq, uniqBy } from 'lodash'; +import { uniqBy } from 'lodash'; import { useEffect, useMemo, useState } from 'react'; import { AppEvents, GrafanaTheme2, SelectableValue } from '@grafana/data'; -import { getAppEvents, getDataSourceSrv } from '@grafana/runtime'; +import { getAppEvents } from '@grafana/runtime'; import { EmptyState, FilterInput, InlineLabel, MultiSelect, Spinner, useStyles2, Stack, Badge } from '@grafana/ui'; import { t, Trans } from 'app/core/internationalization'; -import { createQueryText } from 'app/core/utils/richHistory'; import { useListQueryTemplateQuery } from 'app/features/query-library'; -import { getUserInfo } from 'app/features/query-library/api/user'; import { QueryTemplate } from 'app/features/query-library/types'; -import { getDatasourceSrv } from '../../plugins/datasource_srv'; import { convertDataQueryResponseToQueryTemplates } from '../../query-library/api/mappers'; +import { UserDataQueryResponse } from '../../query-library/api/types'; import { QueryLibraryProps } from './QueryLibrary'; import { queryLibraryTrackFilterDatasource } from './QueryLibraryAnalyticsEvents'; import { QueryLibraryExpmInfo } from './QueryLibraryExpmInfo'; import QueryTemplatesTable from './QueryTemplatesTable'; -import { QueryTemplateRow } from './QueryTemplatesTable/types'; +import { useLoadQueryMetadata, useLoadUsers } from './utils/dataFetching'; import { searchQueryLibrary } from './utils/search'; interface QueryTemplatesListProps extends QueryLibraryProps {} @@ -31,115 +29,29 @@ export function QueryTemplatesList(props: QueryTemplatesListProps) { const [datasourceFilters, setDatasourceFilters] = useState>>( props.activeDatasources?.map((ds) => ({ value: ds, label: ds })) || [] ); - const [userData, setUserData] = useState([]); const [userFilters, setUserFilters] = useState>>([]); - - const [allQueryTemplateRows, setAllQueryTemplateRows] = useState([]); - const [isRowsLoading, setIsRowsLoading] = useState(true); const styles = useStyles2(getStyles); - useEffect(() => { - let shouldCancel = true; + const loadUsersResult = useLoadUsersWithError(data); + const userNames = loadUsersResult.value ? loadUsersResult.value.display.map((user) => user.displayName) : []; - const fetchRows = async () => { - if (!data) { - setIsRowsLoading(false); - return; - } + const loadQueryMetadataResult = useLoadQueryMetadataWithError(data, loadUsersResult.value); - let userDataList; - const userQtList = uniq(compact(data.map((qt) => qt.user?.uid))); - const usersParam = userQtList.map((userUid) => `key=${encodeURIComponent(userUid)}`).join('&'); - try { - userDataList = await getUserInfo(`?${usersParam}`); - } catch (error) { - getAppEvents().publish({ - type: AppEvents.alertError.name, - payload: [ - t('query-library.user-info-get-error', 'Error attempting to get user info from the library: {{error}}', { - error: JSON.stringify(error), - }), - ], - }); - setIsRowsLoading(false); - return; - } - - setUserData(userDataList.display.map((user) => user.displayName)); - - const rowsPromises = data.map(async (queryTemplate: QueryTemplate, index: number) => { - try { - const datasourceRef = queryTemplate.targets[0]?.datasource; - const datasourceApi = await getDataSourceSrv().get(datasourceRef); - const datasourceType = getDatasourceSrv().getInstanceSettings(datasourceRef)?.meta.name || ''; - const query = queryTemplate.targets[0]; - const queryText = createQueryText(query, datasourceApi); - const datasourceName = datasourceApi?.name || ''; - const extendedUserData = userDataList.display.find( - (user) => `${user?.identity.type}:${user?.identity.name}` === queryTemplate.user?.uid - ); - - return { - index: index.toString(), - uid: queryTemplate.uid, - datasourceName, - datasourceRef, - datasourceType, - createdAtTimestamp: queryTemplate?.createdAtTimestamp || 0, - query, - queryText, - description: queryTemplate.title, - user: { - uid: queryTemplate.user?.uid || '', - displayName: extendedUserData?.displayName || '', - avatarUrl: extendedUserData?.avatarUrl || '', - }, - }; - } catch (error) { - getAppEvents().publish({ - type: AppEvents.alertError.name, - payload: [ - t( - 'query-library.query-template-get-error', - 'Error attempting to get query template from the library: {{error}}', - { error: JSON.stringify(error) } - ), - ], - }); - return { index: index.toString(), error }; - } - }); - - const results = await Promise.allSettled(rowsPromises); - const rows = results.filter((result) => result.status === 'fulfilled').map((result) => result.value); - - if (shouldCancel) { - setAllQueryTemplateRows(rows); - setIsRowsLoading(false); - } - }; - - fetchRows(); - - return () => { - shouldCancel = false; - }; - }, [data]); - - const queryTemplateRows = useMemo( + // Filtering right now is done just on the frontend until there is better backend support for this. + const filteredRows = useMemo( () => searchQueryLibrary( - allQueryTemplateRows, + loadQueryMetadataResult.value || [], searchQuery, datasourceFilters.map((f) => f.value || ''), userFilters.map((f) => f.value || '') ), - [allQueryTemplateRows, searchQuery, datasourceFilters, userFilters] + [loadQueryMetadataResult.value, searchQuery, datasourceFilters, userFilters] ); const datasourceNames = useMemo(() => { - return uniqBy(allQueryTemplateRows, 'datasourceName').map((row) => row.datasourceName); - }, [allQueryTemplateRows]); + return uniqBy(loadQueryMetadataResult.value, 'datasourceName').map((row) => row.datasourceName); + }, [loadQueryMetadataResult.value]); if (error) { return ( @@ -149,7 +61,7 @@ export function QueryTemplatesList(props: QueryTemplatesListProps) { ); } - if (isLoading || isRowsLoading) { + if (isLoading || loadUsersResult.loading || loadQueryMetadataResult.loading) { return ; } @@ -197,13 +109,14 @@ export function QueryTemplatesList(props: QueryTemplatesListProps) { User name(s): { setUserFilters(items); actionMeta.action === 'select-option' && queryLibraryTrackFilterDatasource(); }} value={userFilters} - options={userData.map((r) => { + options={userNames.map((r) => { return { value: r, label: r }; })} placeholder={'Filter queries for user name(s)'} @@ -219,11 +132,83 @@ export function QueryTemplatesList(props: QueryTemplatesListProps) { onClick={() => setIsModalOpen(true)} /> - + ); } +/** + * Wrap useLoadUsers with error handling. + * @param data + */ +function useLoadUsersWithError(data: QueryTemplate[] | undefined) { + const userUIDs = useMemo(() => data?.map((qt) => qt.user?.uid).filter((uid) => uid !== undefined), [data]); + const loadUsersResult = useLoadUsers(userUIDs); + useEffect(() => { + if (loadUsersResult.error) { + getAppEvents().publish({ + type: AppEvents.alertError.name, + payload: [ + t('query-library.user-info-get-error', 'Error attempting to get user info from the library: {{error}}', { + error: JSON.stringify(loadUsersResult.error), + }), + ], + }); + } + }, [loadUsersResult.error]); + return loadUsersResult; +} + +/** + * Wrap useLoadQueryMetadata with error handling. + * @param queryTemplates + * @param userDataList + */ +function useLoadQueryMetadataWithError( + queryTemplates: QueryTemplate[] | undefined, + userDataList: UserDataQueryResponse | undefined +) { + const result = useLoadQueryMetadata(queryTemplates, userDataList); + + // useLoadQueryMetadata returns errors in the values so we filter and group them and later alert only one time for + // all the errors. This way we show data that is loaded even if some rows errored out. + // TODO: maybe we could show the rows with incomplete data to see exactly which ones errored out. I assume this + // can happen for example when data source for saved query was deleted. Would be nice if user would still be able + // to delete such row or decide what to do. + const [values, errors] = useMemo(() => { + let errors: Error[] = []; + let values = []; + if (!result.loading) { + for (const value of result.value!) { + if (value.error) { + errors.push(value.error); + } else { + values.push(value); + } + } + } + return [values, errors]; + }, [result]); + + useEffect(() => { + if (errors.length) { + getAppEvents().publish({ + type: AppEvents.alertError.name, + payload: [ + t('query-library.query-template-get-error', 'Error attempting to load query template metadata: {{error}}', { + error: JSON.stringify(errors), + }), + ], + }); + } + }, [errors]); + + return { + loading: result.loading, + value: values, + }; +} + const getStyles = (theme: GrafanaTheme2) => ({ searchInput: css({ maxWidth: theme.spacing(55), diff --git a/public/app/features/explore/QueryLibrary/QueryTemplatesTable/ActionsCell.tsx b/public/app/features/explore/QueryLibrary/QueryTemplatesTable/ActionsCell.tsx index 88174485be1..575f10bd063 100644 --- a/public/app/features/explore/QueryLibrary/QueryTemplatesTable/ActionsCell.tsx +++ b/public/app/features/explore/QueryLibrary/QueryTemplatesTable/ActionsCell.tsx @@ -9,14 +9,13 @@ import { useDeleteQueryTemplateMutation } from 'app/features/query-library'; import { dispatch } from 'app/store/store'; import { ShowConfirmModalEvent } from 'app/types/events'; -import ExploreRunQueryButton from '../../ExploreRunQueryButton'; -import { useQueriesDrawerContext } from '../../QueriesDrawer/QueriesDrawerContext'; import { queryLibaryTrackDeleteQuery, queryLibraryTrackAddOrEditDescription, queryLibraryTrackRunQuery, } from '../QueryLibraryAnalyticsEvents'; import { QueryTemplateForm } from '../QueryTemplateForm'; +import { QueryActionButton } from '../types'; import { useQueryLibraryListStyles } from './styles'; import { QueryTemplateRow } from './types'; @@ -25,12 +24,12 @@ interface ActionsCellProps { queryUid?: string; queryTemplate: QueryTemplateRow; rootDatasourceUid?: string; + QueryActionButton?: QueryActionButton; } -function ActionsCell({ queryTemplate, rootDatasourceUid, queryUid }: ActionsCellProps) { +function ActionsCell({ queryTemplate, rootDatasourceUid, queryUid, QueryActionButton }: ActionsCellProps) { const [deleteQueryTemplate] = useDeleteQueryTemplateMutation(); const [editFormOpen, setEditFormOpen] = useState(false); - const { setDrawerOpened } = useQueriesDrawerContext(); const styles = useQueryLibraryListStyles(); const onDeleteQuery = (queryUid: string) => { @@ -82,15 +81,15 @@ function ActionsCell({ queryTemplate, rootDatasourceUid, queryUid }: ActionsCell queryLibraryTrackAddOrEditDescription(); }} /> - { - setDrawerOpened(false); - queryLibraryTrackRunQuery(queryTemplate.datasourceType || ''); - }} - /> + {QueryActionButton && ( + { + queryLibraryTrackRunQuery(queryTemplate.datasourceType || ''); + }} + /> + )} = (rowA, rowB, _, desc) => { return desc ? timeA - timeB : timeB - timeA; }; -const columns: Array> = [ - { id: 'description', header: 'Data source and query', cell: QueryDescriptionCell }, - { id: 'addedBy', header: 'Added by', cell: ({ row: { original } }) => }, - { id: 'datasourceType', header: 'Datasource type', cell: DatasourceTypeCell, sortType: 'string' }, - { id: 'createdAtTimestamp', header: 'Date added', cell: DateAddedCell, sortType: timestampSort }, - { - id: 'actions', - header: '', - cell: ({ row: { original } }) => ( - - ), - }, -]; +function createColumns(queryActionButton?: QueryActionButton): Array> { + return [ + { id: 'description', header: 'Data source and query', cell: QueryDescriptionCell }, + { id: 'addedBy', header: 'Added by', cell: ({ row: { original } }) => }, + { id: 'datasourceType', header: 'Datasource type', cell: DatasourceTypeCell, sortType: 'string' }, + { id: 'createdAtTimestamp', header: 'Date added', cell: DateAddedCell, sortType: timestampSort }, + { + id: 'actions', + header: '', + cell: ({ row: { original } }) => ( + + ), + }, + ]; +} type Props = { queryTemplateRows: QueryTemplateRow[]; + queryActionButton?: QueryActionButton; }; -export default function QueryTemplatesTable({ queryTemplateRows }: Props) { +export default function QueryTemplatesTable({ queryTemplateRows, queryActionButton }: Props) { const styles = useStyles2(getStyles); + const columns = createColumns(queryActionButton); + return ( { + openAddQueryModal(query); + setShowQueryLibraryBadgeButton(false); + }} + style={{ cursor: 'pointer' }} + /> + ) : ( + { + openAddQueryModal(query); + }} + /> + ); +} diff --git a/public/app/features/explore/QueryLibrary/types.ts b/public/app/features/explore/QueryLibrary/types.ts new file mode 100644 index 00000000000..b36c7280fe8 --- /dev/null +++ b/public/app/features/explore/QueryLibrary/types.ts @@ -0,0 +1,11 @@ +import { ComponentType } from 'react'; + +import { DataQuery } from '@grafana/schema'; + +export type QueryActionButtonProps = { + queries: DataQuery[]; + datasourceUid?: string; + onClick: () => void; +}; + +export type QueryActionButton = ComponentType; diff --git a/public/app/features/explore/QueryLibrary/utils/dataFetching.ts b/public/app/features/explore/QueryLibrary/utils/dataFetching.ts new file mode 100644 index 00000000000..67078936d93 --- /dev/null +++ b/public/app/features/explore/QueryLibrary/utils/dataFetching.ts @@ -0,0 +1,106 @@ +import { compact, uniq } from 'lodash'; +import { useAsync } from 'react-use'; +import { AsyncState } from 'react-use/lib/useAsync'; + +import { getDataSourceSrv } from '@grafana/runtime'; +import { DataQuery, DataSourceRef } from '@grafana/schema'; + +import { createQueryText } from '../../../../core/utils/richHistory'; +import { getDatasourceSrv } from '../../../plugins/datasource_srv'; +import { UserDataQueryResponse } from '../../../query-library/api/types'; +import { getUserInfo } from '../../../query-library/api/user'; +import { QueryTemplate } from '../../../query-library/types'; + +export function useLoadUsers(userUIDs: string[] | undefined) { + return useAsync(async () => { + if (!userUIDs) { + return undefined; + } + const userQtList = uniq(compact(userUIDs)); + const usersParam = userQtList.map((userUid) => `key=${encodeURIComponent(userUid)}`).join('&'); + return await getUserInfo(`?${usersParam}`); + }, [userUIDs]); +} + +// Explicitly type the result so TS knows to discriminate between the error result and good result by the error prop +// value. +type MetadataValue = + | { + index: string; + uid: string; + datasourceName: string; + datasourceRef: DataSourceRef | undefined | null; + datasourceType: string; + createdAtTimestamp: number; + query: DataQuery; + queryText: string; + description: string; + user: { + uid: string; + displayName: string; + avatarUrl: string; + }; + error: undefined; + } + | { + index: string; + error: Error; + }; + +/** + * Map metadata to query templates we get from the DB. + * @param queryTemplates + * @param userDataList + */ +export function useLoadQueryMetadata( + queryTemplates: QueryTemplate[] | undefined, + userDataList: UserDataQueryResponse | undefined +): AsyncState { + return useAsync(async () => { + if (!(queryTemplates && userDataList)) { + return []; + } + + const rowsPromises = queryTemplates.map( + async (queryTemplate: QueryTemplate, index: number): Promise => { + try { + const datasourceRef = queryTemplate.targets[0]?.datasource; + const datasourceApi = await getDataSourceSrv().get(datasourceRef); + const datasourceType = getDatasourceSrv().getInstanceSettings(datasourceRef)?.meta.name || ''; + const query = queryTemplate.targets[0]; + const queryText = createQueryText(query, datasourceApi); + const datasourceName = datasourceApi?.name || ''; + const extendedUserData = userDataList.display.find( + (user) => `${user?.identity.type}:${user?.identity.name}` === queryTemplate.user?.uid + ); + + return { + index: index.toString(), + uid: queryTemplate.uid, + datasourceName, + datasourceRef, + datasourceType, + createdAtTimestamp: queryTemplate?.createdAtTimestamp || 0, + query, + queryText, + description: queryTemplate.title, + user: { + uid: queryTemplate.user?.uid || '', + displayName: extendedUserData?.displayName || '', + avatarUrl: extendedUserData?.avatarUrl || '', + }, + error: undefined, + }; + } catch (error) { + // Instead of throwing we collect the errors in the result so upstream code can decide what to do. + return { + index: index.toString(), + error: error instanceof Error ? error : new Error('unknown error ' + JSON.stringify(error)), + }; + } + } + ); + + return Promise.all(rowsPromises); + }, [queryTemplates, userDataList]); +} diff --git a/public/app/features/explore/RichHistory/RichHistory.tsx b/public/app/features/explore/RichHistory/RichHistory.tsx index b614401cde6..60bce0a6ec3 100644 --- a/public/app/features/explore/RichHistory/RichHistory.tsx +++ b/public/app/features/explore/RichHistory/RichHistory.tsx @@ -11,15 +11,12 @@ import { RichHistorySettings, createDatasourcesList, } from 'app/core/utils/richHistory'; -import { QUERY_LIBRARY_GET_LIMIT } from 'app/features/query-library/api/factory'; import { useSelector } from 'app/types'; import { RichHistoryQuery } from 'app/types/explore'; import { supportedFeatures } from '../../../core/history/richHistoryStorageProvider'; -import { useListQueryTemplateQuery } from '../../query-library'; -import { Tabs, useQueriesDrawerContext } from '../QueriesDrawer/QueriesDrawerContext'; +import { Tabs } from '../QueriesDrawer/QueriesDrawerContext'; import { i18n } from '../QueriesDrawer/utils'; -import { QueryLibrary } from '../QueryLibrary/QueryLibrary'; import { selectExploreDSMaps } from '../state/selectors'; import { RichHistoryQueriesTab } from './RichHistoryQueriesTab'; @@ -55,8 +52,6 @@ export function RichHistory(props: RichHistoryProps) { const [loading, setLoading] = useState(false); - const { queryLibraryAvailable } = useQueriesDrawerContext(); - const updateSettings = (settingsToUpdate: Partial) => { props.updateHistorySettings({ ...props.richHistorySettings, ...settingsToUpdate }); }; @@ -98,16 +93,6 @@ export function RichHistory(props: RichHistoryProps) { .map((eDs) => listOfDatasources.find((ds) => ds.uid === eDs.datasource?.uid)?.name) .filter((name): name is string => !!name); - const { data } = useListQueryTemplateQuery({}); - const queryTemplatesCount = data?.items?.length ?? 0; - - const QueryLibraryTab: TabConfig = { - label: `${i18n.queryLibrary} (${queryTemplatesCount}/${QUERY_LIBRARY_GET_LIMIT})`, - value: Tabs.QueryLibrary, - content: , - icon: 'book', - }; - const QueriesTab: TabConfig = { label: i18n.queryHistory, value: Tabs.RichHistory, @@ -164,7 +149,7 @@ export function RichHistory(props: RichHistoryProps) { icon: 'sliders-v-alt', }; - let tabs = (queryLibraryAvailable ? [QueryLibraryTab] : []).concat([QueriesTab, StarredTab, SettingsTab]); + let tabs = [QueriesTab, StarredTab, SettingsTab]; return ( Loading... diff --git a/public/app/features/explore/spec/helper/interactions.ts b/public/app/features/explore/spec/helper/interactions.ts index a74ffb783c1..41b3f41ae73 100644 --- a/public/app/features/explore/spec/helper/interactions.ts +++ b/public/app/features/explore/spec/helper/interactions.ts @@ -26,9 +26,17 @@ export const runQuery = async (exploreId = 'left') => { }; export const openQueryHistory = async () => { - const button = screen.getByRole('button', { name: 'Query history' }); - await userEvent.click(button); - expect(await screen.findByPlaceholderText('Search queries')).toBeInTheDocument(); + let button = screen.queryByRole('button', { name: 'Query history' }); + if (button) { + await userEvent.click(button); + expect(await screen.findByPlaceholderText('Search queries')).toBeInTheDocument(); + } else { + button = screen.getByRole('button', { name: 'Open query library or query history' }); + await userEvent.click(button); + button = await screen.findByRole('menuitem', { name: 'Query history' }); + await userEvent.click(button); + expect(await screen.findByPlaceholderText('Search queries')).toBeInTheDocument(); + } }; export const openQueryLibrary = async () => { @@ -41,13 +49,6 @@ export const openQueryLibrary = async () => { }); }; -export const switchToQueryHistory = async () => { - const tab = screen.getByRole('tab', { - name: /query history/i, - }); - await userEvent.click(tab); -}; - export const addQueryHistoryToQueryLibrary = async () => { const button = withinQueryHistory().getByRole('button', { name: /add to library/i }); await userEvent.click(button); diff --git a/public/app/features/explore/spec/helper/setup.tsx b/public/app/features/explore/spec/helper/setup.tsx index 2ec9733a4d4..c57cf52c435 100644 --- a/public/app/features/explore/spec/helper/setup.tsx +++ b/public/app/features/explore/spec/helper/setup.tsx @@ -47,6 +47,7 @@ import { ExploreQueryParams } from '../../../../types'; import { initialUserState } from '../../../profile/state/reducers'; import ExplorePage from '../../ExplorePage'; import { QueriesDrawerContextProvider } from '../../QueriesDrawer/QueriesDrawerContext'; +import { QueryLibraryContextProvider } from '../../QueryLibrary/QueryLibraryContext'; type DatasourceSetup = { settings: DataSourceInstanceSettings; api: DataSourceApi }; @@ -182,25 +183,29 @@ export function setupExplore(options?: SetupOptions): { - - {options?.withAppChrome ? ( - - - } - /> - - - ) : ( - } - /> - )} - + + + {options?.withAppChrome ? ( + + + ( + + )} + /> + + + ) : ( + } + /> + )} + + diff --git a/public/app/features/explore/spec/queryHistory.test.tsx b/public/app/features/explore/spec/queryHistory.test.tsx index 90d889c4625..1e3d7b3a75d 100644 --- a/public/app/features/explore/spec/queryHistory.test.tsx +++ b/public/app/features/explore/spec/queryHistory.test.tsx @@ -7,6 +7,7 @@ import store from 'app/core/store'; import { silenceConsoleOutput } from '../../../../test/core/utils/silenceConsoleOutput'; import * as localStorage from '../../../core/history/RichHistoryLocalStorage'; +import { Tabs } from '../QueriesDrawer/QueriesDrawerContext'; import { assertDataSourceFilterVisibility, @@ -127,6 +128,7 @@ describe('Explore: Query History', () => { expect(reportInteractionMock).toBeCalledWith('grafana_explore_query_history_opened', { queryHistoryEnabled: false, + selectedTab: Tabs.RichHistory, }); }); diff --git a/public/app/features/explore/spec/queryLibrary.test.tsx b/public/app/features/explore/spec/queryLibrary.test.tsx index dc942fa21a8..a9cc5345b23 100644 --- a/public/app/features/explore/spec/queryLibrary.test.tsx +++ b/public/app/features/explore/spec/queryLibrary.test.tsx @@ -16,7 +16,6 @@ import { openQueryHistory, openQueryLibrary, submitAddToQueryLibrary, - switchToQueryHistory, } from './helper/interactions'; import { setupExplore, waitForExplore } from './helper/setup'; @@ -115,8 +114,7 @@ describe('QueryLibrary', () => { it('Shows add to query library button only when the toggle is enabled', async () => { setupQueryLibrary(); await waitForExplore(); - await openQueryLibrary(); - await switchToQueryHistory(); + await openQueryHistory(); await assertQueryHistory(['{"expr":"TEST"}']); await assertAddToQueryLibraryButtonExists(true); }); @@ -134,8 +132,7 @@ describe('QueryLibrary', () => { it('Shows a notification when a template is added and hides the add button', async () => { setupQueryLibrary(); await waitForExplore(); - await openQueryLibrary(); - await switchToQueryHistory(); + await openQueryHistory(); await assertQueryHistory(['{"expr":"TEST"}']); await addQueryHistoryToQueryLibrary(); await submitAddToQueryLibrary({ description: 'Test' }); diff --git a/public/app/features/query/components/QueryEditorRow.tsx b/public/app/features/query/components/QueryEditorRow.tsx index 7094901511f..a3dc05930d7 100644 --- a/public/app/features/query/components/QueryEditorRow.tsx +++ b/public/app/features/query/components/QueryEditorRow.tsx @@ -24,7 +24,7 @@ import { toLegacyResponseData, } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; -import { AngularComponent, getAngularLoader, getDataSourceSrv, reportInteraction } from '@grafana/runtime'; +import { AngularComponent, config, getAngularLoader, getDataSourceSrv, reportInteraction } from '@grafana/runtime'; import { Badge, ErrorBoundaryAlert } from '@grafana/ui'; import { OperationRowHelp } from 'app/core/components/QueryOperationRow/OperationRowHelp'; import { @@ -40,6 +40,8 @@ import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv'; import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; import { PanelModel } from 'app/features/dashboard/state/PanelModel'; +import { SaveQueryButton as SaveQueryToQueryLibraryButton } from '../../explore/QueryLibrary/SaveQueryButton'; + import { QueryActionComponent, RowActionComponents } from './QueryActionComponent'; import { QueryEditorRowHeader } from './QueryEditorRowHeader'; import { QueryErrorAlert } from './QueryErrorAlert'; @@ -487,6 +489,7 @@ export class QueryEditorRow extends PureComponent )} {this.renderExtraActions()} + {config.featureToggles.queryLibrary && } - - - - - - {props.pageBanners.map((Banner, index) => ( - + + + + + + + {props.pageBanners.map((Banner, index) => ( + + ))} + {props.routes} + + {props.bodyRenderHooks.map((Hook, index) => ( + ))} - {props.routes} - - {props.bodyRenderHooks.map((Hook, index) => ( - - ))} - - - + + + + diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 7781727270f..3a270e122c3 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -1077,6 +1077,12 @@ "angular-deprecation-description": "Angular panels options can only be edited using the JSON editor.", "angular-deprecation-heading": "Panel options" }, + "panel-queries": { + "add-query-from-library": "Add query from library" + }, + "query-library": { + "add-query-button": "Add query" + }, "settings": { "variables": { "dependencies": { @@ -1188,6 +1194,7 @@ "close-tooltip": "Close query history", "datasource-a-z": "Data source A-Z", "datasource-z-a": "Data source Z-A", + "library-history-dropdown": "Open query library or query history", "newest-first": "Newest first", "oldest-first": "Oldest first", "query-history": "Query history", @@ -2796,7 +2803,7 @@ "query-library": { "datasource-names": "Datasource name(s):", "delete-query-button": "Delete query", - "query-template-get-error": "Error attempting to get query template from the library: {{error}}", + "query-template-get-error": "Error attempting to load query template metadata: {{error}}", "search": "Search by data source, query content or description", "user-info-get-error": "Error attempting to get user info from the library: {{error}}", "user-names": "User name(s):" @@ -2811,6 +2818,7 @@ "hide-response": "Hide response", "remove-query": "Remove query", "save-to-query-library": "Save to query library", + "save-to-query-library-new": "New: Save to query library", "show-response": "Show response", "toggle-edit-mode": "Toggle text edit mode" }, diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index 006e0f4a07f..1495a78caee 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -1077,6 +1077,12 @@ "angular-deprecation-description": "Åʼnģūľäř päʼnęľş őpŧįőʼnş čäʼn őʼnľy þę ęđįŧęđ ūşįʼnģ ŧĥę ĴŜØŃ ęđįŧőř.", "angular-deprecation-heading": "Päʼnęľ őpŧįőʼnş" }, + "panel-queries": { + "add-query-from-library": "Åđđ qūęřy ƒřőm ľįþřäřy" + }, + "query-library": { + "add-query-button": "Åđđ qūęřy" + }, "settings": { "variables": { "dependencies": { @@ -1188,6 +1194,7 @@ "close-tooltip": "Cľőşę qūęřy ĥįşŧőřy", "datasource-a-z": "Đäŧä şőūřčę Å-Ż", "datasource-z-a": "Đäŧä şőūřčę Ż-Å", + "library-history-dropdown": "Øpęʼn qūęřy ľįþřäřy őř qūęřy ĥįşŧőřy", "newest-first": "Ńęŵęşŧ ƒįřşŧ", "oldest-first": "Øľđęşŧ ƒįřşŧ", "query-history": "Qūęřy ĥįşŧőřy", @@ -2796,7 +2803,7 @@ "query-library": { "datasource-names": "Đäŧäşőūřčę ʼnämę(ş):", "delete-query-button": "Đęľęŧę qūęřy", - "query-template-get-error": "Ēřřőř äŧŧęmpŧįʼnģ ŧő ģęŧ qūęřy ŧęmpľäŧę ƒřőm ŧĥę ľįþřäřy: {{error}}", + "query-template-get-error": "Ēřřőř äŧŧęmpŧįʼnģ ŧő ľőäđ qūęřy ŧęmpľäŧę męŧäđäŧä: {{error}}", "search": "Ŝęäřčĥ þy đäŧä şőūřčę, qūęřy čőʼnŧęʼnŧ őř đęşčřįpŧįőʼn", "user-info-get-error": "Ēřřőř äŧŧęmpŧįʼnģ ŧő ģęŧ ūşęř įʼnƒő ƒřőm ŧĥę ľįþřäřy: {{error}}", "user-names": "Ůşęř ʼnämę(ş):" @@ -2811,6 +2818,7 @@ "hide-response": "Ħįđę řęşpőʼnşę", "remove-query": "Ŗęmővę qūęřy", "save-to-query-library": "Ŝävę ŧő qūęřy ľįþřäřy", + "save-to-query-library-new": "Ńęŵ: Ŝävę ŧő qūęřy ľįþřäřy", "show-response": "Ŝĥőŵ řęşpőʼnşę", "toggle-edit-mode": "Ŧőģģľę ŧęχŧ ęđįŧ mőđę" },