mirror of https://github.com/grafana/grafana.git
Explore: Add a query template to query library from query history (#88047)
* Add basic button for adding a query template * Add hook to create a template * Handle notifications * Add tags to invalidate cache * Generate translations * Updates types * Add tests * Simplify code * Add a better default title
This commit is contained in:
parent
b8c9ae0eb7
commit
84ef99c1dc
|
|
@ -0,0 +1,45 @@
|
|||
import { t } from 'i18next';
|
||||
import React from 'react';
|
||||
|
||||
import { AppEvents, dateTime } from '@grafana/data';
|
||||
import { getAppEvents } from '@grafana/runtime';
|
||||
import { DataQuery } from '@grafana/schema';
|
||||
import { Button } from '@grafana/ui';
|
||||
import { isQueryLibraryEnabled, useAddQueryTemplateMutation } from 'app/features/query-library';
|
||||
import { AddQueryTemplateCommand } from 'app/features/query-library/types';
|
||||
|
||||
type Props = {
|
||||
query: DataQuery;
|
||||
};
|
||||
|
||||
export const RichHistoryAddToLibrary = ({ query }: Props) => {
|
||||
const [addQueryTemplate, { isSuccess }] = useAddQueryTemplateMutation();
|
||||
|
||||
const handleAddQueryTemplate = async (addQueryTemplateCommand: AddQueryTemplateCommand) => {
|
||||
const result = await addQueryTemplate(addQueryTemplateCommand);
|
||||
if (!result.error) {
|
||||
getAppEvents().publish({
|
||||
type: AppEvents.alertSuccess.name,
|
||||
payload: [
|
||||
t('explore.rich-history-card.query-template-added', 'Query template successfully added to the library'),
|
||||
],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const buttonLabel = t('explore.rich-history-card.add-to-library', 'Add to library');
|
||||
|
||||
return isQueryLibraryEnabled() && !isSuccess ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
aria-label={buttonLabel}
|
||||
onClick={() => {
|
||||
const timestamp = dateTime().toISOString();
|
||||
const temporaryDefaultTitle = `Imported from Explore - ${timestamp}`;
|
||||
handleAddQueryTemplate({ title: temporaryDefaultTitle, targets: [query] });
|
||||
}}
|
||||
>
|
||||
{buttonLabel}
|
||||
</Button>
|
||||
) : undefined;
|
||||
};
|
||||
|
|
@ -21,6 +21,8 @@ import { RichHistoryQuery } from 'app/types/explore';
|
|||
|
||||
import ExploreRunQueryButton from '../ExploreRunQueryButton';
|
||||
|
||||
import { RichHistoryAddToLibrary } from './RichHistoryAddToLibrary';
|
||||
|
||||
const mapDispatchToProps = {
|
||||
changeDatasource,
|
||||
deleteHistoryItem,
|
||||
|
|
@ -342,6 +344,7 @@ export function RichHistoryCard(props: Props) {
|
|||
)}
|
||||
{activeUpdateComment && updateComment}
|
||||
</div>
|
||||
{!activeUpdateComment && <RichHistoryAddToLibrary query={queryHistoryItem?.queries[0]} />}
|
||||
{!activeUpdateComment && (
|
||||
<div className={styles.runButton}>
|
||||
<ExploreRunQueryButton queries={queryHistoryItem.queries} rootDatasourceUid={cardRootDatasource?.uid} />
|
||||
|
|
|
|||
|
|
@ -33,6 +33,19 @@ export const assertQueryLibraryTemplateExists = async (datasource: string, descr
|
|||
});
|
||||
};
|
||||
|
||||
export const assertAddToQueryLibraryButtonExists = async (value = true) => {
|
||||
await waitFor(() => {
|
||||
// ensures buttons for the card have been loaded to avoid false positives
|
||||
expect(withinQueryHistory().getByRole('button', { name: /run query/i })).toBeInTheDocument();
|
||||
|
||||
if (value) {
|
||||
expect(withinQueryHistory().queryByRole('button', { name: /add to library/i })).toBeInTheDocument();
|
||||
} else {
|
||||
expect(withinQueryHistory().queryByRole('button', { name: /add to library/i })).not.toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const assertQueryHistoryIsEmpty = async () => {
|
||||
const selector = withinQueryHistory();
|
||||
const queryTexts = selector.queryAllByLabelText('Query text');
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { screen, within } from '@testing-library/react';
|
||||
import { screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
|
|
@ -36,6 +36,23 @@ export const openQueryLibrary = async () => {
|
|||
const explore = withinExplore('left');
|
||||
const button = explore.getByRole('button', { name: 'Query library' });
|
||||
await userEvent.click(button);
|
||||
await waitFor(async () => {
|
||||
screen.getByRole('tab', {
|
||||
name: /tab query library/i,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const switchToQueryHistory = async () => {
|
||||
const tab = screen.getByRole('tab', {
|
||||
name: /tab 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);
|
||||
};
|
||||
|
||||
export const closeQueryHistory = async () => {
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ type SetupOptions = {
|
|||
queryHistory?: { queryHistory: Array<Partial<RichHistoryRemoteStorageDTO>>; totalCount: number };
|
||||
urlParams?: ExploreQueryParams;
|
||||
prevUsedDatasource?: { orgId: number; datasource: string };
|
||||
failAddToLibrary?: boolean;
|
||||
};
|
||||
|
||||
type TearDownOptions = {
|
||||
|
|
|
|||
|
|
@ -3,15 +3,30 @@ import { Props } from 'react-virtualized-auto-sizer';
|
|||
|
||||
import { EventBusSrv } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { DataQuery } from '@grafana/schema/dist/esm/veneer/common.types';
|
||||
|
||||
import { silenceConsoleOutput } from '../../../../test/core/utils/silenceConsoleOutput';
|
||||
|
||||
import { assertQueryLibraryTemplateExists } from './helper/assert';
|
||||
import { openQueryLibrary } from './helper/interactions';
|
||||
import {
|
||||
assertAddToQueryLibraryButtonExists,
|
||||
assertQueryHistory,
|
||||
assertQueryLibraryTemplateExists,
|
||||
} from './helper/assert';
|
||||
import {
|
||||
addQueryHistoryToQueryLibrary,
|
||||
openQueryHistory,
|
||||
openQueryLibrary,
|
||||
switchToQueryHistory,
|
||||
} from './helper/interactions';
|
||||
import { setupExplore, waitForExplore } from './helper/setup';
|
||||
|
||||
const reportInteractionMock = jest.fn();
|
||||
const testEventBus = new EventBusSrv();
|
||||
testEventBus.publish = jest.fn();
|
||||
|
||||
interface MockQuery extends DataQuery {
|
||||
expr: string;
|
||||
}
|
||||
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
...jest.requireActual('@grafana/runtime'),
|
||||
|
|
@ -55,22 +70,76 @@ jest.mock('react-virtualized-auto-sizer', () => {
|
|||
};
|
||||
});
|
||||
|
||||
function setupQueryLibrary() {
|
||||
const mockQuery: MockQuery = { refId: 'TEST', expr: 'TEST' };
|
||||
setupExplore({
|
||||
queryHistory: {
|
||||
queryHistory: [{ datasourceUid: 'loki', queries: [mockQuery] }],
|
||||
totalCount: 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let previousQueryLibraryEnabled: boolean | undefined;
|
||||
let previousQueryHistoryEnabled: boolean;
|
||||
|
||||
describe('QueryLibrary', () => {
|
||||
silenceConsoleOutput();
|
||||
|
||||
beforeAll(() => {
|
||||
previousQueryLibraryEnabled = config.featureToggles.queryLibrary;
|
||||
previousQueryHistoryEnabled = config.queryHistoryEnabled;
|
||||
|
||||
config.featureToggles.queryLibrary = true;
|
||||
config.queryHistoryEnabled = true;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
config.featureToggles.queryLibrary = false;
|
||||
config.featureToggles.queryLibrary = previousQueryLibraryEnabled;
|
||||
config.queryHistoryEnabled = previousQueryHistoryEnabled;
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('Load query templates', async () => {
|
||||
setupExplore();
|
||||
setupQueryLibrary();
|
||||
await waitForExplore();
|
||||
await openQueryLibrary();
|
||||
await assertQueryLibraryTemplateExists('loki', 'Loki Query Template');
|
||||
await assertQueryLibraryTemplateExists('elastic', 'Elastic Query Template');
|
||||
});
|
||||
|
||||
it('Shows add to query library button only when the toggle is enabled', async () => {
|
||||
setupQueryLibrary();
|
||||
await waitForExplore();
|
||||
await openQueryLibrary();
|
||||
await switchToQueryHistory();
|
||||
await assertQueryHistory(['{"expr":"TEST"}']);
|
||||
await assertAddToQueryLibraryButtonExists(true);
|
||||
});
|
||||
|
||||
it('Does not show the query library button when the toggle is disabled', async () => {
|
||||
config.featureToggles.queryLibrary = false;
|
||||
setupQueryLibrary();
|
||||
await waitForExplore();
|
||||
await openQueryHistory();
|
||||
await assertQueryHistory(['{"expr":"TEST"}']);
|
||||
await assertAddToQueryLibraryButtonExists(false);
|
||||
config.featureToggles.queryLibrary = true;
|
||||
});
|
||||
|
||||
it('Shows a notification when a template is added and hides the add button', async () => {
|
||||
setupQueryLibrary();
|
||||
await waitForExplore();
|
||||
await openQueryLibrary();
|
||||
await switchToQueryHistory();
|
||||
await assertQueryHistory(['{"expr":"TEST"}']);
|
||||
await addQueryHistoryToQueryLibrary();
|
||||
expect(testEventBus.publish).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'alert-success',
|
||||
payload: ['Query template successfully added to the library'],
|
||||
})
|
||||
);
|
||||
await assertAddToQueryLibraryButtonExists(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,16 +1,25 @@
|
|||
import { createApi } from '@reduxjs/toolkit/query/react';
|
||||
|
||||
import { QueryTemplate } from '../types';
|
||||
import { AddQueryTemplateCommand, QueryTemplate } from '../types';
|
||||
|
||||
import { convertDataQueryResponseToQueryTemplates } from './mappers';
|
||||
import { convertAddQueryTemplateCommandToDataQuerySpec, convertDataQueryResponseToQueryTemplates } from './mappers';
|
||||
import { baseQuery } from './query';
|
||||
|
||||
export const queryLibraryApi = createApi({
|
||||
baseQuery,
|
||||
tagTypes: ['QueryTemplatesList'],
|
||||
endpoints: (builder) => ({
|
||||
allQueryTemplates: builder.query<QueryTemplate[], void>({
|
||||
query: () => undefined,
|
||||
query: () => ({}),
|
||||
transformResponse: convertDataQueryResponseToQueryTemplates,
|
||||
providesTags: ['QueryTemplatesList'],
|
||||
}),
|
||||
addQueryTemplate: builder.mutation<QueryTemplate, AddQueryTemplateCommand>({
|
||||
query: (addQueryTemplateCommand) => ({
|
||||
method: 'POST',
|
||||
data: convertAddQueryTemplateCommandToDataQuerySpec(addQueryTemplateCommand),
|
||||
}),
|
||||
invalidatesTags: ['QueryTemplatesList'],
|
||||
}),
|
||||
}),
|
||||
reducerPath: 'queryLibrary',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { QueryTemplate } from '../types';
|
||||
import { AddQueryTemplateCommand, QueryTemplate } from '../types';
|
||||
|
||||
import { DataQuerySpecResponse, DataQueryTarget } from './types';
|
||||
import { API_VERSION, QueryTemplateKinds } from './query';
|
||||
import { DataQuerySpec, DataQuerySpecResponse, DataQueryTarget } from './types';
|
||||
|
||||
export const convertDataQueryResponseToQueryTemplates = (result: DataQuerySpecResponse): QueryTemplate[] => {
|
||||
if (!result.items) {
|
||||
|
|
@ -15,3 +16,24 @@ export const convertDataQueryResponseToQueryTemplates = (result: DataQuerySpecRe
|
|||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const convertAddQueryTemplateCommandToDataQuerySpec = (
|
||||
addQueryTemplateCommand: AddQueryTemplateCommand
|
||||
): DataQuerySpec => {
|
||||
const { title, targets } = addQueryTemplateCommand;
|
||||
return {
|
||||
apiVersion: API_VERSION,
|
||||
kind: QueryTemplateKinds.QueryTemplate,
|
||||
metadata: {
|
||||
generateName: 'A' + title,
|
||||
},
|
||||
spec: {
|
||||
title: title,
|
||||
vars: [], // TODO: Detect variables in #86838
|
||||
targets: targets.map((dataQuery) => ({
|
||||
variables: {},
|
||||
properties: dataQuery,
|
||||
})),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,25 +1,41 @@
|
|||
import { BaseQueryFn } from '@reduxjs/toolkit/query/react';
|
||||
import { lastValueFrom } from 'rxjs';
|
||||
|
||||
import { getBackendSrv, isFetchError } from '@grafana/runtime/src/services/backendSrv';
|
||||
import { BackendSrvRequest, getBackendSrv, isFetchError } from '@grafana/runtime/src/services/backendSrv';
|
||||
|
||||
import { DataQuerySpecResponse } from './types';
|
||||
|
||||
/**
|
||||
* @alpha
|
||||
*/
|
||||
export const API_VERSION = 'peakq.grafana.app/v0alpha1';
|
||||
|
||||
/**
|
||||
* @alpha
|
||||
*/
|
||||
export enum QueryTemplateKinds {
|
||||
QueryTemplate = 'QueryTemplate',
|
||||
}
|
||||
|
||||
/**
|
||||
* Query Library is an experimental feature. API (including the URL path) will likely change.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export const BASE_URL = '/apis/peakq.grafana.app/v0alpha1/namespaces/default/querytemplates/';
|
||||
export const BASE_URL = `/apis/${API_VERSION}/namespaces/default/querytemplates/`;
|
||||
|
||||
/**
|
||||
* TODO: similar code is duplicated in many places. To be unified in #86960
|
||||
*/
|
||||
export const baseQuery: BaseQueryFn<void, DataQuerySpecResponse, Error> = async () => {
|
||||
export const baseQuery: BaseQueryFn<Pick<BackendSrvRequest, 'data' | 'method'>, DataQuerySpecResponse, Error> = async (
|
||||
requestOptions
|
||||
) => {
|
||||
try {
|
||||
const responseObservable = getBackendSrv().fetch<DataQuerySpecResponse>({
|
||||
url: BASE_URL,
|
||||
showErrorAlert: true,
|
||||
method: requestOptions.method || 'GET',
|
||||
data: requestOptions.data,
|
||||
});
|
||||
return await lastValueFrom(responseObservable);
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -7,10 +7,16 @@
|
|||
* @alpha
|
||||
*/
|
||||
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
import { queryLibraryApi } from './api/factory';
|
||||
import { mockData } from './api/mocks';
|
||||
|
||||
export const { useAllQueryTemplatesQuery } = queryLibraryApi;
|
||||
export const { useAllQueryTemplatesQuery, useAddQueryTemplateMutation } = queryLibraryApi;
|
||||
|
||||
export function isQueryLibraryEnabled() {
|
||||
return config.featureToggles.queryLibrary;
|
||||
}
|
||||
|
||||
export const QueryLibraryMocks = {
|
||||
data: mockData,
|
||||
|
|
|
|||
|
|
@ -6,3 +6,8 @@ export type QueryTemplate = {
|
|||
targets: DataQuery[];
|
||||
createdAtTimestamp: number;
|
||||
};
|
||||
|
||||
export type AddQueryTemplateCommand = {
|
||||
title: string;
|
||||
targets: DataQuery[];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -479,6 +479,7 @@
|
|||
"rich-history-card": {
|
||||
"add-comment-form": "Add comment form",
|
||||
"add-comment-tooltip": "Add comment",
|
||||
"add-to-library": "Add to library",
|
||||
"cancel": "Cancel",
|
||||
"confirm-delete": "Delete",
|
||||
"copy-query-tooltip": "Copy query to clipboard",
|
||||
|
|
@ -493,6 +494,7 @@
|
|||
"edit-comment-tooltip": "Edit comment",
|
||||
"optional-description": "An optional description of what the query does.",
|
||||
"query-comment-label": "Query comment",
|
||||
"query-template-added": "Query template successfully added to the library",
|
||||
"query-text-label": "Query text",
|
||||
"save-comment": "Save comment",
|
||||
"star-query-tooltip": "Star query",
|
||||
|
|
|
|||
|
|
@ -479,6 +479,7 @@
|
|||
"rich-history-card": {
|
||||
"add-comment-form": "Åđđ čőmmęʼnŧ ƒőřm",
|
||||
"add-comment-tooltip": "Åđđ čőmmęʼnŧ",
|
||||
"add-to-library": "Åđđ ŧő ľįþřäřy",
|
||||
"cancel": "Cäʼnčęľ",
|
||||
"confirm-delete": "Đęľęŧę",
|
||||
"copy-query-tooltip": "Cőpy qūęřy ŧő čľįpþőäřđ",
|
||||
|
|
@ -493,6 +494,7 @@
|
|||
"edit-comment-tooltip": "Ēđįŧ čőmmęʼnŧ",
|
||||
"optional-description": "Åʼn őpŧįőʼnäľ đęşčřįpŧįőʼn őƒ ŵĥäŧ ŧĥę qūęřy đőęş.",
|
||||
"query-comment-label": "Qūęřy čőmmęʼnŧ",
|
||||
"query-template-added": "Qūęřy ŧęmpľäŧę şūččęşşƒūľľy äđđęđ ŧő ŧĥę ľįþřäřy",
|
||||
"query-text-label": "Qūęřy ŧęχŧ",
|
||||
"save-comment": "Ŝävę čőmmęʼnŧ",
|
||||
"star-query-tooltip": "Ŝŧäř qūęřy",
|
||||
|
|
|
|||
Loading…
Reference in New Issue