diff --git a/public/app/features/explore/RichHistory/RichHistoryAddToLibrary.tsx b/public/app/features/explore/RichHistory/RichHistoryAddToLibrary.tsx
new file mode 100644
index 00000000000..89d5fde0aab
--- /dev/null
+++ b/public/app/features/explore/RichHistory/RichHistoryAddToLibrary.tsx
@@ -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 ? (
+
+ ) : undefined;
+};
diff --git a/public/app/features/explore/RichHistory/RichHistoryCard.tsx b/public/app/features/explore/RichHistory/RichHistoryCard.tsx
index 350cc43e886..a6496a6e908 100644
--- a/public/app/features/explore/RichHistory/RichHistoryCard.tsx
+++ b/public/app/features/explore/RichHistory/RichHistoryCard.tsx
@@ -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}
+ {!activeUpdateComment && }
{!activeUpdateComment && (
diff --git a/public/app/features/explore/spec/helper/assert.ts b/public/app/features/explore/spec/helper/assert.ts
index f68e477254d..9ed88e92283 100644
--- a/public/app/features/explore/spec/helper/assert.ts
+++ b/public/app/features/explore/spec/helper/assert.ts
@@ -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');
diff --git a/public/app/features/explore/spec/helper/interactions.ts b/public/app/features/explore/spec/helper/interactions.ts
index 19db9f932b5..fa63c6554a9 100644
--- a/public/app/features/explore/spec/helper/interactions.ts
+++ b/public/app/features/explore/spec/helper/interactions.ts
@@ -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 () => {
diff --git a/public/app/features/explore/spec/helper/setup.tsx b/public/app/features/explore/spec/helper/setup.tsx
index da69dba6881..c2aa3187aff 100644
--- a/public/app/features/explore/spec/helper/setup.tsx
+++ b/public/app/features/explore/spec/helper/setup.tsx
@@ -53,6 +53,7 @@ type SetupOptions = {
queryHistory?: { queryHistory: Array
>; totalCount: number };
urlParams?: ExploreQueryParams;
prevUsedDatasource?: { orgId: number; datasource: string };
+ failAddToLibrary?: boolean;
};
type TearDownOptions = {
diff --git a/public/app/features/explore/spec/queryLibrary.test.tsx b/public/app/features/explore/spec/queryLibrary.test.tsx
index 33badc3fd61..4b0c0ba2b92 100644
--- a/public/app/features/explore/spec/queryLibrary.test.tsx
+++ b/public/app/features/explore/spec/queryLibrary.test.tsx
@@ -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);
+ });
});
diff --git a/public/app/features/query-library/api/factory.ts b/public/app/features/query-library/api/factory.ts
index 083680e8855..bff1973010f 100644
--- a/public/app/features/query-library/api/factory.ts
+++ b/public/app/features/query-library/api/factory.ts
@@ -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({
- query: () => undefined,
+ query: () => ({}),
transformResponse: convertDataQueryResponseToQueryTemplates,
+ providesTags: ['QueryTemplatesList'],
+ }),
+ addQueryTemplate: builder.mutation({
+ query: (addQueryTemplateCommand) => ({
+ method: 'POST',
+ data: convertAddQueryTemplateCommandToDataQuerySpec(addQueryTemplateCommand),
+ }),
+ invalidatesTags: ['QueryTemplatesList'],
}),
}),
reducerPath: 'queryLibrary',
diff --git a/public/app/features/query-library/api/mappers.ts b/public/app/features/query-library/api/mappers.ts
index 9dce0c19bfa..3a6e3bf836b 100644
--- a/public/app/features/query-library/api/mappers.ts
+++ b/public/app/features/query-library/api/mappers.ts
@@ -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,
+ })),
+ },
+ };
+};
diff --git a/public/app/features/query-library/api/query.ts b/public/app/features/query-library/api/query.ts
index b3f39234579..80464e1625b 100644
--- a/public/app/features/query-library/api/query.ts
+++ b/public/app/features/query-library/api/query.ts
@@ -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 = async () => {
+export const baseQuery: BaseQueryFn, DataQuerySpecResponse, Error> = async (
+ requestOptions
+) => {
try {
const responseObservable = getBackendSrv().fetch({
url: BASE_URL,
showErrorAlert: true,
+ method: requestOptions.method || 'GET',
+ data: requestOptions.data,
});
return await lastValueFrom(responseObservable);
} catch (error) {
diff --git a/public/app/features/query-library/index.ts b/public/app/features/query-library/index.ts
index b9b339b096b..3c2e74e1f81 100644
--- a/public/app/features/query-library/index.ts
+++ b/public/app/features/query-library/index.ts
@@ -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,
diff --git a/public/app/features/query-library/types.ts b/public/app/features/query-library/types.ts
index 0629b327d1a..030d292c901 100644
--- a/public/app/features/query-library/types.ts
+++ b/public/app/features/query-library/types.ts
@@ -6,3 +6,8 @@ export type QueryTemplate = {
targets: DataQuery[];
createdAtTimestamp: number;
};
+
+export type AddQueryTemplateCommand = {
+ title: string;
+ targets: DataQuery[];
+};
diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json
index 088ac95e07c..bb7a7713ae4 100644
--- a/public/locales/en-US/grafana.json
+++ b/public/locales/en-US/grafana.json
@@ -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",
diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json
index 68d631a4efe..59fc5847e61 100644
--- a/public/locales/pseudo-LOCALE/grafana.json
+++ b/public/locales/pseudo-LOCALE/grafana.json
@@ -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",