QueryLibrary: Move to enterprise (#100133)

This commit is contained in:
Andrej Ocenas 2025-02-07 11:09:51 +01:00 committed by GitHub
parent ccb9cab131
commit 4b9fee61a8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 79 additions and 1771 deletions

View File

@ -4701,27 +4701,6 @@ exports[`better eslint`] = {
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"]
],
"public/app/features/explore/QueryLibrary/QueryLibraryExpmInfo.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"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "2"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "3"]
],
"public/app/features/explore/QueryLibrary/QueryTemplateForm.tsx:5381": [
[0, 0, 0, "\'@grafana/ui/src/components/Input/Input\' import is restricted from being used by a pattern. Import from the public export instead.", "0"]
],
"public/app/features/explore/QueryLibrary/QueryTemplatesList.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"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "2"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "3"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "4"],
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "5"]
],
"public/app/features/explore/QueryLibrary/QueryTemplatesTable/QueryDescriptionCell.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. Wrap text with <Trans />", "1"]
],
"public/app/features/explore/RichHistory/RichHistoryStarredTab.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
],

View File

@ -1,5 +1,5 @@
import { Action, KBarProvider } from 'kbar';
import { Component, ComponentType, Fragment } from 'react';
import { Component, ComponentType, Fragment, ReactNode } from 'react';
import CacheProvider from 'react-inlinesvg/provider';
import { Provider } from 'react-redux';
import { Route, Routes } from 'react-router-dom-v5-compat';
@ -37,6 +37,11 @@ interface AppWrapperState {
/** Used by enterprise */
let bodyRenderHooks: ComponentType[] = [];
let pageBanners: ComponentType[] = [];
const enterpriseProviders: Array<ComponentType<{ children: ReactNode }>> = [];
export function addEnterpriseProviders(provider: ComponentType<{ children: ReactNode }>) {
enterpriseProviders.push(provider);
}
export function addBodyRenderHook(fn: ComponentType) {
bodyRenderHooks.push(fn);
@ -100,6 +105,7 @@ export class AppWrapper extends Component<AppWrapperProps, AppWrapperState> {
routes: ready && this.renderRoutes(),
pageBanners,
bodyRenderHooks,
providers: enterpriseProviders,
};
const MaybeTimeRangeProvider = config.featureToggles.timeRangeProvider ? TimeRangeProvider : Fragment;

View File

@ -309,7 +309,7 @@ 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();
const { openDrawer: openQueryLibraryDrawer, queryLibraryEnabled } = useQueryLibraryContext();
if (!datasource || !dsSettings || !data) {
return null;
@ -355,7 +355,7 @@ export function PanelDataQueriesTabRendered({ model }: SceneComponentProps<Panel
>
Add query
</Button>
{config.featureToggles.queryLibrary && (
{queryLibraryEnabled && (
<Button
icon="plus"
onClick={() => {

View File

@ -5,7 +5,7 @@ import { shallowEqual } from 'react-redux';
import { DataSourceInstanceSettings, RawTimeRange, GrafanaTheme2 } from '@grafana/data';
import { Components } from '@grafana/e2e-selectors';
import { config, reportInteraction } from '@grafana/runtime';
import { reportInteraction } from '@grafana/runtime';
import {
defaultIntervals,
PageToolbar,
@ -29,6 +29,7 @@ import { ExploreTimeControls } from './ExploreTimeControls';
import { LiveTailButton } from './LiveTailButton';
import { useQueriesDrawerContext } from './QueriesDrawer/QueriesDrawerContext';
import { QueriesDrawerDropdown } from './QueriesDrawer/QueriesDrawerDropdown';
import { useQueryLibraryContext } from './QueryLibrary/QueryLibraryContext';
import { ShortLinkButtonMenu } from './ShortLinkButtonMenu';
import { ToolbarExtensionPoint } from './extensions/ToolbarExtensionPoint';
import { changeDatasource } from './state/datasource';
@ -92,6 +93,7 @@ export function ExploreToolbar({ exploreId, onChangeTime, onContentOutlineToogle
const isCorrelationsEditorMode = correlationDetails?.editorMode || false;
const isLeftPane = useSelector(isLeftPaneSelector(exploreId));
const { drawerOpened, setDrawerOpened } = useQueriesDrawerContext();
const { queryLibraryEnabled } = useQueryLibraryContext();
const shouldRotateSplitIcon = useMemo(
() => (isLeftPane && isLargerPane) || (!isLeftPane && !isLargerPane),
@ -206,7 +208,7 @@ export function ExploreToolbar({ exploreId, onChangeTime, onContentOutlineToogle
const navBarActions = [<ShortLinkButtonMenu key="share" />];
if (config.featureToggles.queryLibrary) {
if (queryLibraryEnabled) {
navBarActions.unshift(<QueriesDrawerDropdown key="queryLibrary" variant="full" />);
} else {
navBarActions.unshift(

View File

@ -1,7 +1,6 @@
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';
@ -9,7 +8,6 @@ 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';
@ -39,6 +37,7 @@ export function QueriesDrawerDropdown({ variant }: Props) {
openDrawer: openQueryLibraryDrawer,
closeDrawer: closeQueryLibraryDrawer,
isDrawerOpen: isQueryLibraryDrawerOpen,
queryLibraryEnabled,
} = useQueryLibraryContext();
const [queryOption, setQueryOption] = useState<'library' | 'history'>('library');
@ -48,7 +47,7 @@ export function QueriesDrawerDropdown({ variant }: Props) {
const styles = useStyles2(getStyles);
// In case query library is not enabled we show only simple button for query history in the parent.
if (!config.featureToggles.queryLibrary) {
if (!queryLibraryEnabled) {
return undefined;
}
@ -73,7 +72,6 @@ export function QueriesDrawerDropdown({ variant }: Props) {
openQueryLibraryDrawer(activeDatasources, ExploreRunQueryButtonWrapper);
}
queryLibraryTrackToggle(!isQueryLibraryDrawerOpen);
}
const menu = (

View File

@ -1,36 +0,0 @@
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>
);
}

View File

@ -1,36 +0,0 @@
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 = {
explore: {
notifyUserAboutQueryLibrary: 'grafana.explore.query-library.notifyUserAboutQueryLibrary',
newButton: 'grafana.explore.query-library.newButton',
},
};
export function QueryLibrary({ activeDatasources, queryActionButton }: QueryLibraryProps) {
const [notifyUserAboutQueryLibrary, setNotifyUserAboutQueryLibrary] = useLocalStorage(
QUERY_LIBRARY_LOCAL_STORAGE_KEYS.explore.notifyUserAboutQueryLibrary,
true
);
return (
<>
<QueryLibraryExpmInfo
isOpen={notifyUserAboutQueryLibrary || false}
onDismiss={() => setNotifyUserAboutQueryLibrary(false)}
/>
<QueryTemplatesList activeDatasources={activeDatasources} queryActionButton={queryActionButton} />
</>
);
}

View File

@ -1,56 +0,0 @@
import { reportInteraction } from '@grafana/runtime';
const QUERY_LIBRARY_EXPLORE_EVENT = 'query_library_explore_clicked';
export function queryLibraryTrackToggle(open: boolean) {
reportInteraction(QUERY_LIBRARY_EXPLORE_EVENT, {
item: 'query_library_toggle',
type: open ? 'open' : 'close',
});
}
export function queryLibraryTrackAddFromQueryHistory(datasourceType: string) {
reportInteraction(QUERY_LIBRARY_EXPLORE_EVENT, {
item: 'add_query_from_query_history',
type: datasourceType,
});
}
export function queryLibraryTrackAddFromQueryHistoryAddModalShown() {
reportInteraction(QUERY_LIBRARY_EXPLORE_EVENT, {
item: 'add_query_modal_from_query_history',
type: 'open',
});
}
export function queryLibraryTrackAddFromQueryRow(datasourceType: string) {
reportInteraction(QUERY_LIBRARY_EXPLORE_EVENT, {
item: 'add_query_from_query_row',
type: datasourceType,
});
}
export function queryLibaryTrackDeleteQuery() {
reportInteraction(QUERY_LIBRARY_EXPLORE_EVENT, {
item: 'delete_query',
});
}
export function queryLibraryTrackRunQuery(datasourceType: string) {
reportInteraction(QUERY_LIBRARY_EXPLORE_EVENT, {
item: 'run_query',
type: datasourceType,
});
}
export function queryLibraryTrackAddOrEditDescription() {
reportInteraction(QUERY_LIBRARY_EXPLORE_EVENT, {
item: 'add_or_edit_description',
});
}
export function queryLibraryTrackFilterDatasource() {
reportInteraction(QUERY_LIBRARY_EXPLORE_EVENT, {
item: 'filter_datasource',
});
}

View File

@ -1,79 +0,0 @@
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();
});
});
});

View File

@ -1,10 +1,8 @@
import { PropsWithChildren, useState, createContext, useContext, useCallback, useMemo } from 'react';
import { createContext, ReactNode, useContext } from 'react';
import { DataQuery } from '@grafana/schema';
import { AddToQueryLibraryModal } from './AddToQueryLibraryModal';
import { QueryLibraryDrawer } from './QueryLibraryDrawer';
import { QueryActionButton, QueryActionButtonProps } from './types';
import { QueryActionButton } from './types';
/**
* Context with state and action to interact with Query Library. The Query Library feature consists of a drawer
@ -19,17 +17,33 @@ export type QueryLibraryContextType = {
* @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.
* @param options.context Used for tracking. Should identify the context this is called from, like 'explore' or
* 'dashboard'.
*/
openDrawer: (datasourceFilters: string[], queryActionButton: QueryActionButton) => void;
openDrawer: (
datasourceFilters: string[],
queryActionButton: QueryActionButton,
options?: { context?: string }
) => void;
closeDrawer: () => void;
isDrawerOpen: boolean;
/**
* Opens a modal for adding a query to the library.
* @param query Query to be saved
* @param options.onSave Callback that will be called after the query is saved.
* @param options.context Used for tracking. Should identify the context this is called from, like 'explore' or
* 'dashboard'.
*/
openAddQueryModal: (query: DataQuery, options?: { onSave?: () => void; context?: string }) => void;
closeAddQueryModal: () => void;
/**
* Returns a predefined small button that can be used to save a query to the library.
* @param query
*/
openAddQueryModal: (query: DataQuery) => void;
closeAddQueryModal: () => void;
renderSaveQueryButton: (query: DataQuery) => ReactNode;
queryLibraryEnabled: boolean;
};
export const QueryLibraryContext = createContext<QueryLibraryContextType>({
@ -39,80 +53,14 @@ export const QueryLibraryContext = createContext<QueryLibraryContextType>({
openAddQueryModal: () => {},
closeAddQueryModal: () => {},
renderSaveQueryButton: () => {
return null;
},
queryLibraryEnabled: false,
});
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>
);
}

View File

@ -1,53 +0,0 @@
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/api';
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>
)
);
}

View File

@ -1,23 +0,0 @@
import { Alert, Modal } from '@grafana/ui';
interface Props {
isOpen: boolean;
onDismiss: () => void;
}
export function QueryLibraryExpmInfo({ isOpen, onDismiss }: Props) {
return (
<Modal title="Query Library" isOpen={isOpen} onDismiss={onDismiss}>
<Alert
severity="info"
title="Query library is in the experimental mode. It is a place where you can save your queries and share them with
your team. Once you save a query, it will be available for the whole organization to use."
/>
<Alert severity="info" title=" Currently we are limiting the number of saved queries per organization to 1000." />
<Alert
severity="warning"
title="Although it's unlikely, some data loss may occur during the experimental phase."
/>
</Modal>
);
}

View File

@ -1,180 +0,0 @@
import { useForm } from 'react-hook-form';
import { useAsync } from 'react-use';
import { AppEvents, dateTime } from '@grafana/data';
import { DataSourcePicker, getAppEvents, getDataSourceSrv } from '@grafana/runtime';
import { DataQuery } from '@grafana/schema';
import { Button, InlineSwitch, Modal, RadioButtonGroup, TextArea } from '@grafana/ui';
import { Field } from '@grafana/ui/';
import { Input } from '@grafana/ui/src/components/Input/Input';
import { Trans, t } from 'app/core/internationalization';
import { getQueryDisplayText } from 'app/core/utils/richHistory';
import { useCreateQueryTemplateMutation, useUpdateQueryTemplateMutation } from 'app/features/query-library';
import { AddQueryTemplateCommand, EditQueryTemplateCommand } from 'app/features/query-library/types';
import { convertAddQueryTemplateCommandToDataQuerySpec } from '../../query-library/api/mappers';
import { useDatasource } from '../QueryLibrary/utils/useDatasource';
import { QueryTemplateRow } from './QueryTemplatesTable/types';
type Props = {
onCancel: () => void;
onSave: (isSuccess: boolean) => void;
queryToAdd?: DataQuery;
templateData?: QueryTemplateRow;
};
export type QueryDetails = {
description: string;
};
const getInstuctions = (isAdd: boolean) => {
return isAdd
? t(
'explore.query-template-modal.add-info',
`You're about to save this query. Once saved, you can easily access it in the Query Library tab for future use and reference.`
)
: t(
'explore.query-template-modal.edit-info',
`You're about to edit this query. Once saved, you can easily access it in the Query Library tab for future use and reference.`
);
};
export const QueryTemplateForm = ({ onCancel, onSave, queryToAdd, templateData }: Props) => {
const { register, handleSubmit } = useForm<QueryDetails>({
defaultValues: {
description: templateData?.description,
},
});
const [addQueryTemplate] = useCreateQueryTemplateMutation();
const [editQueryTemplate] = useUpdateQueryTemplateMutation();
const datasource = useDatasource(queryToAdd?.datasource);
// this is an array to support multi query templates sometime in the future
const queries =
queryToAdd !== undefined ? [queryToAdd] : templateData?.query !== undefined ? [templateData?.query] : [];
const handleAddQueryTemplate = async (addQueryTemplateCommand: AddQueryTemplateCommand) => {
return addQueryTemplate({
queryTemplate: convertAddQueryTemplateCommandToDataQuerySpec(addQueryTemplateCommand),
})
.unwrap()
.then(() => {
getAppEvents().publish({
type: AppEvents.alertSuccess.name,
payload: [t('explore.query-library.query-template-added', 'Query successfully saved to the library')],
});
return true;
})
.catch(() => {
getAppEvents().publish({
type: AppEvents.alertError.name,
payload: [
t('explore.query-library.query-template-add-error', 'Error attempting to save this query to the library'),
],
});
return false;
});
};
const handleEditQueryTemplate = async (editQueryTemplateCommand: EditQueryTemplateCommand) => {
return editQueryTemplate({
name: editQueryTemplateCommand.uid,
patch: {
spec: editQueryTemplateCommand.partialSpec,
},
})
.unwrap()
.then(() => {
getAppEvents().publish({
type: AppEvents.alertSuccess.name,
payload: [t('explore.query-library.query-template-edited', 'Query template successfully edited')],
});
return true;
})
.catch(() => {
getAppEvents().publish({
type: AppEvents.alertError.name,
payload: [t('explore.query-library.query-template-edit-error', 'Error attempting to edit this query')],
});
return false;
});
};
const onSubmit = async (data: QueryDetails) => {
const timestamp = dateTime().toISOString();
const temporaryDefaultTitle =
data.description || t('explore.query-library.default-description', 'Public', { timestamp: timestamp });
if (templateData?.uid) {
handleEditQueryTemplate({ uid: templateData.uid, partialSpec: { title: data.description } }).then((isSuccess) => {
onSave(isSuccess);
});
} else if (queryToAdd) {
handleAddQueryTemplate({ title: temporaryDefaultTitle, targets: [queryToAdd] }).then((isSuccess) => {
onSave(isSuccess);
});
}
};
const { value: queryText } = useAsync(async () => {
const promises = queries.map(async (query, i) => {
const datasource = await getDataSourceSrv().get(query.datasource);
return datasource?.getQueryDisplayText?.(query) || getQueryDisplayText(query);
});
return Promise.all(promises);
});
return (
<form onSubmit={handleSubmit(onSubmit)}>
<p>{getInstuctions(templateData === undefined)}</p>
{queryText &&
queryText.map((queryString, i) => (
<Field key={`query-${i}`} label={t('explore.query-template-modal.query', 'Query')}>
<TextArea readOnly={true} value={queryString}></TextArea>
</Field>
))}
{queryToAdd && (
<>
<Field label={t('explore.query-template-modal.data-source-name', 'Data source name')}>
<DataSourcePicker current={datasource?.uid} disabled={true} />
</Field>
<Field label={t('explore.query-template-modall.data-source-type', 'Data source type')}>
<Input disabled={true} defaultValue={datasource?.meta.name}></Input>
</Field>
</>
)}
<Field label={t('explore.query-template-modal.description', 'Description')}>
<Input id="query-template-description" autoFocus={true} {...register('description')}></Input>
</Field>
<Field label={t('explore.query-template-modal.visibility', 'Visibility')}>
<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}
disabled={true}
label={t(
'explore.query-template-modal.auto-star',
'Auto-star this query to add it to your starred list in the Query Library.'
)}
/>
<Modal.ButtonRow>
<Button variant="secondary" onClick={() => onCancel()} fill="outline">
<Trans i18nKey="explore.query-library.cancel">Cancel</Trans>
</Button>
<Button variant="primary" type="submit">
<Trans i18nKey="explore.query-library.save">Save</Trans>
</Button>
</Modal.ButtonRow>
</form>
);
};

View File

@ -1,139 +0,0 @@
import { render, waitFor, screen } from '@testing-library/react';
import { AnnoKeyCreatedBy } from '../../apiserver/types';
import { ListQueryTemplateApiResponse } from '../../query-library/api/endpoints.gen';
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: {
[AnnoKeyCreatedBy]: 'viewer:JohnDoe',
},
},
spec: {
title: 'Test Query title',
targets: [
{
variables: {},
properties: {
refId: 'A',
datasource: {
uid: 'Prometheus',
type: 'prometheus',
},
},
},
],
},
},
];

View File

@ -1,222 +0,0 @@
import { css } from '@emotion/css';
import { uniqBy } from 'lodash';
import { useEffect, useMemo, useState } from 'react';
import { AppEvents, GrafanaTheme2, SelectableValue } from '@grafana/data';
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 { useListQueryTemplateQuery } from 'app/features/query-library';
import { QueryTemplate } from 'app/features/query-library/types';
import { convertDataQueryResponseToQueryTemplates } from '../../query-library/api/mappers';
import { QueryLibraryProps } from './QueryLibrary';
import { queryLibraryTrackFilterDatasource } from './QueryLibraryAnalyticsEvents';
import { QueryLibraryExpmInfo } from './QueryLibraryExpmInfo';
import QueryTemplatesTable from './QueryTemplatesTable';
import { useLoadQueryMetadata, useLoadUsers } from './utils/dataFetching';
import { searchQueryLibrary } from './utils/search';
interface QueryTemplatesListProps extends QueryLibraryProps {}
export function QueryTemplatesList(props: QueryTemplatesListProps) {
const { data: rawData, isLoading, error } = useListQueryTemplateQuery({});
const data = useMemo(() => (rawData ? convertDataQueryResponseToQueryTemplates(rawData) : undefined), [rawData]);
const [isModalOpen, setIsModalOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [datasourceFilters, setDatasourceFilters] = useState<Array<SelectableValue<string>>>(
props.activeDatasources?.map((ds) => ({ value: ds, label: ds })) || []
);
const [userFilters, setUserFilters] = useState<Array<SelectableValue<string>>>([]);
const styles = useStyles2(getStyles);
const loadUsersResult = useLoadUsersWithError(data);
const userNames = loadUsersResult.data ? loadUsersResult.data.display.map((user) => user.displayName) : [];
const loadQueryMetadataResult = useLoadQueryMetadataWithError(data, loadUsersResult.data);
// Filtering right now is done just on the frontend until there is better backend support for this.
const filteredRows = useMemo(
() =>
searchQueryLibrary(
loadQueryMetadataResult.value || [],
searchQuery,
datasourceFilters.map((f) => f.value || ''),
userFilters.map((f) => f.value || '')
),
[loadQueryMetadataResult.value, searchQuery, datasourceFilters, userFilters]
);
const datasourceNames = useMemo(() => {
return uniqBy(loadQueryMetadataResult.value, 'datasourceName').map((row) => row.datasourceName);
}, [loadQueryMetadataResult.value]);
if (error instanceof Error) {
return (
<EmptyState variant="not-found" message={`Something went wrong`}>
{error.message}
</EmptyState>
);
}
if (isLoading || loadUsersResult.isLoading || loadQueryMetadataResult.loading) {
return <Spinner />;
}
if (!data || data.length === 0) {
return (
<EmptyState message={`Query Library`} variant="not-found">
<p>
{
"You haven't saved any queries to your library yet. Start adding them from Explore or your Query History tab."
}
</p>
</EmptyState>
);
}
return (
<>
<QueryLibraryExpmInfo isOpen={isModalOpen} onDismiss={() => setIsModalOpen(false)} />
<Stack gap={0.5}>
<FilterInput
className={styles.searchInput}
placeholder={t('query-library.search', 'Search by data source, query content or description')}
aria-label={t('query-library.search', 'Search by data source, query content or description')}
value={searchQuery}
onChange={(query) => setSearchQuery(query)}
escapeRegex={false}
/>
<InlineLabel className={styles.label} width="auto">
<Trans i18nKey="query-library.datasource-names">Datasource name(s):</Trans>
</InlineLabel>
<MultiSelect
className={styles.multiSelect}
onChange={(items, actionMeta) => {
setDatasourceFilters(items);
actionMeta.action === 'select-option' && queryLibraryTrackFilterDatasource();
}}
value={datasourceFilters}
options={datasourceNames.map((r) => {
return { value: r, label: r };
})}
placeholder={'Filter queries for data sources(s)'}
aria-label={'Filter queries for data sources(s)'}
/>
<InlineLabel className={styles.label} width="auto">
<Trans i18nKey="query-library.user-names">User name(s):</Trans>
</InlineLabel>
<MultiSelect
isLoading={loadUsersResult.isLoading}
className={styles.multiSelect}
onChange={(items, actionMeta) => {
setUserFilters(items);
actionMeta.action === 'select-option' && queryLibraryTrackFilterDatasource();
}}
value={userFilters}
options={userNames.map((r) => {
return { value: r, label: r };
})}
placeholder={'Filter queries for user name(s)'}
aria-label={'Filter queries for user name(s)'}
/>
<Badge
text=""
icon="info"
aria-label="info"
tooltip={'Click here for more informationn about Query library'}
color="blue"
style={{ cursor: 'pointer' }}
onClick={() => setIsModalOpen(true)}
/>
</Stack>
<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: ReturnType<typeof useLoadUsers>['data']
) {
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),
}),
multiSelect: css({
maxWidth: theme.spacing(65),
}),
label: css({
marginLeft: theme.spacing(1),
border: `1px solid ${theme.colors.secondary.border}`,
}),
});

View File

@ -1,110 +0,0 @@
import { useState } from 'react';
import { getAppEvents } from '@grafana/runtime';
import { IconButton, Modal } from '@grafana/ui';
import { notifyApp } from 'app/core/actions';
import { createSuccessNotification } from 'app/core/copy/appNotification';
import { t } from 'app/core/internationalization';
import { useDeleteQueryTemplateMutation } from 'app/features/query-library';
import { dispatch } from 'app/store/store';
import { ShowConfirmModalEvent } from 'app/types/events';
import {
queryLibaryTrackDeleteQuery,
queryLibraryTrackAddOrEditDescription,
queryLibraryTrackRunQuery,
} from '../QueryLibraryAnalyticsEvents';
import { QueryTemplateForm } from '../QueryTemplateForm';
import { QueryActionButton } from '../types';
import { useQueryLibraryListStyles } from './styles';
import { QueryTemplateRow } from './types';
interface ActionsCellProps {
queryUid?: string;
queryTemplate: QueryTemplateRow;
rootDatasourceUid?: string;
QueryActionButton?: QueryActionButton;
}
function ActionsCell({ queryTemplate, rootDatasourceUid, queryUid, QueryActionButton }: ActionsCellProps) {
const [deleteQueryTemplate] = useDeleteQueryTemplateMutation();
const [editFormOpen, setEditFormOpen] = useState(false);
const styles = useQueryLibraryListStyles();
const onDeleteQuery = (queryUid: string) => {
const performDelete = (queryUid: string) => {
deleteQueryTemplate({
name: queryUid,
deleteOptions: {},
});
dispatch(notifyApp(createSuccessNotification(t('explore.query-library.query-deleted', 'Query deleted'))));
queryLibaryTrackDeleteQuery();
};
getAppEvents().publish(
new ShowConfirmModalEvent({
title: t('explore.query-library.delete-query-title', 'Delete query'),
text: t(
'explore.query-library.delete-query-text',
"You're about to remove this query from the query library. This action cannot be undone. Do you want to continue?"
),
yesText: t('query-library.delete-query-button', 'Delete query'),
icon: 'trash-alt',
onConfirm: () => performDelete(queryUid),
})
);
};
return (
<div className={styles.cell}>
<IconButton
className={styles.actionButton}
size="lg"
name="trash-alt"
title={t('explore.query-library.delete-query', 'Delete query')}
tooltip={t('explore.query-library.delete-query', 'Delete query')}
onClick={() => {
if (queryUid) {
onDeleteQuery(queryUid);
}
}}
/>
<IconButton
className={styles.actionButton}
size="lg"
name="comment-alt"
title={t('explore.query-library.add-edit-description', 'Add/edit description')}
tooltip={t('explore.query-library.add-edit-description', 'Add/edit description')}
onClick={() => {
setEditFormOpen(true);
queryLibraryTrackAddOrEditDescription();
}}
/>
{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}
onDismiss={() => setEditFormOpen(false)}
>
<QueryTemplateForm
onCancel={() => setEditFormOpen(false)}
templateData={queryTemplate}
onSave={() => {
setEditFormOpen(false);
}}
/>
</Modal>
</div>
);
}
export default ActionsCell;

View File

@ -1,20 +0,0 @@
import { Avatar } from '@grafana/ui';
import { User } from 'app/features/query-library/types';
import { useQueryLibraryListStyles } from './styles';
type AddedByCellProps = {
user?: User;
};
export function AddedByCell(props: AddedByCellProps) {
const styles = useQueryLibraryListStyles();
return (
<div>
<span className={styles.logo}>
<Avatar src={props.user?.avatarUrl || 'https://secure.gravatar.com/avatar'} alt="unknown" />
</span>
<span className={styles.otherText}>{props.user?.displayName || 'Unknown'}</span>
</div>
);
}

View File

@ -1,13 +0,0 @@
import { CellProps } from 'react-table';
import { useDatasource } from '../utils/useDatasource';
import { useQueryLibraryListStyles } from './styles';
import { QueryTemplateRow } from './types';
export function DatasourceTypeCell(props: CellProps<QueryTemplateRow>) {
const datasourceApi = useDatasource(props.row.original.datasourceRef);
const styles = useQueryLibraryListStyles();
return <p className={styles.otherText}>{datasourceApi?.meta.name}</p>;
}

View File

@ -1,13 +0,0 @@
import { CellProps } from 'react-table';
import { dateTime } from '@grafana/data';
import { useQueryLibraryListStyles } from './styles';
import { QueryTemplateRow } from './types';
export function DateAddedCell(props: CellProps<QueryTemplateRow>) {
const styles = useQueryLibraryListStyles();
const formattedTime = dateTime(props.row.original.createdAtTimestamp).format('YYYY-MM-DD HH:mm:ss');
return <p className={styles.otherText}>{formattedTime}</p>;
}

View File

@ -1,55 +0,0 @@
import { css, cx } from '@emotion/css';
import { CellProps } from 'react-table';
import { GrafanaTheme2 } from '@grafana/data';
import { Spinner, Tooltip, useStyles2 } from '@grafana/ui';
import { useDatasource } from '../utils/useDatasource';
import { useQueryLibraryListStyles } from './styles';
import { QueryTemplateRow } from './types';
export function QueryDescriptionCell(props: CellProps<QueryTemplateRow>) {
const datasourceApi = useDatasource(props.row.original.datasourceRef);
const queryLibraryListStyles = useQueryLibraryListStyles();
const styles = useStyles2(getStyles);
if (!datasourceApi) {
return <Spinner />;
}
if (!props.row.original.query) {
return <div>No queries</div>;
}
const queryDisplayText = props.row.original.queryText;
const description = props.row.original.description;
const dsName = props.row.original.datasourceName;
return (
<div className={styles.container} aria-label={`Query template for ${dsName}: ${description}`}>
<p className={queryLibraryListStyles.header}>
<img
className={queryLibraryListStyles.logo}
src={datasourceApi?.meta.info.logos.small || 'public/img/icn-datasource.svg'}
alt={datasourceApi?.meta.info.description}
/>
{dsName}
</p>
<Tooltip content={queryDisplayText ?? ''} placement="bottom-start">
<p className={cx(queryLibraryListStyles.mainText, queryLibraryListStyles.singleLine, styles.queryDisplayText)}>
{queryDisplayText}
</p>
</Tooltip>
<p className={cx(queryLibraryListStyles.otherText, queryLibraryListStyles.singleLine)}>{description}</p>
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
container: css({
maxWidth: theme.spacing(60),
}),
queryDisplayText: css({
backgroundColor: theme.colors.background.canvas,
}),
});

View File

@ -1,81 +0,0 @@
import { css } from '@emotion/css';
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';
import { DateAddedCell } from './DateAddedCell';
import { QueryDescriptionCell } from './QueryDescriptionCell';
import { QueryTemplateRow } from './types';
const timestampSort: SortByFn<QueryTemplateRow> = (rowA, rowB, _, desc) => {
const timeA = rowA.original.createdAtTimestamp || 0;
const timeB = rowB.original.createdAtTimestamp || 0;
return desc ? timeA - timeB : timeB - timeA;
};
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, queryActionButton }: Props) {
const styles = useStyles2(getStyles);
const columns = createColumns(queryActionButton);
return (
<InteractiveTable
columns={columns}
data={queryTemplateRows}
getRowId={(row: { index: string }) => row.index}
pageSize={20}
className={styles.table}
/>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
table: css({
'tbody tr': {
position: 'relative',
backgroundColor: theme.colors.background.secondary,
borderCollapse: 'collapse',
borderBottom: 'unset',
overflow: 'hidden', // Ensure the row doesn't overflow and cause additonal scrollbars
},
/* Adds the pseudo-element for the lines between table rows */
'tbody tr::after': {
content: '""',
position: 'absolute',
inset: 'auto 0 0 0',
height: theme.spacing(0.5),
backgroundColor: theme.colors.background.primary,
},
}),
});

View File

@ -1,47 +0,0 @@
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data/';
import { useStyles2 } from '@grafana/ui/';
export const useQueryLibraryListStyles = () => {
return useStyles2(getStyles);
};
const getStyles = (theme: GrafanaTheme2) => ({
logo: css({
marginRight: theme.spacing(2),
width: '16px',
}),
header: css({
margin: 0,
fontSize: theme.typography.h5.fontSize,
color: theme.colors.text.secondary,
}),
mainText: css({
margin: 0,
fontSize: theme.typography.body.fontSize,
textOverflow: 'ellipsis',
}),
otherText: css({
margin: 0,
fontSize: theme.typography.body.fontSize,
color: theme.colors.text.secondary,
textOverflow: 'ellipsis',
}),
singleLine: css({
display: '-webkit-box',
WebkitBoxOrient: 'vertical',
WebkitLineClamp: 1,
overflow: 'hidden',
}),
cell: css({
display: 'flex',
alignItems: 'center',
'&:last-child': {
justifyContent: 'end',
},
}),
actionButton: css({
padding: theme.spacing(1),
}),
});

View File

@ -1,15 +0,0 @@
import { DataQuery, DataSourceRef } from '@grafana/schema';
import { User } from 'app/features/query-library/types';
export type QueryTemplateRow = {
index: string;
datasourceName?: string;
description?: string;
query?: DataQuery;
queryText?: string;
datasourceRef?: DataSourceRef | null;
datasourceType?: string;
createdAtTimestamp?: number;
user?: User;
uid?: string;
};

View File

@ -1,44 +0,0 @@
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);
}}
/>
);
}

View File

@ -1,106 +0,0 @@
import { skipToken } from '@reduxjs/toolkit/query';
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 { useGetDisplayMappingQuery } from '../../../iam';
import { getDatasourceSrv } from '../../../plugins/datasource_srv';
import { QueryTemplate } from '../../../query-library/types';
export function useLoadUsers(userUIDs: string[] | undefined) {
const userQtList = uniq(compact(userUIDs));
return useGetDisplayMappingQuery(
userUIDs
? {
key: userQtList,
}
: skipToken
);
}
// 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: ReturnType<typeof useLoadUsers>['data']
): 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]);
}

View File

@ -1,24 +0,0 @@
import { QueryTemplateRow } from '../QueryTemplatesTable/types';
export const searchQueryLibrary = (
queryLibrary: QueryTemplateRow[],
query: string,
dsFilters: string[],
userNameFilters: string[]
) => {
const result = queryLibrary.filter((item) => {
const matchesDsFilter =
dsFilters.length === 0 || dsFilters.some((f) => item.datasourceName?.toLowerCase().includes(f.toLowerCase()));
const matchesUserNameFilter =
userNameFilters.length === 0 || userNameFilters.includes(item.user?.displayName || '');
return (
(item.datasourceName?.toLowerCase().includes(query.toLowerCase()) ||
item.datasourceType?.toLowerCase().includes(query.toLowerCase()) ||
item.description?.toLowerCase().includes(query.toLowerCase()) ||
item.queryText?.toLowerCase().includes(query.toLowerCase())) &&
matchesDsFilter &&
matchesUserNameFilter
);
});
return result;
};

View File

@ -1,9 +0,0 @@
import { useAsync } from 'react-use';
import { getDataSourceSrv } from '@grafana/runtime';
import { DataSourceRef } from '@grafana/schema';
export function useDatasource(dataSourceRef?: DataSourceRef | null) {
const { value } = useAsync(async () => await getDataSourceSrv().get(dataSourceRef), [dataSourceRef]);
return value;
}

View File

@ -1,57 +1,32 @@
import { useState } from 'react';
import { DataQuery } from '@grafana/schema';
import { Button, Modal } from '@grafana/ui';
import { Button } from '@grafana/ui';
import { t } from 'app/core/internationalization';
import { isQueryLibraryEnabled, useListQueryTemplateQuery } from 'app/features/query-library';
import {
queryLibraryTrackAddFromQueryHistory,
queryLibraryTrackAddFromQueryHistoryAddModalShown,
} from '../QueryLibrary/QueryLibraryAnalyticsEvents';
import { QueryTemplateForm } from '../QueryLibrary/QueryTemplateForm';
import { useQueryLibraryContext } from '../QueryLibrary/QueryLibraryContext';
type Props = {
query: DataQuery;
};
export const RichHistoryAddToLibrary = ({ query }: Props) => {
const { refetch } = useListQueryTemplateQuery({});
const [isOpen, setIsOpen] = useState(false);
const [hasBeenSaved, setHasBeenSaved] = useState(false);
const { openAddQueryModal, queryLibraryEnabled } = useQueryLibraryContext();
const buttonLabel = t('explore.rich-history-card.add-to-library', 'Add to library');
return isQueryLibraryEnabled() && !hasBeenSaved ? (
return queryLibraryEnabled && !hasBeenSaved ? (
<>
<Button
variant="secondary"
aria-label={buttonLabel}
onClick={() => {
setIsOpen(true);
queryLibraryTrackAddFromQueryHistoryAddModalShown();
openAddQueryModal(query, { onSave: () => setHasBeenSaved(true), context: 'richHistory' });
}}
>
{buttonLabel}
</Button>
<Modal
title={t('explore.query-template-modal.add-title', 'Add query to Query Library')}
isOpen={isOpen}
onDismiss={() => setIsOpen(false)}
>
<QueryTemplateForm
onCancel={() => setIsOpen(() => false)}
queryToAdd={query}
onSave={(isSuccess) => {
if (isSuccess) {
setIsOpen(false);
setHasBeenSaved(true);
refetch();
queryLibraryTrackAddFromQueryHistory(query.datasource?.type || '');
}
}}
/>
</Modal>
</>
) : undefined;
};

View File

@ -4,6 +4,7 @@ import { createMemoryHistory } from 'history';
import { KBarProvider } from 'kbar';
import { fromPairs } from 'lodash';
import { stringify } from 'querystring';
import { ComponentType, ReactNode } from 'react';
import { Provider } from 'react-redux';
// eslint-disable-next-line no-restricted-imports
import { Route, Router } from 'react-router-dom';
@ -47,7 +48,6 @@ 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 };
@ -60,6 +60,7 @@ type SetupOptions = {
failAddToLibrary?: boolean;
// Use AppChrome wrapper around ExplorePage - needed to test query library/history
withAppChrome?: boolean;
provider?: ComponentType<{ children: ReactNode }>;
};
type TearDownOptions = {
@ -179,12 +180,18 @@ export function setupExplore(options?: SetupOptions): {
const contextMock = getGrafanaContextMock({ location });
const FinalProvider =
options?.provider ||
(({ children }) => {
return children;
});
const { unmount, container } = render(
<Provider store={storeState}>
<GrafanaContext.Provider value={contextMock}>
<Router history={history}>
<QueryLibraryContextProvider>
<QueriesDrawerContextProvider>
<FinalProvider>
{options?.withAppChrome ? (
<KBarProvider>
<AppChrome>
@ -204,8 +211,8 @@ export function setupExplore(options?: SetupOptions): {
render={(props) => <GrafanaRoute {...props} route={{ component: ExplorePage, path: '/explore' }} />}
/>
)}
</FinalProvider>
</QueriesDrawerContextProvider>
</QueryLibraryContextProvider>
</Router>
</GrafanaContext.Provider>
</Provider>

View File

@ -1,172 +0,0 @@
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 {
assertAddToQueryLibraryButtonExists,
assertQueryHistory,
assertQueryLibraryTemplateExists,
} from './helper/assert';
import {
addQueryHistoryToQueryLibrary,
openQueryHistory,
openQueryLibrary,
submitAddToQueryLibrary,
} 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('../QueryLibrary/utils/dataFetching', () => {
return {
__esModule: true,
...jest.requireActual('../QueryLibrary/utils/dataFetching'),
useLoadUsers: () => {
return {
data: {
display: [
{
avatarUrl: '',
displayName: 'john doe',
identity: {
name: 'JohnDoe',
type: 'viewer',
},
},
],
},
isLoading: false,
error: null,
};
},
};
});
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
reportInteraction: (...args: object[]) => {
reportInteractionMock(...args);
},
getAppEvents: () => testEventBus,
usePluginLinks: jest.fn().mockReturnValue({ links: [] }),
}));
jest.mock('app/core/core', () => ({
contextSrv: {
hasPermission: () => true,
isSignedIn: true,
getValidIntervals: (defaultIntervals: string[]) => defaultIntervals,
user: {
isSignedIn: true,
},
},
}));
jest.mock('app/core/services/PreferencesService', () => ({
PreferencesService: function () {
return {
patch: jest.fn(),
load: jest.fn().mockResolvedValue({
queryHistory: {
homeTab: 'query',
},
}),
};
},
}));
jest.mock('../hooks/useExplorePageTitle', () => ({
useExplorePageTitle: jest.fn(),
}));
jest.mock('react-virtualized-auto-sizer', () => {
return {
__esModule: true,
default(props: Props) {
return <div>{props.children({ height: 1, scaledHeight: 1, scaledWidth: 1000, width: 1000 })}</div>;
},
};
});
function setupQueryLibrary() {
const mockQuery: MockQuery = { refId: 'TEST', expr: 'TEST' };
setupExplore({
queryHistory: {
queryHistory: [{ datasourceUid: 'loki', queries: [mockQuery] }],
totalCount: 1,
},
withAppChrome: true,
});
}
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 = previousQueryLibraryEnabled;
config.queryHistoryEnabled = previousQueryHistoryEnabled;
jest.restoreAllMocks();
});
it('Load query templates', async () => {
setupQueryLibrary();
await waitForExplore();
await openQueryLibrary();
await assertQueryLibraryTemplateExists('loki', 'Loki Query Template');
});
it('Shows add to query library button only when the toggle is enabled', async () => {
setupQueryLibrary();
await waitForExplore();
await openQueryHistory();
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 openQueryHistory();
await assertQueryHistory(['{"expr":"TEST"}']);
await addQueryHistoryToQueryLibrary();
await submitAddToQueryLibrary({ description: 'Test' });
expect(testEventBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: 'alert-success',
payload: ['Query successfully saved to the library'],
})
);
await assertAddToQueryLibraryButtonExists(false);
});
});

View File

@ -7,8 +7,6 @@
* @alpha
*/
import { config } from '@grafana/runtime';
import { QUERY_LIBRARY_GET_LIMIT } from './api/api';
import { generatedQueryLibraryApi } from './api/endpoints.gen';
import { mockData } from './api/mocks';
@ -46,10 +44,6 @@ export const {
},
});
export function isQueryLibraryEnabled() {
return config.featureToggles.queryLibrary;
}
export const QueryLibraryMocks = {
data: mockData.all,
};

View File

@ -8,7 +8,6 @@ import { PureComponent, ReactNode } from 'react';
// Utils & Services
import {
CoreApp,
DataQuery,
DataSourceApi,
DataSourceInstanceSettings,
DataSourcePluginContextProvider,
@ -24,7 +23,8 @@ import {
toLegacyResponseData,
} from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { AngularComponent, config, getAngularLoader, getDataSourceSrv, reportInteraction } from '@grafana/runtime';
import { AngularComponent, getAngularLoader, getDataSourceSrv, reportInteraction } from '@grafana/runtime';
import { DataQuery } from '@grafana/schema';
import { Badge, ErrorBoundaryAlert } from '@grafana/ui';
import { OperationRowHelp } from 'app/core/components/QueryOperationRow/OperationRowHelp';
import {
@ -40,7 +40,7 @@ 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 { useQueryLibraryContext } from '../../explore/QueryLibrary/QueryLibraryContext';
import { QueryActionComponent, RowActionComponents } from './QueryActionComponent';
import { QueryEditorRowHeader } from './QueryEditorRowHeader';
@ -489,7 +489,7 @@ export class QueryEditorRow<TQuery extends DataQuery> extends PureComponent<Prop
/>
)}
{this.renderExtraActions()}
{config.featureToggles.queryLibrary && <SaveQueryToQueryLibraryButton query={query} />}
<MaybeQueryLibrarySaveButton query={query} />
<QueryOperationAction
title={t('query-operation.header.duplicate-query', 'Duplicate query')}
icon="copy"
@ -659,3 +659,9 @@ export function filterPanelDataToQuery(data: PanelData, refId: string): PanelDat
timeRange,
};
}
// Will render anything only if query library is enabled
function MaybeQueryLibrarySaveButton(props: { query: DataQuery }) {
const { renderSaveQueryButton } = useQueryLibraryContext();
return renderSaveQueryButton(props.query);
}

View File

@ -1,5 +1,5 @@
import { css } from '@emotion/css';
import { ComponentType } from 'react';
import { ComponentType, ReactNode } from 'react';
// eslint-disable-next-line no-restricted-imports
import { Router } from 'react-router-dom';
import { CompatRouter } from 'react-router-dom-v5-compat';
@ -18,12 +18,18 @@ 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';
function ExtraProviders(props: { children: ReactNode; providers: Array<ComponentType<{ children: ReactNode }>> }) {
return props.providers.reduce((tree, Provider): ReactNode => {
return <Provider>{tree}</Provider>;
}, props.children);
}
type RouterWrapperProps = {
routes?: JSX.Element | false;
bodyRenderHooks: ComponentType[];
pageBanners: ComponentType[];
providers: Array<ComponentType<{ children: ReactNode }>>;
};
export function RouterWrapper(props: RouterWrapperProps) {
return (
@ -31,7 +37,7 @@ export function RouterWrapper(props: RouterWrapperProps) {
<LocationServiceProvider service={locationService}>
<CompatRouter>
<QueriesDrawerContextProvider>
<QueryLibraryContextProvider>
<ExtraProviders providers={props.providers}>
<ModalsContextProvider>
<AppChrome>
<AngularRoot />
@ -48,7 +54,7 @@ export function RouterWrapper(props: RouterWrapperProps) {
</AppChrome>
<ModalRoot />
</ModalsContextProvider>
</QueryLibraryContextProvider>
</ExtraProviders>
</QueriesDrawerContextProvider>
</CompatRouter>
</LocationServiceProvider>

View File

@ -1346,36 +1346,6 @@
"scan-for-older-logs": "Scan for older logs",
"stop-scan": "Stop scan"
},
"query-library": {
"add-edit-description": "Add/edit description",
"cancel": "Cancel",
"default-description": "Public",
"delete-query": "Delete query",
"delete-query-text": "You're about to remove this query from the query library. This action cannot be undone. Do you want to continue?",
"delete-query-title": "Delete query",
"private": "Private",
"public": "Public",
"query-deleted": "Query deleted",
"query-template-add-error": "Error attempting to save this query to the library",
"query-template-added": "Query successfully saved to the library",
"query-template-edit-error": "Error attempting to edit this query",
"query-template-edited": "Query template successfully edited",
"save": "Save"
},
"query-template-modal": {
"add-info": "You're about to save this query. Once saved, you can easily access it in the Query Library tab for future use and reference.",
"add-title": "Add query to Query Library",
"auto-star": "Auto-star this query to add it to your starred list in the Query Library.",
"data-source-name": "Data source name",
"description": "Description",
"edit-info": "You're about to edit this query. Once saved, you can easily access it in the Query Library tab for future use and reference.",
"edit-title": "Edit query",
"query": "Query",
"visibility": "Visibility"
},
"query-template-modall": {
"data-source-type": "Data source type"
},
"rich-history": {
"close-tooltip": "Close query history",
"datasource-a-z": "Data source A-Z",
@ -2993,14 +2963,6 @@
"role-label": "Role"
}
},
"query-library": {
"datasource-names": "Datasource name(s):",
"delete-query-button": "Delete query",
"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):"
},
"query-operation": {
"header": {
"collapse-row": "Collapse query row",
@ -3010,8 +2972,6 @@
"expand-row": "Expand query row",
"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"
},

View File

@ -1346,36 +1346,6 @@
"scan-for-older-logs": "Ŝčäʼn ƒőř őľđęř ľőģş",
"stop-scan": "Ŝŧőp şčäʼn"
},
"query-library": {
"add-edit-description": "Åđđ/ęđįŧ đęşčřįpŧįőʼn",
"cancel": "Cäʼnčęľ",
"default-description": "Pūþľįč",
"delete-query": "Đęľęŧę qūęřy",
"delete-query-text": "Ÿőū'řę äþőūŧ ŧő řęmővę ŧĥįş qūęřy ƒřőm ŧĥę qūęřy ľįþřäřy. Ŧĥįş äčŧįőʼn čäʼnʼnőŧ þę ūʼnđőʼnę. Đő yőū ŵäʼnŧ ŧő čőʼnŧįʼnūę?",
"delete-query-title": "Đęľęŧę qūęřy",
"private": "Přįväŧę",
"public": "Pūþľįč",
"query-deleted": "Qūęřy đęľęŧęđ",
"query-template-add-error": "Ēřřőř äŧŧęmpŧįʼnģ ŧő şävę ŧĥįş qūęřy ŧő ŧĥę ľįþřäřy",
"query-template-added": "Qūęřy şūččęşşƒūľľy şävęđ ŧő ŧĥę ľįþřäřy",
"query-template-edit-error": "Ēřřőř äŧŧęmpŧįʼnģ ŧő ęđįŧ ŧĥįş qūęřy",
"query-template-edited": "Qūęřy ŧęmpľäŧę şūččęşşƒūľľy ęđįŧęđ",
"save": "Ŝävę"
},
"query-template-modal": {
"add-info": "Ÿőū'řę äþőūŧ ŧő şävę ŧĥįş qūęřy. Øʼnčę şävęđ, yőū čäʼn ęäşįľy äččęşş įŧ įʼn ŧĥę Qūęřy Ŀįþřäřy ŧäþ ƒőř ƒūŧūřę ūşę äʼnđ řęƒęřęʼnčę.",
"add-title": "Åđđ qūęřy ŧő Qūęřy Ŀįþřäřy",
"auto-star": "Åūŧő-şŧäř ŧĥįş qūęřy ŧő äđđ įŧ ŧő yőūř şŧäřřęđ ľįşŧ įʼn ŧĥę Qūęřy Ŀįþřäřy.",
"data-source-name": "Đäŧä şőūřčę ʼnämę",
"description": "Đęşčřįpŧįőʼn",
"edit-info": "Ÿőū'řę äþőūŧ ŧő ęđįŧ ŧĥįş qūęřy. Øʼnčę şävęđ, yőū čäʼn ęäşįľy äččęşş įŧ įʼn ŧĥę Qūęřy Ŀįþřäřy ŧäþ ƒőř ƒūŧūřę ūşę äʼnđ řęƒęřęʼnčę.",
"edit-title": "Ēđįŧ qūęřy",
"query": "Qūęřy",
"visibility": "Vįşįþįľįŧy"
},
"query-template-modall": {
"data-source-type": "Đäŧä şőūřčę ŧypę"
},
"rich-history": {
"close-tooltip": "Cľőşę qūęřy ĥįşŧőřy",
"datasource-a-z": "Đäŧä şőūřčę Å-Ż",
@ -2993,14 +2963,6 @@
"role-label": "Ŗőľę"
}
},
"query-library": {
"datasource-names": "Đäŧäşőūřčę ʼnämę(ş):",
"delete-query-button": "Đęľęŧę qūęřy",
"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ę(ş):"
},
"query-operation": {
"header": {
"collapse-row": "Cőľľäpşę qūęřy řőŵ",
@ -3010,8 +2972,6 @@
"expand-row": "Ēχpäʼnđ qūęřy řőŵ",
"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őđę"
},