Query Library: Search and filter (#94038)
* Search by query text, pagination * Support default filtering by active datasource; filter by datasource name; improve table display * Cleanup * Fix update and delete url paths * Fix test * Use Stack, remove uneccessary function wrapper * Notify when something is wrong with a row, add interaction tracking * i18n
This commit is contained in:
@@ -1,5 +1,11 @@
|
||||
import { QueryTemplatesList } from './QueryTemplatesList';
|
||||
|
||||
export function QueryLibrary() {
|
||||
return <QueryTemplatesList />;
|
||||
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[];
|
||||
}
|
||||
|
||||
export function QueryLibrary({ activeDatasources }: QueryLibraryProps) {
|
||||
return <QueryTemplatesList activeDatasources={activeDatasources} />;
|
||||
}
|
||||
|
||||
@@ -48,3 +48,9 @@ export function queryLibraryTrackAddOrEditDescription() {
|
||||
item: 'add_or_edit_description',
|
||||
});
|
||||
}
|
||||
|
||||
export function queryLibraryTrackFilterDatasource() {
|
||||
reportInteraction(QUERY_LIBRARY_EXPLORE_EVENT, {
|
||||
item: 'filter_datasource',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,14 +1,110 @@
|
||||
import { EmptyState, Spinner } from '@grafana/ui';
|
||||
import { css } from '@emotion/css';
|
||||
import { uniqBy } from 'lodash';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { AppEvents, GrafanaTheme2, SelectableValue } from '@grafana/data';
|
||||
import { getAppEvents, getDataSourceSrv } from '@grafana/runtime';
|
||||
import { EmptyState, FilterInput, InlineLabel, MultiSelect, Spinner, useStyles2, Stack } from '@grafana/ui';
|
||||
import { t, Trans } from 'app/core/internationalization';
|
||||
import { createQueryText } from 'app/core/utils/richHistory';
|
||||
import { useAllQueryTemplatesQuery } from 'app/features/query-library';
|
||||
import { QueryTemplate } from 'app/features/query-library/types';
|
||||
|
||||
import { getDatasourceSrv } from '../../plugins/datasource_srv';
|
||||
|
||||
import { QueryLibraryProps } from './QueryLibrary';
|
||||
import { queryLibraryTrackFilterDatasource } from './QueryLibraryAnalyticsEvents';
|
||||
import QueryTemplatesTable from './QueryTemplatesTable';
|
||||
import { QueryTemplateRow } from './QueryTemplatesTable/types';
|
||||
import { searchQueryLibrary } from './utils/search';
|
||||
|
||||
export function QueryTemplatesList() {
|
||||
interface QueryTemplatesListProps extends QueryLibraryProps {}
|
||||
|
||||
export function QueryTemplatesList(props: QueryTemplatesListProps) {
|
||||
const { data, isLoading, error } = useAllQueryTemplatesQuery();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [datasourceFilters, setDatasourceFilters] = useState<Array<SelectableValue<string>>>(
|
||||
props.activeDatasources?.map((ds) => ({ value: ds, label: ds })) || []
|
||||
);
|
||||
|
||||
const [allQueryTemplateRows, setAllQueryTemplateRows] = useState<QueryTemplateRow[]>([]);
|
||||
const [isRowsLoading, setIsRowsLoading] = useState(true);
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
useEffect(() => {
|
||||
let shouldCancel = true;
|
||||
|
||||
const fetchRows = async () => {
|
||||
if (!data) {
|
||||
setIsRowsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const rowsPromises = data.map(async (queryTemplate: QueryTemplate, index: number) => {
|
||||
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 || '';
|
||||
|
||||
return {
|
||||
index: index.toString(),
|
||||
uid: queryTemplate.uid,
|
||||
datasourceName,
|
||||
datasourceRef,
|
||||
datasourceType,
|
||||
createdAtTimestamp: queryTemplate?.createdAtTimestamp || 0,
|
||||
query,
|
||||
queryText,
|
||||
description: queryTemplate.title,
|
||||
user: queryTemplate.user,
|
||||
};
|
||||
} catch (error) {
|
||||
getAppEvents().publish({
|
||||
type: AppEvents.alertError.name,
|
||||
payload: [
|
||||
t(
|
||||
'query-library.query-template-get-error',
|
||||
'Error attempting to get query template from the library: {{error}}',
|
||||
{ error: JSON.stringify(error) }
|
||||
),
|
||||
],
|
||||
});
|
||||
return { index: index.toString(), error };
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.allSettled(rowsPromises);
|
||||
const rows = results.filter((result) => result.status === 'fulfilled').map((result) => result.value);
|
||||
|
||||
if (shouldCancel) {
|
||||
setAllQueryTemplateRows(rows);
|
||||
setIsRowsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchRows();
|
||||
|
||||
return () => {
|
||||
shouldCancel = false;
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
const queryTemplateRows = useMemo(
|
||||
() =>
|
||||
searchQueryLibrary(
|
||||
allQueryTemplateRows,
|
||||
searchQuery,
|
||||
datasourceFilters.map((f) => f.value || '')
|
||||
),
|
||||
[allQueryTemplateRows, searchQuery, datasourceFilters]
|
||||
);
|
||||
|
||||
const datasourceNames = useMemo(() => {
|
||||
return uniqBy(allQueryTemplateRows, 'datasourceName').map((row) => row.datasourceName);
|
||||
}, [allQueryTemplateRows]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
@@ -18,7 +114,7 @@ export function QueryTemplatesList() {
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
if (isLoading || isRowsLoading) {
|
||||
return <Spinner />;
|
||||
}
|
||||
|
||||
@@ -34,20 +130,48 @@ export function QueryTemplatesList() {
|
||||
);
|
||||
}
|
||||
|
||||
const queryTemplateRows: QueryTemplateRow[] = data.map((queryTemplate: QueryTemplate, index: number) => {
|
||||
const datasourceRef = queryTemplate.targets[0]?.datasource;
|
||||
const datasourceType = getDatasourceSrv().getInstanceSettings(datasourceRef)?.meta.name || '';
|
||||
return {
|
||||
index: index.toString(),
|
||||
uid: queryTemplate.uid,
|
||||
datasourceRef,
|
||||
datasourceType,
|
||||
createdAtTimestamp: queryTemplate?.createdAtTimestamp || 0,
|
||||
query: queryTemplate.targets[0],
|
||||
description: queryTemplate.title,
|
||||
user: queryTemplate.user,
|
||||
};
|
||||
});
|
||||
|
||||
return <QueryTemplatesTable queryTemplateRows={queryTemplateRows} />;
|
||||
return (
|
||||
<>
|
||||
<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)'}
|
||||
/>
|
||||
</Stack>
|
||||
<QueryTemplatesTable queryTemplateRows={queryTemplateRows} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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}`,
|
||||
}),
|
||||
});
|
||||
|
||||
+24
-13
@@ -1,8 +1,8 @@
|
||||
import { cx } from '@emotion/css';
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { CellProps } from 'react-table';
|
||||
|
||||
import { Spinner, Tooltip } from '@grafana/ui';
|
||||
import { createQueryText } from 'app/core/utils/richHistory';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Spinner, Tooltip, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { useDatasource } from '../utils/useDatasource';
|
||||
|
||||
@@ -11,7 +11,8 @@ import { QueryTemplateRow } from './types';
|
||||
|
||||
export function QueryDescriptionCell(props: CellProps<QueryTemplateRow>) {
|
||||
const datasourceApi = useDatasource(props.row.original.datasourceRef);
|
||||
const styles = useQueryLibraryListStyles();
|
||||
const queryLibraryListStyles = useQueryLibraryListStyles();
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
if (!datasourceApi) {
|
||||
return <Spinner />;
|
||||
@@ -20,25 +21,35 @@ export function QueryDescriptionCell(props: CellProps<QueryTemplateRow>) {
|
||||
if (!props.row.original.query) {
|
||||
return <div>No queries</div>;
|
||||
}
|
||||
const query = props.row.original.query;
|
||||
const queryDisplayText = createQueryText(query, datasourceApi);
|
||||
const queryDisplayText = props.row.original.queryText;
|
||||
const description = props.row.original.description;
|
||||
const dsName = datasourceApi?.name || '';
|
||||
const dsName = props.row.original.datasourceName;
|
||||
|
||||
return (
|
||||
<div aria-label={`Query template for ${dsName}: ${description}`}>
|
||||
<p className={styles.header}>
|
||||
<div className={styles.container} aria-label={`Query template for ${dsName}: ${description}`}>
|
||||
<p className={queryLibraryListStyles.header}>
|
||||
<img
|
||||
className={styles.logo}
|
||||
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(styles.mainText, styles.singleLine)}>{queryDisplayText}</p>
|
||||
<Tooltip content={queryDisplayText ?? ''} placement="bottom-start">
|
||||
<p className={cx(queryLibraryListStyles.mainText, queryLibraryListStyles.singleLine, styles.queryDisplayText)}>
|
||||
{queryDisplayText}
|
||||
</p>
|
||||
</Tooltip>
|
||||
<p className={cx(styles.otherText, styles.singleLine)}>{description}</p>
|
||||
<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,4 +1,3 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { SortByFn } from 'react-table';
|
||||
|
||||
import { Column, InteractiveTable } from '@grafana/ui';
|
||||
@@ -30,14 +29,6 @@ const columns: Array<Column<QueryTemplateRow>> = [
|
||||
},
|
||||
];
|
||||
|
||||
const styles = {
|
||||
tableWithSpacing: css({
|
||||
'th:first-child': {
|
||||
width: '50%',
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
type Props = {
|
||||
queryTemplateRows: QueryTemplateRow[];
|
||||
};
|
||||
@@ -45,10 +36,10 @@ type Props = {
|
||||
export default function QueryTemplatesTable({ queryTemplateRows }: Props) {
|
||||
return (
|
||||
<InteractiveTable
|
||||
className={styles.tableWithSpacing}
|
||||
columns={columns}
|
||||
data={queryTemplateRows}
|
||||
getRowId={(row: { index: string }) => row.index}
|
||||
pageSize={20}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@ import { DataQuery, DataSourceRef } from '@grafana/schema';
|
||||
|
||||
export type QueryTemplateRow = {
|
||||
index: string;
|
||||
datasourceName?: string;
|
||||
description?: string;
|
||||
query?: DataQuery;
|
||||
queryText?: string;
|
||||
datasourceRef?: DataSourceRef | null;
|
||||
datasourceType?: string;
|
||||
createdAtTimestamp?: number;
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { QueryTemplateRow } from '../QueryTemplatesTable/types';
|
||||
|
||||
export const searchQueryLibrary = (queryLibrary: QueryTemplateRow[], query: string, filter: string[]) => {
|
||||
const result = queryLibrary.filter((item) => {
|
||||
const matchesFilter =
|
||||
filter.length === 0 || filter.some((f) => item.datasourceName?.toLowerCase().includes(f.toLowerCase()));
|
||||
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())) &&
|
||||
matchesFilter
|
||||
);
|
||||
});
|
||||
return result;
|
||||
};
|
||||
@@ -5,13 +5,20 @@ import { SelectableValue } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { TabbedContainer, TabConfig } from '@grafana/ui';
|
||||
import { t } from 'app/core/internationalization';
|
||||
import { SortOrder, RichHistorySearchFilters, RichHistorySettings } from 'app/core/utils/richHistory';
|
||||
import {
|
||||
SortOrder,
|
||||
RichHistorySearchFilters,
|
||||
RichHistorySettings,
|
||||
createDatasourcesList,
|
||||
} from 'app/core/utils/richHistory';
|
||||
import { useSelector } from 'app/types';
|
||||
import { RichHistoryQuery } from 'app/types/explore';
|
||||
|
||||
import { supportedFeatures } from '../../../core/history/richHistoryStorageProvider';
|
||||
import { Tabs, useQueriesDrawerContext } from '../QueriesDrawer/QueriesDrawerContext';
|
||||
import { i18n } from '../QueriesDrawer/utils';
|
||||
import { QueryLibrary } from '../QueryLibrary/QueryLibrary';
|
||||
import { selectExploreDSMaps } from '../state/selectors';
|
||||
|
||||
import { RichHistoryQueriesTab } from './RichHistoryQueriesTab';
|
||||
import { RichHistorySettingsTab } from './RichHistorySettingsTab';
|
||||
@@ -83,10 +90,16 @@ export function RichHistory(props: RichHistoryProps) {
|
||||
setLoading(false);
|
||||
}, [richHistory]);
|
||||
|
||||
const exploreActiveDS = useSelector(selectExploreDSMaps);
|
||||
const listOfDatasources = createDatasourcesList();
|
||||
const activeDatasources = exploreActiveDS.dsToExplore
|
||||
.map((eDs) => listOfDatasources.find((ds) => ds.uid === eDs.datasource?.uid)?.name)
|
||||
.filter((name): name is string => !!name);
|
||||
|
||||
const QueryLibraryTab: TabConfig = {
|
||||
label: i18n.queryLibrary,
|
||||
value: Tabs.QueryLibrary,
|
||||
content: <QueryLibrary />,
|
||||
content: <QueryLibrary activeDatasources={activeDatasources} />,
|
||||
icon: 'book',
|
||||
};
|
||||
|
||||
@@ -104,6 +117,8 @@ export function RichHistory(props: RichHistoryProps) {
|
||||
richHistorySettings={props.richHistorySettings}
|
||||
richHistorySearchFilters={props.richHistorySearchFilters}
|
||||
height={height}
|
||||
activeDatasources={activeDatasources}
|
||||
listOfDatasources={listOfDatasources}
|
||||
/>
|
||||
),
|
||||
icon: 'history',
|
||||
|
||||
@@ -20,6 +20,8 @@ const setup = (propOverrides?: Partial<RichHistoryQueriesTabProps>) => {
|
||||
updateFilters: jest.fn(),
|
||||
clearRichHistoryResults: jest.fn(),
|
||||
loadMoreRichHistory: jest.fn(),
|
||||
activeDatasources: ['test-ds'],
|
||||
listOfDatasources: [{ name: 'test-ds', uid: 'test-123' }],
|
||||
richHistorySearchFilters: {
|
||||
search: '',
|
||||
sortOrder: SortOrder.Descending,
|
||||
|
||||
@@ -7,18 +7,14 @@ import { config, getDataSourceSrv } from '@grafana/runtime';
|
||||
import { Button, FilterInput, MultiSelect, RangeSlider, Select, useStyles2 } from '@grafana/ui';
|
||||
import { Trans, t } from 'app/core/internationalization';
|
||||
import {
|
||||
createDatasourcesList,
|
||||
mapNumbertoTimeInSlider,
|
||||
mapQueriesToHeadings,
|
||||
SortOrder,
|
||||
RichHistorySearchFilters,
|
||||
RichHistorySettings,
|
||||
} from 'app/core/utils/richHistory';
|
||||
import { useSelector } from 'app/types';
|
||||
import { RichHistoryQuery } from 'app/types/explore';
|
||||
|
||||
import { selectExploreDSMaps } from '../state/selectors';
|
||||
|
||||
import { getSortOrderOptions } from './RichHistory';
|
||||
import RichHistoryCard from './RichHistoryCard';
|
||||
|
||||
@@ -31,6 +27,8 @@ export interface RichHistoryQueriesTabProps {
|
||||
loadMoreRichHistory: () => void;
|
||||
richHistorySettings: RichHistorySettings;
|
||||
richHistorySearchFilters?: RichHistorySearchFilters;
|
||||
activeDatasources: string[];
|
||||
listOfDatasources: Array<{ name: string; uid: string }>;
|
||||
height: number;
|
||||
}
|
||||
|
||||
@@ -125,21 +123,18 @@ export function RichHistoryQueriesTab(props: RichHistoryQueriesTabProps) {
|
||||
loadMoreRichHistory,
|
||||
richHistorySettings,
|
||||
height,
|
||||
listOfDatasources,
|
||||
activeDatasources,
|
||||
} = props;
|
||||
|
||||
const exploreActiveDS = useSelector(selectExploreDSMaps);
|
||||
const styles = useStyles2(getStyles, height);
|
||||
|
||||
const listOfDatasources = createDatasourcesList();
|
||||
|
||||
// on mount, set filter to either active datasource or all datasources
|
||||
useEffect(() => {
|
||||
const datasourceFilters =
|
||||
!richHistorySettings.activeDatasourcesOnly && richHistorySettings.lastUsedDatasourceFilters
|
||||
? richHistorySettings.lastUsedDatasourceFilters
|
||||
: exploreActiveDS.dsToExplore
|
||||
.map((eDs) => listOfDatasources.find((ds) => ds.uid === eDs.datasource?.uid)?.name)
|
||||
.filter((name): name is string => !!name);
|
||||
: activeDatasources;
|
||||
const filters: RichHistorySearchFilters = {
|
||||
search: '',
|
||||
sortOrder: SortOrder.Descending,
|
||||
|
||||
@@ -105,7 +105,6 @@ describe('QueryLibrary', () => {
|
||||
await waitForExplore();
|
||||
await openQueryLibrary();
|
||||
await assertQueryLibraryTemplateExists('loki', 'Loki Query Template');
|
||||
await assertQueryLibraryTemplateExists('elastic', 'Elastic Query Template');
|
||||
});
|
||||
|
||||
it('Shows add to query library button only when the toggle is enabled', async () => {
|
||||
|
||||
@@ -5,12 +5,18 @@ import { AddQueryTemplateCommand, DeleteQueryTemplateCommand, EditQueryTemplateC
|
||||
import { convertAddQueryTemplateCommandToDataQuerySpec, convertDataQueryResponseToQueryTemplates } from './mappers';
|
||||
import { baseQuery } from './query';
|
||||
|
||||
// Currently, we are loading all query templates
|
||||
// Organizations can have maximum of 1000 query templates
|
||||
const GET_LIMIT = 1000;
|
||||
|
||||
export const queryLibraryApi = createApi({
|
||||
baseQuery,
|
||||
tagTypes: ['QueryTemplatesList'],
|
||||
endpoints: (builder) => ({
|
||||
allQueryTemplates: builder.query<QueryTemplate[], void>({
|
||||
query: () => ({}),
|
||||
query: () => ({
|
||||
url: `?limit=${GET_LIMIT}`,
|
||||
}),
|
||||
transformResponse: convertDataQueryResponseToQueryTemplates,
|
||||
providesTags: ['QueryTemplatesList'],
|
||||
}),
|
||||
@@ -23,14 +29,14 @@ export const queryLibraryApi = createApi({
|
||||
}),
|
||||
deleteQueryTemplate: builder.mutation<void, DeleteQueryTemplateCommand>({
|
||||
query: ({ uid }) => ({
|
||||
url: `${uid}`,
|
||||
url: `/${uid}`,
|
||||
method: 'DELETE',
|
||||
}),
|
||||
invalidatesTags: ['QueryTemplatesList'],
|
||||
}),
|
||||
editQueryTemplate: builder.mutation<void, EditQueryTemplateCommand>({
|
||||
query: (editQueryTemplateCommand) => ({
|
||||
url: `${editQueryTemplateCommand.uid}`,
|
||||
url: `/${editQueryTemplateCommand.uid}`,
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/merge-patch+json',
|
||||
|
||||
@@ -23,7 +23,7 @@ export enum QueryTemplateKinds {
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export const BASE_URL = `/apis/${API_VERSION}/namespaces/${config.namespace}/querytemplates/`;
|
||||
export const BASE_URL = `/apis/${API_VERSION}/namespaces/${config.namespace}/querytemplates`;
|
||||
|
||||
// URL is optional for these requests
|
||||
interface QueryLibraryBackendRequest extends Pick<BackendSrvRequest, 'data' | 'method'> {
|
||||
|
||||
@@ -2198,7 +2198,10 @@
|
||||
}
|
||||
},
|
||||
"query-library": {
|
||||
"delete-query-button": "Delete query"
|
||||
"datasource-names": "Datasource name(s):",
|
||||
"delete-query-button": "Delete query",
|
||||
"query-template-get-error": "Error attempting to get query template from the library: {{error}}",
|
||||
"search": "Search by data source, query content or description"
|
||||
},
|
||||
"query-operation": {
|
||||
"header": {
|
||||
|
||||
@@ -2198,7 +2198,10 @@
|
||||
}
|
||||
},
|
||||
"query-library": {
|
||||
"delete-query-button": "Đęľęŧę qūęřy"
|
||||
"datasource-names": "Đäŧäşőūřčę ʼnämę(ş):",
|
||||
"delete-query-button": "Đęľęŧę qūęřy",
|
||||
"query-template-get-error": "Ēřřőř äŧŧęmpŧįʼnģ ŧő ģęŧ qūęřy ŧęmpľäŧę ƒřőm ŧĥę ľįþřäřy: {{error}}",
|
||||
"search": "Ŝęäřčĥ þy đäŧä şőūřčę, qūęřy čőʼnŧęʼnŧ őř đęşčřįpŧįőʼn"
|
||||
},
|
||||
"query-operation": {
|
||||
"header": {
|
||||
|
||||
Reference in New Issue
Block a user