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