mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
QueryLibrary: Move to enterprise (#100133)
This commit is contained in:
parent
ccb9cab131
commit
4b9fee61a8
@ -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"]
|
||||
],
|
||||
|
@ -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;
|
||||
|
@ -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={() => {
|
||||
|
@ -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(
|
||||
|
@ -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 = (
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
@ -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',
|
||||
});
|
||||
}
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
};
|
@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
@ -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}`,
|
||||
}),
|
||||
});
|
@ -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;
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>;
|
||||
}
|
@ -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>;
|
||||
}
|
@ -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,
|
||||
}),
|
||||
});
|
@ -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,
|
||||
},
|
||||
}),
|
||||
});
|
@ -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),
|
||||
}),
|
||||
});
|
@ -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;
|
||||
};
|
@ -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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
@ -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]);
|
||||
}
|
@ -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;
|
||||
};
|
@ -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;
|
||||
}
|
@ -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;
|
||||
};
|
||||
|
@ -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>
|
||||
<QueriesDrawerContextProvider>
|
||||
<FinalProvider>
|
||||
{options?.withAppChrome ? (
|
||||
<KBarProvider>
|
||||
<AppChrome>
|
||||
@ -204,8 +211,8 @@ export function setupExplore(options?: SetupOptions): {
|
||||
render={(props) => <GrafanaRoute {...props} route={{ component: ExplorePage, path: '/explore' }} />}
|
||||
/>
|
||||
)}
|
||||
</QueriesDrawerContextProvider>
|
||||
</QueryLibraryContextProvider>
|
||||
</FinalProvider>
|
||||
</QueriesDrawerContextProvider>
|
||||
</Router>
|
||||
</GrafanaContext.Provider>
|
||||
</Provider>
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
@ -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,
|
||||
};
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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"
|
||||
},
|
||||
|
@ -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őđę"
|
||||
},
|
||||
|
Loading…
Reference in New Issue
Block a user