mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
QueryLibrary: Make query library available outside of Explore (#99319)
This commit is contained in:
parent
a92c8145f1
commit
86a68627dd
@ -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"]
|
||||
|
@ -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>;
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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(
|
||||
|
@ -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,
|
||||
|
@ -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>
|
||||
|
@ -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,
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
118
public/app/features/explore/QueryLibrary/QueryLibraryContext.tsx
Normal file
118
public/app/features/explore/QueryLibrary/QueryLibraryContext.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
@ -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}
|
||||
|
@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
@ -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),
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
|
44
public/app/features/explore/QueryLibrary/SaveQueryButton.tsx
Normal file
44
public/app/features/explore/QueryLibrary/SaveQueryButton.tsx
Normal 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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
11
public/app/features/explore/QueryLibrary/types.ts
Normal file
11
public/app/features/explore/QueryLibrary/types.ts
Normal 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>;
|
106
public/app/features/explore/QueryLibrary/utils/dataFetching.ts
Normal file
106
public/app/features/explore/QueryLibrary/utils/dataFetching.ts
Normal 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]);
|
||||
}
|
@ -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}
|
||||
|
@ -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>
|
||||
|
@ -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);
|
||||
|
@ -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>
|
||||
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -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' });
|
||||
|
@ -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"
|
||||
|
@ -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>
|
||||
|
@ -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"
|
||||
},
|
||||
|
@ -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őđę"
|
||||
},
|
||||
|
Loading…
Reference in New Issue
Block a user