QueryLibrary: Make query library available outside of Explore (#99319)

This commit is contained in:
Andrej Ocenas 2025-01-30 14:53:01 +01:00 committed by GitHub
parent a92c8145f1
commit 86a68627dd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 968 additions and 347 deletions

View File

@ -2545,8 +2545,7 @@ exports[`better eslint`] = {
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "7"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "8"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "9"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "10"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "11"]
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "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 <Trans /> or use t()", "0"],
@ -4644,9 +4643,6 @@ exports[`better eslint`] = {
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "8"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "9"]
],
"public/app/features/explore/ExplorePage.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"]
],
"public/app/features/explore/ExploreRunQueryButton.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "1"]

View File

@ -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<DataSourceApi>;

View File

@ -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<PanelDataQueriesTabStat
export function PanelDataQueriesTabRendered({ model }: SceneComponentProps<PanelDataQueriesTab>) {
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 (
<div data-testid={selectors.components.QueryTab.content}>
<QueryGroupTopSection
@ -336,14 +346,28 @@ export function PanelDataQueriesTabRendered({ model }: SceneComponentProps<Panel
<Stack gap={2}>
{showAddButton && (
<Button
icon="plus"
onClick={model.addQueryClick}
variant="secondary"
data-testid={selectors.components.QueryTab.addQuery}
>
Add query
</Button>
<>
<Button
icon="plus"
onClick={model.addQueryClick}
variant="secondary"
data-testid={selectors.components.QueryTab.addQuery}
>
Add query
</Button>
{config.featureToggles.queryLibrary && (
<Button
icon="plus"
onClick={() => {
openQueryLibraryDrawer(getDatasourceNames(datasource, queries), addQueryActionButton);
}}
variant="secondary"
data-testid={selectors.components.QueryTab.addQuery}
>
<Trans i18nKey={'dashboards.panel-queries.add-query-from-library'}>Add query from library</Trans>
</Button>
)}
</>
)}
{config.expressionsEnabled && model.isExpressionsSupported(dsSettings) && (
<Button
@ -361,6 +385,38 @@ export function PanelDataQueriesTabRendered({ model }: SceneComponentProps<Panel
);
}
/**
* Creates a button component that will be used in query library as action next to each query.
* @param addQueries
*/
function makeQueryActionButton(addQueries: (queries: DataQuery[]) => void) {
return function AddQueryFromLibraryButton(props: QueryActionButtonProps) {
const label = t('dashboards.query-library.add-query-button', 'Add query');
return (
<Button
variant={'primary'}
aria-label={label}
onClick={() => {
addQueries(props.queries);
props.onClick();
}}
>
{label}
</Button>
);
};
}
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;
}

View File

@ -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 <ExplorePageContent {...props} />;
@ -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<DataQuery | undefined>();
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 ? (
<Badge
key={props.key}
text={`New: ${t('query-operation.header.save-to-query-library', 'Save to query library')}`}
icon="save"
color="blue"
onClick={() => {
setQueryToAdd(props.query);
setShowQueryLibraryBadgeButton(false);
}}
style={{ cursor: 'pointer' }}
/>
) : (
<QueryOperationAction
key={props.key}
title={t('query-operation.header.save-to-query-library', 'Save to query library')}
icon="save"
onClick={() => {
setQueryToAdd(props.query);
}}
/>
),
});
}
}, [showQueryLibraryBadgeButton, setShowQueryLibraryBadgeButton]);
useKeyboardShortcuts();
return (
@ -139,7 +93,7 @@ function ExplorePageContent(props: GrafanaRouteComponentProps<{}, ExploreQueryPa
})}
</SplitPaneWrapper>
{drawerOpened && (
<ExploreDrawer initialHeight={queryLibraryAvailable ? '75vh' : undefined}>
<ExploreDrawer>
<RichHistoryContainer
onClose={() => {
setDrawerOpened(false);
@ -147,24 +101,6 @@ function ExplorePageContent(props: GrafanaRouteComponentProps<{}, ExploreQueryPa
/>
</ExploreDrawer>
)}
<Modal
title={t('explore.query-template-modal.add-title', 'Add query to Query Library')}
isOpen={queryToAdd !== undefined}
onDismiss={() => setQueryToAdd(undefined)}
>
<QueryTemplateForm
onCancel={() => {
setQueryToAdd(undefined);
}}
onSave={(isSuccess) => {
if (isSuccess) {
setQueryToAdd(undefined);
queryLibraryTrackAddFromQueryRow(queryToAdd?.datasource?.type || '');
}
}}
queryToAdd={queryToAdd!}
/>
</Modal>
</div>
);
}

View File

@ -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 (
<Button
variant={variant}
variant={'primary'}
aria-label={buttonText.translation}
onClick={() => {
runQuery(exploreId);

View File

@ -5,7 +5,7 @@ import { shallowEqual } from 'react-redux';
import { DataSourceInstanceSettings, RawTimeRange, GrafanaTheme2 } from '@grafana/data';
import { Components } from '@grafana/e2e-selectors';
import { reportInteraction } from '@grafana/runtime';
import { config, reportInteraction } from '@grafana/runtime';
import {
defaultIntervals,
PageToolbar,
@ -91,7 +91,7 @@ export function ExploreToolbar({ exploreId, onChangeTime, onContentOutlineToogle
const correlationDetails = useSelector(selectCorrelationDetails);
const isCorrelationsEditorMode = correlationDetails?.editorMode || false;
const isLeftPane = useSelector(isLeftPaneSelector(exploreId));
const { drawerOpened, setDrawerOpened, queryLibraryAvailable } = useQueriesDrawerContext();
const { drawerOpened, setDrawerOpened } = useQueriesDrawerContext();
const shouldRotateSplitIcon = useMemo(
() => (isLeftPane && isLargerPane) || (!isLeftPane && !isLargerPane),
@ -206,7 +206,7 @@ export function ExploreToolbar({ exploreId, onChangeTime, onContentOutlineToogle
const navBarActions = [<ShortLinkButtonMenu key="share" />];
if (queryLibraryAvailable) {
if (config.featureToggles.queryLibrary) {
navBarActions.unshift(<QueriesDrawerDropdown key="queryLibrary" variant="full" />);
} else {
navBarActions.unshift(

View File

@ -1,29 +1,25 @@
import { PropsWithChildren, useState, createContext, useContext, useEffect } from 'react';
import { config } from '@grafana/runtime';
import { useSelector } from 'app/types';
import { selectRichHistorySettings } from '../state/selectors';
export enum Tabs {
QueryLibrary = 'Query library',
RichHistory = 'Query history',
Starred = 'Starred',
Settings = 'Settings',
}
type QueryLibraryContextType = {
selectedTab?: Tabs;
type RichHistoryContextType = {
selectedTab: Tabs;
setSelectedTab: (tab: Tabs) => void;
queryLibraryAvailable: boolean;
drawerOpened: boolean;
setDrawerOpened: (value: boolean) => void;
};
export const QueriesDrawerContext = createContext<QueryLibraryContextType>({
selectedTab: undefined,
export const QueriesDrawerContext = createContext<RichHistoryContextType>({
selectedTab: Tabs.RichHistory,
setSelectedTab: () => {},
queryLibraryAvailable: false,
drawerOpened: false,
setDrawerOpened: () => {},
});
@ -33,24 +29,20 @@ export function useQueriesDrawerContext() {
}
export function QueriesDrawerContextProvider({ children }: PropsWithChildren) {
const queryLibraryAvailable = config.featureToggles.queryLibrary === true;
const [selectedTab, setSelectedTab] = useState<Tabs | undefined>(
queryLibraryAvailable ? Tabs.QueryLibrary : undefined
);
const [selectedTab, setSelectedTab] = useState<Tabs>(Tabs.RichHistory);
const [drawerOpened, setDrawerOpened] = useState<boolean>(false);
const settings = useSelector(selectRichHistorySettings);
useEffect(() => {
if (settings && !queryLibraryAvailable) {
if (settings) {
setSelectedTab(settings.starredTabAsFirstTab ? Tabs.Starred : Tabs.RichHistory);
}
}, [settings, setSelectedTab, queryLibraryAvailable]);
}, [settings, setSelectedTab]);
return (
<QueriesDrawerContext.Provider
value={{
queryLibraryAvailable,
selectedTab,
setSelectedTab,
drawerOpened,

View File

@ -1,68 +1,114 @@
import { css } from '@emotion/css';
import { ComponentProps, useState } from 'react';
import { config } from '@grafana/runtime';
import { Button, ButtonGroup, Dropdown, Menu, ToolbarButton } from '@grafana/ui';
import { useStyles2 } from '@grafana/ui/';
import { t } from 'app/core/internationalization';
import { createDatasourcesList } from '../../../core/utils/richHistory';
import { useSelector } from '../../../types';
import ExploreRunQueryButton from '../ExploreRunQueryButton';
import { queryLibraryTrackToggle } from '../QueryLibrary/QueryLibraryAnalyticsEvents';
import { useQueryLibraryContext } from '../QueryLibrary/QueryLibraryContext';
import { QueryActionButton } from '../QueryLibrary/types';
import { selectExploreDSMaps } from '../state/selectors';
import { Tabs, useQueriesDrawerContext } from './QueriesDrawerContext';
import { useQueriesDrawerContext } from './QueriesDrawerContext';
import { i18n } from './utils';
// This makes TS happy as ExploreRunQueryButton has optional onClick prop while QueryActionButton doesn't
// in addition to map the rootDatasourceUid prop.
function ExploreRunQueryButtonWrapper(props: ComponentProps<QueryActionButton>) {
return <ExploreRunQueryButton {...props} rootDatasourceUid={props.datasourceUid} />;
}
type Props = {
variant: 'compact' | 'full';
};
/**
* Dropdown button that can either open a Query History drawer or a Query Library drawer.
* @param variant
* @constructor
*/
export function QueriesDrawerDropdown({ variant }: Props) {
const { selectedTab, setSelectedTab, queryLibraryAvailable, drawerOpened, setDrawerOpened } =
useQueriesDrawerContext();
const { drawerOpened, setDrawerOpened } = useQueriesDrawerContext();
const {
openDrawer: openQueryLibraryDrawer,
closeDrawer: closeQueryLibraryDrawer,
isDrawerOpen: isQueryLibraryDrawerOpen,
} = useQueryLibraryContext();
const [queryOption, setQueryOption] = useState<'library' | 'history'>('library');
const exploreActiveDS = useSelector(selectExploreDSMaps);
const styles = useStyles2(getStyles);
if (!queryLibraryAvailable) {
// In case query library is not enabled we show only simple button for query history in the parent.
if (!config.featureToggles.queryLibrary) {
return undefined;
}
function toggle(tab: Tabs) {
tab === Tabs.QueryLibrary && queryLibraryTrackToggle(!drawerOpened);
function toggleRichHistory() {
setQueryOption('history');
setDrawerOpened(!drawerOpened);
}
setSelectedTab(tab);
setDrawerOpened(false);
setDrawerOpened(true);
function toggleQueryLibrary() {
setQueryOption('library');
if (isQueryLibraryDrawerOpen) {
closeQueryLibraryDrawer();
} else {
// Prefill the query library filter with the dataSource.
// Get current dataSource that is open. As this is only used in Explore we get it from Explore state.
const listOfDatasources = createDatasourcesList();
const activeDatasources = exploreActiveDS.dsToExplore
.map((eDs) => {
return listOfDatasources.find((ds) => ds.uid === eDs.datasource?.uid)?.name;
})
.filter((name): name is string => !!name);
openQueryLibraryDrawer(activeDatasources, ExploreRunQueryButtonWrapper);
}
queryLibraryTrackToggle(!isQueryLibraryDrawerOpen);
}
const menu = (
<Menu>
<Menu.Item label={i18n.queryLibrary} onClick={() => toggle(Tabs.QueryLibrary)} />
<Menu.Item label={i18n.queryHistory} onClick={() => toggle(Tabs.RichHistory)} />
<Menu.Item label={i18n.queryLibrary} onClick={() => toggleQueryLibrary()} />
<Menu.Item label={i18n.queryHistory} onClick={() => toggleRichHistory()} />
</Menu>
);
const buttonLabel = queryOption === 'library' ? i18n.queryLibrary : i18n.queryHistory;
const toggle = queryOption === 'library' ? toggleQueryLibrary : toggleRichHistory;
return (
<ButtonGroup>
<ToolbarButton
icon="book"
variant={drawerOpened ? 'active' : 'canvas'}
onClick={() => {
setDrawerOpened(!drawerOpened);
selectedTab === Tabs.QueryLibrary && queryLibraryTrackToggle(!drawerOpened);
}}
aria-label={selectedTab}
variant={drawerOpened || isQueryLibraryDrawerOpen ? 'active' : 'canvas'}
onClick={() => toggle()}
aria-label={buttonLabel}
>
{variant === 'full' ? selectedTab : undefined}
{variant === 'full' ? buttonLabel : undefined}
</ToolbarButton>
{drawerOpened ? (
<Button
className={styles.close}
variant="secondary"
icon="times"
onClick={() => {
setDrawerOpened(false);
selectedTab === Tabs.QueryLibrary && queryLibraryTrackToggle(false);
}}
></Button>
{/* 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 ? (
<Button className={styles.close} variant="secondary" icon="times" onClick={() => toggle()}></Button>
) : (
<Dropdown overlay={menu}>
<ToolbarButton className={styles.toggle} variant="canvas" icon="angle-down" />
<ToolbarButton
className={styles.toggle}
variant="canvas"
icon="angle-down"
aria-label={t('explore.rich-history.library-history-dropdown', 'Open query library or query history')}
/>
</Dropdown>
)}
</ButtonGroup>

View File

@ -8,13 +8,12 @@ type Props = {
} & PropsWithChildren;
export function QueriesDrawerContextProviderMock(props: Props) {
const [selectedTab, setSelectedTab] = useState<Tabs>(Tabs.QueryLibrary);
const [selectedTab, setSelectedTab] = useState<Tabs>(Tabs.RichHistory);
const [drawerOpened, setDrawerOpened] = useState<boolean>(false);
return (
<QueriesDrawerContext.Provider
value={{
queryLibraryAvailable: props.queryLibraryAvailable || false,
selectedTab,
setSelectedTab,
drawerOpened,

View File

@ -0,0 +1,36 @@
import { DataQuery } from '@grafana/schema';
import { Modal } from '@grafana/ui';
import { t } from '../../../core/internationalization';
import { queryLibraryTrackAddFromQueryRow } from './QueryLibraryAnalyticsEvents';
import { QueryTemplateForm } from './QueryTemplateForm';
type Props = {
isOpen: boolean;
close: () => void;
query?: DataQuery;
};
export function AddToQueryLibraryModal({ query, close, isOpen }: Props) {
return (
<Modal
title={t('explore.query-template-modal.add-title', 'Add query to Query Library')}
isOpen={isOpen}
onDismiss={() => close()}
>
<QueryTemplateForm
onCancel={() => {
close();
}}
onSave={(isSuccess) => {
if (isSuccess) {
close();
queryLibraryTrackAddFromQueryRow(query?.datasource?.type || '');
}
}}
queryToAdd={query!}
/>
</Modal>
);
}

View File

@ -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)}
/>
<QueryTemplatesList activeDatasources={activeDatasources} />
<QueryTemplatesList activeDatasources={activeDatasources} queryActionButton={queryActionButton} />
</>
);
}

View File

@ -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 && <div>QUERY_MODAL {JSON.stringify(props.query)}</div>,
}));
jest.mock('./QueryLibraryDrawer', () => ({
__esModule: true,
QueryLibraryDrawer: (props: {
isOpen: boolean;
activeDatasources: string[] | undefined;
queryActionButton: ComponentType;
}) =>
props.isOpen && (
<div>
QUERY_DRAWER {JSON.stringify(props.activeDatasources)} {props.queryActionButton && <props.queryActionButton />}
</div>
),
}));
function setup() {
let ctx: { current: QueryLibraryContextType | undefined } = { current: undefined };
function TestComp() {
ctx.current = useQueryLibraryContext();
return <div></div>;
}
// rendering instead of just using renderHook so we can check if the modal and drawer actually render.
const renderResult = render(
<QueryLibraryContextProvider>
<TestComp />
</QueryLibraryContextProvider>
);
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'], () => <div>QUERY_ACTION_BUTTON</div>);
});
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();
});
});
});

View File

@ -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<QueryLibraryContextType>({
openDrawer: () => {},
closeDrawer: () => {},
isDrawerOpen: false,
openAddQueryModal: () => {},
closeAddQueryModal: () => {},
});
export function useQueryLibraryContext() {
return useContext(QueryLibraryContext);
}
export function QueryLibraryContextProvider({ children }: PropsWithChildren) {
const [isDrawerOpen, setIsDrawerOpen] = useState<boolean>(false);
const [activeDatasources, setActiveDatasources] = useState<string[]>([]);
const [isAddQueryModalOpen, setIsAddQueryModalOpen] = useState<boolean>(false);
const [activeQuery, setActiveQuery] = useState<DataQuery | undefined>(undefined);
const [queryActionButton, setQueryActionButton] = useState<QueryActionButton | undefined>(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 (
<QButton
{...props}
onClick={() => {
props.onClick();
closeDrawer();
}}
/>
);
};
}, [closeDrawer, queryActionButton]);
return (
<QueryLibraryContext.Provider
value={{
isDrawerOpen,
openDrawer,
closeDrawer,
openAddQueryModal,
closeAddQueryModal,
}}
>
{children}
<QueryLibraryDrawer
isOpen={isDrawerOpen}
close={closeDrawer}
activeDatasources={activeDatasources}
queryActionButton={finalActionButton}
/>
<AddToQueryLibraryModal isOpen={isAddQueryModalOpen} close={closeAddQueryModal} query={activeQuery} />
</QueryLibraryContext.Provider>
);
}

View File

@ -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: <QueryLibrary activeDatasources={activeDatasources} queryActionButton={queryActionButton} />,
icon: 'book',
},
];
return (
isOpen && (
<ExploreDrawer initialHeight={'75vh'}>
<TabbedContainer
tabs={tabs}
onClose={close}
defaultTab={'Query library'}
closeIconTooltip={t('explore.rich-history.close-tooltip', 'Close query history')}
testId={selectors.pages.Explore.QueryHistory.container}
/>
</ExploreDrawer>
)
);
}

View File

@ -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 }
<Input id="query-template-description" autoFocus={true} {...register('description')}></Input>
</Field>
<Field label={t('explore.query-template-modal.visibility', 'Visibility')}>
<RadioButtonGroup options={VisibilityOptions} value={'Public'} disabled={true} />
<RadioButtonGroup
options={[
{ value: 'Public', label: t('explore.query-library.public', 'Public') },
{ value: 'Private', label: t('explore.query-library.private', 'Private') },
]}
value={'Public'}
disabled={true}
/>
</Field>
<InlineSwitch
showLabel={true}

View File

@ -0,0 +1,139 @@
import { render, waitFor, screen } from '@testing-library/react';
import { ListQueryTemplateApiResponse } from '../../query-library/api/endpoints.gen';
import { CREATED_BY_KEY } from '../../query-library/api/types';
import { QueryTemplatesList } from './QueryTemplatesList';
import { QueryActionButtonProps } from './types';
let data: ListQueryTemplateApiResponse = {
items: [],
};
jest.mock('app/features/query-library', () => {
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(<QueryTemplatesList />);
await waitFor(() => {
expect(screen.getByText(/You haven't saved any queries to your library yet/)).toBeInTheDocument();
});
});
it('renders query', async () => {
data.items = testItems;
render(<QueryTemplatesList />);
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 <button>TEST_ACTION_BUTTON</button>;
};
render(<QueryTemplatesList queryActionButton={queryActionButton} />);
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',
},
},
},
],
},
},
];

View File

@ -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<Array<SelectableValue<string>>>(
props.activeDatasources?.map((ds) => ({ value: ds, label: ds })) || []
);
const [userData, setUserData] = useState<string[]>([]);
const [userFilters, setUserFilters] = useState<Array<SelectableValue<string>>>([]);
const [allQueryTemplateRows, setAllQueryTemplateRows] = useState<QueryTemplateRow[]>([]);
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 <Spinner />;
}
@ -197,13 +109,14 @@ export function QueryTemplatesList(props: QueryTemplatesListProps) {
<Trans i18nKey="query-library.user-names">User name(s):</Trans>
</InlineLabel>
<MultiSelect
isLoading={loadUsersResult.loading}
className={styles.multiSelect}
onChange={(items, actionMeta) => {
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)}
/>
</Stack>
<QueryTemplatesTable queryTemplateRows={queryTemplateRows} />
<QueryTemplatesTable queryTemplateRows={filteredRows} queryActionButton={props.queryActionButton} />
</>
);
}
/**
* 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),

View File

@ -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();
}}
/>
<ExploreRunQueryButton
queries={queryTemplate.query ? [queryTemplate.query] : []}
rootDatasourceUid={rootDatasourceUid}
variant="primary"
onClick={() => {
setDrawerOpened(false);
queryLibraryTrackRunQuery(queryTemplate.datasourceType || '');
}}
/>
{QueryActionButton && (
<QueryActionButton
queries={queryTemplate.query ? [queryTemplate.query] : []}
datasourceUid={rootDatasourceUid}
onClick={() => {
queryLibraryTrackRunQuery(queryTemplate.datasourceType || '');
}}
/>
)}
<Modal
title={t('explore.query-template-modal.edit-title', 'Edit query')}
isOpen={editFormOpen}

View File

@ -4,6 +4,8 @@ import { SortByFn } from 'react-table';
import { GrafanaTheme2 } from '@grafana/data';
import { Column, InteractiveTable, useStyles2 } from '@grafana/ui';
import { QueryActionButton } from '../types';
import ActionsCell from './ActionsCell';
import { AddedByCell } from './AddedByCell';
import { DatasourceTypeCell } from './DatasourceTypeCell';
@ -17,26 +19,36 @@ const timestampSort: SortByFn<QueryTemplateRow> = (rowA, rowB, _, desc) => {
return desc ? timeA - timeB : timeB - timeA;
};
const columns: Array<Column<QueryTemplateRow>> = [
{ id: 'description', header: 'Data source and query', cell: QueryDescriptionCell },
{ id: 'addedBy', header: 'Added by', cell: ({ row: { original } }) => <AddedByCell user={original.user} /> },
{ id: 'datasourceType', header: 'Datasource type', cell: DatasourceTypeCell, sortType: 'string' },
{ id: 'createdAtTimestamp', header: 'Date added', cell: DateAddedCell, sortType: timestampSort },
{
id: 'actions',
header: '',
cell: ({ row: { original } }) => (
<ActionsCell queryTemplate={original} rootDatasourceUid={original.datasourceRef?.uid} queryUid={original.uid} />
),
},
];
function createColumns(queryActionButton?: QueryActionButton): Array<Column<QueryTemplateRow>> {
return [
{ id: 'description', header: 'Data source and query', cell: QueryDescriptionCell },
{ id: 'addedBy', header: 'Added by', cell: ({ row: { original } }) => <AddedByCell user={original.user} /> },
{ id: 'datasourceType', header: 'Datasource type', cell: DatasourceTypeCell, sortType: 'string' },
{ id: 'createdAtTimestamp', header: 'Date added', cell: DateAddedCell, sortType: timestampSort },
{
id: 'actions',
header: '',
cell: ({ row: { original } }) => (
<ActionsCell
queryTemplate={original}
rootDatasourceUid={original.datasourceRef?.uid}
queryUid={original.uid}
QueryActionButton={queryActionButton}
/>
),
},
];
}
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 (
<InteractiveTable
columns={columns}

View File

@ -0,0 +1,44 @@
import { useLocalStorage } from 'react-use';
import { DataQuery } from '@grafana/schema';
import { Badge } from '@grafana/ui';
import { QueryOperationAction } from '../../../core/components/QueryOperationRow/QueryOperationAction';
import { t } from '../../../core/internationalization';
import { QUERY_LIBRARY_LOCAL_STORAGE_KEYS } from './QueryLibrary';
import { useQueryLibraryContext } from './QueryLibraryContext';
interface Props {
query: DataQuery;
}
export function SaveQueryButton({ query }: Props) {
const { openAddQueryModal } = useQueryLibraryContext();
const [showQueryLibraryBadgeButton, setShowQueryLibraryBadgeButton] = useLocalStorage(
QUERY_LIBRARY_LOCAL_STORAGE_KEYS.explore.newButton,
true
);
return showQueryLibraryBadgeButton ? (
<Badge
text={t('query-operation.header.save-to-query-library-new', 'New: Save to query library')}
icon="save"
color="blue"
onClick={() => {
openAddQueryModal(query);
setShowQueryLibraryBadgeButton(false);
}}
style={{ cursor: 'pointer' }}
/>
) : (
<QueryOperationAction
title={t('query-operation.header.save-to-query-library', 'Save to query library')}
icon="save"
onClick={() => {
openAddQueryModal(query);
}}
/>
);
}

View File

@ -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<QueryActionButtonProps>;

View File

@ -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<MetadataValue[]> {
return useAsync(async () => {
if (!(queryTemplates && userDataList)) {
return [];
}
const rowsPromises = queryTemplates.map(
async (queryTemplate: QueryTemplate, index: number): Promise<MetadataValue> => {
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]);
}

View File

@ -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<RichHistorySettings>) => {
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: <QueryLibrary activeDatasources={activeDatasources} />,
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 (
<TabbedContainer
tabs={tabs}

View File

@ -89,7 +89,7 @@ export function RichHistoryContainer(props: Props) {
}
}, [tracked, selectedTab]);
if (!richHistorySettings || !selectedTab) {
if (!richHistorySettings) {
return (
<span>
<Trans i18nKey="explore.rich-history-container.loading">Loading...</Trans>

View File

@ -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);

View File

@ -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): {
<Provider store={storeState}>
<GrafanaContext.Provider value={contextMock}>
<Router history={history}>
<QueriesDrawerContextProvider>
{options?.withAppChrome ? (
<KBarProvider>
<AppChrome>
<Route
path="/explore"
exact
render={(props) => <GrafanaRoute {...props} route={{ component: ExplorePage, path: '/explore' }} />}
/>
</AppChrome>
</KBarProvider>
) : (
<Route
path="/explore"
exact
render={(props) => <GrafanaRoute {...props} route={{ component: ExplorePage, path: '/explore' }} />}
/>
)}
</QueriesDrawerContextProvider>
<QueryLibraryContextProvider>
<QueriesDrawerContextProvider>
{options?.withAppChrome ? (
<KBarProvider>
<AppChrome>
<Route
path="/explore"
exact
render={(props) => (
<GrafanaRoute {...props} route={{ component: ExplorePage, path: '/explore' }} />
)}
/>
</AppChrome>
</KBarProvider>
) : (
<Route
path="/explore"
exact
render={(props) => <GrafanaRoute {...props} route={{ component: ExplorePage, path: '/explore' }} />}
/>
)}
</QueriesDrawerContextProvider>
</QueryLibraryContextProvider>
</Router>
</GrafanaContext.Provider>
</Provider>

View File

@ -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,
});
});

View File

@ -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' });

View File

@ -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<TQuery extends DataQuery> extends PureComponent<Prop
/>
)}
{this.renderExtraActions()}
{config.featureToggles.queryLibrary && <SaveQueryToQueryLibraryButton query={query} />}
<QueryOperationAction
title={t('query-operation.header.duplicate-query', 'Duplicate query')}
icon="copy"

View File

@ -18,6 +18,7 @@ import { AppChrome } from '../core/components/AppChrome/AppChrome';
import { AppNotificationList } from '../core/components/AppNotifications/AppNotificationList';
import { ModalsContextProvider } from '../core/context/ModalsContextProvider';
import { QueriesDrawerContextProvider } from '../features/explore/QueriesDrawer/QueriesDrawerContext';
import { QueryLibraryContextProvider } from '../features/explore/QueryLibrary/QueryLibraryContext';
type RouterWrapperProps = {
routes?: JSX.Element | false;
@ -30,22 +31,24 @@ export function RouterWrapper(props: RouterWrapperProps) {
<LocationServiceProvider service={locationService}>
<CompatRouter>
<QueriesDrawerContextProvider>
<ModalsContextProvider>
<AppChrome>
<AngularRoot />
<AppNotificationList />
<Stack gap={0} grow={1} direction="column">
{props.pageBanners.map((Banner, index) => (
<Banner key={index.toString()} />
<QueryLibraryContextProvider>
<ModalsContextProvider>
<AppChrome>
<AngularRoot />
<AppNotificationList />
<Stack gap={0} grow={1} direction="column">
{props.pageBanners.map((Banner, index) => (
<Banner key={index.toString()} />
))}
{props.routes}
</Stack>
{props.bodyRenderHooks.map((Hook, index) => (
<Hook key={index.toString()} />
))}
{props.routes}
</Stack>
{props.bodyRenderHooks.map((Hook, index) => (
<Hook key={index.toString()} />
))}
</AppChrome>
<ModalRoot />
</ModalsContextProvider>
</AppChrome>
<ModalRoot />
</ModalsContextProvider>
</QueryLibraryContextProvider>
</QueriesDrawerContextProvider>
</CompatRouter>
</LocationServiceProvider>

View File

@ -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"
},

View File

@ -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őđę"
},