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:
Piotr Jamróz 2024-05-23 10:38:17 +02:00 committed by GitHub
parent b8c9ae0eb7
commit 84ef99c1dc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 224 additions and 14 deletions

View File

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

View File

@ -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} />

View File

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

View File

@ -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 () => {

View File

@ -53,6 +53,7 @@ type SetupOptions = {
queryHistory?: { queryHistory: Array<Partial<RichHistoryRemoteStorageDTO>>; totalCount: number };
urlParams?: ExploreQueryParams;
prevUsedDatasource?: { orgId: number; datasource: string };
failAddToLibrary?: boolean;
};
type TearDownOptions = {

View File

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

View File

@ -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',

View File

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

View File

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

View File

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

View File

@ -6,3 +6,8 @@ export type QueryTemplate = {
targets: DataQuery[];
createdAtTimestamp: number;
};
export type AddQueryTemplateCommand = {
title: string;
targets: DataQuery[];
};

View File

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

View File

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